# SkuDetailModal 商品选择弹窗

`SkuDetailModal` 负责加入购物车前的商品选择，包含商品详情、SKU、数量、备注、称重和价格处理。create 模式可通过 `skuDetailConfig.steps` 传固定步骤，也可通过 `skuDetailConfig.resolveSteps` 按当前可用商品详情返回商品级步骤；步骤可以组合 SKU、日期、时间、资源和 Holder 模块。最终回调仍是原有 `onConfirm(ProductData)`，组件不负责购物车写入或订单转换。

## 基本用法

```tsx
import React, { useRef } from 'react';
import {
  ProSkuDetailModal,
  type ProductData,
  type SkuDetailModalRef,
} from '@pisell/private-materials';

const Example = ({ product }: { product: ProductData }) => {
  const modalRef = useRef<SkuDetailModalRef>(null);

  return (
    <>
      <button onClick={() => modalRef.current?.open(product)}>选择商品</button>
      <ProSkuDetailModal
        ref={modalRef}
        onConfirm={(data) => console.log('确认结果', data)}
        onRemove={(data) => console.log('移除商品', data)}
        onClose={() => console.log('用户关闭弹窗')}
      />
    </>
  );
};
```

`ref.close()` 会静默关闭，不补发 `onClose`；`onRemove` 接收完整 `ProductData`，不是商品 ID。

## 自定义选择步骤（一期）

自定义步骤通过 create 模式的 `open` 参数传入。推荐把 Date 放在 SKU 前面，因为日期水合可能更新可选 SKU；调用方也可以把多个模块合并到同一步。

```tsx
import type { SelectionFlowStepConfig } from '@pisell/private-materials';

const steps: readonly SelectionFlowStepConfig[] = [
  { key: 'date', title: 'Date', modules: ['date'] },
  { key: 'sku', title: 'SKU', modules: ['sku'] },
  { key: 'booking', title: 'Booking', modules: ['time', 'resource'] },
  { key: 'holder', title: 'Holder', modules: ['holder'] },
];

modalRef.current?.open({
  id: product.id,
  date: '2026-08-04',
  productData: product,
  skuDetailConfig: { steps },
});
```

可用模块类型为 `sku | weighing | date | multiDayDate | time | bookingPrice | resource | holder | price`。步骤顺序不会被组件自动重排，但必须满足依赖：`time` 进入前需要 `startDate`，`resource` 进入前需要 `skuValue`、日期、开始时间和时长。若 Flow 内配置了这些字段的 provider，它必须排在依赖模块之前；只有 Flow 内没有 provider 的外部根字段才可由打开时的初始值满足。配置为空数组、步骤为空、类型未知、类型重复、依赖缺失或依赖位于后方时，编译器会显示配置错误并阻止确认。

包入口同时导出 `customerSteps`、`posSteps` 和 `selectionFlowPresets`；业务方可以直接复用，也可以按相同协议传自己的步骤数组。

商品级配置使用 `resolveSteps({ productData })`：返回步骤数组表示启用本次 Flow，返回 `null` 表示明确回到旧流程，返回 `[]` 会作为配置错误交给编译器阻断。打开时优先复用现有 `productData`；只有本地数据不满足原详情复用条件、显式设置 `isCallProductDetail`，或日期变化主动清空本地数据时才请求远程详情。Resolver 在本次可用商品数据准备完成后、每次 `open()` 只执行一次；日期变化只刷新模块数据，不会中途改变步骤顺序。配置优先级为“商品 resolver > 商品固定 steps > 宿主默认 resolver > 宿主默认 steps”。

Host 的预约判定以当前完成准备的商品数据为依据。列表精简数据若暂时缺少 `duration/schedules/schedule.ids`，不会让预约模块在不完整 BookingContext 中继续运行：组件会在发现 `time/resource/holder` 需要完整上下文时交还 legacy Host。普通商品若包含 SKU、Option、Bundle 或称重能力，则使用普通商品 Flow；没有任何可选择内容时 Resolver 返回 `null` 并继续直接加购。

### Kiosk 与 POS 接入

`KioskSale` 与 POS 模板都通过 `selectionFlowScene="product-selection"` 使用同一个 `resolveUnifiedProductSelectionFlowSteps`：`isBookingProduct` 命中的单日预约统一进入 Availability 时间片；未命中但仍携带旧预约协议的服务进入 Start time、Duration、Guest、Resource 手动流程，Price 仅在 POS Host 明确授权时展示；普通 SKU、Option、Bundle、称重商品只编译实际存在的模块；无选择内容的普通商品继续直接加购。跨日预约使用独立日期区间 Flow，其 Price 同样只对 POS 开放。调用方若已传入 `normalProductDetailConfig.steps` 或 `normalProductDetailConfig.resolveSteps`，则保留调用方配置，不会被默认 Resolver 覆盖。旧的 `appointment-booking`、`resolveBookingSelectionFlowSteps` 与 `enableBookingSelectionFlowForCart` 暂时作为兼容别名保留。Kiosk 与 POS 共享业务分流和步骤，弹窗尺寸、列数等展示配置仍由各自模板决定。

POS 模板负责提供现有销售运行时及其生命周期；POS 的 `BookingPos` / `SaleFood` 入口不创建、不注册，也不重新配置 OS 实例，只向 BigSale 传入 `selectionFlowScene="product-selection"`。预约购物车行设置 `mergeBookingLinesForDisplay={false}`，保证每一条预约都能独立编辑。

Kiosk 使用 `variant: 'phone'` 的移动 Drawer，并开启 Procedure 当前步骤展示。移动端顶部负责显示当前步骤、Back 和 Close；底部继续使用 SkuDetailModal 原有的 Cancel / Next / Confirm，不额外渲染 Selection Flow 自带导航。原按钮通过 `selectionFlowRef.next()` 执行当前步骤校验、前进和最终提交，顶部 Back 通过 `selectionFlowRef.back()` 返回上一步。

同一个弹窗只保留原 SkuDetailModal 主导航；Selection Flow 的 `showNavigation` 固定为 `false`，数量、价格、备注、手动改价和原操作按钮保持原有布局。

Date & Time 步骤不再显示模块内部的“选择日期 / 选择时间段”标题。时间段选择器使用 `displayMode: 'plain'`，移除外层卡片、边框和折叠按钮，所有时间段始终展开；该模式仅由 Selection Flow 启用，原 Event Booking 页面保持旧样式。

Selection Flow 的日期可用性和时间段加载不再单独渲染 Ant Design `Spin`，与商品详情初始化共用 `SkuDetailLoadingPanel` 骨架屏；错误态仍由各模块显式展示。

数量变化会触发 Resource 模块重新校验可用性。UBS 资源刷新会按 `requirementGroupId` 从当前 `booking.requirementSelections` 恢复已选资源，并与新返回的 options 取交集；因此数量加减不会无条件清空资源，只有原资源在新结果中已不可用时才会被移除。

Kiosk Drawer 内的周日历不依赖 viewport 断点：左右箭头保持固定宽度，7 个日期卡片以 `8px` 间距平分中间剩余宽度。这避免 Kiosk 设备视口大于 `959px` 时，日期区仍按内容宽度收缩在左侧；原 Event Booking 页面不受该 Drawer 作用域影响。

移动端 Drawer 统一按内容自适应高度，并以 `95dvh` 为最大高度；内容超过上限时，仅上方 `.sku-detail-right-content` 使用剩余高度并纵向滚动，数量、价格汇总和原有操作区保持固定在底部。Selection Flow、普通商品和 SKU 商品共用该高度规则，SKU 选择器的滚动父节点继续指向右侧内容区。统一的是 Drawer 高度边界，不把整个 Drawer body 变成滚动容器。

测试时需要按商品能力区分预期：

- 普通 SKU、组合规格或套餐不属于 Kiosk 一期预约 Flow，继续使用原有商品详情与加购链。
- 支持一期预约能力且带 SKU 的商品依次展示 `SKU → Date & Time → Resource → Holder`；无 SKU 商品会从 `Date & Time` 开始，未启用的 Resource、Holder 步骤继续按适用性过滤。
- Date & Time 的时间段在手机宽度（不超过 `744px`）下每行展示 1 个；平板和桌面端仍保持每行 4 个。
- 无 SKU 且不适用预约模块的商品会回退原有直接确认或旧详情行为。
- `session_product`、`session_ticket` 与普通单日预约的新建、购物车单行编辑都使用同一 Selection Flow；跨日预约继续走日期区间旧链路。
- 首次决策直接命中 `requiresBookingEdit` 的预约商品，只在打开前已能确定未配置 Flow、商品类型不受支持、缺少 Booking Host 或无有效商品 ID 时走旧预约抽屉。新 Selection Flow 一旦打开，不再因步骤解析结果或 120 秒超时切换到 Edit Service；当前 Flow 负责展示错误，由用户继续处理或关闭。
- 带 SKU 的预约商品由同一个 Selection Flow 先完成 SKU，再完成日期、时间、资源与 Holder；成功提交后外层不会重复调用旧预约确认链。
- `requiresDetail` 会把本次初始数量和 Holder 预选值送入 Flow，并为预约模块准备 locale、资源池、service 与 Holder 字典；确认时会补齐旧 `_extend.holder` 协议。

一期边界：

- `steps` / `resolveSteps` 只在 create 模式生效；edit/remove 保持旧流程。

- Date 只支持单日模式；跨日商品、`session_product` 和 `session_ticket` 不由一期预约模块接管，误配时会显式阻断。
- Resource 需要完整 `BookingContext.state`，并复用现有资源请求与容量逻辑。
- Holder 还要求组件位于 `SalesSdkProvider` 的 Holder Host 子树内；客户或数量变化会清理并要求重新确认 Holder。Host bridge 异常会保持为独立阻断状态，只有依赖变化后真实恢复或用户重试成功才解除。
- 不传 `steps` 和 `resolveSteps` 时，直接确认、旧 SKU、称重、价格和库存处理保持原行为。
- 同一次打开期间把 `steps` 视为只读配置；需要改变 key、type 或顺序时应关闭后重新 `open`，一期不支持运行中热替换模块身份。

### Availability UI 复用边界

`EventBookingAvailabilityCalendar` 新增可选 `availabilityDates`，直接展示 UnifiedBookingSales `target: 'date'` 的日期状态；不传时继续使用原事件预约数据。`EventTimeResourceSelector` 新增受控 `value` 和 `onSelectionChange`，时间项携带完整 `candidate`，旧的非受控与低代码调用保持不变。

Kiosk Flow 的时间段列表通过该组件新增的可选 `columns` 参数固定为每行 4 个；其他调用方不传时仍保持移动端 2 个、桌面端 4 个。时间卡片选中态继续使用紫底白字，并显式覆盖基础卡片背景，避免出现背景被覆盖后的白底白字。

传入 `availabilityDates` 时，日历会关闭旧 EventBooking 基于 `cutTime` 推导的 Call-to-book 状态与提示，只展示 UBS 返回能够表达的可用、有限、已满和不可用状态；状态图例在组件边界初始化 EventBooking 语言包，并在渲染时按当前 locale 取文案。周历的七个日期格由 UI 围绕当前日期排布，每格状态来自 `queryBookingAvailability(target: 'date')` 返回的 `group.dates`。

周视图始终完整展示 7 天，不再沿用旧横向列表根据当前星期写入 `scrollLeft` 的定位方式。Selection Flow 在 Kiosk 手机/平板宽度（小于 `960px`）下，两侧换周箭头固定占位，7 个日期卡平分剩余宽度并允许收缩，避免左箭头或日期卡被 `overflow` 裁切；其他 EventBooking 调用和桌面宽度保持原有间距。

Kiosk 日期行在该宽度下将日期卡横向间距收敛为 `2px`，箭头与日期列表之间不追加 gap，使左右空间更紧凑；日期卡的纵向尺寸和状态展示保持不变。

Resource Module 使用 AppointmentBooking 资源页同款 `PisellCustomCheckboxGroup + ResourceDisplay(mode="rule")` 卡片展示，但不复用绑定旧 AppointmentBooking Context 和购物车副作用的整页 Resource 组件。卡片数据严格来自 `queryBookingAvailability(target: 'resource')` 的 `requirementGroups[].options[]`，选中结果写入 `requirementSelections`；无 SKU 的预约商品不再被 Resource Module 的静态 `skuValue` 依赖阻断。

BigSale 默认运行在 UnifiedBookingSales 上：传入 `osModuleName` 时复用宿主已经注册的实例；未传时由 BigSale 按自身 `osKey + mode + businessCode` 创建并注册实例。预约商品打开时，`NormalProductDetailHostRenderer` 直接调用 `prepareAvailabilityProjection`，再把 `contextId`、`getProductTimeRanges` 和 `queryBookingAvailability` 注入 Flow；关闭、取消、确认或初始化失败时调用 `releaseAvailabilityContext`。这里不创建第二个 UnifiedBookingSales，不保存 `AvailabilityProjection`，也不为缺少方法的运行时增加 BookingTicket 回退。

预约商品点击后，Host 不再等待 `queryProductDetail + prepareAvailabilityProjection` 或 UBS Holder 字典刷新全部完成才调用 `SkuDetailModal.open()`。Host 会立即注入一个延迟就绪的 Availability service，并先使用已有 Holder 缓存，弹窗先展示原有 loading 骨架；完整 Holder 字典在弹窗内的商品水合阶段刷新，Date/Time/Resource 首次查询时等待同一个 context Promise，并自动替换为真实 `contextId`。用户在初始化期间关闭时，晚返回的 context 会立即释放；初始化失败由对应 Flow 模块显示错误，不让页面保持“点击后无反应”。

Kiosk 背景商品列表和预约 Availability 共用该实例的商品 catalog。准备单个预约商品时，Host 先通过只读 `queryProductDetail` 补齐 `product_resource`、`capacity` 等详情，再把“当前完整 catalog + 补齐后的当前商品”作为 `rawProducts` 传给 `prepareAvailabilityProjection`。投影内部仍按 `productIds` 只计算当前商品，但不会把单商品查询结果发布或保存成新的完整目录，避免背景商品列表只剩当前商品。

当前一期按接口指南的单人 Case 固定使用 `partySize: 1`。商品购买数量与预约人数是否等价尚无数据契约来源，因此没有擅自用 `quantity` 替代；若后续支持多人预约，需要由调用方明确传入 party size，再让 Date/Time/Resource/commit 四处共用同一个值。

数据链固定为：Date 模块查询 `target: 'date'`；Time 模块用选中日期调用 `getProductTimeRanges`，再将全部 candidates 查询 `target: 'time'`；Resource 模块把用户选中的完整 candidate 查询 `target: 'resource'`。Flow 草稿保存完整 `selectedCandidate`、资源查询返回的 `commitCandidate` 与 `requirementSelections`，禁止用日期和时间字符串重建 context/version。确认输出通过 `_extend.availabilitySelection` 交给 Host；Flow Module 本身不写购物车，只有 Host 最终确认时调用一次 `commitAvailabilitySelection`。无 SKU 预约的 `booking-create` 与先选 SKU 的 `create` 都会在成功后直接返回 UBS 当前 cart，外层不会再调用旧 `confirmBookingEdit/confirmDetail`，避免重复加车。

## 类型与模块注册

公开配置类型从 `packages/private-materials/src/pro/skuDetailModal/types.ts` 导出；模块类型由静态注册表 `flow/registry.ts` 的 key 自动推导。新增类型时：

1. 在 `flow/modules/` 新建适配器，声明组件、`provides`、`hardRequires`、`refreshOn`、初始化、依赖清理与校验。
2. 在 `flow/registry.ts` 增加唯一 key；`SelectionModuleType` 会自动包含该类型。
3. 补充 Draft 字段及最终 `ProductData` 映射（如需要），并验证依赖变化后的 stale、重试和返回步骤行为。
4. 更新本文与飞书技术方案的注册表、数据契约和兼容矩阵。

一期不提供运行时 `register()` API，避免不同调用方在运行期间得到不一致的模块集合。

## 输出契约

Flow 内只维护一份选择草稿。确认时只由本次实际参与编译的模块覆盖其负责字段，同时保留输入 `ProductData` 的其他既有字段，再进入 `SkuDetailModal` 原有价格、称重、库存和 `onConfirm` 管线：

- SKU 继续以旧链路的 `_extend.other` 作为持久化主来源，并同步写入
  `_extend.skuValue`、`_extend._skuValue` 兼容镜像；非 SKU 的 `other`
  元数据会保留。OS 订单行的 `bundle_id/bundle_group_id` 只允许在 Host
  边界转换为 Selector 使用的 `id/group_id`，组件内部不感知 OS 协议。
- 编辑回填时，`SKUOptionsSelection` 必须先完成当前商品数据源和值的转换，
  再挂载 `SelectorGroup`；卸载只释放内部缓存，不调用会触发 `onChange` 的
  `clear()`。否则 Flow 的首次挂载或步骤重挂载会把已有 SKU 误写成空选择。
- 日期在组件边界保留 Dayjs，并同步 `startDate/endDate` 与 `start_date/end_date`。
- 时间、资源、容量和 Holder 分别写入既有 `_extend` 字段。
- UnifiedBookingSales 预约选择额外写入 `_extend.availabilitySelection`，其中 candidate 和 requirementSelections 均来自 UBS 原始返回；该字段只是组件到 Host 的交接数据，Host 的 `commitAvailabilitySelection` 成功后才表示已经写入 tempOrder。
- 购物车编辑使用 `prepareAvailabilityProjection({ editingProductLineUids })` 排除当前行占用，并通过 `commitAvailabilitySelection({ mode: 'edit', productLineUid })` 原位更新；不得把编辑结果当成新商品再次 add。
- 无资源需求的预约会跳过 Resource Module，但 Time Module 仍执行一次 `target: 'resource'` 获取最终 commit candidate，并以空 `requirementSelections` 提交。
- 当前 UBS edit 一次只接受一个真实 `productLineUid`。合并展示行若包含多个真实预约实例会阻止直接编辑，必须先拆分或选择具体预约实例，避免出现部分成功。
- Resource 模块确认时会把最新 `resourcesOrigin/resources/resourceMaps/capacitys` 合并回 `_data`，避免依赖刷新后提交旧资源缓存。
- 历史 `type=product` 折扣不参与弹窗内第二次扣减；用户未改价时恢复原折扣列表，用户本次改价时才移除旧 product 项并写入新的 override。
- Holder 的商品级 `holder_config` 与选项字典以本次完成准备的商品数据为真源；需要远程详情时，Host 会在 Flow 编译前刷新 BookingContext，确认输出也优先使用水合结果，避免列表精简数据回退到错误表单。
- 多数量 Holder 在新建链保留 `quantity + holder_id[] + holder.form_record[]`，不生成当前单次加车链无法消费的拆分残留。
- `customerId`、`moduleState`、请求缓存和模块临时数据只用于组件内部，不进入结果。
- 称重数据只有在当前商品或当前 SKU 仍为称重商品时才会序列化。

## API

### Props

| 参数 | 说明 | 类型 |
| --- | --- | --- |
| `onConfirm` | 最终确认回调；返回 `false` 或 `Promise<false>` 时保留弹窗 | `(data: ProductData) => void \| boolean \| Promise<void \| boolean>` |
| `onRemove` | edit 模式移除回调 | `(data: ProductData) => void` |
| `onClose` | 用户取消或关闭回调 | `() => void` |

### Ref

| 方法 | 说明 | 参数 |
| --- | --- | --- |
| `open` | 打开 create 或 edit 弹窗 | `ProductData | SkuDetailModalOpenParams` |
| `close` | 静默关闭并终止未完成的详情加载 | 无 |

真实字段以 `types.ts` 为准，不在 README 复制一份容易过期的完整 `ProductData` 声明。
