# PaymentModal 收敛到 BaseSales 迁移规划

## 背景与目标

`packages/private-materials/src/components/checkout/PaymentModal.tsx` 最早是预约购物车 footer 的结账弹窗。它由 `packages/private-materials/src/components/booking/components/footer/index.tsx` 直接 JSX 挂载，并接收 footer 侧通过 `formatValues`、`getSumTotal`、`state.client`、`state.shop_discount` 等购物车状态拼出的 `paymentModalData`。这导致 `PaymentModal` 既依赖购物车，又依赖旧 OS `checkout` 解决方案在弹窗内重新创建一份本地虚拟订单。

后来新增 `packages/private-materials/src/components/checkout/plugin.tsx` 插件入口。主项目 `/Users/hansuku/Desktop/WorkSpace/saas_shop_pos/src/components/LowCode/appHelper/actions.ts` 中 `pisell1.handleOpenPayment` 会转发到 `PaymentPlugin.handleOpenPayment`，插件再打开 `PaymentModal`。当前插件版主要面向已有订单：有 `order_id` 时先通过 `_getBookingDetail` 拉云端详情，再用 `_formatBookingDetail`、`formatValues`、`getSumTotal` 和 `checkout.getPaymentMethodsAsync` 复原出类似本地购物车的结构。新入口契约改为调用方传入要结账订单所属 BaseSales 实例的 `baseSalesModuleName`。因为 pisellos 运行时可能同时存在多个 BaseSales 或其扩展实例，仅靠 `order_id` / `external_sale_number` 无法判断该调用应落到哪个 BaseSales；plugin 不再接收订单标识作为定位依据。

新规划不再有“购物车是结账事实来源”的概念。销售页面进入后应产生一个内存里的临时订单，商品、客户、备注、折扣、支付都围绕同一个本地订单变更。OS 侧资料显示 `BaseSales` 已经承载工作流门面，`OrderModule` 已经提供 `ensureTempOrder`、`addNewOrder`、`loadSalesDetail`、`getOrderSnapshot`、`getOrderAmountSnapshot`、`getTempOrderNote`、`updateTempOrderNote`、`submitTempOrder`、`syncPaymentsToOrder` 等能力；`PaymentModule` 继续负责支付方式、支付项、钱包、现金、EFTPOS、舍入和剩余金额计算。

本规划目标：

- `PaymentModal` 不再对接 OS `checkout` 解决方案，不再调用 `checkout.createLocalOrderAsync` / `updateLocalOrderAsync` / `manualSyncOrderAsync` 等旧本地订单生命周期 API。
- `PaymentModal` 内需要的订单、金额、支付项、支付渠道、定金状态和支付提交能力都统一由 `BaseSales` 提供；`PaymentModule` 只作为 OS 内部实现，不作为 `PaymentModal` 的直接依赖。
- `booking/footer` 不再直接挂载 `PaymentModal`，统一通过 `plugin.tsx` 唤醒 checkout。
- 支付完成通知和打印逻辑从 `footer` 收掉，全局只保留一处支付结果处理。
- 借迁移机会治理 `PaymentModal.tsx` 体积。当前文件 3000+ 行，可读性和回归风险都很高；新链路应把编排逻辑、支付动作、打印结果、弹窗状态和纯 UI 分层拆出，让 `PaymentModal` 回到“页面级容器 + 少量状态编排”的职责。
- 不设计旧逻辑兼容和降级。本规划按新链路替换旧链路，阻塞点直接列为待确认。

## 调研范围

已读取并梳理：

- `packages/private-materials/src/components/checkout/PaymentModal.tsx`
- `packages/private-materials/src/components/checkout/plugin.tsx`
- `packages/private-materials/src/components/checkout/types.ts`
- `packages/private-materials/src/components/checkout/components/SearchAndClientModule/index.tsx`
- `packages/private-materials/src/components/checkout/components/WalletPassModule/index.tsx`
- `packages/private-materials/src/components/checkout/hooks/useWalletPass.ts`
- `packages/private-materials/src/components/checkout/components/AmountSummary/index.tsx`
- `packages/private-materials/src/components/checkout/components/CashPaymentModule/index.tsx`
- `packages/private-materials/src/components/checkout/components/PaymentOptionsModule/index.tsx`
- `packages/private-materials/src/components/checkout/components/PaymentOptionsModule/PaymentMethodItem.tsx`
- `packages/private-materials/src/components/checkout/components/AdditionalModule/index.tsx`
- `packages/private-materials/src/components/checkout/components/SavePayLaterHandler/index.tsx`
- `packages/private-materials/src/components/checkout/components/SendPaymentLinkModal/index.tsx`
- `packages/private-materials/src/components/checkout/components/PaymentResultToast/index.tsx`
- `packages/private-materials/src/components/checkout/components/PaymentResultToast/PaymentResultToastProvider.tsx`
- `packages/private-materials/src/components/checkout/utils/PaymentResultToastUtils.tsx`
- `packages/private-materials/src/components/booking/components/footer/index.tsx`
- `/Users/hansuku/Desktop/WorkSpace/more/pisell_os/src/solution/Checkout/CHECKOUT_MIGRATION_PLAN.md`
- `/Users/hansuku/Desktop/WorkSpace/more/pisell_os/src/solution/BaseSales/index.ts`
- `/Users/hansuku/Desktop/WorkSpace/more/pisell_os/src/solution/BaseSales/types.ts`
- `/Users/hansuku/Desktop/WorkSpace/more/pisell_os/src/modules/Order/index.ts`
- `/Users/hansuku/Desktop/WorkSpace/more/pisell_os/src/modules/Order/types.ts`
- `/Users/hansuku/Desktop/WorkSpace/more/pisell_os/src/modules/Order/utils.ts`
- `/Users/hansuku/Desktop/WorkSpace/more/pisell_os/src/modules/Payment/index.ts`
- `/Users/hansuku/Desktop/WorkSpace/more/pisell_os/src/modules/Payment/types.ts`
- `/Users/hansuku/Desktop/WorkSpace/saas_shop_pos/src/components/LowCode/appHelper/actions.ts`

调研缺口：

- 未发现以上必读路径存在读取失败。
- 本规划未读取主项目 `PaymentPlugin` 具体实现，只确认 `actions.ts` 中 `pisell1.handleOpenPayment` 通过 `paymentPlugin("handleOpenPayment")` 转发。后续代码阶段需要再确认插件 ref 的参数透传和关闭回调契约。
- 客户切换、定金语义、B 端打印数据返回规则和支付链接发送归属已确认。

## 现状依赖图

```mermaid
flowchart TD
  Footer[booking/footer] -->|直接 JSX 挂载| PaymentModal
  Footer -->|formatValues/getSumTotal/state| CartShape[购物车形状 paymentModalData]
  HostAction[saas_shop_pos actions pisell1.handleOpenPayment] --> PaymentPlugin[checkout/plugin.tsx]
  PaymentPlugin -->|有 order_id 拉云端并复原购物车形状| PaymentModal
  PaymentModal --> Checkout[pisellos checkout module]
  PaymentModal --> PaymentMethodsUI[pay/toB/PaymentMethods]
  PaymentModal --> Toast[PaymentResultToast]
  PaymentModal --> NativePrint[usePrinter/nativePrint]
  PaymentModal --> Till[interaction postMessage open_till]
  PaymentModal --> Children[AmountSummary / WalletPass / SearchClient / Cash / Options / Additional]
  AmountSummary --> Checkout
  WalletPassHook[useWalletPass] --> Checkout
  WalletPassHook --> PaymentSubModule[checkout.payment.wallet]
  WalletPassHook --> ShopDiscount[useShopDiscountModule]
  SearchClient --> BookingTicket[useBookingTicket scan listener]
  SearchClient --> CartClientCard[booking cart client card]
```

当前问题不是单点 API 替换，而是 `PaymentModal` 同时拥有：

- 订单生命周期：创建、更新、取消、同步本地订单。
- 支付会话：支付方式、支付项、钱包、现金、EFTPOS、舍入。
- 结果编排：支付结果 toast、打印、关闭弹窗、清空购物车。
- 购物车桥接：读 `cartData`、更新 footer state、根据购物车进入编辑态。
- 多处事件监听：`checkout:onStateAmountChanged`、`checkout:onBalanceDueAmountChanged`、`checkout:onOrderSubmitStart`、`checkout:onOrderSubmitEnd`、`checkout:onPaymentItemAdded`、`checkout:onOrderSynced`。

## 目标架构

```mermaid
flowchart TD
  SalesPage[销售页面] -->|进入即创建/恢复| BaseSales[目标 BaseSales 实例]
  BaseSales --> OrderModule[OrderModule tempOrder]
  BaseSales --> PaymentModule[PaymentModule 内部支付能力]
  Footer[booking/footer] -->|只发起 action| HostAction[pisell1.handleOpenPayment]
  HostAction --> Plugin[checkout/plugin.tsx]
  Plugin -->|传入 baseSalesModuleName| PaymentModal
  PaymentModal -->|pisellos.getModule(baseSalesModuleName)| BaseSales
  PaymentModal -->|读订单/金额/支付项/支付渠道/定金状态| BaseSales
  PaymentModal -->|提交支付动作并判断是否全额| BaseSalesSync[BaseSales 支付编排]
  BaseSalesSync --> ResultPresenter[统一支付结果展示与打印]
```

目标职责拆分：

- `BaseSales`：销售工作流门面。提供当前销售订单、金额快照、客户/商品/备注/折扣上下文、提交订单入口。
- `OrderModule`：订单事实来源。维护 `tempOrder`、`summary`、订单身份、同步状态、备注、后端详情缓存和 `/order/sales/checkout` 提交。
- `PaymentModule`：BaseSales 背后的支付能力实现。可以维护支付方式、支付项、钱包、现金推荐、舍入和剩余金额，但不暴露为 `PaymentModal` 的直接上下文；EFTPOS/MX51 的具体支付流程仍由 `pay/toB/PaymentMethods` 负责。
- `PaymentModal`：只做 UI 编排和事件派发，不再创建旧 Checkout 本地订单，不再自己监听旧 Checkout 同步事件来决定全局结果。
- `plugin.tsx`：唯一 checkout 弹窗入口，负责接收并透传 `baseSalesModuleName`，不再通过订单标识查找订单。
- `baseSalesModuleName`：调用方负责传入要结账订单所属的 BaseSales 模块名；`PaymentModal` 根据该 moduleName 从 pisellos 中获取对应 BaseSales 实例。
- `footer/index.tsx`：只负责发起 checkout action 和必要的保存/校验，不直接渲染 `PaymentModal`，不订阅支付结果事件并展示 toast。

## 关键决策

1. 新链路只对接 `BaseSales`，不再给 `Checkout` 单独方案。

`/Users/hansuku/Desktop/WorkSpace/more/pisell_os/src/solution/Checkout/CHECKOUT_MIGRATION_PLAN.md` 中已经说明旧 `Checkout` 混合了订单所有权和支付编排。用户当前明确要求不兼容旧逻辑，因此本规划不保留 legacy fallback，也不要求新 UI 适配旧 `CheckoutModuleAPI` 形状。

2. `PaymentModal` 的订单输入从 `cartData` 改为 `orderSnapshot`。

目标 props 不应继续要求：

- `data.order_info`
- `data.subtotal_info`
- `data.existPayment`
- `shopDiscount`
- `currentTotalPrice`
- `onChangeShopDiscount`
- `onChangeOrderNote`
- `onDeleteOrderNote`
- `setEditCartMode`

这些都应被 `BaseSales.getOrderSnapshot()`、`BaseSales.getOrderAmountSnapshot()`、`BaseSales.getSummary()`、`BaseSales.getTempOrderNote()`、`BaseSales.updateTempOrderNote()`、`BaseSales.submitSalesOrder()` 或事件回调替代。确实不属于 BaseSales 的外部能力，通过 `PaymentModal` 事件带出去处理。

3. 支付写入必须在同一上下文内完成“写支付项 -> 判断全额 -> 展示结果 -> 打印”。

例如点击现金支付收 5 元：

- `PaymentModal` 先调用 BaseSales 当前订单上下文提供的支付写入方法，提交 `cashPaymentItem`。
- 写入成功后读取当前订单上下文内的支付项。
- 调用 `BaseSales` / `OrderModule.syncPaymentsToOrder({ payments, submitWhenPaid })` 或等价编排方法。
- 根据返回的 `isFullyPaid`、`submitResult`、剩余金额快照展示全额支付或部分支付。
- 打印作为同一支付操作的后续步骤调用，不再由 `footer` 监听 `checkout:onOrderSynced` 后另起一条链路。

4. 打印、查看详情、钱箱等非订单字段不在文档中猜测归属；客户选择已明确为订单字段更新 + 外部同步回调，支付链接发送归属 `OrderModule`。

这些能力当前依赖外部 appHelper、booking state、native plugin 或未确认接口。改造时必须事件化：由 `PaymentModal` 触发语义事件，外部销售页面或 host app 在同一个 BaseSales 实例上下文内完成读写。

## 改造范围

### 必改文件

- `packages/private-materials/src/components/checkout/PaymentModal.tsx`
- `packages/private-materials/src/components/checkout/plugin.tsx`
- `packages/private-materials/src/components/checkout/types.ts`
- `packages/private-materials/src/components/checkout/components/AmountSummary/index.tsx`
- `packages/private-materials/src/components/checkout/components/SearchAndClientModule/index.tsx`
- `packages/private-materials/src/components/checkout/components/WalletPassModule/index.tsx`
- `packages/private-materials/src/components/checkout/hooks/useWalletPass.ts`
- `packages/private-materials/src/components/checkout/components/PaymentResultToast/*`
- `packages/private-materials/src/components/booking/components/footer/index.tsx`

### 可能要改

- `packages/private-materials/src/components/checkout/components/CashPaymentModule/index.tsx`
- `packages/private-materials/src/components/checkout/components/PaymentOptionsModule/index.tsx`
- `packages/private-materials/src/components/checkout/components/PaymentOptionsModule/PaymentMethodItem.tsx`
- `packages/private-materials/src/components/checkout/components/AdditionalModule/index.tsx`
- `packages/private-materials/src/components/checkout/components/SavePayLaterHandler/index.tsx`
- `packages/private-materials/src/components/checkout/components/SendPaymentLinkModal/index.tsx`
- `packages/private-materials/src/components/pay/toB/paymentMethods`
- 主项目 `PaymentPlugin` 对 `handleOpenPayment` 的参数处理。

### 明确不做

- 不改造旧 OS `Checkout` 为新方案。
- 不在 `footer/index.tsx` 继续保留直接 JSX 挂载 `PaymentModal` 的并行入口。
- 不让 `footer/index.tsx` 继续监听 checkout 支付结果事件并展示 toast/打印。
- 不把未知业务字段映射到 BaseSales；字段语义不明时先列待确认。

## PaymentModal 现状依赖清单

### 订单与金额

当前调用：

- `checkout.setOtherParams`
- `checkout.createLocalOrderAsync`
- `checkout.updateLocalOrderAsync`
- `checkout.cancelCurrentOrderAsync`
- `checkout.getCurrentOrderInfo`
- `checkout.getCurrentOrderId`
- `checkout.isCurrentOrderSynced`
- `checkout.getCartSummary`
- `checkout.getBalanceDueAmount`
- `checkout.getStateAmount`
- `checkout.setStateAmountAsync`
- `checkout.updateOrderDepositStatusAsync`
- `checkout.setDepositAmountAsync`
- `checkout.roundAmountAsync`
- `checkout.manualSyncOrderAsync`
- `checkout.saveForLaterPaymentAsync`

目标替换：

- 本地订单创建：`BaseSales.addNewOrder()` 或 `OrderModule.ensureTempOrder()`，由销售页面进入销售流时保证存在；checkout plugin 不再主动创建空订单。
- BaseSales 实例定位：plugin 透传 `baseSalesModuleName`；`PaymentModal` 通过 `pisellos.getModule(baseSalesModuleName)` 获取对应 BaseSales 实例，并只与该实例的当前订单上下文交互。
- 订单快照：`BaseSales.getOrderSnapshot()`。
- 订单身份：`BaseSales.getOrderIdentity()`。
- 金额快照：`BaseSales.getOrderAmountSnapshot()`。
- Summary：`BaseSales.getSummary()`。
- 备注：`BaseSales.getTempOrderNote()` / `BaseSales.updateTempOrderNote()`。
- 提交与支付同步：`BaseSales.submitSalesOrder()` 或 `OrderModule.syncPaymentsToOrder()`。
- 支付舍入：由 BaseSales 暴露舍入能力，内部可委托 PaymentModule 实现。
- 支付输入金额：原 `setStateAmountAsync` / `getStateAmount` 只是 UI 当前输入金额，初始值来自订单待付金额，后续允许用户手动修改；迁移后放在 `PaymentModal` 本地 state，不进入 `BaseSales` / `Order`。只有在支付动作提交时，才将当前输入金额作为支付上下文的一部分传给支付方法。
- 定金阶段：原 `updateOrderDepositStatusAsync` / `setDepositAmountAsync` 需要拆成两层语义处理。订单层维护顶层 `is_deposit`、`deposit_amount`；支付层在新增支付项时写入 `type`，区分定金支付项和普通支付项。UI 层负责根据订单和支付项快照决定当前是否处于定金支付阶段。
- 定金默认态：当订单总额需要付 100，规则检测到需要先付 30 定金时，`PaymentModal` 默认进入定金支付 UI，当前支付输入金额默认取定金待付金额。用户可以在未被锁定前手动切换为普通支付。
- 定金锁定态：只要订单已经产生普通支付项，或者定金已经足额支付，UI 都不允许再切回定金支付。定金足额支付后，UI 自动切换为普通支付阶段，后续新增支付项都应按普通支付写入。
- Save for later：保留旧策略。点击 save for later 时检测当前是否只有 voucher 类支付项；如果是，弹出确认框询问用户是否记录这个折扣。用户确认“扣”时，需要把该 voucher/折扣结果写入本地订单上下文并保存；用户取消时，只保存订单草稿，不记录这笔 voucher 折扣。该决策结果由 `PaymentModal` 编排后传给 `BaseSales` / `OrderModule`，不要在子组件内静默处理。

### 支付方式与支付项

当前调用：

- `checkout.getPaymentMethodsAsync`
- `checkout.getCurrentOrderPaymentItemsAsync`
- `checkout.addPaymentItemAsync`
- `checkout.updateVoucherPaymentItemsAsync`
- `checkout.deletePaymentItemAsync`
- `checkout.payment.cash.getRecommendedAmount`
- `checkout.payment.wallet.*`

目标替换：

- `PaymentModal` 需要的支付方式、支付项、剩余金额、voucher 覆盖、现金推荐、定金状态和支付提交能力，都从 BaseSales 当前订单上下文读取或调用。
- `PaymentModule` 可以继续作为 OS 内部实现，但不再由 `PaymentModal` 直接管理支付上下文，也不再要求 `PaymentModal` 传入 payment order / tender session 标识。
- 支付项提交到订单由 BaseSales 编排，内部可继续复用 `OrderModule.syncPaymentsToOrder({ payments, submitWhenPaid })`、`mapPaymentItemsToOrderPayments` 等能力，并保证 `uuid -> metadata.unique_payment_number`，`id -> custom_payment_id`，`isSynced` 不提交。

已定方案：

- `plugin.tsx` 的打开参数改为 `{ baseSalesModuleName: string; ... }`。`PaymentModal` 不再接收或感知 `order_id`、`external_sale_number`、`paymentOrderUuid` / `paymentSession`。
- `baseSalesModuleName` 是定位 BaseSales 实例的唯一入口参数；调用方必须保证该 moduleName 对应的 BaseSales 当前上下文就是要结账的订单。
- 不再调用 `PaymentModule.createPaymentOrderAsync`。`PaymentModal` 内需要的订单、金额、支付项、剩余金额、定金阶段等数据都应由 BaseSales 提供。定金字段仍以已确认语义为准：`is_deposit` 表示订单当前是否存在定金阶段，`deposit_amount` 表示该阶段应付定金金额，支付项 `type` 表示本次支付项属于定金还是普通支付。

### 支付完成通知与打印

当前 `PaymentModal` 内部有两套结果路径：

- `PaymentContent` 监听 `checkout:onPaymentItemAdded`，当余额大于 0 时展示部分支付 toast，并可能开钱箱。
- `PaymentModal.subscribeOrderSynced` 监听 `checkout:onOrderSynced`，展示 paid / partially_paid / failed toast、关闭 modal、打印完整订单。

当前 `footer/index.tsx` 也监听：

- `checkout:onOrderSubmitStart`
- `checkout:onOrderSubmitEnd`
- `checkout:onOrderSynced`

并在非弹窗打开时展示支付结果 toast、调用 `nativePrint` 打印完整订单、最后 `handleClearAll()`。

目标替换：

- 删除 footer 中支付结果监听和打印逻辑。
- 抽出一个支付结果编排方法，例如 `presentPaymentResult({ paymentStatus, orderSnapshot, submitResult, paymentItems, printContext })`，只由支付动作完成后调用。
- 抽出打印方法，例如 `printPaymentReceipt({ orderId, mode, printInfo })`，被支付动作或 toast 按钮调用，不通过 effects.on 另行监听。
- B 端下单需要在 OrderModule 下单参数中传 `small_ticket_data_flag: 1`，以确保新 `/order/sales/checkout` 返回打印所需的小票数据。
- `PaymentResultToast` 可保留为 UI 展示组件，但输入应来自统一编排结果，不再从 footer 和 PaymentModal 两处分别计算。

### 客户选择

当前依赖：

- `PaymentModal` 从 props 接收 `client`。
- `SearchAndClientModule` 内部渲染 `CartClientCard`，依赖 booking 购物车客户卡片。
- `SearchAndClientModule` 的 `onClientChange` 只通知“变更了”，没有把新 client 值传回。
- 扫码依赖 `useBookingTicket().scanUniversalListener`，搜到 wallet code 后通过 `clientCardRef.current?.handleSetClient` 改客户卡。
- `PaymentModal` 用 `clientChangedRef` 配合 `checkout:onStateAmountChanged` 展示“客户已变更 / 钱包已更新”提示。

目标替换：

- `SearchAndClientModule` 改为受控组件：`clientInfo` + `onClientChange(client, meta)`。
- 客户变更不再写 booking state。`PaymentModal` 通过 BaseSales/Order 更新当前订单里的客户字段，例如 `customer_id`、`customer_name`、`phone`、`email` 等。
- `plugin.tsx` 的打开参数增加 `onCustomerChange?: (payload: { customer: unknown; source: string; orderIdentity: unknown }) => void`，用于把客户变更同步给调用 checkout plugin 的外部页面或其他模块。
- 客户字段写入 Order 后，由 BaseSales 负责触发必要的 summary、折扣、钱包推荐等重算；`onCustomerChange` 只做外部同步，不作为订单事实来源。
- 扫码能力也要事件化：`onIdentificationCodeScanned(code)` 或 `onSearchIdentificationCode(code)`，不能在子组件里直接依赖 booking ticket。
- 客户切换规则：只要当前订单没有产生非 wallet pass 支付项，就允许切换客户；切换客户后需要重新拉取 wallet pass 推荐/可用列表。
- wallet pass 支付项判断：支付项中 `voucher_id` 存在且不为 `0` 的支付项视为 wallet pass 支付项。
- 客户锁定规则：只要当前订单产生了任意非 wallet pass 支付项，就不允许再切换客户；`SearchAndClientModule` 应展示禁用态或由 `PaymentModal` 拦截并提示。

### 钱包与折扣

当前依赖：

- `WalletPassModule` 通过 `useWalletPass` 读取 `checkoutModule`、`checkoutModule.payment` 和 `useShopDiscountModule`。
- `useWalletPass` 监听 `paymentModule.effectsOn('onWalletRecommendListUpdated')`、`checkoutModule.effectsOn('onPaymentStarted')`、`checkoutModule.effectsOn('onPaymentItemAdded')`、`checkoutModule.effectsOn('onWalletDataInitialized')` 等事件。
- 选中钱包后 `PaymentModal.handleSelectWalletChange` 转换为 voucher 支付项并调用 `checkout.updateVoucherPaymentItemsAsync`。
- 折扣卡选择调用 `shopDiscount.setDiscountSelected`，并通过 `pisellos.effects.emit('shopDiscount:onSelectedDiscountListChange')` 对外广播。

目标替换：

- `useWalletPass` 不再从 `checkoutModule` 获取 payment 子模块，而是接收 BaseSales 提供的钱包/折扣上下文、`orderSnapshot`、`amountSnapshot`、`products`。
- 钱包初始化和推荐由 BaseSales 暴露，内部可继续委托 `PaymentModule.wallet`。
- 商品券/折扣卡如果属于订单折扣，应收敛到 `BaseSales.getDiscountList()`、`BaseSales.setDiscountSelected()`、`BaseSales.scanPromotionCode()` 等订单上下文能力。
- 钱包选中结果不直接写 checkout；由 `PaymentModal` 调用 BaseSales 当前订单上下文的 voucher 覆盖方法，再调用统一同步/结果判断流程。

待确认：

- wallet pass 的推荐计算是否必须依赖 `OrderModule.tempOrder.products` 的完整商品结构。
- 商品券折扣卡和 wallet pass 是否都应该由 PaymentModule.wallet 处理，还是商品券折扣卡应完全归 BaseSales/Order 折扣体系。

### 备注、发送支付链接、查看详情

当前依赖：

- 备注：`checkout.getOrderNote`、`checkout.updateOrderNoteAsync`、`checkout.editOrderNoteByOrderIdAsync`，并同时回调 footer 的 `onChangeOrderNote` / `onDeleteOrderNote`。
- 支付链接：`checkout.sendCustomerPayLinkAsync({ emails, order_ids })`，之前还会先 `manualSyncOrderAsync`。
- 查看详情：Web POS 使用 `NativePage.open({ page: "/native/order/detail" })`，terminal 使用 `interaction.utils.postMessageToApp({ module: 'booking', key: 'view_order' })`。

目标替换：

- 备注：未提交本地订单使用 `BaseSales.updateTempOrderNote`；已提交远端订单编辑备注是否调用 `OrderModule.getOrderInfo` / 新增远端备注 API，待确认。
- 支付链接：`SendPaymentLinkModal` 保持纯 UI；发送动作交给 `OrderModule`。发送前必须先把本地订单提交到云端，只有后端提交成功并拿到远端订单身份后，才允许调用发送支付链接能力。
- 查看详情：保留为外部事件 `onViewOrderDetailRequested(orderIdentity)`，由 host 处理 Web POS / terminal 差异。

### 现金、EFTPOS、MX51、钱箱

当前依赖：

- 现金：`CashPaymentModule` 只负责输入和调用 `onPaymentComplete`，`PaymentModal` 查找现金支付方式后调用 `checkout.addPaymentItemAsync`，同时打开钱箱。
- EFTPOS/MX51：`PaymentModal` 通过 `PaymentMethods` ref 调 `onPay(payType, ...)`，成功后 `checkout.addPaymentItemAsync`。
- 钱箱：通过 `interaction?.utils?.postMessageToApp({ module: 'till', key: 'open_till' })` 打开。

目标替换：

- `CashPaymentModule` 基本保留为纯 UI，但 `roundingFunction` 改接 BaseSales 暴露的舍入能力。
- EFTPOS/MX51 继续依赖 `pay/toB/PaymentMethods`。`PaymentModal` 只负责调用 `PaymentMethods` 发起支付，拿到 tender result 后交给 BaseSales 写入支付项；pisellos/BaseSales 不实现具体 EFTPOS/MX51 支付过程。
- 钱箱打开作为支付动作的副作用，由统一支付操作内部调用一次。不要在 `onPaymentItemAdded` 监听里再次打开。

已定方案：

- 不移除 `pay/toB/PaymentMethods` ref 依赖；该组件仍是 EFTPOS/MX51 真实支付流程的执行者。

字段映射：

- 现金支付“实际收款 / 找零 / rounding_amount”的字段映射对齐 `/Users/hansuku/Desktop/WorkSpace/more/pisell_os/src/modules/Order/TEMP_ORDER_FIELDS_SUMMARY.md`。
- `rounding_amount` 写入支付项顶层 `payments[].rounding_amount`。
- 实际收款写入 `payments[].metadata.actual_paid_amount`。
- 找零金额写入 `payments[].metadata.change_given_amount`。

## 迁移步骤

### Step 1：定义 PaymentModal 新输入协议

新增或重写 `PaymentModal` props，使它接收：

- `open`
- `baseSalesModuleName: string`
- `paymentModuleName?: string` 或 `payment?: PaymentModule`
- `orderSnapshot`
- `amountSnapshot`
- `paymentResultDisplayMode`
- `config`
- 事件回调：客户变更、查看详情、打印、发送支付链接、关闭、支付完成。

删除旧 props：

- `data`
- `shopDiscount`
- `currentTotalPrice`
- `onChangeShopDiscount`
- `onChangeOrderNote`
- `onDeleteOrderNote`
- `setEditCartMode`
- `onSetLocalOrderId`

验收：

- `PaymentModal` 类型中不再出现 `PaymentModalData.order_info/subtotal_info` 作为核心输入。
- `PaymentModal` 不再从 props 接收购物车 state 派生字段。

### Step 2：改造 plugin 为唯一打开入口

`packages/private-materials/src/components/checkout/plugin.tsx` 负责：

- 从 host action 参数读取 `baseSalesModuleName`，并校验其存在。
- 透传可选的 `onCustomerChange` 回调给 `PaymentModal`，用于客户变更后同步外部模块。
- 不在 plugin 内获取 BaseSales，也不在 plugin 内加载或恢复订单。
- 如果 `baseSalesModuleName` 缺失，阻止打开并给出明确错误。
- 渲染 `PaymentModal`。

删除：

- 插件中 `_getBookingDetail`、`_formatBookingDetail`、`formatValues`、`getSumTotal` 复原购物车结构的流程。
- 插件中为了还原 `existPayment` 调用 `checkout.getPaymentMethodsAsync` 的逻辑。

验收：

- `plugin.tsx` 不再 import booking footer 的 `formatValues` / `getSumTotal`。
- `plugin.tsx` 不再构造 `order_info/subtotal_info`。
- `plugin.tsx` 打开前始终能得到 `baseSalesModuleName` 或明确报错。
- `PaymentModal` 根据 `baseSalesModuleName` 获取不到 BaseSales 模块时，必须阻止支付并给出明确错误。

### Step 3：删除 footer 直接挂载 PaymentModal

`packages/private-materials/src/components/booking/components/footer/index.tsx` 改为：

- 移除 `PaymentModal` import。
- 移除 `isPaymentModalOpen`、`paymentModalData`、`paymentCallback`、`editOrderId` 中仅为直接弹窗服务的状态。
- `handleCheckoutInternal` 不再 `setIsPaymentModalOpen(true)`；统一调用 `state.action({ type: 'pisell1.handleOpenPayment', data, callback })`。
- 如果 footer 仍需保存当前编辑内容，应保存进 BaseSales 本地订单，而不是拼 `modalData`。

验收：

- footer JSX 中不存在 `<PaymentModal ... />`。
- footer 中不存在 `setPaymentModalData` / `setIsPaymentModalOpen` 的 checkout 打开路径。
- checkout 打开链路只经过 `pisell1.handleOpenPayment`。

### Step 4：收掉 footer 支付结果监听和打印

删除 footer 约 `checkout:onOrderSubmitStart`、`checkout:onOrderSubmitEnd`、`checkout:onOrderSynced` 的支付结果副作用：

- 不再在 footer 里根据 `checkout:onOrderSynced` 展示 `PaymentResultToast`。
- 不再在 footer 里调用 `nativePrint` 打印完整订单。
- 不再通过 `isCurrentOrderSynceFromCart` 判断“来自购物车同步”的支付结果。

新增统一能力：

- `presentPaymentResult`：支付动作完成后根据 `syncPaymentsToOrder` / `submitTempOrder` 的结果展示 paid / partially_paid / failed。
- `printReceipt`：独立方法，被支付完成流或 toast 按钮显式调用。

验收：

- footer 中不存在 `checkout:onOrderSynced` 支付结果展示逻辑。
- paid / partially_paid / failed 的 toast 只由一处统一编排产生。
- 自动打印和手动打印都走 `printReceipt`。

### Step 5：PaymentModal 改接 BaseSales

替换核心方法：

- 初始化：不再 `checkout.createLocalOrderAsync` / `updateLocalOrderAsync`，改读取 `BaseSales.getOrderSnapshot()`。
- 支付方式：改读 BaseSales 当前订单上下文提供的支付方式列表。
- 支付项：改读 BaseSales 当前订单上下文提供的支付项列表。
- 添加支付项：调用 BaseSales 当前订单上下文的支付写入方法。
- voucher 覆盖：调用 BaseSales 当前订单上下文的 voucher 覆盖方法。
- 删除支付项：调用 BaseSales 当前订单上下文的支付删除方法。
- 余额判断：使用 BaseSales 当前订单上下文返回的剩余金额或支付同步结果。
- 订单提交：`BaseSales.submitSalesOrder({ payments, paymentStatus })` 或 `OrderModule.syncPaymentsToOrder({ payments, submitWhenPaid })`。

移除旧 effects：

- `checkout:onStateAmountChanged`
- `checkout:onBalanceDueAmountChanged`
- `checkout:onOrderSubmitStart`
- `checkout:onOrderSubmitEnd`
- `checkout:onPaymentItemAdded`
- `checkout:onOrderSynced`

验收：

- `PaymentModal.tsx` 不再出现 `pisellos?.getModule('checkout')`。
- `PaymentModal.tsx` 不再出现 `CheckoutHooks`。
- 支付动作完成后能同步刷新 `paymentItems`、金额快照和结果 toast。

### Step 6：子组件事件化

按组件拆分：

- `SearchAndClientModule`：改为纯搜索/客户显示组件，输出 `onSearch`、`onClientChange(client, meta)`。移除 `useBookingTicket` 和 `CartClientCard` 的隐式 state 写入，或将客户卡封装为受控组件。
- `AmountSummary`：移除 `checkoutModule.getCartSummary`、`checkoutModule.deletePaymentItemAsync`。summary 由 props 输入，void 支付项通过 `onVoidPayment(paymentUuid)` 回调交给 PaymentModal。
- `WalletPassModule/useWalletPass`：显式接收 BaseSales 钱包/折扣上下文，不再 `pisellos.getModule('checkout')`。所有选中结果通过 `onSelectChange` 输出，写支付项由 PaymentModal 调用 BaseSales 完成。
- `PaymentOptionsModule` / `PaymentMethodItem`：保持 UI，`onClick(method)` 返回含输入金额和手续费的数据；不直接写支付项。
- `CashPaymentModule`：保持 UI，`onPaymentComplete(result)` 由 PaymentModal 调用 BaseSales 写支付项；舍入函数由 BaseSales 暴露。
- `AdditionalModule`：保持 UI，按钮事件交给 PaymentModal，不直接处理外部能力。
- `SavePayLaterHandler`：保持确认 UI，`onSavePayLater(action)` 由 PaymentModal 触发 BaseSales/Order 保存逻辑。
- `SendPaymentLinkModal`：保持 UI，发送动作事件化。

验收：

- 子组件不再直接依赖旧 Checkout。
- 需要外部状态的组件都通过 props + callback 明确读写。

### Step 7：支付结果与打印单点化

建议新增本地工具文件，例如：

- `packages/private-materials/src/components/checkout/payment-result-flow.ts`
- `packages/private-materials/src/components/checkout/printReceipt.ts`

职责：

- `resolvePaymentStatus({ amountSnapshot, paymentItems, syncResult })`
- `presentPaymentResult({ status, orderTotalAmount, gapAmount, changeGivenAmount, failureReason })`
- `printReceipt({ nativePrint, orderId, printPayload, type })`

注意：

- `PaymentResultToast` 继续作为 UI 层，不再承担订单同步和打印判断。
- 自动打印配置 `auto_print_receipt` 仍可通过 `interaction.utils.asyncDataManager` 读取，但读取点只能在统一支付结果流。

验收：

- `PaymentModal` 和 `footer` 不再重复计算 toast 参数。
- 打印防重复逻辑若仍需要，放在 `printReceipt` 内部，不散落在监听器里。

### Step 8：PaymentModal 体积治理与可维护性拆分

目标不是为了拆而拆，而是在功能不崩的前提下把 3000+ 行文件中的稳定边界抽出来，降低后续支付、定金、钱包、打印改动的互相影响。

建议拆分方向：

- `usePaymentModalBaseSales`：根据 `baseSalesModuleName` 获取 BaseSales，读取订单快照、金额快照、支付项、客户、定金状态，并暴露刷新方法。
- `usePaymentActions`：封装现金、自定义支付、wallet pass、EFTPOS/MX51 tender result 写入、save for later、void payment 等动作；所有动作都通过 BaseSales 当前订单上下文执行。
- `useCustomerGuard`：封装客户切换锁定规则，包括 `voucher_id` 非 0 的 wallet pass 支付项判断、非 wallet pass 支付项存在时禁止切换、切换后重拉 wallet pass。
- `useDepositStage`：封装定金默认态、锁定态、定金足额后自动切普通支付，以及支付项 `type` 写入规则。
- `payment-result-flow.ts` / `printReceipt.ts`：承接 Step 7 的结果展示、自动打印、手动打印和防重复逻辑。
- `PaymentModalView` 或 `PaymentModalLayout`：只接收视图 props，负责 header、summary、tabs、footer、弹窗布局和响应式结构，不直接读 pisellos。

拆分约束：

- 每一步拆分都要保持现有可验证行为，避免在同一个 commit 中同时做大规模 UI 重排和支付语义替换。
- 优先抽纯函数和 hook，再抽布局组件；先拆低风险工具，再拆支付动作。
- 新抽出的 hook 不直接依赖旧 `checkout` module，不引入新的全局事件监听。
- `PaymentModal.tsx` 最终只保留入口 props、BaseSales module 获取、主要 hook 调用、事件桥接和视图组合。

验收：

- `PaymentModal.tsx` 不再承载支付结果计算、打印执行、客户切换规则、定金阶段判断等细节实现。
- 新增 hook / 工具均有清晰输入输出，能通过 mock BaseSales 做单元测试。
- 文件体积显著下降；不要求一次性追求固定行数，但后续新增支付能力不应继续把业务逻辑堆回 `PaymentModal.tsx`。

## 验收标准

- `PaymentModal.tsx` 中不再直接调用 `pisellos.getModule('checkout')`。
- `PaymentModal.tsx` 中不再定义或监听 `checkout:*` hook。
- `PaymentModal.tsx` 完成体积治理，订单读取、支付动作、客户切换、定金阶段、结果展示和打印逻辑分别拆到 hook / 工具 / 子组件中。
- `plugin.tsx` 是唯一 checkout 弹窗打开入口。
- `footer/index.tsx` 不再 import 或 JSX 挂载 `PaymentModal`。
- `footer/index.tsx` 不再展示支付结果 toast 或调用支付完成打印。
- 点击现金支付、普通自定义支付、EFTPOS/MX51 成功回调、wallet pass 支付都走同一条：调用 BaseSales 当前订单上下文写入支付项 -> 同步/提交 -> 判断全额/部分 -> 展示结果 -> 可选打印。
- 打开 checkout 时必须传入 `baseSalesModuleName`，`PaymentModal` 只操作该 moduleName 对应 BaseSales 的当前订单上下文。
- 支付项进入 Sales payload 时符合 OS `OrderPaymentData`：不提交 `isSynced`，`uuid` 进入 `metadata.unique_payment_number`，`id` 归一为 `custom_payment_id`。

## 测试与验证计划

### 单元/组件测试

- `PaymentModal`：mock BaseSales，覆盖现金支付 5 元后部分支付和全额支付两条路径。
- `usePaymentActions`：mock BaseSales，覆盖现金支付、EFTPOS/MX51 tender result 写入、wallet pass 覆盖、save for later、void payment。
- `useCustomerGuard`：覆盖无支付项、仅 wallet pass 支付项、存在非 wallet pass 支付项三种客户切换规则。
- `useDepositStage`：覆盖默认定金、手动切普通支付、定金足额后锁定普通支付。
- `AmountSummary`：只通过 props 渲染 summary 和 paymentItems，void 按钮触发回调。
- `SearchAndClientModule`：客户变更必须把新客户对象带出。
- `useWalletPass`：不依赖 checkoutModule 时仍能完成推荐、选择、清空、搜索回调。
- `payment-result-flow`：paid / partially_paid / failed 的 toast 入参计算稳定。
- `printReceipt`：自动打印和手动打印都只调用一次 nativePrint。

### 集成测试

- 打开 checkout 时传入有效 `baseSalesModuleName`：`PaymentModal` 通过 `pisellos.getModule(baseSalesModuleName)` 获取对应 BaseSales，并读取其当前订单上下文。
- 打开 checkout 时 `baseSalesModuleName` 缺失：plugin 阻止打开并提示调用参数错误。
- 打开 checkout 时 `baseSalesModuleName` 对应模块不存在或不是可结账的 BaseSales 实例：`PaymentModal` 阻止支付并提示模块错误。
- 现金支付 5 元：先通过 BaseSales 写入支付项，再调用 BaseSales/Order 判断，结果展示部分支付或全额支付。
- wallet pass 覆盖：选择/取消 voucher 后 BaseSales 当前订单支付项与 UI 金额一致。
- 仅存在 wallet pass 支付项时切换客户：允许切换，并重新拉取 wallet pass。
- 已存在非 wallet pass 支付项时切换客户：禁止切换，不在子组件内部静默改 state。

### 回归验证

- Web POS `pisell1.handleOpenPayment` 仍能打开插件 checkout。
- terminal / Web POS 查看详情、打印、开钱箱分别由 host 能力处理。
- 支付成功后不会出现 footer 和 PaymentModal 双 toast / 双打印。
- 订单备注在未提交订单和已提交订单场景分别符合确认后的 API。

## 风险

- `PaymentModal` 当前 3000+ 行，订单、支付、toast、打印、备注、响应式 UI 混在一个文件；直接替换 API 风险高，应先拆编排工具再替换调用。
- `SearchAndClientModule` 当前只输出“客户变更了”，没有输出客户值；如果不先事件化，会继续依赖 booking 外部 state。
- 钱包/折扣同时涉及 `PaymentModule.wallet` 和 `useShopDiscountModule`，如果归属不清会再次形成双事实来源。
- 入口参数统一为 `baseSalesModuleName` 后，调用方必须保证 moduleName 指向要结账订单所属的 BaseSales 实例；传错 moduleName 会直接操作错误订单上下文。
- 多 BaseSales / 扩展 BaseSales 并存时，必须明确模块命名和生命周期，否则 `PaymentModal` 可能拿到已销毁、未初始化或非当前页面的实例。
- 已有订单云端支付项 `existPayment` 当前由 plugin 从 bookingOrigin.payments 转换。新链路必须确认目标 BaseSales 加载订单时如何把历史支付项同步到当前订单支付上下文。
- B 端打印数据依赖 OrderModule 下单参数 `small_ticket_data_flag: 1`；如果调用方漏传，支付成功后的打印上下文会缺少小票数据。

## 待确认问题

当前无待确认问题；后续实现阶段若发现 BaseSales / OrderModule API 缺口，再按具体方法补充。
