# HDLKiosk 设备、扫码与称重

> 范围：Scanner、Scale、自动入口、设备生命周期、任意页面扫码和称重商品写入。

## 1. 结论与边界

扫码应当是 **Shell/Session 级输入能力**，不属于 Entry，也不属于某个 Slice。目标方案如下：

- HDLKiosk 活跃期间只保留一个物理扫码消费者；
- Entry 首码先创建并激活 Session，再由该 Session 消费条码；当前 Barcode 首屏是 Catalog 只是 Workflow 配置结果，不是扫码前置条件；
- 已进入 Quick、Barcode 或 Scale Workflow 后，在购物车仍可写的页面都能扫码加购；
- 扫码任务进入统一的有限队列，串行调用 `barcode.consume`，不由 Screen 直接调用 SalesSDK；
- 支付执行中、支付结果不确定、订单结束或正在 reset 时拒绝扫码写入；
- 支付尚未 dispatch 且购物车可写时允许扫码；任何购物车、会员或卡券变更都会使 Checkout Prepared 状态失效，支付协调器在真正支付前保证当前订单版本已 prepare；
- 仅允许商品码。现有通用扫码 API 会执行多种业务码，必须补充“先分类后执行”或商品专用 API；
- 不记录、持久化或写入遥测原始条码。

## 2. 运行链路

### 2.1 Scanner 链路

```text
Native scanner / pubsub
→ BigSale scanner adapter
→ ScannerIngress.accept
→ ScannerSessionRouter（Entry / starting / Flow 共用 bounded FIFO）
  ├─ idle：首码建立 startId 并触发 onScan
  ├─ starting：后续扫码追加到同一 FIFO
  └─ active：Flow 扫码继续追加到同一 FIFO
→ Application Runtime admission/open/create/prepare/attach
→ Registry started
→ Scanner Attachment activate
→ barcode.consume(eventId-scoped intent)
```

安全能力：

- Scanner adapter 优先申请 `hdl-kiosk` 扫码焦点，无法使用焦点栈时才回退 pubsub；
- ScannerIngress 已有 eventId/LRU 去重、350ms 降级指纹去重和 payment freeze 门禁；
- ScannerSessionRouter 使用真实未完成任务数做 5 项背压，Quick/Scale 启动期间的扫码会等待同一个 pending Session；
- `barcode.consume` 使用 cart scope，且不设置 Operation 超时；`added` 才执行写后 cart consistency、Summary 和 receipt/fingerprint 验证，`requiresSelection` 等待 Host 选择流程；
- 支付 Operation 通过 Checkout freshness 检查复用或建立当前事实版本的 token；
- 本地 cart/customer 写入会把 Prepared 状态标记为 `stale`，外部写入由 fingerprint/Summary/requirement 复验识别。

### 2.2 Host 与交互边界

1. BigSale adapter 仍使用通用 `executeScan/handleGlobalScanCode`，它可能执行商品以外的业务；结果映射又丢失 `kind`、`promotionResult` 等信息。
2. `requiresSelection` 只表示中间态，没有最终完成、取消或超时后的闭环协议。
3. `partial/unknown` 会触发 Actor 阻断，但扫码恢复 UI 和对账动作还没有在阻断前完整建立。
4. 焦点栈不可用时，HDLKiosk fallback listener 与 SalesSDK 自身 listener 可能同时消费同一物理事件。
5. SalesSDK 已有“扫码处理中禁止支付、支付中屏蔽扫码”的活动上下文，但尚未通过 Host Bridge 接入独立 HDLKiosk Controller。
6. 当前只有遥测反馈，队列状态、商品处理中/成功/失败和 Bottom Bar disabled reason 尚未投影到 Shell UI。

## 3. 架构

Scanner 属于无头 Runtime 设备层，与 React Entry/Workflow View 解耦：

```text
Application Runtime
├─ Trigger Contract / Adapter
├─ Admission / Session lifecycle / Registry
├─ ScannerSessionRouter
│  ├─ scanner focus / subscription
│  ├─ ingress validation + dedupe
│  ├─ bounded task queue
│  ├─ startId / activation generation
│  └─ attach/activate/dispose ScannerSessionPort
├─ ScannerSessionPort
│  ├─ cart/payment policy
│  ├─ navigation policy
│  └─ barcode.consume dispatcher
└─ React Session Controller（只订阅 Runtime Snapshot）
   ├─ Entry View
   └─ Workflow View
   ├─ Header
   ├─ current Slice
   ├─ Cart Overlay
   └─ global Bottom Bar
```

Controller 依赖 Session/Projection/Payment 状态，但不依赖具体 Screen。Screen 只展示全局扫码反馈，不能订阅硬件或直接执行扫码。

建议增加以下内部模型：

```text
ScanTask
  eventId              稳定事件 ID
  receivedAt           接收时间
  sessionEpoch         接收时关联的会话世代
  target               pending session 或 active session
  status               queued | running | waitingSelection | completed | failed

ScanPolicyDecision
  accept               进入队列
  acceptAndInvalidateCheckout
                       成功后使当前 checkout prepared 状态失效
  pause                已接收，但等待阻塞型 UI 关闭后执行
  reject               不进入队列，给出明确原因
```

原始 `code` 只允许存在于内存任务中，任务结束或 Session reset 后立即释放。日志只记录 eventId、状态、耗时和非敏感错误码。

## 4. 设备所有权与生命周期

- HDLKiosk active 时由 ScannerSessionRouter 申请焦点；KeepAlive 隐藏、unmount 或 capability 消失时释放。
- 页面切换不重建订阅，Session 切换只更新扫码策略和任务归属。
- Scanner adapter 必须成为 HDLKiosk 生命周期内唯一的物理消费者。
- 有焦点栈时，HDLKiosk 持有焦点，会员登录等更高优先级的本地交互可暂时抢占。
- 无焦点栈时，必须能显式暂停 SalesSDK 的全局扫码 listener；如果 Host 不提供该能力，生产环境应把 Scanner capability 标记为不可用，不能依赖两个 listener 的短窗去重。
- 复用 SalesSDK 的 global scan/payment activity context，形成双向门禁：队列 running/waitingSelection 时禁止支付；支付 processing 时禁止新扫码任务。

## 5. Entry 首码：先激活 Session，再消费

Entry 收到合法商品扫码事件时：

1. ScannerIngress 完成 capability、去重和背压校验；
2. Entry Arbiter claim `barcode`；
3. `session.ensure` 创建或恢复安全订单；
4. 创建 Barcode Session，并把首码任务绑定到该 `sessionEpoch`；
5. `setSession`，由 Session Controller 完成 Actor、Operation Coordinator、Host binding 和 Projection subscription 的安装；
6. Session Runtime 进入 `active` 并发布与 Screen 无关的 `sessionReadyForCommands`，同时由交易策略给出独立的 `sessionCartWritable`；
7. ScannerSessionRouter 把首码交给该 Session，串行执行 `barcode.consume`；
8. 成功后刷新 Projection/Summary；失败则留在该 Session 的当前页面展示可恢复错误，不回退到 Entry；
9. 只有无法建立安全 Session 时才释放 claim 并返回 Entry。

`sessionReadyForCommands` 是 Session Runtime 的生命周期状态，不由 Catalog 或任意 Screen 的 mount/commit 事件驱动。未来某类 Workflow 即使以 Identity、Fulfillment 或其他页面作为首屏，也不需要改 ScannerSessionRouter。

这里应区分三个概念：

- `sessionCreated`：已有 Session identity，但运行时依赖可能尚未安装；
- `sessionReadyForCommands`：Session 已激活，能够安全接受串行 Operation；
- `sessionCartWritable`：当前交易阶段允许修改购物车。

首码等待前两者完成且第三项为真。UI 只是订阅并投影 Session 状态，不承担解除扫码任务的职责。

若 Quick/Scale Session 正在 `ensure`，此时收到的扫码任务应绑定到该 pending session，等 Session 发布和目标页面可写后消费；不得另起 Barcode Session，也不得静默丢弃。

为避免重放，扫码 intent id 使用 `scan:{sessionEpoch}:{eventId}`。eventId 必须从设备事件一直透传到 Operation，不能在 adapter、ingress 和 command 各自重新生成。

## 6. 任意页面扫码策略

“任意页面可扫码”指任意 **购物车仍可安全写入** 的页面，而不是在支付结果或 reset 阶段强行改订单。

| 当前状态/页面 | 策略 | 扫码成功后的行为 |
| --- | --- | --- |
| Entry、无 Session | 接受 | 创建并激活 Barcode Session，由 Session 消费首码 |
| Entry、Quick/Scale 正在启动 | 接受并暂存 | 绑定 pending session，Session 可写后消费 |
| Identity | 接受 | 留在 Identity，刷新全局购物车 |
| Fulfillment | 接受 | 留在 Fulfillment，刷新全局购物车 |
| Customization | 接受 | 留在 Customization；主称重商品码须走防重复规则 |
| Catalog | 接受 | 留在 Catalog，刷新商品与金额 |
| Cart Overlay | 接受 | Overlay 保持打开并刷新列表、小计 |
| 主商品 Prompt、会员登录等阻塞弹层 | 接受但暂停 | 弹层结束后按接收顺序执行 |
| End Order 确认弹层 | 接受并取消退出 | 取消当前 `pendingExit`，再处理扫码 |
| 支付尚未 dispatch、Session 仍可写 | 接受并使 prepared 失效 | 留在当前页面；支付前由 Checkout Coordinator 保证重新 prepare |
| EFTPOS 已 dispatch | 拒绝 | 提示支付处理中，订单不可修改 |
| 店员支付授权 Command 执行中 | 拒绝 | 保持 Payment 页，等待当前操作结束 |
| payment pending/unknown/succeeded | 拒绝 | 进入既有支付恢复/结果流程 |
| Payment Completed、blocked、resetting | 拒绝 | 不修改订单，等待新 Session |

扫码不触发 Workflow `Next`，不改变当前节点。Barcode Flow 的首码也只是启动方式，不意味着后续扫码只能发生在 Barcode Flow。

Customization 需额外识别当前主称重商品：扫描同一主商品不能按普通商品重复加购，应提示用户通过称重详情修改；其他普通商品仍可加入。

## 7. 队列、去重与背压

ScannerSessionRouter 维护真实的有限 FIFO 队列：

- 同一 Session 内最多保留建议 5 个待处理任务，running/waitingSelection 也计入占用；
- 队列满时立即拒绝，并提示等待当前商品处理完成；
- 不同 Session 的任务不得串用；启动以 startId 绑定，激活和 Session generation
  变化时隔离旧结果；
- 同 eventId 只接受一次；无稳定 eventId 的设备继续使用非明文 350ms fingerprint 作为降级方案；
- cart mutation scope 串行执行，不在多个 Screen 中并行调用 `barcode.consume`；
- 将真实 `pendingEventCount` 传入 ScannerIngress，删除固定 `0`；
- `requiresSelection` 拒绝在选择态建立前已经排队的旧任务并结束本轮 drain；选择弹层
  通过 Scanner focus stack 临时接管物理扫码，关闭后新扫码按正常准入重新进入队列。

## 8. 只消费商品码

业务要求是“扫码添加商品”，所以必须在副作用发生前确定条码类型。优先级如下：

1. Host 提供 `classifyBarcode` + `consumeProductBarcode`；
2. 或提供原子的 `consumeProductBarcode`，非商品码无副作用返回 `unsupported`；
3. 临时阶段才复用通用扫码 API，但必须保留完整 `kind`、`promotionResult`、商品 receipt 和选择态结果。

通用 `handleGlobalScanCode` 已经执行完再返回 `kind`，因此“执行后发现不是商品再拒绝”不能作为生产方案：会员码、订单码或促销码可能已经产生不可逆业务副作用。

建议扩展 HDLKiosk Host 结果：

```text
added              商品已加入，含可验证 receipt
requiresSelection  等待 Host 选择 UI 的最终结果
notFound           未找到商品
unsupported        非商品码或当前渠道不支持
cancelled          用户取消选择
failed             已确认无写入的失败
partial            可能部分写入，需要对账
unknown            结果未知，禁止盲目重试
```

## 9. `requiresSelection` 完成协议

固定 Operation 超时不能代替完成协议，也不能限制顾客的选择时间。Host 必须返回可等待的 selection handle，或通过稳定 callback/event 终结同一 eventId：

```text
requiresSelection
→ completed(receipt)
  | cancelled
  | failed(noWrite)
  | timeout(unknown)
```

等待期间：

- 队列状态为 `waitingSelection`；
- 全局扫码 UI 显示“请完成商品选择”；
- 禁止 Checkout 和支付；
- 用户取消后继续下一任务；
- 超时不能当成普通失败重试，必须进入 unknown 对账。

## 10. Checkout Prepared 状态与支付安全

Checkout prepare 不是公开 Operation，也不绑定 Catalog Checkout、Payment Method mount 或某个 Workflow 事件。它是支付派发函数内部维护的、绑定以下事实版本的可失效状态：

- session epoch；
- cart fingerprint/revision；
- customer、voucher/promotion revision；
- Summary 应付金额和币种；
- payment capability/terminal context。

建议将状态建模为：

```text
CheckoutPreparation
  idle
  | preparing(version)
  | prepared(version, safetyToken)
  | stale(previousVersion)
  | failed(version, reason)
```

任何会改变上述版本的 Operation，包括扫码、商品编辑、会员同步、卡券切换和备注中会影响订单定价的写入，都只负责发布事实变更并把 `prepared` 标记为 `stale`，不直接执行支付准备。

真正发起任意支付方式前统一调用：

```text
ensureCheckoutPrepared(currentVersion)
→ 复用同版本 prepared
  | 等待同版本 preparing
  | 对当前版本执行内部 prepareCheckout
→ 校验返回 token 仍匹配 currentVersion
→ payment dispatch
```

因此支付安全流程是：

1. 立即作废旧的 Checkout Safety Token；
2. 等所有写操作、cart consistency 和 Summary 刷新完成；
3. 用户请求支付时，由支付协调器对当前事实版本执行 `ensurePrepared`；
4. prepare 成功且版本仍匹配时才允许 dispatch；
5. prepare 过程中事实再次变化则丢弃旧结果，针对新版本重新判断；
6. prepare 失败时由当前 Workflow 的全局错误呈现机制展示，不要求停留在某个固定 Screen。

支付 dispatch 一旦开始，ScannerIngress 必须在任务入队前拒绝；不能先排队等支付结束后再写入旧订单。

## 11. 失败、对账与恢复

扫码失败按写入确定性处理：

- `notFound/unsupported/cancelled/failed(noWrite)`：留在当前页面，显示短时反馈，可继续扫；
- `partial/unknown`：停止队列、禁用 Checkout，刷新 Projection，等待 cart consistency，重算 Summary，并按 receipt/fingerprint 判断商品是否实际加入；
- 对账确认已写入：把任务标记 completed，不重试；
- 对账确认未写入：允许用户显式重试，同一 intent 仍需幂等；
- 无法确认：进入全局阻断恢复态，提供重试对账、店员协助或安全 reset，不得自动再次消费原始条码。

当前 Actor 会对 Operation 的 `partial/unknown` 直接阻断。实现时应让扫码控制器在 Actor 最终 blocked 前先持有恢复状态和必要的非敏感对账信息，或增加专用 scanner failure mode；不能在 Actor 已不可交互后才尝试打开恢复 UI。

## 12. 全局扫码反馈

反馈 UI 属于 `HDLKioskWorkflowView`/Shell 的组合区域，不放进每个 Slice：

- 顶部或 Catalog 内容区显示当前商品“处理中/已加入/未找到”；
- Bottom Bar 的支付动作在队列非空、等待选择、Checkout facts 不稳定或恢复期间禁用；非支付 Next 是否禁用由当前 Activity policy 决定；
- Cart Overlay 打开时实时刷新商品与小计；
- 队列满、支付冻结、非商品码使用统一 toast/banner；
- 登录弹层、主商品 Prompt 等只控制执行暂停，不接管硬件订阅。

## 13. 分阶段实施

### Phase 1：移动所有权并支持页面内扫码（已完成）

- 新建无头 ScannerSessionRouter；
- 订阅生命周期从“仅 Entry 无 Session”改为“HDLKiosk active”；
- 建立 scan policy、真实队列和 session epoch；
- Quick/Scale/Barcode 的可写页面共用 `barcode.consume`；
- scan/payment activity context 留待 Phase 3 的 Host Bridge 契约。

### Phase 2：Session 接管首码与 Checkout freshness（核心已完成）

- 把首码从 `prepareHDLKioskEntry` 移到 Session Runtime 的 `sessionReadyForCommands` 之后；
- 支持扫码任务绑定 pending Quick/Scale session；
- 引入版本化 Checkout Preparation，所有订单事实变更只使其失效，支付 dispatch 前统一 `ensurePrepared`；
- 全局反馈和 Bottom Bar disabled reason 尚待 UI 状态投影。

### Phase 3：生产 Host 契约

- 增加商品码专用分类/消费 API；
- eventId 端到端透传；
- 完整保留 kind、receipt、promotion 和 selection 信息；
- 补齐 `requiresSelection` 完成协议；
- 明确 fallback 模式下 SalesSDK listener 的暂停能力。

### Phase 4：异常恢复与实机验收

- partial/unknown 对账与恢复态；
- 扫码枪快速连扫、重复事件、断连重连和焦点抢占；
- 与 EFTPOS、店员付款、登录弹层、Cart Overlay、结束订单弹层交叉验证。

## 14. 验收矩阵

至少覆盖：

- Entry 首码只在 Session `readyForCommands && cartWritable` 后消费，替换首屏类型不影响扫码控制器；
- Quick/Scale 正在启动时扫码不会另起 Session；
- Identity、Fulfillment、Customization、Catalog、Cart Overlay 各自扫码后页面不跳转；
- 任意页面发生订单事实变更都会使 prepared 失效，所有支付方式 dispatch 前都经过同一个 `ensurePrepared(currentVersion)`；
- EFTPOS、店员支付授权 Command、payment unknown/completed/resetting 扫码无写入；
- 5 个连续扫码按序处理，第 6 个按背压策略拒绝；
- 同 eventId、350ms 内重复原始事件不重复加购；
- `requiresSelection` 完成、取消、超时分别闭环；
- 会员码、订单码、促销码不产生副作用；
- 主称重商品在 Customization 不被普通扫码重复加入；
- partial/unknown 不盲目重试，Projection 对账后才决定结果；
- 页面切换、KeepAlive、隐藏/恢复、扫码设备重连无重复监听。

## 15. 顶层 Scale Runtime

称重能力仍在 HDLKiosk 业务顶层共享，不跟随 Entry 或详情页反复挂载：

```text
useNativeScale（一次）
→ connection + raw weight
→ normalized scale runtime
├─ Entry：消费首次有效正重边沿，启动 Scale
└─ CustomizationScreen：持续读取毛重/皮重/净重/稳定态
```

Runtime 只保存 owner 发布的最新快照和递增 `sampleSequence`。断连、停止监听、读取错误、
负重或超载会立即使当前采样失效；重连后必须收到新的 native `onChange` 帧才能重新
启用提交。稳定读数不会因为顾客花时间选择规格而按固定秒数过期。

Entry 仍允许有效正重边沿（包括正在趋稳的读数）启动 Flow；这一步不写购物车。
Customization 必须同时满足当前连接周期已有新采样、连接并监听、状态 stable、无错误和
正净重阈值。点击 Next 时同步读取 Runtime 最新快照并冻结点击当下重量，避免 React 旧
render 与断连/抖动同批发生时提交旧值；捕获成功后顾客可以正常取走商品。

`SkuDetailModal` 属于有员工操作的通用商品场景，继续保留 Manual 和编辑态冻结快照；
但启动实时 Auto 后同样只有 `liveData + listening + connected + stable` 才输出
`is_valid=true`。两处共享安全原则，不强行共享不同的业务交互策略。

Scanner 与 Scale 共用 Entry Arbiter，但进入 Session 后互不抢占业务消费权。

`CustomizationScreen` 只维护称重和 options 草稿；点击 Next 后才由 `product.syncPrimaryWeightedProduct` 交给 OS 加购。最终金额、订单行和 Summary 始终以 Host Projection 为准。
