@pisell/pisellos 0.10.28 → 0.10.30

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 (85) hide show
  1. package/dist/core/index.d.ts +7 -0
  2. package/dist/core/index.js +108 -65
  3. package/dist/model/strategy/adapter/dataVariant/evaluator.js +13 -2
  4. package/dist/model/strategy/adapter/promotion/index.js +0 -9
  5. package/dist/modules/Discount/index.js +4 -1
  6. package/dist/modules/Discount/types.d.ts +14 -4
  7. package/dist/modules/Order/index.d.ts +3 -1
  8. package/dist/modules/Order/index.js +89 -16
  9. package/dist/modules/Order/types.d.ts +13 -0
  10. package/dist/modules/Order/utils.d.ts +17 -2
  11. package/dist/modules/Order/utils.js +133 -27
  12. package/dist/modules/Quotation/index.d.ts +6 -0
  13. package/dist/modules/Quotation/index.js +77 -25
  14. package/dist/modules/ResourcePlanner/planner.js +14 -4
  15. package/dist/modules/ResourcePlanner/types.d.ts +2 -0
  16. package/dist/plugins/request.d.ts +1 -0
  17. package/dist/server/index.d.ts +13 -2
  18. package/dist/server/index.js +1531 -1230
  19. package/dist/server/modules/floor-plan/index.d.ts +4 -0
  20. package/dist/server/modules/floor-plan/index.js +240 -98
  21. package/dist/server/modules/menu/index.js +48 -23
  22. package/dist/server/modules/order/index.d.ts +17 -7
  23. package/dist/server/modules/order/index.js +419 -207
  24. package/dist/server/modules/order/types.d.ts +50 -0
  25. package/dist/server/modules/order/types.js +2 -0
  26. package/dist/server/modules/products/index.d.ts +10 -4
  27. package/dist/server/modules/products/index.js +177 -84
  28. package/dist/server/modules/resource/index.js +21 -13
  29. package/dist/server/utils/product.d.ts +1 -1
  30. package/dist/server/utils/product.js +186 -22
  31. package/dist/server/utils/small-ticket.d.ts +4 -1
  32. package/dist/server/utils/small-ticket.js +543 -101
  33. package/dist/solution/BaseSales/index.d.ts +3 -3
  34. package/dist/solution/BaseSales/index.js +171 -198
  35. package/dist/solution/BaseSales/utils/parseSalesResponse.d.ts +2 -0
  36. package/dist/solution/BaseSales/utils/parseSalesResponse.js +1 -1
  37. package/dist/solution/BaseSales/utils/transformBaseProductToOrderProduct.js +3 -2
  38. package/dist/solution/BookingByStep/index.d.ts +2 -2
  39. package/dist/solution/Sales/index.d.ts +7 -0
  40. package/dist/solution/Sales/index.js +87 -31
  41. package/dist/solution/UnifiedBookingSales/index.d.ts +2 -2
  42. package/dist/solution/UnifiedBookingSales/index.js +38 -21
  43. package/lib/core/index.d.ts +7 -0
  44. package/lib/core/index.js +20 -2
  45. package/lib/model/strategy/adapter/dataVariant/evaluator.js +12 -2
  46. package/lib/model/strategy/adapter/promotion/index.js +46 -1
  47. package/lib/modules/Discount/index.js +4 -1
  48. package/lib/modules/Discount/types.d.ts +14 -4
  49. package/lib/modules/Order/index.d.ts +3 -1
  50. package/lib/modules/Order/index.js +69 -1
  51. package/lib/modules/Order/types.d.ts +13 -0
  52. package/lib/modules/Order/utils.d.ts +17 -2
  53. package/lib/modules/Order/utils.js +131 -18
  54. package/lib/modules/Quotation/index.d.ts +6 -0
  55. package/lib/modules/Quotation/index.js +52 -8
  56. package/lib/modules/ResourcePlanner/planner.js +9 -1
  57. package/lib/modules/ResourcePlanner/types.d.ts +2 -0
  58. package/lib/plugins/request.d.ts +1 -0
  59. package/lib/server/index.d.ts +13 -2
  60. package/lib/server/index.js +210 -19
  61. package/lib/server/modules/floor-plan/index.d.ts +4 -0
  62. package/lib/server/modules/floor-plan/index.js +60 -7
  63. package/lib/server/modules/menu/index.js +26 -4
  64. package/lib/server/modules/order/index.d.ts +17 -7
  65. package/lib/server/modules/order/index.js +194 -57
  66. package/lib/server/modules/order/types.d.ts +50 -0
  67. package/lib/server/modules/order/types.js +1 -0
  68. package/lib/server/modules/products/index.d.ts +10 -4
  69. package/lib/server/modules/products/index.js +93 -23
  70. package/lib/server/modules/resource/index.js +12 -2
  71. package/lib/server/utils/product.d.ts +1 -1
  72. package/lib/server/utils/product.js +163 -3
  73. package/lib/server/utils/small-ticket.d.ts +4 -1
  74. package/lib/server/utils/small-ticket.js +507 -75
  75. package/lib/solution/BaseSales/index.d.ts +3 -3
  76. package/lib/solution/BaseSales/index.js +9 -14
  77. package/lib/solution/BaseSales/utils/parseSalesResponse.d.ts +2 -0
  78. package/lib/solution/BaseSales/utils/parseSalesResponse.js +1 -1
  79. package/lib/solution/BaseSales/utils/transformBaseProductToOrderProduct.js +3 -2
  80. package/lib/solution/BookingByStep/index.d.ts +2 -2
  81. package/lib/solution/Sales/index.d.ts +7 -0
  82. package/lib/solution/Sales/index.js +48 -3
  83. package/lib/solution/UnifiedBookingSales/index.d.ts +2 -2
  84. package/lib/solution/UnifiedBookingSales/index.js +19 -3
  85. package/package.json +1 -1
@@ -213,7 +213,11 @@ function evaluateCandidateTimeGate(params) {
213
213
  };
214
214
  }
215
215
  if (policy.type === 'before_start') {
216
- if (now < candidateRange.start) return null;
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;
220
+ if (hasNotReachedStart) return null;
217
221
  return {
218
222
  status: 'unavailable',
219
223
  reasonCodes: ['booking_cutoff_before_start'],
@@ -946,6 +950,7 @@ function getProductTimeRanges(projection, request = {}, runtimeOptions = {}) {
946
950
  }
947
951
  const requiredGroups = (product.requirementGroups || []).filter(group => group.required !== false);
948
952
  const operatingRanges = buildOperatingRanges(projection, dateRange);
953
+ const operatingEndAt = dateRange.startDate === dateRange.endDate && operatingRanges.length > 0 ? formatDateTime(Math.max(...operatingRanges.map(range => range.end)), projection.meta.timezone, fixedOffsetMinutes) : undefined;
949
954
  const resourceRanges = requiredGroups.length ? requiredGroups.map(group => buildGroupWorkingRanges({
950
955
  projection,
951
956
  product,
@@ -1012,6 +1017,9 @@ function getProductTimeRanges(projection, request = {}, runtimeOptions = {}) {
1012
1017
  title: product.title,
1013
1018
  kind: product.kind,
1014
1019
  slotStepMinutes,
1020
+ ...(operatingEndAt ? {
1021
+ operatingEndAt
1022
+ } : {}),
1015
1023
  ranges: dedupeProductRanges(ranges)
1016
1024
  };
1017
1025
  });
@@ -213,6 +213,8 @@ export interface ProductTimeRangeGroup {
213
213
  title?: string;
214
214
  kind: AvailabilityProductKind;
215
215
  slotStepMinutes?: number;
216
+ /** 单日查询对应营业窗口的最终结束时刻,不受资源排班是否为空影响。 */
217
+ operatingEndAt?: string;
216
218
  ranges: ProductTimeRange[];
217
219
  }
218
220
  export interface AvailabilityAssignment extends AvailabilityAbsoluteRange {
@@ -68,6 +68,7 @@ export interface RequestOptions {
68
68
  useCache?: boolean;
69
69
  customToast?: () => void;
70
70
  cache?: CacheProps;
71
+ cacheUpdateChange?: (data: any) => void;
71
72
  osServer?: boolean;
72
73
  isNocobase?: boolean;
73
74
  prefix?: boolean;
@@ -51,6 +51,8 @@ declare class Server {
51
51
  private productQueryScheduleTimers;
52
52
  /** 餐牌筛选为空时才触发的菜单/日程缓存自愈,避免每次商品查询都请求远端。 */
53
53
  private menuScheduleRefreshInFlight;
54
+ /** 启动阶段模块预加载任务;预渲染商品查询复用该任务,避免缓存尚未 ready 时误触发远端自愈。 */
55
+ private startupModulePreloadInFlight;
54
56
  private lastMenuScheduleRefreshAt;
55
57
  private readonly MENU_SCHEDULE_REFRESH_COOLDOWN_MS;
56
58
  private orderQuerySubscribers;
@@ -126,6 +128,9 @@ declare class Server {
126
128
  private runCheckoutSync;
127
129
  private omitCheckoutSyncMode;
128
130
  private buildRemoteCheckoutData;
131
+ private buildCheckoutDeviceHeaders;
132
+ private applyKioskCheckoutOperator;
133
+ private resolveKioskCheckoutOperatorId;
129
134
  private mergeCheckoutSyncPayments;
130
135
  private persistSyncedCheckoutOrder;
131
136
  /**
@@ -157,13 +162,17 @@ declare class Server {
157
162
  * 重新拉取全量 SSE 接口,更新本地数据后触发 onProductsSyncCompleted
158
163
  * 不影响当前界面展示,适用于切回前台、定时刷新等场景
159
164
  */
160
- refreshProductsInBackground(): Promise<void>;
165
+ refreshProductsInBackground(options?: {
166
+ trigger?: string;
167
+ }): Promise<void>;
161
168
  /**
162
169
  * 后台静默刷新订单数据
163
170
  * 重新拉取全量 SSE 接口,更新本地数据后触发 onOrdersSyncCompleted
164
171
  * 不影响当前界面展示,适用于切回前台、定时刷新等场景
165
172
  */
166
- refreshOrdersInBackground(): Promise<void>;
173
+ refreshOrdersInBackground(options?: {
174
+ trigger?: string;
175
+ }): Promise<void>;
167
176
  /**
168
177
  * 编辑正式订单时强制查询 Server 最新快照并覆盖本地缓存。
169
178
  * 商品成员增删由 OrderModule 在 SQLite/Store 更新完成后广播,
@@ -524,6 +533,8 @@ declare class Server {
524
533
  private getLocalPrintOrderLookup;
525
534
  private mergeLocalPrintOrderInput;
526
535
  private handleLocalPrintOrder;
536
+ private normalizeRequestedReceiptLocales;
537
+ private handleLocalOrderReceiptSnapshot;
527
538
  private handleMachinecodeBatchMeta;
528
539
  private resolveMachinecodeBatchMetadataLookup;
529
540
  private syncMachinecodeRedeemStatusToLocalOrder;
@@ -103,6 +103,8 @@ class Server {
103
103
  productQueryScheduleTimers = new Map();
104
104
  /** 餐牌筛选为空时才触发的菜单/日程缓存自愈,避免每次商品查询都请求远端。 */
105
105
  menuScheduleRefreshInFlight = null;
106
+ /** 启动阶段模块预加载任务;预渲染商品查询复用该任务,避免缓存尚未 ready 时误触发远端自愈。 */
107
+ startupModulePreloadInFlight = null;
106
108
  lastMenuScheduleRefreshAt = 0;
107
109
  MENU_SCHEDULE_REFRESH_COOLDOWN_MS = 30 * 1000;
108
110
 
@@ -505,7 +507,15 @@ class Server {
505
507
 
506
508
  // 自动预加载数据
507
509
  if (autoPreload) {
508
- await this.preloadModulesData(registeredModules, options);
510
+ const preloadTask = this.preloadModulesData(registeredModules, options);
511
+ this.startupModulePreloadInFlight = preloadTask;
512
+ try {
513
+ await preloadTask;
514
+ } finally {
515
+ if (this.startupModulePreloadInFlight === preloadTask) {
516
+ this.startupModulePreloadInFlight = null;
517
+ }
518
+ }
509
519
  } else {
510
520
  this.logInfo('跳过自动预加载', {
511
521
  autoPreload
@@ -634,12 +644,16 @@ class Server {
634
644
  const numberedData = await this.ensureCheckoutOrderNumbers(normalizedData, title);
635
645
  const checkoutData = this.normalizeCheckoutCollections(this.applyCheckoutFulfillmentBuzzer(numberedData, title));
636
646
  const remoteCheckoutData = this.buildRemoteCheckoutData(checkoutData, title);
647
+ const deviceHeaders = await this.buildCheckoutDeviceHeaders(title);
637
648
  if (!this.app?.request?.post) {
638
649
  throw new Error('app.request 不可用');
639
650
  }
640
651
  const response = await this.app.request.post(backendPath, remoteCheckoutData, {
641
652
  isShopApi: true,
642
- customToast: () => {}
653
+ customToast: () => {},
654
+ ...(Object.keys(deviceHeaders).length > 0 ? {
655
+ headers: deviceHeaders
656
+ } : {})
643
657
  });
644
658
  if (this.isCheckoutResponseRejected(response)) {
645
659
  return {
@@ -705,6 +719,7 @@ class Server {
705
719
  const record = next;
706
720
  delete record[OS_FULFILLMENT_REF_MODE_FIELD];
707
721
  delete record[OS_INVOICE_LAYOUT_CONFIG_FIELD];
722
+ this.applyKioskCheckoutOperator(record);
708
723
  if (!Array.isArray(record.payments)) return next;
709
724
  const originalPayments = record.payments;
710
725
  const filteredPayments = originalPayments.filter(payment => !(0, _paymentUtils.isPendingOrderPayment)(payment));
@@ -719,6 +734,44 @@ class Server {
719
734
  });
720
735
  return next;
721
736
  }
737
+ async buildCheckoutDeviceHeaders(title) {
738
+ const getAsyncIotDeviceInfo = this.getAppData('async_iot_device_info');
739
+ if (!getAsyncIotDeviceInfo) return {};
740
+ let deviceInfo;
741
+ try {
742
+ deviceInfo = typeof getAsyncIotDeviceInfo === 'function' ? await getAsyncIotDeviceInfo() : getAsyncIotDeviceInfo;
743
+ } catch (error) {
744
+ this.logWarning(`${title}: 读取 async_iot_device_info 失败,跳过 checkout 设备请求头`, {
745
+ error: this.getUnknownErrorMessage(error)
746
+ });
747
+ return {};
748
+ }
749
+ if (!deviceInfo || typeof deviceInfo !== 'object') return {};
750
+ const headers = {};
751
+ const assignHeader = (key, value) => {
752
+ if (this.isBlankCheckoutValue(value)) return;
753
+ headers[key] = String(value);
754
+ };
755
+ assignHeader('Device-ID', deviceInfo.id);
756
+ assignHeader('Device-Type-Code', deviceInfo.type_code);
757
+ assignHeader('Device-Model-Code', deviceInfo.model_code);
758
+ return headers;
759
+ }
760
+ applyKioskCheckoutOperator(record) {
761
+ if (record.platform !== 'kiosk') return;
762
+ record.operator_type = 'customer';
763
+ record.operator_id = this.resolveKioskCheckoutOperatorId(record.customer_id);
764
+ }
765
+ resolveKioskCheckoutOperatorId(customerId) {
766
+ if (customerId === undefined || customerId === null) return 1;
767
+ if (typeof customerId === 'string') {
768
+ const trimmed = customerId.trim();
769
+ if (trimmed === '' || trimmed === '0' || trimmed === '1') return 1;
770
+ return customerId;
771
+ }
772
+ if (customerId === 0 || customerId === 1) return 1;
773
+ return customerId;
774
+ }
722
775
  mergeCheckoutSyncPayments(checkoutData, fresh, title) {
723
776
  const localPayments = Array.isArray(checkoutData?.payments) ? checkoutData.payments : [];
724
777
  const freshRecord = fresh;
@@ -820,11 +873,24 @@ class Server {
820
873
  return;
821
874
  }
822
875
  try {
823
- await this.schedule.loadAllSchedule();
824
876
  this.products.clearPriceCache();
825
877
  await this.products.setupQuotationPriceBridge({
826
878
  scheduleModule: this.schedule,
827
- channel: this.core.context?.channel
879
+ channel: this.core.context?.channel,
880
+ onQuotationUpdated: () => {
881
+ void this.recomputeAndNotifyProductQuery();
882
+ }
883
+ });
884
+
885
+ // Schedule 已在模块 preload 阶段加载完成。价格桥接先使用本地数据,
886
+ // 远端刷新转为后台校准,避免二次启动重复阻塞请求。
887
+ void this.schedule.loadAllSchedule().then(() => {
888
+ this.products?.clearPriceCache();
889
+ void this.recomputeAndNotifyProductQuery();
890
+ }).catch(error => {
891
+ this.logWarning('Products 报价单价格桥接后台刷新 Schedule 失败,继续使用本地数据', {
892
+ error: error instanceof Error ? error.message : String(error)
893
+ });
828
894
  });
829
895
  this.logInfo('Products 报价单价格桥接初始化完成');
830
896
  } catch (error) {
@@ -883,17 +949,23 @@ class Server {
883
949
  * 重新拉取全量 SSE 接口,更新本地数据后触发 onProductsSyncCompleted
884
950
  * 不影响当前界面展示,适用于切回前台、定时刷新等场景
885
951
  */
886
- async refreshProductsInBackground() {
952
+ async refreshProductsInBackground(options = {}) {
887
953
  if (!this.products) {
888
954
  this.logWarning('refreshProductsInBackground: Products 模块未注册');
889
955
  return;
890
956
  }
891
- this.logInfo('refreshProductsInBackground 开始');
957
+ const trigger = options.trigger || 'background';
958
+ this.logInfo('refreshProductsInBackground 开始', {
959
+ trigger
960
+ });
892
961
  const startTime = Date.now();
893
962
  try {
894
- await this.products.silentRefresh();
963
+ await this.products.silentRefresh({
964
+ trigger
965
+ });
895
966
  const duration = Date.now() - startTime;
896
967
  this.logInfo('refreshProductsInBackground 完成', {
968
+ trigger,
897
969
  duration: `${duration}ms`
898
970
  });
899
971
  } catch (error) {
@@ -901,6 +973,7 @@ class Server {
901
973
  const errorMessage = error instanceof Error ? error.message : String(error);
902
974
  console.error('[Server] refreshProductsInBackground 失败:', error);
903
975
  this.logError('refreshProductsInBackground 失败', {
976
+ trigger,
904
977
  duration: `${duration}ms`,
905
978
  error: errorMessage
906
979
  });
@@ -912,17 +985,23 @@ class Server {
912
985
  * 重新拉取全量 SSE 接口,更新本地数据后触发 onOrdersSyncCompleted
913
986
  * 不影响当前界面展示,适用于切回前台、定时刷新等场景
914
987
  */
915
- async refreshOrdersInBackground() {
988
+ async refreshOrdersInBackground(options = {}) {
916
989
  if (!this.order) {
917
990
  this.logWarning('refreshOrdersInBackground: Order 模块未注册');
918
991
  return;
919
992
  }
920
- this.logInfo('refreshOrdersInBackground 开始');
993
+ const trigger = options.trigger || 'background';
994
+ this.logInfo('refreshOrdersInBackground 开始', {
995
+ trigger
996
+ });
921
997
  const startTime = Date.now();
922
998
  try {
923
- await this.order.silentRefresh();
999
+ await this.order.silentRefresh({
1000
+ trigger
1001
+ });
924
1002
  const duration = Date.now() - startTime;
925
1003
  this.logInfo('refreshOrdersInBackground 完成', {
1004
+ trigger,
926
1005
  duration: `${duration}ms`
927
1006
  });
928
1007
  } catch (error) {
@@ -930,6 +1009,7 @@ class Server {
930
1009
  const errorMessage = error instanceof Error ? error.message : String(error);
931
1010
  console.error('[Server] refreshOrdersInBackground 失败:', error);
932
1011
  this.logError('refreshOrdersInBackground 失败', {
1012
+ trigger,
933
1013
  duration: `${duration}ms`,
934
1014
  error: errorMessage
935
1015
  });
@@ -1240,6 +1320,10 @@ class Server {
1240
1320
  method: 'post',
1241
1321
  path: '/shop/local/print-order',
1242
1322
  handler: this.handleLocalPrintOrder.bind(this)
1323
+ }, {
1324
+ method: 'post',
1325
+ path: '/shop/local/order-receipt-snapshot',
1326
+ handler: this.handleLocalOrderReceiptSnapshot.bind(this)
1243
1327
  }, {
1244
1328
  method: 'post',
1245
1329
  path: '/shop/local/getLocalPayment',
@@ -1466,7 +1550,7 @@ class Server {
1466
1550
  // timerIds.push(timerId);
1467
1551
  // }
1468
1552
  setInterval(() => {
1469
- const currentTime = (0, _dayjs.default)().format('hh:mm');
1553
+ const currentTime = (0, _dayjs.default)().format('HH:mm');
1470
1554
  if (timePoints.includes(currentTime)) {
1471
1555
  this.onScheduleTimePointTimerFire(subscriberId);
1472
1556
  }
@@ -3688,7 +3772,8 @@ class Server {
3688
3772
  order_id: printIdentity,
3689
3773
  small_ticket_data: params.syncedOrder?.payment_info?.small_ticket_data,
3690
3774
  skip_wristband: true,
3691
- skip_voucher: true
3775
+ skip_voucher: true,
3776
+ skip_discount: true
3692
3777
  } : {
3693
3778
  order_id: printIdentity,
3694
3779
  machine_code_print_info: params.syncedOrder?.payment_info?.machine_code_print_info,
@@ -3904,6 +3989,99 @@ class Server {
3904
3989
  };
3905
3990
  }
3906
3991
  };
3992
+ normalizeRequestedReceiptLocales(value) {
3993
+ if (!Array.isArray(value)) return [];
3994
+ return Array.from(new Set(value.filter(locale => typeof locale === 'string').map(locale => locale.trim()).filter(Boolean)));
3995
+ }
3996
+ handleLocalOrderReceiptSnapshot = async ({
3997
+ data
3998
+ }) => {
3999
+ const input = data && typeof data === 'object' && !Array.isArray(data) ? data : {};
4000
+ const lookup = this.getLocalPrintOrderLookup(input);
4001
+ if (lookup === undefined) {
4002
+ return {
4003
+ code: 400,
4004
+ status: false,
4005
+ message: '订单 lookup 缺失',
4006
+ data: {
4007
+ small_ticket_data: null
4008
+ }
4009
+ };
4010
+ }
4011
+ if (!this.order?.getLocalOrderByLookup) {
4012
+ return {
4013
+ code: 500,
4014
+ status: false,
4015
+ message: 'Order 模块未注册',
4016
+ data: {
4017
+ small_ticket_data: null
4018
+ }
4019
+ };
4020
+ }
4021
+ try {
4022
+ let sourceOrder = await this.order.getLocalOrderByLookup(lookup);
4023
+ if (!sourceOrder) {
4024
+ const remoteResult = await this.fetchAndPersistSalesOrderByLookup(lookup, {
4025
+ with: ['all']
4026
+ }, 'handleLocalOrderReceiptSnapshot');
4027
+ if (!remoteResult.order) {
4028
+ return {
4029
+ code: remoteResult.code,
4030
+ status: false,
4031
+ message: remoteResult.message,
4032
+ data: {
4033
+ small_ticket_data: null
4034
+ }
4035
+ };
4036
+ }
4037
+ sourceOrder = remoteResult.order;
4038
+ }
4039
+ const paymentInfo = {
4040
+ ...(sourceOrder.payment_info || {})
4041
+ };
4042
+ delete paymentInfo.small_ticket_data;
4043
+ const rebuiltOrder = await this.withLocalSmallTicketData({
4044
+ ...sourceOrder,
4045
+ small_ticket_data_flag: 1,
4046
+ payment_info: paymentInfo
4047
+ }, {
4048
+ locales: this.normalizeRequestedReceiptLocales(input.locales)
4049
+ });
4050
+ const smallTicketData = rebuiltOrder.payment_info?.small_ticket_data;
4051
+ if (!(0, _smallTicket.hasSmallTicketData)(smallTicketData)) {
4052
+ return {
4053
+ code: 500,
4054
+ status: false,
4055
+ message: '本地小票 Snapshot 生成失败',
4056
+ data: {
4057
+ small_ticket_data: null
4058
+ }
4059
+ };
4060
+ }
4061
+ return {
4062
+ code: 200,
4063
+ status: true,
4064
+ message: '',
4065
+ data: {
4066
+ small_ticket_data: smallTicketData
4067
+ }
4068
+ };
4069
+ } catch (error) {
4070
+ const message = error instanceof Error ? error.message : String(error);
4071
+ this.logError('handleLocalOrderReceiptSnapshot: 生成失败', {
4072
+ lookup,
4073
+ error: message
4074
+ });
4075
+ return {
4076
+ code: 500,
4077
+ status: false,
4078
+ message,
4079
+ data: {
4080
+ small_ticket_data: null
4081
+ }
4082
+ };
4083
+ }
4084
+ };
3907
4085
  handleMachinecodeBatchMeta = async ({
3908
4086
  data
3909
4087
  }) => {
@@ -4740,7 +4918,7 @@ class Server {
4740
4918
  const smallTicketData = (0, _smallTicket.buildSmallTicketData)({
4741
4919
  order: enrichedOrder,
4742
4920
  shopInfo: this.getSmallTicketShopInfo(),
4743
- locales: (0, _OpenData.resolveInvoiceReceiptLocales)(options?.invoiceTemplateSettings),
4921
+ locales: [...(0, _OpenData.resolveInvoiceReceiptLocales)(options?.invoiceTemplateSettings), ...(options?.locales || [])],
4744
4922
  productMap
4745
4923
  });
4746
4924
  return {
@@ -5638,12 +5816,6 @@ class Server {
5638
5816
  strategy_context
5639
5817
  } = context;
5640
5818
  const queryIds = Array.isArray(ids) ? ids.map(id => Number(id)).filter(id => Number.isFinite(id)) : [];
5641
- const formatterContext = this.buildProductFormatterContext({
5642
- schedule_date,
5643
- schedule_datetime,
5644
- customer_id,
5645
- strategy_context
5646
- });
5647
5819
  this.logInfo('computeProductQueryResult 开始', {
5648
5820
  menuListIdsCount: menu_list_ids?.length ?? 0,
5649
5821
  ids: queryIds,
@@ -5655,6 +5827,23 @@ class Server {
5655
5827
  strategyContextKeys: strategy_context ? Object.keys(strategy_context) : [],
5656
5828
  changedIds: options?.changedIds
5657
5829
  });
5830
+
5831
+ // 预渲染可能在 PisellOS 顺序 preload 尚未完成时发起商品查询。此时 Menu/Schedule
5832
+ // 暂时为空不代表缓存失效;等待当前 preload 后再按原逻辑查询,避免并发远端刷新先 clear
5833
+ // SQLite/IndexDB,造成 preload 读到空数据并重复请求。正常运行期该字段为 null,不增加等待。
5834
+ const startupPreloadTask = this.startupModulePreloadInFlight;
5835
+ if (startupPreloadTask) {
5836
+ await startupPreloadTask;
5837
+ }
5838
+
5839
+ // Menu/Schedule preload 会替换 store 中的数组;必须在等待完成后再读取,
5840
+ // 避免 formatterContext 持有预加载前的空数组快照。
5841
+ const formatterContext = this.buildProductFormatterContext({
5842
+ schedule_date,
5843
+ schedule_datetime,
5844
+ customer_id,
5845
+ strategy_context
5846
+ });
5658
5847
  if (!this.products) {
5659
5848
  this.logError('computeProductQueryResult: Products 模块未注册');
5660
5849
  return {
@@ -5867,6 +6056,8 @@ class Server {
5867
6056
  schedule_datetime: context.schedule_datetime,
5868
6057
  customer_id: context.customer_id,
5869
6058
  strategy_context: context.strategy_context,
6059
+ // 固定本次商品查询使用的语言,确保格式化与缓存使用同一份 locale 快照。
6060
+ locale: this.core.context?.locale,
5870
6061
  menuList: this.menu?.getMenuList?.() || [],
5871
6062
  scheduleList: scheduleModule?.getScheduleList?.() || [],
5872
6063
  dataVariantEvaluator: this.core.context?.dataVariantEvaluator
@@ -10,6 +10,7 @@ export declare class FloorPlanModule extends BaseModule implements Module {
10
10
  private store;
11
11
  private request;
12
12
  private logger;
13
+ private dbManager;
13
14
  private floorPlanDataSource;
14
15
  private pendingSyncMessages;
15
16
  private syncTimer?;
@@ -28,6 +29,9 @@ export declare class FloorPlanModule extends BaseModule implements Module {
28
29
  private mergeItems;
29
30
  private removeByIds;
30
31
  private loadListFromServer;
32
+ private loadListFromSQLite;
33
+ private saveListToSQLite;
34
+ private refreshListFromServer;
31
35
  fetchByIdFromServer(id: number | string): Promise<FloorPlanItem | null>;
32
36
  fetchByCodeFromServer(code: string): Promise<FloorPlanItem | null>;
33
37
  preload(): Promise<void>;
@@ -7,6 +7,7 @@ exports.FloorPlanModule = void 0;
7
7
  var _BaseModule = require("../../../modules/BaseModule");
8
8
  var _types = require("./types");
9
9
  const FLOOR_PLAN_SYNC_DEBOUNCE_MS = 3000;
10
+ const FLOOR_PLAN_STORE_NAME = 'floor_plans';
10
11
 
11
12
  /**
12
13
  * 平面图模块:预加载列表、pubsub 同步、供 OS Server 路由读本地 store
@@ -17,6 +18,7 @@ class FloorPlanModule extends _BaseModule.BaseModule {
17
18
  store;
18
19
  request;
19
20
  logger;
21
+ dbManager;
20
22
  floorPlanDataSource;
21
23
  pendingSyncMessages = [];
22
24
  syncTimer;
@@ -40,6 +42,7 @@ class FloorPlanModule extends _BaseModule.BaseModule {
40
42
  if (appPlugin) {
41
43
  const app = appPlugin.getApp();
42
44
  this.logger = app.logger;
45
+ this.dbManager = app.sqlite || app.dbManager;
43
46
  }
44
47
  this.initFloorPlanDataSource();
45
48
  this.setupFloorPlanSync();
@@ -147,6 +150,45 @@ class FloorPlanModule extends _BaseModule.BaseModule {
147
150
  return [];
148
151
  }
149
152
  }
153
+ async loadListFromSQLite() {
154
+ if (!this.dbManager) return [];
155
+ try {
156
+ const list = await this.dbManager.getAll(FLOOR_PLAN_STORE_NAME);
157
+ return Array.isArray(list) ? list : [];
158
+ } catch (error) {
159
+ this.logWarning('从 SQLite 读取平面图失败', {
160
+ error: error instanceof Error ? error.message : String(error)
161
+ });
162
+ return [];
163
+ }
164
+ }
165
+ async saveListToSQLite(list) {
166
+ if (!this.dbManager) return;
167
+ try {
168
+ await this.dbManager.clear(FLOOR_PLAN_STORE_NAME);
169
+ if (list.length > 0) {
170
+ await this.dbManager.bulkAdd(FLOOR_PLAN_STORE_NAME, list);
171
+ }
172
+ } catch (error) {
173
+ this.logWarning('保存平面图到 SQLite 失败', {
174
+ count: list.length,
175
+ error: error instanceof Error ? error.message : String(error)
176
+ });
177
+ }
178
+ }
179
+ async refreshListFromServer() {
180
+ const list = await this.loadListFromServer();
181
+ if (list.length === 0) {
182
+ this.logWarning('远端平面图刷新完成但列表为空');
183
+ return;
184
+ }
185
+ this.replaceStore(list);
186
+ await this.saveListToSQLite(list);
187
+ this.core.effects.emit(_types.FloorPlanHooks.onFloorPlansChanged, this.getSortedList());
188
+ this.logInfo('远端平面图刷新完成', {
189
+ count: list.length
190
+ });
191
+ }
150
192
  async fetchByIdFromServer(id) {
151
193
  try {
152
194
  const res = await this.request.get(`/schedule/floor-plan/${id}`, {}, {
@@ -174,16 +216,25 @@ class FloorPlanModule extends _BaseModule.BaseModule {
174
216
  }
175
217
  async preload() {
176
218
  this.logInfo('开始预加载平面图列表');
177
- const list = await this.loadListFromServer();
178
- if (list.length > 0) {
179
- this.replaceStore(list);
219
+ const cachedList = await this.loadListFromSQLite();
220
+ if (cachedList.length > 0) {
221
+ this.replaceStore(cachedList);
180
222
  this.core.effects.emit(_types.FloorPlanHooks.onFloorPlansChanged, this.getSortedList());
181
- this.logInfo('预加载完成', {
182
- count: list.length
223
+ this.logInfo('预加载完成(SQLite)', {
224
+ count: cachedList.length
183
225
  });
184
- } else {
185
- this.logWarning('预加载完成但列表为空');
226
+
227
+ // 二次启动立即使用本地平面图,远端刷新完成后自动更新 store 与订阅者。
228
+ void this.refreshListFromServer().catch(error => {
229
+ this.logWarning('后台刷新平面图失败,继续使用本地数据', {
230
+ error: error instanceof Error ? error.message : String(error)
231
+ });
232
+ });
233
+ return;
186
234
  }
235
+
236
+ // 首次启动没有缓存时仍等待一次远端请求,并为后续启动建立缓存。
237
+ await this.refreshListFromServer();
187
238
  }
188
239
  initFloorPlanDataSource() {
189
240
  const Ctor = this.core.serverOptions?.All_DATA_SOURCES?.FloorPlanDataSource;
@@ -282,6 +333,7 @@ class FloorPlanModule extends _BaseModule.BaseModule {
282
333
  }
283
334
  }
284
335
  if (changed) {
336
+ await this.saveListToSQLite(this.store.list);
285
337
  this.core.effects.emit(_types.FloorPlanHooks.onFloorPlansChanged, this.getSortedList());
286
338
  this.core.effects.emit(_types.FloorPlanHooks.onFloorPlanSyncCompleted, null);
287
339
  }
@@ -290,6 +342,7 @@ class FloorPlanModule extends _BaseModule.BaseModule {
290
342
  this.store.list = [];
291
343
  this.store.map.clear();
292
344
  this.store.codeMap.clear();
345
+ await this.saveListToSQLite([]);
293
346
  }
294
347
  destroy() {
295
348
  if (this.syncTimer) {
@@ -190,17 +190,20 @@ class MenuModule extends _BaseModule.BaseModule {
190
190
  this.logWarning('loadMenuFromIndexDB: dbManager 不可用');
191
191
  return [];
192
192
  }
193
+ const startedAt = performance.now();
193
194
  try {
194
195
  // 直接获取所有餐牌记录
195
196
  const menuList = await this.dbManager.getAll(INDEXDB_STORE_NAME);
196
197
  this.logInfo('从 IndexDB 加载餐牌数据', {
197
- menuCount: menuList?.length ?? 0
198
+ menuCount: menuList?.length ?? 0,
199
+ durationMs: +(performance.now() - startedAt).toFixed(2)
198
200
  });
199
201
  return menuList || [];
200
202
  } catch (error) {
201
203
  const errorMessage = error instanceof Error ? error.message : String(error);
202
204
  console.error('[Menu] 从 IndexDB 读取数据失败:', error);
203
205
  this.logError('从 IndexDB 读取数据失败', {
206
+ durationMs: +(performance.now() - startedAt).toFixed(2),
204
207
  error: errorMessage
205
208
  });
206
209
  return [];
@@ -216,24 +219,43 @@ class MenuModule extends _BaseModule.BaseModule {
216
219
  this.logWarning('saveMenuToIndexDB: dbManager 不可用');
217
220
  return;
218
221
  }
222
+ const startedAt = performance.now();
223
+ let clearDurationMs = 0;
224
+ let writeDurationMs = 0;
219
225
  this.logInfo('开始保存餐牌数据到 IndexDB', {
220
226
  menuCount: menuList.length
221
227
  });
222
228
  try {
223
229
  // 先清空现有的餐牌数据
224
- await this.dbManager.clear(INDEXDB_STORE_NAME);
230
+ const clearStartedAt = performance.now();
231
+ try {
232
+ await this.dbManager.clear(INDEXDB_STORE_NAME);
233
+ } finally {
234
+ clearDurationMs = +(performance.now() - clearStartedAt).toFixed(2);
235
+ }
225
236
 
226
237
  // 逐个保存餐牌(每个餐牌是独立的记录)
238
+ const writeStartedAt = performance.now();
227
239
  const savePromises = menuList.map(menu => this.dbManager.add(INDEXDB_STORE_NAME, menu));
228
- await Promise.all(savePromises);
240
+ try {
241
+ await Promise.all(savePromises);
242
+ } finally {
243
+ writeDurationMs = +(performance.now() - writeStartedAt).toFixed(2);
244
+ }
229
245
  this.logInfo('餐牌数据已保存到 IndexDB', {
230
- menuCount: menuList.length
246
+ menuCount: menuList.length,
247
+ clearDurationMs,
248
+ writeDurationMs,
249
+ totalDurationMs: +(performance.now() - startedAt).toFixed(2)
231
250
  });
232
251
  } catch (error) {
233
252
  const errorMessage = error instanceof Error ? error.message : String(error);
234
253
  console.error('[Menu] 保存数据到 IndexDB 失败:', error);
235
254
  this.logError('保存数据到 IndexDB 失败', {
236
255
  menuCount: menuList.length,
256
+ clearDurationMs,
257
+ writeDurationMs,
258
+ totalDurationMs: +(performance.now() - startedAt).toFixed(2),
237
259
  error: errorMessage
238
260
  });
239
261
  }