# 菜品管理与设备同步

## 背景

本次需求包含两部分：

- “餐厅管理 -> 菜品管理”的增、删、改操作，同步下发留样柜设备端
- “餐厅管理 -> 菜谱管理”的前厅配餐数据变更，同步下发留样柜设备端

设备协议来源：
- `外置秤盘留样柜设备接口协议.docx`
- websocket 下发方向为：`外部系统 -> 留样柜设备`
- 本次已实现：
  - 第 5.2 节 `foodInfo`，即菜品新增、删除、修改
  - 第 5.3 节 `preFoodInfo`，即配餐新增、删除、修改

协议关键信息：
- websocket 地址格式：`{root}/{mode}/{simNo}/{appVersion}/{androidDeviceId}`
- `mode` 固定为 `lockLyg`
- 消息格式：

```json
{
  "deviceType": "lockLyg",
  "msgType": "foodInfo",
  "deviceId": "67f88471cf3b5d04",
  "msgContent": "{\"msgType\":\"foodInfo\",\"dataStatus\":\"upd\",\"data\":[{\"id\":\"uuid\",\"foodName\":\"西红柿\",\"userName\":\"pc-admin\",\"userNo\":\"\"}],\"timestamp\":1752740213511}"
}
```

```json
{
  "deviceType": "lockLyg",
  "msgType": "preFoodInfo",
  "deviceId": "67f88471cf3b5d04",
  "msgContent": "{\"msgType\":\"preFoodInfo\",\"dataStatus\":\"add\",\"data\":[{\"id\":\"recipes-uuid\",\"foodId\":\"dishes-uuid\",\"foodName\":\"西红柿炒蛋\",\"mealTypeId\":\"2\",\"mealTypeName\":\"午餐\",\"date\":\"2026-03-17\",\"userName\":\"PC-admin\",\"userNo\":\"1\"}],\"timestamp\":1752740213511}"
}
```

## 本次改动

### 1. 菜品增删改后自动触发设备同步

已在餐厅端菜品服务中接入留样柜消息下发：
- `application/store/service/dishes/Dishes.php`

接入点：
- `add()` 成功后下发 `dataStatus=add`
- `edit()` 成功后下发 `dataStatus=upd`
- `setDelete()` 成功后下发 `dataStatus=del`

说明：
- 下发逻辑挂在业务成功分支，不影响原有称重台 MQTT 同步逻辑
- 原有 `EquipmentLogic->syncDishes()` 保持不变，仍用于称重台排菜
- 留样柜同步为新增链路，和称重台链路互不冲突

### 2. 新增留样柜 websocket 下发服务

新增文件：
- `application/common/service/equipment/WebSocketClient.php`
- `application/common/service/equipment/LockLygPush.php`

职责：

`WebSocketClient.php`
- 负责建立 `ws/wss` 连接
- 完成 websocket 握手
- 发送客户端文本帧
- 使用 PHP 原生 `stream_socket_client` 实现，避免新增 composer 依赖

`LockLygPush.php`
- 负责筛选可下发的留样柜设备
- 组装 websocket URL
- 组装 `foodInfo`、`preFoodInfo` 消息体
- 遍历设备逐台推送
- 将成功/失败信息写入日志

### 3. 菜谱管理配餐数据自动同步到设备

已在前厅菜谱保存链路中接入 `preFoodInfo` 下发：
- `application/p/logic/Bill.php`

接入点：
- `add()`，对应餐厅管理 -> 菜谱管理 -> 编辑菜谱 -> 保存
- `copy_menu()`，对应菜谱复制

实现方式：
- 保存前先读取目标日期、目标餐厅下的旧菜谱数据
- 用 `dishes_uuid + meal_times + type` 作为业务键做新旧比对
- 对未变化的配餐项复用原 `ydy_recipes.uuid`
- 对真正删除的配餐项下发 `dataStatus=del`
- 对真正新增的配餐项下发 `dataStatus=add`

这样处理的原因：
- 当前 `p/bill/add` 原本是“整天删除后重建”的保存方式
- 如果不复用未变化配餐项的 `uuid`，设备端保存的 `id` 会和后台下一次保存后的 `id` 脱节
- 后续删除同一条配餐时，设备可能收不到正确的删除 `id`

因此本次对后台保存链路做了“差异同步 + 稳定 uuid”处理。

### 4. 新增留样柜设备类型

后端枚举新增：
- `application/common/enum/EquipmentType.php`
- 新增类型：`12 => 留样柜`
- 新增方法：`lockLygTypes()`

前端设备类型选项新增：
- `public/static/src/view/system/device/typeConfig.js`
- 新增选项：`{ value: 12, label: '留样柜' }`

兼容调整：
- `application/p/logic/Equipment.php` 的设备类型中文映射中补充了类型 `8/9/10/11/12`
- 避免设备列表页回显为空

### 5. 新增默认配置

新增配置：
- `application/config.php`

配置项：

```php
'lock_lyg_websocket' => [
    'root' => 'wss://api.cyzkc.com/websocket',
    'mode' => 'lockLyg',
    'default_app_version' => '10010',
],
```

同时支持从配置表读取以下覆盖项：
- `lock_lyg_websocket_root`
- `lock_lyg_websocket_default_app_version`

## 字段映射说明

### 设备 URL 映射

按照当前系统字段，映射如下：

- `root`
  - 来源：`config('lock_lyg_websocket.root')`
  - 默认值：`wss://api.cyzkc.com/websocket`

- `mode`
  - 固定：`lockLyg`

- `simNo`
  - 映射：`equipment.sn`
  - 若为空，则使用协议要求的 `none`

- `appVersion`
  - 映射：`equipment.version_number`
  - 若为空，则回退默认值 `10010`

- `androidDeviceId`
  - 映射：`equipment.code`

### 消息体字段映射

- `deviceType`
  - 固定：`lockLyg`

- `msgType`
  - 固定：`foodInfo`

- `deviceId`
  - 映射：`equipment.code`

- `msgContent.msgType`
  - 固定：`foodInfo`

- `msgContent.dataStatus`
  - 菜品新增：`add`
  - 菜品修改：`upd`
  - 菜品删除：`del`

- `msgContent.data[].id`
  - 映射：菜品表 `ydy_dishes.uuid`

- `msgContent.data[].foodName`
  - 映射：菜品名称 `name`

- `msgContent.data[].userName`
  - 取当前登录用户名称
  - 自动补前缀 `PC-`

- `msgContent.data[].userNo`
  - 优先取当前登录用户 `id`
  - 其次取当前登录用户 `uuid`
  - 仍无则为空字符串

- `msgContent.timestamp`
  - 当前毫秒时间戳

### `preFoodInfo` 字段映射

- `deviceType`
  - 固定：`lockLyg`

- `msgType`
  - 固定：`preFoodInfo`

- `deviceId`
  - 映射：`equipment.code`

- `msgContent.msgType`
  - 固定：`preFoodInfo`

- `msgContent.dataStatus`
  - 配餐新增：`add`
  - 配餐删除：`del`
  - 当前保存链路暂无单独“原地修改同一条记录”的接口，因此实际下发以差异计算出的 `add/del` 为主
  - `LockLygPush.php` 已预留 `upd` 能力，后续若有单条配餐编辑接口可直接复用

- `msgContent.data[].id`
  - 映射：配餐表 `ydy_recipes.uuid`

- `msgContent.data[].foodId`
  - 映射：菜品表 `ydy_dishes.uuid`

- `msgContent.data[].foodName`
  - 映射：菜品名称 `ydy_dishes.name`

- `msgContent.data[].mealTypeId`
  - 当前项目没有独立“餐别主键”字段
  - 暂按现有业务餐次值下发：`1=早餐`、`2=午餐`、`3=晚餐`

- `msgContent.data[].mealTypeName`
  - 映射：
    - `1 -> 早餐`
    - `2 -> 午餐`
    - `3 -> 晚餐`

- `msgContent.data[].date`
  - 映射：菜谱日期 `menu_date`

- `msgContent.data[].userName`
  - 取当前登录用户名称
  - 自动补前缀 `PC-`

- `msgContent.data[].userNo`
  - 优先取当前登录用户 `id`
  - 其次取当前登录用户 `uuid`
  - 仍无则为空字符串

- `msgContent.timestamp`
  - 当前毫秒时间戳

## 设备筛选逻辑

当前仅对满足以下条件的设备发送：

- `type in (12)`，即留样柜
- `del_flag = 0`
- `mate_flag = 1`

说明：
- 当前未强制校验 `hearttime`
- 原因是协议里没有明确说明 websocket 推送是否依赖设备心跳字段
- 只要设备已配对，就允许尝试下发

## 已知问题 / 风险

### 1. 设备字段映射存在业务约定风险

当前是基于现有设备表结构做的映射：
- `code -> androidDeviceId`
- `sn -> simNo`
- `version_number -> appVersion`

这是当前系统下最合理的映射方式，但文档并未明确说这三个字段在本系统中分别存在哪一列。

风险：
- 如果现场设备管理时 `code` 并没有录入安卓唯一编号，而只是内部编号，那么 websocket 地址会错误
- 如果设备版本号未上报到 `equipment.version_number`，则会回退到 `10010`
- 如果 `sn` 填的不是物联网卡号，也会导致 URL 参数不符合预期

建议：
- 在设备管理流程中明确约束：
  - `code` 必须录入安卓设备唯一编号
  - `sn` 填物联网卡号，没有则留空
  - 设备启动后应上报 `version_number`

### 2. `mealTypeId` 当前使用业务餐次值 1/2/3

协议示例里的 `mealTypeId` 看起来像独立主键，但当前项目的菜谱表 `ydy_recipes` 只有：
- `meal_times`
- `type`

没有单独的餐别 uuid / id 字段。

因此当前实现采用：
- `1 -> 早餐`
- `2 -> 午餐`
- `3 -> 晚餐`

风险：
- 如果设备端强依赖某张独立餐别表中的主键，而不是接受 `1/2/3`，则需要再补一层映射
- 若后续项目新增了餐别配置表，也应优先切到真实餐别 id

### 3. websocket 发送为“尽力而为”，不阻断主业务

当前策略：
- 菜品 CRUD 成功后尝试发送
- 菜谱保存/复制成功后尝试发送
- websocket 发送失败只记日志，不回滚菜品业务

原因：
- 设备端网络状态不可控
- 不能因为设备端瞬时离线，影响后台菜品维护和菜谱保存

影响：
- 后台返回成功，不代表设备一定收到
- 如需强一致，需要补消息补偿机制或发送结果持久化表

### 4. 未新增专门的下发表/重试机制

当前没有做：
- 留样柜下发日志表
- 失败重试队列
- 定时补发
- 成功回执确认

当前只有应用日志：
- 成功日志：`[LockLygPush] push success`
- 失败日志：`[LockLygPush] push failed`

如果后续要用于生产长期运行，建议补：
- 下发任务表
- 失败重试
- 设备 ACK 机制

### 5. 当前只覆盖“餐厅管理 -> 菜谱管理”的前厅菜谱链路

本次 `preFoodInfo` 接入的是：
- `application/p/logic/Bill.php`
- 即前厅菜谱查看/编辑/复制这一条链路

当前未接入：
- 个人配餐、团队配餐等 `DateMenu` / `recommendedMenu` 相关链路
- 其他可能产生前厅展示数据的同步入口

如果现场设备展示依赖的不只是前厅菜谱，而是个体化配餐结果，则还需要继续评估 `p/dateMenu/*` 相关接口。

### 6. 留样柜设备类型是本次新增的 12

当前数据库里原本没有现成留样柜类型数据，本次代码层新增了：
- `EquipmentType::ENUM12`
- 前端设备管理类型选项中的 `12=留样柜`

风险：
- 如果其他环境已经约定某个编号给留样柜，需统一编号后再上线
- 若历史数据中类型 `12` 已被其他业务占用，需要调整

## 验证情况

已完成：
- 新增/修改的 PHP 文件语法检查通过
- 核对了设备表结构：
  - `code`
  - `sn`
  - `version_number`
  - `mate_flag`
- 核对了菜品表结构：
  - `uuid`
  - `name`
- 核对了菜谱表结构：
  - `uuid`
  - `dishes_uuid`
  - `meal_times`
  - `type`

本次新增代码点：
- `application/common/service/equipment/LockLygPush.php`
  - 新增 `preFoodInfo` 下发能力
- `application/p/logic/Bill.php`
  - `add()` 保存菜谱后按差异下发 `preFoodInfo`
  - `copy_menu()` 复制菜谱后按差异下发 `preFoodInfo`
  - 新增配餐差异计算、uuid 复用、操作人映射等辅助方法

未完成：
- 未做真实设备端联调
- 原因是当前库里没有已登记的留样柜设备记录
- 也没有设备在线配对后返回 ACK 的联调结果

## 当前代码落点

### 核心业务触发
- `application/store/service/dishes/Dishes.php`

### 留样柜设备类型
- `application/common/enum/EquipmentType.php`
- `public/static/src/view/system/device/typeConfig.js`

### websocket 客户端
- `application/common/service/equipment/WebSocketClient.php`

### 留样柜消息下发服务
- `application/common/service/equipment/LockLygPush.php`

### 默认配置
- `application/config.php`

## 后续建议

### 第一优先级
- 用真实留样柜设备联调一次菜品新增、修改、删除
- 确认：
  - `equipment.code` 是否就是 `androidDeviceId`
  - `equipment.sn` 是否就是物联网卡号
  - `equipment.version_number` 是否能正确上报
  - 设备端是否能正确解析 `msgContent` 字符串

### 第二优先级
- 补齐 `preFoodInfo` 配餐同步
- 触发点可挂在配餐新增、编辑、删除成功后

### 第三优先级
- 增加发送日志表
- 增加失败重试机制
- 增加设备回执确认机制

## 结论

本次已经完成“菜品管理增删改 -> 留样柜 websocket 下发”的基础实现。

当前方案适合先跑通联调，但还不是最终生产级方案。若后续设备数量增多或对同步可靠性要求更高，需要继续补齐：
- 配餐同步
- 下发持久化
- 失败重试
- ACK 回执
- 字段映射规范化
