@pisell/pisellos 0.10.57 → 0.10.59

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.
@@ -0,0 +1,280 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.salesGuardError = exports.InventoryController = void 0;
7
+ /** Optional, instance-scoped sales inventory. Never stored in ProductData or order payloads. */
8
+
9
+ const salesGuardError = message => Object.assign(new Error(message), {
10
+ code: 'SALES_GUARD_REJECTED'
11
+ });
12
+ exports.salesGuardError = salesGuardError;
13
+ const cancelled = () => Object.assign(new Error('Inventory request cancelled'), {
14
+ name: 'AbortError'
15
+ });
16
+ const variantIdentity = (productId, variantId) => JSON.stringify([String(productId), String(variantId ?? 0)]);
17
+ class InventoryController {
18
+ identity = Symbol('sales-inventory-instance');
19
+ scopeKey;
20
+ entries = new Map();
21
+ lastQuantities = new Map();
22
+ products = new Map();
23
+ keys = new Map();
24
+ catalogProducts = new Map();
25
+ listeners = new Set();
26
+ revision = 0;
27
+ state = 'stale';
28
+ pending;
29
+ disposed = false;
30
+ constructor(source) {
31
+ this.source = source;
32
+ this.scopeKey = source.scopeKey;
33
+ }
34
+ getRevision = () => this.revision;
35
+ subscribe = listener => {
36
+ this.listeners.add(listener);
37
+ return () => {
38
+ this.listeners.delete(listener);
39
+ };
40
+ };
41
+ publish() {
42
+ this.revision += 1;
43
+ this.listeners.forEach(listener => {
44
+ try {
45
+ listener();
46
+ } catch {/* A view cannot interrupt a stock update. */}
47
+ });
48
+ }
49
+ assertCurrent(signal) {
50
+ if (this.disposed || signal?.aborted) throw cancelled();
51
+ this.source.assertCurrent();
52
+ }
53
+ activate() {
54
+ this.disposed = false;
55
+ }
56
+ dispose() {
57
+ this.disposed = true;
58
+ this.invalidate();
59
+ }
60
+ invalidate() {
61
+ this.pending?.controller.abort();
62
+ this.pending = undefined;
63
+ this.state = 'stale';
64
+ this.publish();
65
+ }
66
+
67
+ /** Catalog replaces identities; partial detail only merges its variants, never its prices. */
68
+ registerProducts(products, complete = false) {
69
+ this.assertCurrent();
70
+ let changed = false;
71
+ for (const product of products) {
72
+ const id = String(product.id ?? product.product_id);
73
+ if (complete && this.catalogProducts.get(id) === product) continue;
74
+ if (complete) this.catalogProducts.set(id, product);
75
+ const previous = this.products.get(id);
76
+ if (complete) for (const variantId of previous?.keys() || []) this.keys.delete(variantIdentity(id, variantId));
77
+ const variants = complete ? new Map() : new Map(previous);
78
+ const list = product.variant?.length ? product.variant : [{
79
+ id: 0,
80
+ code: product.code
81
+ }];
82
+ for (const variant of list) {
83
+ const variantId = String(variant.id ?? variant.product_variant_id ?? 0);
84
+ const key = this.source.keyOf(product, variant) ?? previous?.get(variantId);
85
+ variants.set(variantId, key);
86
+ this.keys.set(variantIdentity(id, variantId), key);
87
+ }
88
+ if (!previous || previous.size !== variants.size || [...variants].some(([id, key]) => previous.get(id) !== key)) changed = true;
89
+ this.products.set(id, variants);
90
+ }
91
+ if (changed) this.publish();
92
+ }
93
+
94
+ /** Consumers share I/O; cancelling one detail does not cancel another consumer. */
95
+ async refresh(context) {
96
+ this.assertCurrent(context.signal);
97
+ if (context.force) this.invalidate();
98
+ let read = this.pending;
99
+ if (!read) {
100
+ read = {
101
+ controller: new AbortController(),
102
+ consumers: 0,
103
+ promise: Promise.resolve()
104
+ };
105
+ const current = read;
106
+ this.pending = current;
107
+ this.state = 'loading';
108
+ this.publish();
109
+ current.promise = (async () => {
110
+ try {
111
+ const entries = await this.source.read({
112
+ reason: context.reason,
113
+ signal: current.controller.signal
114
+ });
115
+ this.assertCurrent(current.controller.signal);
116
+ if (this.pending !== current) throw cancelled();
117
+ this.entries = entries;
118
+ for (const [key, stock] of entries) if (stock.quantity !== null && Number.isSafeInteger(stock.quantity) && stock.quantity >= 0) this.lastQuantities.set(key, stock.quantity);
119
+ this.state = 'ready';
120
+ } catch (error) {
121
+ if (this.pending !== current || current.controller.signal.aborted) throw cancelled();
122
+ this.state = 'error';
123
+ throw error;
124
+ } finally {
125
+ if (this.pending === current) {
126
+ this.pending = undefined;
127
+ this.publish();
128
+ }
129
+ }
130
+ })();
131
+ }
132
+ read.consumers += 1;
133
+ try {
134
+ if (!context.signal) await read.promise;else await new Promise((resolve, reject) => {
135
+ const signal = context.signal;
136
+ const abort = () => reject(cancelled());
137
+ signal.addEventListener('abort', abort, {
138
+ once: true
139
+ });
140
+ read.promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
141
+ if (signal.aborted) abort();
142
+ });
143
+ this.assertCurrent(context.signal);
144
+ } finally {
145
+ read.consumers -= 1;
146
+ if (this.pending === read && read.consumers === 0) this.invalidate();
147
+ }
148
+ }
149
+ availabilityForKey(key) {
150
+ const stock = key ? this.entries.get(key) : undefined;
151
+ const quantity = key ? this.lastQuantities.get(key) : undefined;
152
+ if (this.state !== 'ready') return {
153
+ status: this.state,
154
+ quantity,
155
+ message: this.source.message('unavailable')
156
+ };
157
+ if (!stock) return {
158
+ status: 'unmapped',
159
+ quantity,
160
+ message: this.source.message('unmapped')
161
+ };
162
+ if (stock.quantity === null || !Number.isSafeInteger(stock.quantity) || stock.quantity < 0) return {
163
+ status: 'error',
164
+ quantity,
165
+ message: stock.message || this.source.message('unavailable')
166
+ };
167
+ return {
168
+ status: 'ready',
169
+ quantity: stock.quantity
170
+ };
171
+ }
172
+ availability(product, variantId) {
173
+ const id = product.id ?? product.product_id;
174
+ if (variantId !== undefined && variantId !== null) return this.availabilityForKey(this.keys.get(variantIdentity(id, variantId)));
175
+ const keys = [...new Set(this.products.get(String(id))?.values() || [])];
176
+ const states = keys.map(key => this.availabilityForKey(key));
177
+ if (!states.length) return this.availabilityForKey();
178
+ const quantity = states.every(entry => entry.quantity !== undefined) ? states.reduce((sum, entry) => sum + entry.quantity, 0) : undefined;
179
+ const failed = states.find(entry => entry.status !== 'ready');
180
+ if (quantity !== undefined && !Number.isSafeInteger(quantity)) return {
181
+ status: 'error',
182
+ message: this.source.message('unavailable')
183
+ };
184
+ return {
185
+ status: failed?.status || 'ready',
186
+ quantity,
187
+ message: failed?.message
188
+ };
189
+ }
190
+ keyForLine(item) {
191
+ return this.keys.get(variantIdentity(item.product_id, item.product_variant_id ?? item.product_sku?.product_variant_id));
192
+ }
193
+ /** Advisory UI limit only. The serialized cart guard remains authoritative. */
194
+ selectionAvailability(product, variantId, items, editingItem) {
195
+ const identity = variantIdentity(product.id ?? product.product_id, variantId);
196
+ const key = this.keys.get(identity);
197
+ let availability = this.availabilityForKey(key);
198
+ let reserved = 0;
199
+ let retainedQuantity = 0;
200
+ for (const item of items) {
201
+ const matches = key ? this.keyForLine(item) === key : variantIdentity(item.product_id, item.product_variant_id ?? item.product_sku?.product_variant_id) === identity;
202
+ if (!matches) continue;
203
+ const count = Number(item.num ?? item.quantity);
204
+ if (!Number.isSafeInteger(count) || count < 0) {
205
+ availability = {
206
+ ...availability,
207
+ status: 'error',
208
+ message: this.source.message('unavailable')
209
+ };
210
+ continue;
211
+ }
212
+ if (item === editingItem) retainedQuantity = count;else reserved += count;
213
+ }
214
+ if (!Number.isSafeInteger(reserved)) {
215
+ availability = {
216
+ ...availability,
217
+ status: 'error',
218
+ message: this.source.message('unavailable')
219
+ };
220
+ }
221
+ const availableQuantity = availability.status === 'ready' ? Math.max(0, availability.quantity - reserved) : 0;
222
+ return {
223
+ availability,
224
+ availableQuantity,
225
+ retainedQuantity,
226
+ maxQuantity: Math.max(availableQuantity, retainedQuantity)
227
+ };
228
+ }
229
+ totals(items) {
230
+ const totals = new Map();
231
+ for (const item of items) {
232
+ const key = this.keyForLine(item);
233
+ const identity = key ? JSON.stringify(['inventory', key]) : variantIdentity(item.product_id, item.product_variant_id);
234
+ const quantity = Number(item.num ?? item.quantity);
235
+ if (!Number.isSafeInteger(quantity) || quantity < 0) throw salesGuardError(this.source.message('unavailable'));
236
+ if (!quantity) continue;
237
+ const total = (totals.get(identity)?.quantity || 0) + quantity;
238
+ if (!Number.isSafeInteger(total)) throw salesGuardError(this.source.message('unavailable'));
239
+ totals.set(identity, {
240
+ key,
241
+ quantity: total
242
+ });
243
+ }
244
+ return totals;
245
+ }
246
+ validate(proposed, current = [], allowReductions = true) {
247
+ this.assertCurrent();
248
+ const previous = this.totals(current);
249
+ for (const [identity, {
250
+ key,
251
+ quantity
252
+ }] of this.totals(proposed)) {
253
+ if (allowReductions && quantity <= (previous.get(identity)?.quantity || 0)) continue;
254
+ const stock = this.availabilityForKey(key);
255
+ if (stock.status !== 'ready') throw salesGuardError(stock.message || this.source.message('unavailable'));
256
+ if (quantity > stock.quantity) throw salesGuardError(this.source.message('insufficient', {
257
+ key: key,
258
+ quantity: stock.quantity
259
+ }));
260
+ }
261
+ }
262
+ freezeCheckout(items) {
263
+ if (!items.length || items.some(item => Number(item.num ?? item.quantity) <= 0)) {
264
+ throw salesGuardError(this.source.message('unavailable'));
265
+ }
266
+ this.validate(items, [], false);
267
+ return Object.freeze({
268
+ scopeKey: this.scopeKey,
269
+ revision: this.revision,
270
+ lines: Object.freeze(items.map((item, index) => Object.freeze({
271
+ lineId: String(item.metadata?.unique_identification_number ?? item.unique_identification_number ?? item.order_detail_id ?? index),
272
+ productId: item.product_id,
273
+ variantId: item.product_variant_id ?? item.product_sku?.product_variant_id ?? 0,
274
+ quantity: Number(item.num ?? item.quantity),
275
+ inventoryKey: this.keyForLine(item)
276
+ })))
277
+ });
278
+ }
279
+ }
280
+ exports.InventoryController = InventoryController;
@@ -4,7 +4,7 @@ import { OrderModuleAPI, CommitOrderParams, UpdateOrderBookingParams, UpdateOrde
4
4
  import { CartItem } from '../Cart/types';
5
5
  import { type SubmitPayloadEnhancer } from './utils';
6
6
  import { type PickupTimingBooking, type PickupTimingBookingInput } from './pickupTiming';
7
- import type { OrderAmountSnapshot, AddProductBookingInput, AddProductToOrderOptions, OrderIdentitySnapshot, OrderPaymentSource, OrderSnapshot, OrderSyncState, SyncPaymentsToOrderParams, SyncPaymentsToOrderResult, SubmitSalesOrderParams, OrderProduct, OrderProductIdentity, OrderSummary, OrderTempOrder, OrderPaymentData, OrderPaymentUpdateOptions, OrderPaymentUpdateResult, SendCustomerPayLinkParams, UpdateTempOrderCustomerInput, OrderCustomerPatch, OrderCustomerView, OrderCartIntentCommitContext, ReplaceTempOrderOptions, OrderPromotionEvaluator, OrderGiftSelectResolver, OrderUnfulfilledPromotion, OrderLastGiftActions, RemoveProductsFromOrderOptions, SalesNote, SalesNotesMutationSnapshot, SetSalesNoteInput, PickupReferenceMode, PointCardSnapshotMergeContext, PointCardSnapshotItem, ShopServiceData, ShopServiceType } from './types';
7
+ import type { OrderAmountSnapshot, AddProductBookingInput, AddProductToOrderOptions, OrderIdentitySnapshot, OrderPaymentSource, OrderSnapshot, OrderSyncState, SyncPaymentsToOrderParams, SyncPaymentsToOrderResult, SubmitSalesOrderParams, OrderProduct, OrderProductIdentity, OrderSummary, OrderTempOrder, OrderPaymentData, OrderPaymentUpdateOptions, OrderPaymentUpdateResult, SendCustomerPayLinkParams, UpdateTempOrderCustomerInput, OrderCustomerPatch, OrderCustomerView, OrderHooksConfig, OrderCartIntentCommitContext, ReplaceTempOrderOptions, OrderPromotionEvaluator, OrderGiftSelectResolver, OrderUnfulfilledPromotion, OrderLastGiftActions, RemoveProductsFromOrderOptions, SalesNote, SalesNotesMutationSnapshot, SetSalesNoteInput, PickupReferenceMode, PointCardSnapshotMergeContext, PointCardSnapshotItem, ShopServiceData, ShopServiceType } from './types';
8
8
  import type { ICustomer } from '../AccountList/types';
9
9
  import type { Discount } from '../Discount/types';
10
10
  import { UnavailableReason } from '../Rules/types';
@@ -29,6 +29,12 @@ export declare class OrderModule extends BaseModule implements Module, OrderModu
29
29
  private rulesHooksOverride?;
30
30
  private moduleHooksOverride?;
31
31
  private orderHooks?;
32
+ private cartCommandQueue;
33
+ private queueCartCommand;
34
+ /** Use the exact Order normalizers/merge rules on a private draft, not a second projection. */
35
+ private beginCartMutation;
36
+ /** Serializes checkout preparation with local cart commands; hydration remains outside this guard. */
37
+ prepareCheckout<T>(prepare: (items: OrderProduct[]) => Promise<T>): Promise<T>;
32
38
  private otherParams?;
33
39
  /** 评估器引用;由 SalesSdkProvider 注入,未注入时 applyPromotion 静默跳过 */
34
40
  private promotionEvaluator;
@@ -66,7 +72,11 @@ export declare class OrderModule extends BaseModule implements Module, OrderModu
66
72
  * @example
67
73
  * orderModule.updateOtherParams({ cacheId: 'cache-2', channel: 'pos' });
68
74
  */
69
- updateOtherParams(params: Record<string, any>): void;
75
+ updateOtherParams(params: Record<string, any>, options?: {
76
+ hooks?: OrderHooksConfig;
77
+ changedParams?: Record<string, any>;
78
+ cover?: boolean;
79
+ }): void;
70
80
  /** Internal intent boundary shared with asynchronous scanner/detail flows. */
71
81
  createCartIntentCommitGuard(action: OrderCartIntentCommitContext['action'], tempOrder?: OrderTempOrder | null): () => void;
72
82
  /** PC/H5 属于在线店铺;其它或缺失平台统一沿用 POS 行为。 */
@@ -481,6 +491,7 @@ export declare class OrderModule extends BaseModule implements Module, OrderModu
481
491
  * // → tempOrder.products 新增 3 行,每行 num=1,bookings 3 条
482
492
  */
483
493
  addProductToOrder(product: Partial<OrderProduct> & OrderProductIdentity, booking?: AddProductBookingInput, options?: AddProductToOrderOptions): Promise<OrderProduct[]>;
494
+ private addProductToOrderDraft;
484
495
  /**
485
496
  * 批量添加商品行,所有行追加完成后只触发一次促销、折扣、summary 重算与持久化。
486
497
  *
@@ -496,6 +507,7 @@ export declare class OrderModule extends BaseModule implements Module, OrderModu
496
507
  }>, options?: {
497
508
  skipEditDiscountConfigRefresh?: boolean;
498
509
  }): Promise<OrderProduct[]>;
510
+ private addProductsToOrderDraft;
499
511
  private getFullCoveragePassDiscounts;
500
512
  private hasEntitlementPassDiscount;
501
513
  private hasGoodPassDiscount;
@@ -507,8 +519,11 @@ export declare class OrderModule extends BaseModule implements Module, OrderModu
507
519
  private isRestrictedCartProductUpdate;
508
520
  private applyOrderProductUpdateToTempOrder;
509
521
  updateOrderProduct(params: UpdateOrderProductParams): Promise<OrderProduct[]>;
522
+ private updateOrderProductDraft;
510
523
  updateOrderProducts(paramsList: UpdateOrderProductParams[]): Promise<OrderProduct[]>;
524
+ private updateOrderProductsDraft;
511
525
  updateOrderProductQuantity(params: UpdateOrderProductQuantityParams): Promise<OrderProduct[]>;
526
+ private updateOrderProductQuantityDraft;
512
527
  updateOrderBooking(params: UpdateOrderBookingParams): any[];
513
528
  /**
514
529
  * 商品行写路径收尾:促销 → 折扣 → 清洗 → summary → 持久化。
@@ -525,12 +540,14 @@ export declare class OrderModule extends BaseModule implements Module, OrderModu
525
540
  * await order.removeProductsFromOrder([identityA, identityB]);
526
541
  */
527
542
  removeProductsFromOrder(identities: OrderProductIdentity[], options?: RemoveProductsFromOrderOptions): Promise<OrderProduct[]>;
543
+ private removeProductsFromOrderDraft;
528
544
  removeProductFromOrder(identity: OrderProductIdentity, options?: RemoveProductsFromOrderOptions): Promise<OrderProduct[]>;
529
545
  /**
530
546
  * 仅清空购物车行(products / bookings),不重置整单。
531
547
  * 普通整单 note 保持不变;以商品行为主体的 Sales Notes 同步删除。
532
548
  */
533
549
  clearOrderCartLines(): Promise<OrderTempOrder>;
550
+ private clearOrderCartLinesDraft;
534
551
  /**
535
552
  * 切换购物车结算单位时清理所有会影响新币种的订单状态,但保留当前客户。
536
553
  */
@@ -592,7 +609,7 @@ export declare class OrderModule extends BaseModule implements Module, OrderModu
592
609
  generateIdempotencyToken(): string;
593
610
  syncPaymentsToOrder<T = any>(params: SyncPaymentsToOrderParams): Promise<SyncPaymentsToOrderResult<T>>;
594
611
  createOrder(params: CommitOrderParams['query']): {
595
- type: "virtual" | "appointment_booking";
612
+ type: "appointment_booking" | "virtual";
596
613
  platform: string;
597
614
  sales_channel: string;
598
615
  order_sales_channel: string;
@@ -145,6 +145,63 @@ class OrderModule extends _BaseModule.BaseModule {
145
145
  rulesHooksOverride;
146
146
  moduleHooksOverride;
147
147
  orderHooks;
148
+ cartCommandQueue = Promise.resolve();
149
+ queueCartCommand(run) {
150
+ if (!this.orderHooks?.onBeforeCartCommit) return run();
151
+ const pending = this.cartCommandQueue.then(run, run);
152
+ this.cartCommandQueue = pending.catch(() => undefined);
153
+ return pending;
154
+ }
155
+
156
+ /** Use the exact Order normalizers/merge rules on a private draft, not a second projection. */
157
+ beginCartMutation() {
158
+ const current = this.ensureTempOrder();
159
+ const guard = this.orderHooks?.onBeforeCartCommit;
160
+ if (!guard) return {
161
+ current,
162
+ draft: current,
163
+ commit: undefined
164
+ };
165
+ const before = (0, _lodashEs.cloneDeep)(current);
166
+ const draft = (0, _lodashEs.cloneDeep)(current);
167
+ return {
168
+ current,
169
+ draft,
170
+ commit: async beforeCommit => {
171
+ await guard({
172
+ currentItems: (0, _lodashEs.cloneDeep)(before.products),
173
+ proposedItems: (0, _lodashEs.cloneDeep)(draft.products)
174
+ });
175
+ if (this.store.tempOrder !== current || guard !== this.orderHooks?.onBeforeCartCommit || !(0, _lodashEs.isEqual)(current, before)) {
176
+ throw Object.assign(new Error('The cart changed. Please try again.'), {
177
+ code: 'SALES_GUARD_REJECTED'
178
+ });
179
+ }
180
+ // Recheck the accepted intent's scope after asynchronous inventory validation.
181
+ beforeCommit?.();
182
+ Object.assign(current, draft);
183
+ return current;
184
+ }
185
+ };
186
+ }
187
+
188
+ /** Serializes checkout preparation with local cart commands; hydration remains outside this guard. */
189
+ async prepareCheckout(prepare) {
190
+ const run = async () => {
191
+ const order = this.ensureTempOrder();
192
+ const items = (0, _lodashEs.cloneDeep)(order.products);
193
+ const result = await prepare(items);
194
+ if (this.store.tempOrder !== order || !(0, _lodashEs.isEqual)(order.products, items)) {
195
+ throw Object.assign(new Error('The cart changed. Please try again.'), {
196
+ code: 'SALES_GUARD_REJECTED'
197
+ });
198
+ }
199
+ return result;
200
+ };
201
+ const pending = this.cartCommandQueue.then(run, run);
202
+ this.cartCommandQueue = pending.catch(() => undefined);
203
+ return pending;
204
+ }
148
205
  otherParams;
149
206
  // ─── 促销/赠品 ────────────────────────────────────
150
207
  /** 评估器引用;由 SalesSdkProvider 注入,未注入时 applyPromotion 静默跳过 */
@@ -432,8 +489,11 @@ class OrderModule extends _BaseModule.BaseModule {
432
489
  * @example
433
490
  * orderModule.updateOtherParams({ cacheId: 'cache-2', channel: 'pos' });
434
491
  */
435
- updateOtherParams(params) {
492
+ updateOtherParams(params, options) {
436
493
  const otherParams = params || {};
494
+ if (options && Object.prototype.hasOwnProperty.call(options, 'hooks')) {
495
+ this.moduleHooksOverride = options.hooks;
496
+ }
437
497
  this.syncSessionStorageOptions(otherParams);
438
498
  this.salesSummaryModuleName = otherParams.salesSummaryModuleName;
439
499
  this.rulesHooksOverride = otherParams.rules?.hooks;
@@ -3704,8 +3764,12 @@ class OrderModule extends _BaseModule.BaseModule {
3704
3764
  * // → tempOrder.products 新增 3 行,每行 num=1,bookings 3 条
3705
3765
  */
3706
3766
  async addProductToOrder(product, booking, options) {
3707
- const tempOrder = this.ensureTempOrder();
3708
- const beforeCommit = this.createCartIntentCommitGuard('add_product', tempOrder);
3767
+ return this.queueCartCommand(() => this.addProductToOrderDraft(product, booking, options));
3768
+ }
3769
+ async addProductToOrderDraft(product, booking, options) {
3770
+ const mutation = this.beginCartMutation();
3771
+ let tempOrder = mutation.draft;
3772
+ const beforeCommit = this.createCartIntentCommitGuard('add_product', mutation.current);
3709
3773
  const salesNoteDrafts = (options?.salesNotes || []).map(draft => ({
3710
3774
  note_type: String(draft.note_type || '').trim(),
3711
3775
  text: {
@@ -3802,6 +3866,7 @@ class OrderModule extends _BaseModule.BaseModule {
3802
3866
  rollbackProductAndNotesMutation();
3803
3867
  throw error;
3804
3868
  }
3869
+ if (mutation.commit) tempOrder = await mutation.commit(beforeCommit);
3805
3870
  await this.finalizeProductOrderMutation(tempOrder);
3806
3871
  this.logInfo('addProductToOrder success', {
3807
3872
  product_id: product.product_id,
@@ -3820,9 +3885,13 @@ class OrderModule extends _BaseModule.BaseModule {
3820
3885
  * ]);
3821
3886
  */
3822
3887
  async addProductsToOrder(items, options) {
3823
- const tempOrder = this.ensureTempOrder();
3888
+ return this.queueCartCommand(() => this.addProductsToOrderDraft(items, options));
3889
+ }
3890
+ async addProductsToOrderDraft(items, options) {
3891
+ const mutation = this.beginCartMutation();
3892
+ let tempOrder = mutation.draft;
3824
3893
  if (!Array.isArray(items) || items.length === 0) return tempOrder.products;
3825
- const beforeCommit = this.createCartIntentCommitGuard('add_product', tempOrder);
3894
+ const beforeCommit = this.createCartIntentCommitGuard('add_product', mutation.current);
3826
3895
  for (const item of items || []) {
3827
3896
  const {
3828
3897
  product,
@@ -3848,6 +3917,7 @@ class OrderModule extends _BaseModule.BaseModule {
3848
3917
  });
3849
3918
  if (appendResult) await appendResult;
3850
3919
  }
3920
+ if (mutation.commit) tempOrder = await mutation.commit(beforeCommit);
3851
3921
  await this.finalizeProductOrderMutation(tempOrder, options);
3852
3922
  this.logInfo('addProductsToOrder success', {
3853
3923
  itemsCount: items.length
@@ -4190,12 +4260,16 @@ class OrderModule extends _BaseModule.BaseModule {
4190
4260
  return tempOrder.products[productIndex].metadata?.unique_identification_number;
4191
4261
  }
4192
4262
  async updateOrderProduct(params) {
4193
- const tempOrder = this.ensureTempOrder();
4194
- if (this.isRestrictedCartProductUpdate(tempOrder, params)) {
4195
- this.createCartIntentCommitGuard('update_product', tempOrder)();
4196
- }
4263
+ return this.queueCartCommand(() => this.updateOrderProductDraft(params));
4264
+ }
4265
+ async updateOrderProductDraft(params) {
4266
+ const mutation = this.beginCartMutation();
4267
+ let tempOrder = mutation.draft;
4268
+ const beforeCommit = this.isRestrictedCartProductUpdate(tempOrder, params) ? this.createCartIntentCommitGuard('update_product', mutation.current) : undefined;
4269
+ beforeCommit?.();
4197
4270
  const reapplyWeighedDiscountProductUids = [];
4198
4271
  const updatedProductUid = this.applyOrderProductUpdateToTempOrder(tempOrder, params, reapplyWeighedDiscountProductUids);
4272
+ if (mutation.commit) tempOrder = await mutation.commit(beforeCommit);
4199
4273
  const promotionPayload = await this.evaluatePromotion();
4200
4274
  this.applyDiscount({
4201
4275
  reapplyWeighedDiscountProductUids,
@@ -4213,13 +4287,16 @@ class OrderModule extends _BaseModule.BaseModule {
4213
4287
  return tempOrder.products;
4214
4288
  }
4215
4289
  async updateOrderProducts(paramsList) {
4216
- const tempOrder = this.ensureTempOrder();
4290
+ return this.queueCartCommand(() => this.updateOrderProductsDraft(paramsList));
4291
+ }
4292
+ async updateOrderProductsDraft(paramsList) {
4293
+ const mutation = this.beginCartMutation();
4294
+ let tempOrder = mutation.draft;
4217
4295
  if (!paramsList.length) {
4218
4296
  return tempOrder.products;
4219
4297
  }
4220
- if (paramsList.some(params => this.isRestrictedCartProductUpdate(tempOrder, params))) {
4221
- this.createCartIntentCommitGuard('update_product', tempOrder)();
4222
- }
4298
+ const beforeCommit = paramsList.some(params => this.isRestrictedCartProductUpdate(tempOrder, params)) ? this.createCartIntentCommitGuard('update_product', mutation.current) : undefined;
4299
+ beforeCommit?.();
4223
4300
  const reapplyWeighedDiscountProductUids = [];
4224
4301
  const reapplyBookingDiscountProductUids = paramsList.reduce((acc, params) => {
4225
4302
  const updatedProductUid = this.applyOrderProductUpdateToTempOrder(tempOrder, params, reapplyWeighedDiscountProductUids);
@@ -4229,6 +4306,7 @@ class OrderModule extends _BaseModule.BaseModule {
4229
4306
  }
4230
4307
  return acc;
4231
4308
  }, []);
4309
+ if (mutation.commit) tempOrder = await mutation.commit(beforeCommit);
4232
4310
  await this.finalizeAfterProductsMutation(tempOrder, {
4233
4311
  reapplyWeighedDiscountProductUids,
4234
4312
  reapplyBookingDiscountProductUids: reapplyBookingDiscountProductUids.length ? reapplyBookingDiscountProductUids : undefined
@@ -4239,6 +4317,9 @@ class OrderModule extends _BaseModule.BaseModule {
4239
4317
  return tempOrder.products;
4240
4318
  }
4241
4319
  async updateOrderProductQuantity(params) {
4320
+ return this.queueCartCommand(() => this.updateOrderProductQuantityDraft(params));
4321
+ }
4322
+ async updateOrderProductQuantityDraft(params) {
4242
4323
  const {
4243
4324
  product_id,
4244
4325
  product_variant_id,
@@ -4248,7 +4329,8 @@ class OrderModule extends _BaseModule.BaseModule {
4248
4329
  product_sku,
4249
4330
  product_bundle
4250
4331
  } = params;
4251
- const tempOrder = this.ensureTempOrder();
4332
+ const mutation = this.beginCartMutation();
4333
+ let tempOrder = mutation.draft;
4252
4334
  this.repairDuplicateProductLineIdentities(tempOrder);
4253
4335
  const identityLookup = {
4254
4336
  product_id,
@@ -4304,10 +4386,10 @@ class OrderModule extends _BaseModule.BaseModule {
4304
4386
  throw new _virtualSettlement.VirtualCurrencyBalanceError(balance.toFixed(2), requiredAmount.toFixed(2));
4305
4387
  }
4306
4388
  }
4307
- if (normalizedProduct.num > targetProduct.num) {
4308
- this.createCartIntentCommitGuard('increase_quantity', tempOrder)();
4309
- }
4389
+ const beforeCommit = normalizedProduct.num > targetProduct.num ? this.createCartIntentCommitGuard('increase_quantity', mutation.current) : undefined;
4390
+ beforeCommit?.();
4310
4391
  tempOrder.products[productIndex] = normalizedProduct;
4392
+ if (mutation.commit) tempOrder = await mutation.commit(beforeCommit);
4311
4393
  this.prepareVirtualSettlementTempOrder(tempOrder);
4312
4394
  const isVirtualSettlement = this.isActiveVirtualSettlementOrder(tempOrder);
4313
4395
  const promotionPayload = !isVirtualSettlement ? await this.evaluatePromotion() : null;
@@ -4384,7 +4466,11 @@ class OrderModule extends _BaseModule.BaseModule {
4384
4466
  * await order.removeProductsFromOrder([identityA, identityB]);
4385
4467
  */
4386
4468
  async removeProductsFromOrder(identities, options) {
4387
- const tempOrder = this.ensureTempOrder();
4469
+ return this.queueCartCommand(() => this.removeProductsFromOrderDraft(identities, options));
4470
+ }
4471
+ async removeProductsFromOrderDraft(identities, options) {
4472
+ const mutation = this.beginCartMutation();
4473
+ let tempOrder = mutation.draft;
4388
4474
  if (!identities.length) {
4389
4475
  return tempOrder.products;
4390
4476
  }
@@ -4451,6 +4537,7 @@ class OrderModule extends _BaseModule.BaseModule {
4451
4537
  }
4452
4538
  }
4453
4539
  this.removeSalesNotesForMissingProductLines(tempOrder);
4540
+ if (mutation.commit) tempOrder = await mutation.commit();
4454
4541
  await this.finalizeAfterProductsMutation(tempOrder);
4455
4542
  this.logInfo('removeProductsFromOrder success', {
4456
4543
  identities
@@ -4466,7 +4553,11 @@ class OrderModule extends _BaseModule.BaseModule {
4466
4553
  * 普通整单 note 保持不变;以商品行为主体的 Sales Notes 同步删除。
4467
4554
  */
4468
4555
  async clearOrderCartLines() {
4469
- const tempOrder = this.ensureTempOrder();
4556
+ return this.queueCartCommand(() => this.clearOrderCartLinesDraft());
4557
+ }
4558
+ async clearOrderCartLinesDraft() {
4559
+ const mutation = this.beginCartMutation();
4560
+ let tempOrder = mutation.draft;
4470
4561
  this.ensureSalesNotesLoadedForMutation(tempOrder);
4471
4562
  tempOrder.products = [];
4472
4563
  tempOrder.bookings = [];
@@ -4479,6 +4570,7 @@ class OrderModule extends _BaseModule.BaseModule {
4479
4570
  productsByUid: {}
4480
4571
  };
4481
4572
  }
4573
+ if (mutation.commit) tempOrder = await mutation.commit();
4482
4574
  const promotionPayload = await this.evaluatePromotion();
4483
4575
  this.applyDiscount();
4484
4576
  this.sanitizeTempOrderProducts(tempOrder);
@@ -526,6 +526,11 @@ export interface OrderHooksConfig {
526
526
  beforeCartIntentCommit?: (context: OrderCartIntentCommitContext) => void;
527
527
  /** Captures tenant/business runtime identity for asynchronous cart intents. */
528
528
  getCartIntentScope?: () => unknown;
529
+ /** Awaited before a local cart draft is published. Reject to leave the live order unchanged. */
530
+ onBeforeCartCommit?: (context: {
531
+ currentItems: readonly OrderProduct[];
532
+ proposedItems: readonly OrderProduct[];
533
+ }) => void | Promise<void>;
529
534
  onBeforeMergeProductToOrder?: (context: OrderProductMergeDecisionContext) => OrderProductMergeDecisionResult | Promise<OrderProductMergeDecisionResult>;
530
535
  }
531
536
  /**
@@ -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 归零)