# 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';

<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
>
  {children}
</SalesSdkProvider>
```

### 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`, `removeItem`, `updateItem`, `setLineNote`, `setDiscount`, `scanCode`, `submit`, `recalcSummary` |
| `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（自定义订阅时可直接使用）
} from '@pisell/private-materials';
```

---

## 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/updateProductInOrder 用 |

> **关键约束**（遵循 `.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 或不必要的重渲。

---

## 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 相关单测。
