# Sales SDK（plus/salesSdk）

> Headless 业务数据层 SDK：通过 React Provider + Hooks 暴露 OS `BookingTicket` 数据流。
> **不依赖任何 UI 组件**；UI 层（无论是 ticketBooking、新建态空壳，还是未来的销售单组件）按需消费 hook 即可。
> 物理位置：`packages/private-materials/src/plus/salesSdk/`，与既有 `components/Sales` UI 模块完全独立。
> 命名前缀：`SalesSdk*` / `useSalesSdk*`，避免与 `components/Sales` 同名混淆。

---

## 1. 定位与设计原则

| 维度 | 选择 |
|---|---|
| 数据源 | OS `BookingTicket`（pisellos）实例，复用 ticketBooking 已注册的 osKey 实例 |
| 状态管理 | React Context + Hooks（不引入 miniRedux） |
| 订阅机制 | `BookingTicket.effectsOn(event, cb)` + 内部 `setState` 镜像；通用 `useSyncOsModule` 适配 |
| 视图模型 | UI 视图（`SalesSdkCartItemView` / `SalesSdkCustomerView`）通过 enrichment 多源合并，**仅活在 React 内存**，不写入 OS tempOrder 提交契约 |
| Action 引用 | 每个 action 通过 `useMemoizedFn` 包装，引用稳定（详见 §4.5） |
| 编辑/新建一体 | 同一套 Provider，新建态 `tempOrder` 由 OS 维护，编辑态额外注入 `salesDetail`；UI 不需要分两个组件 |
| 入口 | `<SalesSdkProvider osKey="..." orderId={...}>...</SalesSdkProvider>`（单根） |

---

## 2. 顶层结构

```
SalesSdkProvider                    (顶层：管理 BookingTicket 生命周期 + bootstrap)
├── SalesSdkProductProvider         (商品列表：onProductsLoaded)
│   └── SalesSdkSummaryProvider     (汇总：getSummary + onSalesOrderLoaded)
│       └── SalesSdkCartProvider    (购物车：onSalesOrderLoaded + items enrich)
│           └── SalesSdkCustomerProvider(客户：onCustomerSelected / ListUpdate / PaginationChange)
│               └── children        (业务 UI 自由消费各 hook)
```

每个 Provider 内部仅订阅自己关心的 OS 事件；其它兄弟 Provider 各自独立维护状态片段，互不影响。

---

## 3. Public API

### 3.1 Provider

```ts
import { SalesSdkProvider } from '@pisell/private-materials/plus/salesSdk';

<SalesSdkProvider
  osKey="my-page"          // 与 ticketBooking 共享同 osKey 时自动复用其 BookingTicket
  orderId={123456}         // 编辑态：挂载后自动 loadSalesDetail；新建态留空
  autoBootstrap            // 默认 true；不传 orderId 时不会自动 createNew
  businessCode="..."       // 可选：透传给 OS 注册时的 otherParams
  otherParams={{...}}      // 可选：platform / type / channel / dineInConfig 等
  rulesHooks={{...}}       // 可选：业务自定义 OrderModule rules
  uiHosts={{               // 可选：规格弹窗 + 资源抽屉 Host，配置后可用 cart.addProductWithFlow
    openProductDetail: createLegacyOpenProductDetailHost({ action, getRuntime }),
    openBookingEdit: createOpenBookingEditHost(),
  }}
>
  {children}
</SalesSdkProvider>
```

> 加车 / 资源编辑接入详见 **§4.6**。

### 3.2 Hooks（每个都返回 data + actions 扁平结构）

| Hook | 主要数据 | 主要 actions |
|---|---|---|
| `useSalesSdk()` | `bookingTicket`, `osKey`, `status` (`idle`/`loading`/`ready`/`error`), `error`, `isReady`, **`tempOrder`**, **`salesDetail`** | `loadDetail(orderId)`, `loadDetailByRemote(orderId)`, `refreshSalesDetail()`, `createNew()`, `restore()`, `destroy()`, `setBookingStatus(status)`, `setOrderNote(note)`, `setShopDiscount(amount)`, `setContactsInfo(info)`, `openCheckout()` |
| `useSalesSdkProducts()` | `products`, `loading`, `error` | `load(params)`, `loadDetail({product_id})`, `findByIds(ids)`, `scanGlobalListener(cb)`, `scanCustomerListener(cb)`, `scanUniversal(cb, key)`, `activateCamera`, `enableScan`, `disableScan` |
| `useSalesSdkCart()` | `discountList`, **`items` (enriched view)** | `addProduct`, **`addProductWithFlow`**, `confirmDetail`, `confirmBookingEdit`, **`editCartLineBooking`**, `removeItem`, `updateItem`, `setLineNote`, `setDiscount`, `scanCode`, `submit`, `recalcSummary` |
| `useSalesSdkUIHosts()` | - | 读取 Provider 注入的 `openProductDetail` / `openBookingEdit` Host（调试或 UI 条件渲染） |
| `useSalesSdkSummary()` | `summary`（源自 `bookingTicket.getSummary()`） | - |
| `useSalesSdkCustomer()` | `selected`, `list`, `pagination`, `hasMore`, `loading`, `loadingMore`, `searchParams`, **`display` (enriched fallback view)** | `select`, `loadList`, `search`, `loadMore`, `changePage`, `addToFirst`, `clear`, `loadById` |

### 3.3 Pure utilities（可在 Provider 外使用）

```ts
import {
  enrichCartItem,
  enrichCartItems,
  enrichCustomer,
  pickIdentity,
  deriveDisabledEdit,
  deriveSummary,
  isWalkInCustomer,
  useSyncOsModule, // 通用基础 hook（自定义订阅时可直接使用）
  runAddProductFlow, // 非 React 环境 / 单测编排加车链路
  createOpenBookingEditHost,
  createLegacyOpenProductDetailHost,
} from '@pisell/private-materials/plus/salesSdk';
```

---

## 4. 关键契约

### 4.1 enrichCartItem 数据来源（多源合并）

```
SalesSdkCartItemView ⟵ ScanOrderOrderProduct (line)              [OS tempOrder.products，提交契约]
                    ⊕ ISalesBooking by line.metadata.unique_identification_number
                                              .product_uid match  [编辑态：bookings*.detail / bookings*.product]
                    ⊕ ProductData where String(p.id) == String(line.product_id)
                                                                  [新建/编辑态商品列表兜底]
```

| 字段 | 来源优先级 |
|---|---|
| `title` / `cover` / `variant_title` | bookings*.detail → productsCatalog → '' |
| `bundle_titles` / `option_titles` | bookings*.detail → productsCatalog → '' |
| `duration` / `product_resource` | bookings*.product → productsCatalog → undefined |
| `start_at` / `end_at` | bookings 主资源 `start_date+start_time` / `end_date+end_time`（编辑态） |
| `resource_id` | bookings 主资源 `relation_id`（编辑态） |
| `note` | bookings*.detail.note → line.note → '' |
| `_identity` | `pickIdentity(line)`，给 OS removeProductFromOrder/updateOrderProduct 用 |

> **关键约束**（遵循 `.cursor/rules/data-field-semantics.mdc`）：bookings 反查严格通过 `product_uid` 与 `line.metadata.unique_identification_number` 匹配，不发明 `booking_id ↔ product` 等价关系；catalog 通过 `product_id` 字符串比对避免 number/string 误判。

### 4.2 enrichCustomer 数据来源

```
SalesSdkCustomerView ⟵ selected (CustomerModule)            优先：选中的完整客户对象
                     ⊕ tempOrder.customer_*                  fallback：编辑态服务端 hydrate 后写入 tempOrder 顶层
                     → null                                   都缺失
```

`selected.id` 为 `null` / `undefined` / `''` / `0` 时视作未选中，进入 fallback；`isWalkIn` 由 `isWalkInCustomer({ id })` 判定（`'' / '0' / '1' / 0 / 1` → true）。

### 4.3 selectors 派生

| 函数 | 输入 | 输出 |
|---|---|---|
| `deriveDisabledEdit(rootBooking, header)` | `appointment_status` + `payment_status`（`partially_paid` 归一为 `unpaid`） + `metadata.is_other_entrance_edited` | `{ disabledEdit, channelDisabledEdit, appointmentStatus, paymentStatus }` |
| `deriveSummary(summary)` | `ScanOrderSummary` | 12 个金额字段（缺失时 `'0.00'`） |
| `isWalkInCustomer(customer)` | `{ id }` | boolean |

### 4.4 BookingTicket 实例复用

`SalesSdkProvider` 通过 `pisellos.getModule(getBookingTicketKey(osKey))` 命中 ticketBooking 已注册的同 osKey 实例：

- 命中 → 直接复用（不再 `new BookingTicket`）
- 未命中 → `new BookingTicket(...)` + `pisellos.registerModule(...)`

这意味着 ticketBooking 与 Sales SDK 在同 osKey 下共享 `tempOrder` / `currentSalesDetail` / customer 等核心状态，可以渐进式迁移：旧 ticketBooking 与新 Sales SDK 视图共存、消费同源数据。

### 4.5 Action 引用稳定性纪律（强制）

每个 action 必须 **单独** 通过 `useMemoizedFn` 包装：

```tsx
const addProduct = useMemoizedFn(async (input) => {
  if (!bookingTicket) throw new Error('...');
  const result = await bookingTicket.addProductToOrder(input);
  setSnapshot(readSnapshot(bookingTicket));
  return result;
});
```

**约束**：

1. action 内部不要在 React 端再做一份 data 快照（每次执行从 OS getter 拿权威值）。
2. action **不要** 进 `useMemo(value, [...])` 的依赖数组（虽然进了也不会触发重渲，但代码噪音）。
3. 多 action 共用的派生数据交给 `selectors.ts`，不要在 action 内重复实现。

这样，业务侧在 `useEffect`/`useCallback` 中可以放心引用 action 而不担心 stale closure 或不必要的重渲。

---

## 4.6 cart.addProduct 两阶段决策（Phase 2）

与 ticketBooking `handleSelectProduct` 对齐，OS `decideAddProduct` 拆成两段：

1. **`requiresDetail`**：规格 / SKU / 套餐 / Session / 称重（门禁 `isOpenDetailModal` / `cartDetailValue` / `open_sold_weight`）
2. **`requiresBookingEdit`**：资源 / 时间编辑抽屉（门禁 `getIsAutoClose` on prepared `cacheItem`）
3. **`added`**：两关均通过

```ts
const result = await cart.addProduct(product, { quantity: 1 });

if (result.status === 'requiresDetail') {
  openDetailModal(result.payload);
}

if (result.status === 'requiresBookingEdit') {
  openBookingEditDrawer(result.payload.cacheItem);
}

// 规格弹窗 callback：
const afterDetail = await cart.confirmDetail({
  item: product,
  detailResult: { e, extension_type, detail },
});
if (afterDetail.status === 'requiresBookingEdit') {
  openBookingEditDrawer(afterDetail.payload.cacheItem);
  return;
}

// 资源抽屉确认：
await cart.confirmBookingEdit({ cacheItem: editedCacheItem });
```

**关键约束**：
- 第一段门禁用 **`isOpenDetailModal`**，不要用 `getIsEject` 代替（`isEject` 仅给弹窗 UI）。
- `cart.confirmDetail` 可能返回 `requiresBookingEdit`，与 `addProduct` 直接命中第二段等价。
- 跨日 / Holder：通过 `uiHosts.openMultiDaySelect` / `openHolderSelect` 接入（`runAddProductFlow` 内编排，对齐 ticketBooking AddService）；不进 OS 决策。

---

### 4.6.1 新建 / 编辑模块接入总览

Sales SDK **不区分**「新建页」与「编辑页」两套 Provider：同一 `<SalesSdkProvider>` 覆盖两种场景，差异仅在 bootstrap 参数。

| 场景 | Provider 配置 | 业务侧典型流程 |
|---|---|---|
| **新建** | 不传 `orderId`；挂载后调 `createNew()` | 选客户 → 拉商品 → `addProductWithFlow` → `submit` |
| **编辑** | 传 `orderId` + `autoBootstrap`（默认 true） | 自动 `loadSalesDetail` → 改行 / 加行 / 删行 → `submit` |

加车与资源编辑涉及 **OS 决策层** + **SDK 编排层** + **UI Host 层**，推荐按下面 checklist 接入：

```
1. 页面根节点包 SalesSdkProvider，注入 uiHosts
2. openBookingEdit → createOpenBookingEditHost()（资源抽屉，SDK 内置）
3. openProductDetail → createLegacyOpenProductDetailHost()（LCE 页）或页面自研弹窗
4. （LCE 页）子树挂 RuntimeBridge，同步 customerId / date 给规格弹窗 Host
5. 列表 / 按钮内：await cart.addProductWithFlow(product)
6. 购物车行「编辑资源」：await cart.editCartLineBooking(item)
```

**完整链路（加车）**：

```
用户点加车
  → cart.addProductWithFlow(product)
    → OS decideAddProduct
      → requiresDetail → openProductDetail Host → cart.confirmDetail
      → requiresBookingEdit → openBookingEdit Host → cart.confirmBookingEdit
      → added（直接写入 tempOrder）
```

**已加车行编辑资源**（与加车共用同一 `openBookingEdit` Host 与 `BookingEditServiceDrawer`）：

```
用户点「编辑资源」
  → cart.editCartLineBooking(item)
    → OS buildCacheItemFromOrderLine（反查 cacheItem）
    → openBookingEdit({ forceOpen: true })  // 强制打开抽屉，忽略 autoClose
    → transformDetailToProductAndBooking → cart.updateItem
```

> `BookingEditHostRenderer` 由 `SalesSdkProvider` 在检测到 `uiHosts.openBookingEdit` 时**自动挂载**，业务页无需手动渲染抽屉。

---

### 4.6.2 标准 Host 工厂（推荐直接复用）

SDK 已提供两个 Host 工厂，业务页**优先直接引用**，无需自行实现 Promise 编排或挂载抽屉。

| Host | 工厂函数 | 说明 |
|---|---|---|
| `openBookingEdit` | `createOpenBookingEditHost()` | 打开 `BookingEditServiceDrawer`；Provider 内自动挂 `BookingEditHostRenderer` |
| `openProductDetail` | `createLegacyOpenProductDetailHost(deps)` | 对齐 ticketBooking `openProductModal`，走 LCE `pisell1.handleOpenProductModal` |

```tsx
import React, { useMemo, useRef } from 'react';
import useEngineContext from '.../hooks/useEngineContext';
import {
  SalesSdkProvider,
  useSalesSdkCart,
  createOpenBookingEditHost,
  createLegacyOpenProductDetailHost,
  type LegacyProductDetailHostRuntime,
} from '@pisell/private-materials/plus/salesSdk';

/** LCE 页：同步 customerId / date，供 openProductDetail Host 每次打开时读取 */
function SalesSdkHostRuntimeBridge({
  runtimeRef,
}: {
  runtimeRef: React.MutableRefObject<LegacyProductDetailHostRuntime>;
}) {
  const context = useEngineContext();
  const action = context?.appHelper?.utils?.action;
  // 实际项目可从 useSalesSdkCustomer / bookingTicket.getBookingDate 同步，参考 demo/DemoHostRuntimeBridge.tsx
  return null;
}

function MyBookingPageShell({ osKey, orderId, children }: {
  osKey: string;
  orderId?: number;
  children: React.ReactNode;
}) {
  const context = useEngineContext();
  const action = context?.appHelper?.utils?.action;
  const runtimeRef = useRef<LegacyProductDetailHostRuntime>({});

  const uiHosts = useMemo(
    () => ({
      openBookingEdit: createOpenBookingEditHost(),
      ...(action
        ? {
            openProductDetail: createLegacyOpenProductDetailHost({
              action,
              getRuntime: () => runtimeRef.current,
            }),
          }
        : {}),
    }),
    [action],
  );

  return (
    <SalesSdkProvider osKey={osKey} orderId={orderId} uiHosts={uiHosts}>
      <SalesSdkHostRuntimeBridge runtimeRef={runtimeRef} />
      {children}
    </SalesSdkProvider>
  );
}
```

**接入要点**：

- **`createOpenBookingEditHost()`**：零配置；内部 `requestOpenBookingEdit` → `BookingEditHostRenderer` → `BookingEditServiceDrawer`。
- **`createLegacyOpenProductDetailHost({ action, getRuntime })`**：需要 LCE `appHelper.utils.action`；`getRuntime()` 在每次打开弹窗时读取最新 `customerId` / `date`（payload 字段优先）。
- 非 LCE 页面若已有自研规格弹窗，只需实现同签名 Host 替换 `openProductDetail`（见 §4.6.5）。
- Host 引用通过 `useMemo` 保持稳定，避免 Provider 子树无意义重渲。

---

### 4.6.3 `cart.addProductWithFlow`（列表加车，一行调用）

**防抖与 Toast（SDK 内置）**

- 每次调用前走 OS `decideAddProduct`：`requiresDetail` / `requiresBookingEdit` **立即**走弹窗链，不防抖。
- 仅 `action === 'add'`（可直接加车、无需资源抽屉）时：默认 **100ms** 防抖合并 `quantity`，防抖结束后再一次 `addProduct`；**预约商品（含 duration / schedules）不合并**，每次点击独立加车；点击时**立即** Toast（商品名 + `pisell2.ticket-booking.in-cart`）。
- flush 失败会清空 pending 累积（回滚待写入数量），Promise reject；下次点击重新计数。
- `options.debounceMs: 0` 关闭防抖；`options.notShowToast: true` 关闭 Toast。

Host 注入完成后，商品列表 / 扫码回调等处**一行加车**：

```tsx
import { useSalesSdkCart, useSalesSdkUIHosts } from '@pisell/private-materials/plus/salesSdk';

function ProductListRow({ product }: { product: ProductData }) {
  const cart = useSalesSdkCart();
  const hosts = useSalesSdkUIHosts();

  const handleAdd = async () => {
    const result = await cart.addProductWithFlow(product, { quantity: 1 });

    switch (result.status) {
      case 'added':
        // 已写入 OS tempOrder，cart.items 会自动刷新
        break;
      case 'cancelled':
        // 用户在规格弹窗或资源抽屉点取消
        break;
      case 'requiresDetail':
        // openProductDetail 未配置；需页面自行 open 后走 cart.confirmDetail
        console.warn('缺少 openProductDetail Host', result.payload);
        break;
      case 'requiresBookingEdit':
        // openBookingEdit 未配置；需页面自行 open 后走 cart.confirmBookingEdit
        console.warn('缺少 openBookingEdit Host', result.payload);
        break;
    }
  };

  return (
    <button onClick={handleAdd} disabled={!hosts?.openProductDetail}>
      加车
    </button>
  );
}
```

**返回值**（`SalesSdkAddProductWithFlowResult`）：

| status | 含义 |
|---|---|
| `added` | 完整链路结束，含 `{ products }` |
| `cancelled` | Host 弹窗 / 抽屉内用户取消 |
| `requiresDetail` | OS 命中第一段，但 `openProductDetail` 未注入 |
| `requiresBookingEdit` | OS 命中第二段，但 `openBookingEdit` 未注入 |

- 两个 Host 均配置时，调用方通常只需处理 `added` / `cancelled`。
- 未配置 Host 时行为与手动 `cart.addProduct` + `switch(status)` 等价，便于渐进接入。
- 底层 `cart.addProduct` / `confirmDetail` / `confirmBookingEdit` 仍保留，供 demo / 单测 / 需插入跨日流程时使用。

---

### 4.6.4 已加车行「编辑资源」：`cart.editCartLineBooking`

购物车行已有 booking 时，编辑资源 / 时间走独立 action，**复用同一 `openBookingEdit` Host**：

```tsx
import { useSalesSdkCart, type SalesSdkCartItemView } from '@pisell/private-materials/plus/salesSdk';

function CartLineActions({ item }: { item: SalesSdkCartItemView }) {
  const cart = useSalesSdkCart();

  const handleEditBooking = async () => {
    const result = await cart.editCartLineBooking(item);
    if (result.status === 'updated') {
      // cart.items 已刷新
    }
    // cancelled：用户在抽屉取消
  };

  if (!item._extend?.booking) return null;

  return <button onClick={handleEditBooking}>编辑资源</button>;
}
```

与加车链路的差异：

| | 加车 `addProductWithFlow` | 已加车编辑 `editCartLineBooking` |
|---|---|---|
| 入口 | 商品 catalog | `SalesSdkCartItemView` |
| cacheItem 来源 | OS `decideAddProduct` / `confirmDetail` | OS `buildCacheItemFromOrderLine` |
| 写回 | `confirmBookingEdit` → `addProductToOrder` | `updateItem` + booking patch |
| 抽屉 | `autoClose=true` 时可静默跳过 | `forceOpen: true`，始终展示抽屉 |

**前置条件**：Provider 已配置 `uiHosts.openBookingEdit`；OS 需支持 `buildCacheItemFromOrderLine`（pisellos 新版）。

---

### 4.6.5 跨日 / Holder Host（推荐与 `addProductWithFlow` 一并配置）

```ts
import {
  createOpenBookingEditHost,
  createOpenMultiDaySelectHost,
  createOpenHolderSelectHost,
} from '@pisell/private-materials/plus/salesSdk';

uiHosts={{
  openBookingEdit: createOpenBookingEditHost(),
  openMultiDaySelect: createOpenMultiDaySelectHost(),
  openHolderSelect: createOpenHolderSelectHost(),
  openProductDetail: /* ... */,
}}
```

编排顺序（对齐 ticketBooking AddService）：规格弹窗 → 跨日日期（仅 `duration.type === 'days'`）→ `confirmDetail`（内含 Holder + 跨日展开）→ 资源抽屉。

### 4.6.6 手动三段式（自定义插入点）

若需完全自研弹窗、不走内置 Host，可改用手动 API（§4.6 顶部示例）或底层纯函数：

```ts
import { runAddProductFlow } from '@pisell/private-materials/plus/salesSdk';

// 单测 / 非 React：注入 mock cart API + mock hosts
await runAddProductFlow(
  { addProduct, confirmDetail, confirmBookingEdit },
  uiHosts,
  product,
  { quantity: 1 },
);
```

自研 `openProductDetail` 只需满足 Host 契约：

```ts
openProductDetail: async (payload) => {
  // payload: SalesSdkAddProductRequiresDetailPayload（showConfig / isEject / productData / date 等）
  const detailResult = await myOpenSkuModal(payload);
  // 用户取消 → return null
  // 用户确认 → return { e, extension_type, detail }（Info2 callback 三元组）
  return detailResult;
};
```

---

### 4.6.7 SalesSdkDemo（create / edit Tab 参考实现）

新建 / 编辑 Tab 使用 `SalesSdkDemoTabShell`（Demo 专用薄封装，等价于 §4.6.2 的标准接线）：

- `uiHosts.openProductDetail` → `createLegacyOpenProductDetailHost`（`demo/hosts/legacyOpenProductDetail.ts`）
- `uiHosts.openBookingEdit` → `createOpenBookingEditHost()`
- `DemoHostRuntimeBridge` 同步 `customerId` / `date` 到 runtime ref
- 一键步骤 `cart.addProductWithFlow` 走：规格弹窗 → 资源编辑抽屉 → `confirmDetail` / `confirmBookingEdit`
- 购物车行「编辑资源」走 `cart.editCartLineBooking`

业务页可直接复制 `SalesSdkDemoTabShell` 的 `uiHosts` 组装方式，去掉 Demo 前缀即可。

---

### 4.6.8 新建 / 编辑完整示例（saleDetail、ticketBooking 迁移页）

```tsx
import React, { useEffect, useMemo, useRef } from 'react';
import useEngineContext from '../../../hooks/useEngineContext';
import {
  SalesSdkProvider,
  useSalesSdk,
  useSalesSdkCart,
  useSalesSdkCustomer,
  useSalesSdkProducts,
  createOpenBookingEditHost,
  createLegacyOpenProductDetailHost,
  type LegacyProductDetailHostRuntime,
  type SalesSdkCartItemView,
} from '@pisell/private-materials/plus/salesSdk';

// ── 1. 页面 Shell：Provider + uiHosts ──────────────────────────────

function BookingSalesShell({
  osKey,
  orderId,
  children,
}: {
  osKey: string;
  orderId?: number;
  children: React.ReactNode;
}) {
  const context = useEngineContext();
  const action = context?.appHelper?.utils?.action;
  const runtimeRef = useRef<LegacyProductDetailHostRuntime>({});

  const uiHosts = useMemo(
    () => ({
      openBookingEdit: createOpenBookingEditHost(),
      ...(action && {
        openProductDetail: createLegacyOpenProductDetailHost({
          action,
          getRuntime: () => runtimeRef.current,
        }),
      }),
    }),
    [action],
  );

  return (
    <SalesSdkProvider osKey={osKey} orderId={orderId} uiHosts={uiHosts}>
      <HostRuntimeSync runtimeRef={runtimeRef} />
      {children}
    </SalesSdkProvider>
  );
}

/** 将 customer / date 同步给 openProductDetail Host（参考 demo/DemoHostRuntimeBridge.tsx） */
function HostRuntimeSync({
  runtimeRef,
}: {
  runtimeRef: React.MutableRefObject<LegacyProductDetailHostRuntime>;
}) {
  const customer = useSalesSdkCustomer();
  const { bookingTicket } = useSalesSdk();

  useEffect(() => {
    const rawDate = bookingTicket?.getBookingDate?.();
    runtimeRef.current = {
      customerId: customer.selected?.id,
      date: rawDate ? String(rawDate) : undefined,
    };
  }, [bookingTicket, customer.selected?.id, runtimeRef]);

  return null;
}

// ── 2. 新建页：createNew → 选客户 → 加车 ───────────────────────────

function CreateOrderPage({ osKey }: { osKey: string }) {
  return (
    <BookingSalesShell osKey={osKey}>
      <CreateOrderContent />
    </BookingSalesShell>
  );
}

function CreateOrderContent() {
  const { createNew, submit, isReady } = useSalesSdk();
  const products = useSalesSdkProducts();
  const cart = useSalesSdkCart();

  useEffect(() => {
    if (isReady) createNew();
  }, [isReady, createNew]);

  const handleAdd = async (product: ProductData) => {
    const result = await cart.addProductWithFlow(product, { quantity: 1 });
    if (result.status !== 'added') return;
  };

  return (
    <div>
      {products.products.map((p) => (
        <button key={p.id} onClick={() => handleAdd(p)}>
          {p.title}
        </button>
      ))}
      <button onClick={() => submit()}>提交</button>
    </div>
  );
}

// ── 3. 编辑页：orderId 自动 load → 加行 / 改资源 ───────────────────

function EditOrderPage({ osKey, orderId }: { osKey: string; orderId: number }) {
  return (
    <BookingSalesShell osKey={osKey} orderId={orderId}>
      <EditOrderContent />
    </BookingSalesShell>
  );
}

function EditOrderContent() {
  const cart = useSalesSdkCart();
  const { submit } = useSalesSdk();

  const handleEditBooking = async (item: SalesSdkCartItemView) => {
    await cart.editCartLineBooking(item);
  };

  return (
    <div>
      {cart.items.map((item) => (
        <div key={item._identity.unique_identification_number}>
          <span>{item.title}</span>
          {item._extend?.booking ? (
            <button onClick={() => handleEditBooking(item)}>编辑资源</button>
          ) : null}
        </div>
      ))}
      <button onClick={() => submit()}>保存</button>
    </div>
  );
}
```

**使用是否方便**：当前模式下一页只需 **两处固定接线**（`createOpenBookingEditHost` + `createLegacyOpenProductDetailHost`），业务代码侧 **一行 `addProductWithFlow` / 一行 `editCartLineBooking`** 即可；抽屉渲染由 Provider 托管。若页面无 LCE `action`，仅需自实现 `openProductDetail` 一个 Host。

---

新增可选 Provider：`<SalesSdkBookingContextProvider>`，对应 hook：`useSalesSdkBookingContext()`。

ticketBooking + Info2 流不依赖它，可不挂载。新 saleDetail 类组件如果要在弹窗里复用资源/容量算法，挂上即可：

```tsx
<SalesSdkProvider osKey="my-page">
  <SalesSdkBookingContextProvider>
    <SaleDetailScreen />
  </SalesSdkBookingContextProvider>
</SalesSdkProvider>

// SaleDetailScreen 内部
const ctx = useSalesSdkBookingContext();

ctx.setBookingConfig(boardConfig);   // /core/board/management/config 已拉到的数据灌进 OS
ctx.setResources(scheduleList);       // /form/schedule 已拉到的数据灌进 OS
ctx.setDate(dayjs('2026-05-20'));

const resources = ctx.getResourcesForProduct(product);
const cacheItem = ctx.getProductExtend(buildCacheItem(product));
const errors = ctx.getResourceErrors(resources[0], cacheItem);
```

**SDK 不会发起 `/core/board/management/config` 或 `/form/schedule` 请求**，消费方在已经拉到数据的地方一次 `setBookingConfig` / `setResources` 即可（与 ticketBooking 共享同 osKey 时甚至可以不再拉一次）。

### ResourceUnavailableReason → i18n key 对照表

`getResourceErrors` 输出的 `reason` 是 enum，文案不进 OS。UI 端按下表渲染：

| reason | i18n key | 参数 | 说明 |
|---|---|---|---|
| `no_product` | `pisell2.text.no-product-added` | - | 行内无任何商品，对应 info2 `type='products'` |
| `time_out_of_range` | `pisell1.text.resource-error-message-2` | `labelText`, `startText`, `endText`(, `isCrossDay`) | 该资源在所选时间段不在工作时段，对应 info2 `type='time'` |
| `count_out_of_range` | `pisell1.text.resource-error-message-3` | `labelText`, `startText`, `endText`(, `isCrossDay`) | 该资源在所选时间段容量不足，对应 info2 `type='limit'` |
| `resource_unbound` | `pisell1.text.resource-error-message-unbound` | - | 资源未绑定该商品/未挂表单，对应 info2 `type='unbound'` |

> 说明：`isCrossDay=true` 时 UI 端通常在 `endText` 前补一个本地化 "tomorrow" 前缀；`params.labelText` 即资源名（资源行的显示标题）。

---

## 5. 接入示例（不依赖 UI）

```tsx
import {
  SalesSdkProvider,
  useSalesSdk,
  useSalesSdkCart,
  useSalesSdkCustomer,
  useSalesSdkSummary,
  deriveDisabledEdit,
  deriveSummary,
} from '@pisell/private-materials';

function OrderShell({ orderId }: { orderId: number }) {
  return (
    <SalesSdkProvider osKey="order-detail" orderId={orderId}>
      <OrderHeaderArea />
      <OrderClientArea />
      <OrderItemsArea />
      <OrderFooterArea />
    </SalesSdkProvider>
  );
}

function OrderItemsArea() {
  const { items, removeItem } = useSalesSdkCart();
  return items.map((item) => (
    <div key={item._identity.unique_identification_number ?? `${item.product_id}-${item.product_variant_id}`}>
      <span>{item.title} - {item.variant_title}</span>
      <button onClick={() => removeItem(item._identity)}>delete</button>
    </div>
  ));
}

function OrderClientArea() {
  const { display, select } = useSalesSdkCustomer();
  if (!display) return <Empty />;
  return (
    <div>
      {display.name} {display.phone}
      {display.isWalkIn && <Badge>Walk-in</Badge>}
    </div>
  );
}

function OrderFooterArea() {
  const { salesDetail } = useSalesSdk();
  const { summary } = useSalesSdkSummary();
  const { disabledEdit } = deriveDisabledEdit(salesDetail?.rootBooking ?? null, salesDetail?.header);
  const { totalAmount, expectAmount, amountGap } = deriveSummary(summary);
  return (
    <Footer total={totalAmount} expect={expectAmount} gap={amountGap} disabled={disabledEdit} />
  );
}
```

---

## 6. 与 ticketBooking 的关系

| 项 | ticketBooking（既有） | Sales SDK（本模块） |
|---|---|---|
| 状态层 | miniRedux + Context | React Context + `useMemoizedFn` actions |
| 数据来源 | `BookingTicket` 同实例 | `BookingTicket` 同实例（osKey 共享） |
| UI | 提供完整布局 + 子组件 | **无 UI**（headless） |
| 适用 | 维持现有票务详情/编辑页 | 新页 / 新建态 / 自定义 UI 业务接入 |
| 共存 | ✓ | ✓ |

**渐进迁移路径**：先把新页基于 Sales SDK 起步（不动 ticketBooking）；ticketBooking 旧 UI 可在外侧提供 `<SalesSdkProvider>` 直接读 hook（绕过 miniRedux），也可阶段性迁移子组件。

---

## 7. Phase 2 扩展点（不破坏 Phase 1 契约）

| 扩展 | 触发条件 | 改动面 |
|---|---|---|
| `${name}:onTempOrderChanged` 实时刷新 items | OS OrderModule 加上该事件 | `SalesSdkCartProvider` 把事件名加进 `useEffect` 订阅；移除 action 后的手动 `setSnapshot` |
| 行级 `updateItem` 后的 OS 一致性回写 | OS Order CRUD 之后 emit 该事件 | 同上，自动 sync 后无需 action 内手动 setSnapshot |
| 增加 `SalesSdkItemProvider`（行级聚合） | 出现需要 per-item context 的 UI 场景 | 在 `SalesSdkCartProvider` 内为每个 item 提供 `<ItemContext.Provider>`；hook 改为 `useSalesSdkItem()` |
| `useSyncOsModule` 替代部分 `useEffect` 镜像 | OS 同步 getter 完备后 | Customer/Cart Provider 用 `useSyncOsModule(bookingTicket, [...], () => ({...}))` 收敛订阅+快照逻辑 |

---

## 8. 验收口径

### 8.1 已通过

- ✅ TypeScript 严格模式：`tsc --noEmit -p packages/private-materials/tsconfig.json` 在 SDK 与单测路径上 0 报错（`vitest` 模块找不到与既有 `PisellFinancialSummary.test.tsx` 一致，CI 装包后消解）。
- ✅ 单测覆盖（`__tests__/sdk.test.ts`，vitest 写法）：
  - `enrichCartItem`：编辑态 / 新建态 / 全空兜底 / option-bundle detail 优先 / 字符串 product_id 比对
  - `enrichCartItems`：行顺序保持、空数组
  - `enrichCustomer`：selected 优先 / fallback / null / 边界 id（0/''）
  - `deriveDisabledEdit`：cancelled / 非白名单 / partially_paid 归一 / 渠道锁 / 空 payment_status
  - `deriveSummary`：null / 缺字段 / 数值转字符串
  - `isWalkInCustomer`：11 个边界 case
  - `pickIdentity`：metadata 优先 / 顶层 fallback / identity_key fallback / 全缺 / 数组透传
- ✅ 与既有 `components/Sales` UI 模块零冲突：所有 SDK 命名带 `SalesSdk` 前缀；物理目录独立。

### 8.2 后续（非 Phase 1 阻塞）

- 在 ticketBooking 现有页里挂载 `<SalesSdkProvider>` 试运行，确保 osKey 复用机制工作正常。
- Action 引用稳定性的运行时回归（依赖 React 18 + ahooks 的既定行为，无需 SDK 内补测）。
- Phase 2 OS 事件接入时再补 onTempOrderChanged 相关单测。

## 9. 促销 / 赠品（Buy-X-Get-Y · X-for-Y Price）

### 9.1 总览

SDK 与 OS 协作完成促销链路（替换 legacy `usePromotion`）：

```
appHelper.utils.promotionEvaluator  ──┐
                                      ├─▶ OrderModule.applyPromotion()
SalesSdk giftSelectResolver (bridge) ─┘     │
                                            ├─ 评估器：算价 + 拆分 + gifts
                                            ├─ giftActions: toAdd / toReduce / toRemove
                                            ├─ 写回 tempOrder.products（含 _promotion / _giftInfo）
                                            ├─ applyDiscount + recalculateSummary + persist
                                            └─ emit `order:onPromotionApplied`（稳定态）
                                                   │
SalesSdkCartContext  ◀──────────────────────── unfulfilledPromotions / lastGiftActions
SalesSdkProductContext.products  ◀─ appendPromotionTagsForProducts(products, evaluator)
```

- 评估与赠品落地的 **唯一真源** 是 OS `OrderModule`；SDK 不再算价或维护 `toAdd/toReduce`。
- 写路径（`addProductToOrder` / `updateOrderProduct(Quantity)` / `removeProductFromOrder` / `clearOrderCartLines` / `setOrderCustomer`）统一先完成促销评估，再执行 `applyDiscount() → recalculateSummary() → persist`，最后发布 `onPromotionApplied`；订阅方读取到的是可直接同步的稳定态。
- 多选项赠品由 SDK Host 弹窗（`GiftSelectHostRenderer`）让用户选择，OS 同步 `await` 结果后落 cart。

### 9.2 evaluator 注入（host 透明）

`SalesSdkProvider` 挂载时自动读取 `appHelper.utils.promotionEvaluator`：

```ts
const evaluator = utils?.promotionEvaluator;
if (evaluator && typeof bookingTicket.setPromotionEvaluator === 'function') {
  bookingTicket.setPromotionEvaluator(evaluator);
}
```

- 缺失 evaluator 时 OS 不会执行促销，`applyPromotion()` 内提前 return。
- evaluator 来源由宿主自定义；通常由 `pisellos.utils.promotionEvaluator` 或同名实例提供。

### 9.3 GiftSelect Host（多选项弹窗）

- `createGiftSelectBridge()`：Instance Host bridge，payload = `SalesSdkGiftSelectContext`，result = `SalesSdkGiftSelectChoice[]`。
- `createOpenGiftSelectHost(bridge, () => bookingTicket)`：返回 OS `giftSelectResolver`：
  - `giftOptions.length === 1` → 自动选定，返回单个赠品行，无弹窗（与 legacy `handleAutoAddGift` 等价）。
  - `giftOptions.length >= 2` → `bridge.request(ctx)` 打开弹窗，等待用户确认。
  - 用户取消 → 返回 `null`，OS 视为放弃本次添加。
- `GiftSelectHostRenderer` 挂载在 `SalesSdkProvider` 内，订阅 bridge.pending 渲染 `GiftSelectModal`。
- `GiftSelectModal` 使用 `PersistentBottomSheet`（移动端底部弹起 / 桌面端 Modal）；不依赖 miniRedux。

### 9.4 Context 暴露

- `useSalesSdkCart().unfulfilledPromotions`：未满足策略列表（`SalesSdkUnfulfilledPromotion[]`），购物车底部 alert 渲染源。
- `useSalesSdkCart().lastGiftActions`：上一次 `applyPromotion` 的 diff（toAdd / toReduce / toRemove），debug 用。
- `useSalesSdkProducts().products`：自动注入 `_promotionTags`，`PisellProductCard` 直接读 `item._promotionTags` 渲染促销 tag。

### 9.5 OS 事件

- `order:onPromotionApplied`，payload：
  ```ts
  {
    unfulfilledPromotions: OrderUnfulfilledPromotion[];
    gifts: GiftActionWithSource[];
    giftActions: { toAdd; toReduce; toRemove };
    totalDiscount: number;
  }
  ```
- `SalesSdkCartContext` / `SalesSdkProvider` 均订阅；`SalesSdkProvider` 收到后会 `refreshRootSnapshot()` 让顶层 tempOrder 重新读快照（赠品行已被 OS 写入）。

### 9.6 兼容与回归

- legacy `ticketBooking` 路径未改：仍使用 miniRedux 的 `usePromotion`，不与 OS evaluator 冲突（OS 在未注入 evaluator 时不参与）。
- 共享 osKey 的复用：`SalesSdkProvider` 注入 evaluator 后，OS 路径在购物车增减时自动覆盖 legacy 价格；同页面只挂一个 Provider 即可。
- 写路径单测：`pisell_os/src/modules/Order/__tests__` 与 `BaseSales/utils/__tests__/cartPromotion.test.ts` 覆盖买1送1 / 减量回退 / 删除主品 / 未满足提示。
