# Web POS 员工权限

`PosPermission` 保留子元素原来的 `onClick`。当前员工有权限时同步执行；无权限时由 Web POS 宿主弹出员工选择和 PIN 授权，成功后继续原操作一次。

```tsx
import { PosPermission, usePosPermission } from '@pisell/private-materials';

<PosPermission permissionKey="order_refund_amount" scopeKey={orderId} active={open}>
  <Button disabled={refundDisabled} loading={refunding} onClick={() => handleRefund(orderId)}>
    Refund
  </Button>
</PosPermission>
```

组件不新增 DOM 包裹层，也不覆盖子元素的样式、布局或 ref。只接受一个可透传 `onClick` 的 React 元素；业务操作必须写在该元素的 `onClick` 中。`disabled`、`loading`、`aria-disabled` 会阻止执行。

授权弹窗的员工搜索框不会自动聚焦；Enter ID 使用非输入框的数字展示区和自带数字键盘，配有 Clear / OK，避免自动唤起系统键盘。手动点击搜索框后仍可按姓名或 ID 搜索。

授权成功后直接调用回调，不使用 DOM `.click()`。不要依赖异步回调中的 `event.currentTarget`，或依赖链接、表单的默认行为重放；导航和提交应由回调显式执行。父级 `onClickCapture`、子组件内部不透传的事件不在包装组件的拦截范围内。

## 动作配置、菜单和 onTrigger

```tsx
const { runWithPermission } = usePosPermission({
  scopeKey: orderId,
  active: open,
  isAvailable: () => !disabled && !loading,
});

const onTrigger = () => runWithPermission('order_edit', () => openEdit(orderId));
```

| 参数 | 说明 |
| --- | --- |
| `permissionKey` | `order_edit`、`order_refund_amount`、`order_refund_item`、`discount` |
| `scopeKey` | 订单、商品或弹窗会话的稳定标识；变化后取消等待中的操作 |
| `active` | 默认为 `true`；缓存页面或弹窗隐藏时传 `false` |
| `isAvailable` | 点击和授权完成时重新检查业务可操作性 |
| `enabled` | 默认使用现有 `appHelper.utils.isWebPos` 判断；非 Web POS 保持原业务行为 |

`usePosPermission` 同时读取页面已有的 `IsActiveProvider`，不为权限功能增加 Provider。组件卸载、显式 `active` 失活、`scopeKey` 变化时通过 `AbortSignal` 取消授权。不要将每次渲染新建的对象作为 `scopeKey`。

BigSale 和 SaleDetail 的缓存容器使用内部 `useCancelPosPermissionOnHide`：视图从显示变成隐藏时，取消当前正在等待的授权请求。初始隐藏的预加载视图不触发取消；授权通过后已经开始执行的业务回调不在此取消范围内。

`runWithPermission` 在直接放行时返回原回调结果；等待授权时返回 Promise；取消、不允许操作或其他授权正在进行时不执行回调。业务回调中的异常仍由业务处理。对同一入口的连续点击仅保留第一个待授权操作。

## 宿主契约

`saas_shop_pos` 在初始化时注册 `app.usePlugin('posPermission', posPermissionService)`，并在页面根节点挂载一份 `PosPermissionModal`。组件库通过 `appHelper.utils.getApp()` 访问插件；Web POS 缺失插件时拒绝操作并显示提示。

```ts
interface PosPermissionService {
  hasPermission(key: PosPermissionKey): boolean;
  authorize(key: PosPermissionKey, options?: { signal?: AbortSignal }): Promise<boolean>;
}
```

宿主每次判断都读取当前账号的 `pos_permissions`，精确匹配 key。字段缺失、空数组均无权限，`order` 不代表子权限；All Permissions 由接口展开为具体 key。

员工列表和 PIN 验证直接使用宿主的 `app.request.get/post`，复用现有请求、响应和错误处理，按请求层默认行为不启用缓存。PIN 验证仍调用 `/pos/v1.1/account/pos-login`，只提取返回的 `account.pos_permissions`，不保存响应体中的 token、当前员工、登录状态、PIN 或授权记录；响应头的 token 刷新沿用现有请求层行为。

宿主同一时间只保留一个授权请求。关闭、取消、路由变化、切换当前员工/店铺或锁屏会使请求失效；迟到的结果不能触发业务回调。

## 已接入入口

| 权限 | 入口 |
| --- | --- |
| `order_edit` | SaleDetail 两套 Edit 动作；旧版预约底部销售及 Addons 的 Web 编辑入口 |
| `order_refund_amount` | SaleDetail Refund（包含支付区域的退款回调）、旧版底部和取消预约弹窗 Refund |
| `order_refund_item` | SaleDetail 两套 Return item 动作，与 Edit 分别校验 |
| `discount` | BigSale 共享动作 `adjust-order-amount` / `promotion`、SKU Manual Price Change、结账 `manual-discount` 分支（Order Discount / Balance Due） |

授权放在流程入口。已经进入的编辑、退款或改价流程内部确认按钮不重复授权；退出后重新进入仍按当前员工权限判断。浏览订单和商品不增加权限要求。

## 人工验收清单

- 四个 key 分别验证有权限直接执行、无权限弹窗、其他员工授权后自动执行一次。
- 只有 `order_refund_item` 的员工可进入 Return item，不额外要求 `order_edit`。
- 员工列表搜索、输入 ID、更换员工、4 位 PIN 自动验证、键盘输入、取消和关闭。
- 员工不存在、PIN 错误、权限不足、网络错误均不放行；PIN 清空后可重试。
- 授权后当前员工、店铺及登录状态不变，不保存授权员工返回的 token；再次进入重新要求授权。正常的响应头 token 刷新按统一请求层行为处理。
- 连续点击、验证中重复输入、验证中关闭、切换订单/商品、隐藏缓存页面、路由或当前员工变化均不能恢复过期操作。
- 原登录页面的员工切换、PIN 登录、退出、店铺和设备区域保持正常。

本功能未运行 build/test。宿主使用外部组件库产物，联调时需让现有组件库加载流程使用包含这些改动的产物。
