# 菜谱管理与设备同步

## 背景

本次需求是将“餐厅管理 -> 菜谱管理”中的前厅菜谱信息，在保存或复制后同步下发到留样柜设备端。

设备协议来源：
- `外置秤盘留样柜设备接口协议.docx`
- websocket 下发协议第 `5.3` 节：`preFoodInfo`

协议要求的外层消息示例：

```json
{
  "deviceType": "lockLyg",
  "msgType": "preFoodInfo",
  "deviceId": "67f88471cf3b5d04",
  "msgContent": "{\"msgType\":\"preFoodInfo\",\"dataStatus\":\"upd\",\"data\":[{\"id\":\"25887c5f-82fb-45fe-99a9-63bebf70664f\",\"foodId\":\"9e66af9c-163e-443e-a010-20ed6bbc991a\",\"foodName\":\"西红柿\",\"mealTypeId\":\"5e417244-60fa-4cde-958d-3d38efe1d8c4\",\"mealTypeName\":\"早餐\",\"date\":\"2025-12-05\",\"userName\":\"PC-admin\",\"userNo\":\"1\"}],\"timestamp\":1752740213511}"
}
```

## 本次改动

### 1. 菜谱保存后自动下发 `preFoodInfo`

接入文件：
- `application/p/logic/Bill.php`

接入点：
- `add()`：编辑菜谱页面保存
- `copy_menu()`：复制菜谱

当前实现：
- 菜谱保存成功后，按“旧菜谱数据 vs 新菜谱数据”做差异比对
- 对真正删除的配餐项，下发 `dataStatus=del`
- 对真正新增的配餐项，下发 `dataStatus=add`
- `LockLygPush.php` 中已具备 `upd` 能力，但当前 `Bill` 链路本质上是覆盖式保存，因此暂未直接产生稳定的“单条修改”场景

### 2. 复用未变化配餐项的 `uuid`

这里是本次实现里最关键的一点。

`p/bill/add` 原有逻辑是：
- 查询当天已有菜谱
- 全部删除
- 重新生成新的 `ydy_recipes` 记录

如果直接沿用原逻辑，每次保存都会给所有配餐项重新生成新的 `uuid`，会带来两个问题：

- 设备端第一次收到的配餐 `id`，下一次保存后后台已经变成了新的 `id`
- 之后如果删除同一条配餐，后台发出去的 `del` 可能删不到设备端原先保存的那条

所以本次处理方式是：
- 使用 `dishes_uuid + meal_times + type` 作为业务匹配键
- 如果新旧数据能匹配到，沿用原来的 `ydy_recipes.uuid`
- 只有真正新增的记录才生成新的 `uuid`

这样可以保证：
- 同一条未变化配餐项在多次保存后 `id` 稳定
- 设备端后续能收到正确的删除消息

### 3. 复用已有留样柜 websocket 下发能力

下发服务文件：
- `application/common/service/equipment/LockLygPush.php`

本次在原有 `foodInfo` 的基础上补充了：
- `MSG_TYPE_PRE_FOOD_INFO = 'preFoodInfo'`
- `pushPreFoodInfo(string $dataStatus, array $preFoods): bool`

底层 websocket 发送仍由：
- `application/common/service/equipment/WebSocketClient.php`

统一负责：
- 建立 `ws/wss` 连接
- websocket 握手
- 发送文本帧

## 代码落点

### `application/p/logic/Bill.php`

新增或调整的核心方法：
- `add()`
- `copy_menu()`
- `syncRecipesLog()`
- `prepareRecipeSaveData()`
- `groupRecipeRowsByKey()`
- `buildRecipeSyncKey()`
- `flattenRecipeBuckets()`
- `syncLockLygPreFoodInfo()`
- `buildPreFoodPayloads()`
- `getRecipeFoodNameMap()`
- `getLockLygOperatorInfo()`
- `getMealTypeMap()`
- `getLockLygMealTypeIdMap()`
- `getMealTypeAliasMap()`
- `buildFoodSafetyMealTypeDbConfig()`

职责划分：
- `prepareRecipeSaveData()`
  - 负责组装最终入库数据
  - 尽量复用旧记录 `uuid`
  - 计算出新增项、删除项

- `syncLockLygPreFoodInfo()`
  - 负责把差异数据转成设备协议 payload
  - 删除先发 `del`
  - 新增再发 `add`

- `buildPreFoodPayloads()`
  - 负责把数据库字段映射成 `preFoodInfo` 协议字段
  - `mealTypeId` 优先按 `food_safety.sammei_meal_types.name -> data.id` 映射
  - 查不到时回退到本系统餐次值 `1/2/3`

### `application/common/service/equipment/LockLygPush.php`

职责：
- 校验 `preFoodInfo` payload
- 查询所有可下发的留样柜设备
- 组装 websocket URL
- 组装协议外层消息
- 逐台设备发送
- 成功/失败写日志

## 字段映射

### websocket URL

协议格式：

```text
{root}/{mode}/{simNo}/{appVersion}/{androidDeviceId}
```

当前映射：
- `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`

### `preFoodInfo` 消息字段

- `deviceType`
  - 固定：`lockLyg`

- `msgType`
  - 固定：`preFoodInfo`

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

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

- `msgContent.dataStatus`
  - 新增：`add`
  - 删除：`del`
  - 修改：当前服务层支持 `upd`，但 `Bill` 这条保存链路暂未直接产出

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

- `msgContent.data[].foodId`
  - 映射：`ydy_recipes.dishes_uuid`

- `msgContent.data[].foodName`
  - 映射：`ydy_dishes.name`

- `msgContent.data[].mealTypeId`
  - 优先按 `food_safety` 系统库 `sammei_meal_types`
  - 使用 `mealTypeName` 对应 `sammei_meal_types.name`
  - 下发值优先取 `sammei_meal_types.data` JSON 中的 `id`
  - 若 `data.id` 为空，再回退 `ext_id`
  - 若 `ext_id` 仍为空，再回退该表主键 `id`
  - 若当前环境连不上 `food_safety` 库，再回退为本系统餐次值 `1/2/3`

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

- `msgContent.data[].date`
  - 映射：`menu_date`

- `msgContent.data[].userName`
  - 来源：当前登录用户 `App::$user_name`
  - 自动补前缀 `PC-`
  - 取不到时回退 `PC-system`

- `msgContent.data[].userNo`
  - 优先：`App::$user_id`
  - 其次：`App::$user_uuid`
  - 否则空字符串

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

## 设备筛选逻辑

当前仅对以下设备发送：
- `type in (12)`，即留样柜
- `del_flag = 0`
- `mate_flag = 1`

说明：
- 这里复用了前面菜品同步的设备筛选规则
- 设备心跳、在线状态目前不作为发送前置条件
- websocket 发送失败只记日志，不阻断菜谱保存

## 已知问题与风险

### 1. `mealTypeId` 已改为优先读取 `food_safety` 餐别表

当前实现：
- 使用 `mealTypeName`
- 对应查询 `food_safety.sammei_meal_types.name`
- 下发 `data.id`
- `data.id` 为空时回退 `ext_id`
- `ext_id` 为空时回退表主键 `id`

当前剩余风险：
- 若当前运行环境无法访问 `food_safety` 数据库，则会自动回退到 `1/2/3`
- 若 `food_safety` 库中的餐别名称和本系统不一致，需要补别名映射或统一主数据

### 2. 当前 `upd` 在实际保存链路里没有直接产出

虽然协议支持：
- `add`
- `upd`
- `del`

但当前 `Bill` 保存方式是整批覆盖，不是单条记录原地修改。

因此本次实际落地策略是：
- 新项发 `add`
- 旧项删发 `del`

如果后续新增了单条配餐编辑接口，再补 `upd` 会更自然。

### 3. 菜谱复制同样会触发设备同步

`copy_menu()` 也接入了同样的差异同步逻辑。

这意味着：
- 复制到一个本来有菜谱的日期时，会先对比旧数据
- 设备端会收到对应的删除和新增

这是符合当前业务预期的，但上线前最好和产品/现场再确认一次。

### 4. websocket 下发是尽力而为

当前策略：
- 菜谱保存成功后尝试发送
- 设备发送失败只记日志，不回滚主业务

优点：
- 不会因为设备离线影响后台操作

缺点：
- 后台返回成功，不代表设备一定已同步
- 后续如需强一致，需要补偿机制或下发表

## 验证情况

已完成：
- `application/p/logic/Bill.php` 语法检查通过
- `application/common/service/equipment/LockLygPush.php` 语法检查通过
- 文档字段映射已和当前代码实现保持一致

本次未完成：
- 未做真实设备联调
- 未校验设备端是否接受 `mealTypeId=1/2/3`
- 未引入消息重试、失败补发、ACK 回执

## 后续建议

建议后续继续做这几项：

- 和设备端确认 `mealTypeId` 是否接受 `1/2/3`
- 实际挑一台留样柜做一次保存菜谱联调
- 增加下发日志表或任务表，而不是只写应用日志
- 若后续有“单条编辑配餐项”接口，再补 `upd` 的精确下发
