Răsfoiți Sursa

Merge remote-tracking branch 'origin/sit-new-checkstand' into sit-new-checkstand

刘云鑫 2 săptămâni în urmă
părinte
comite
a4538bfea0
100 a modificat fișierele cu 5318 adăugiri și 603 ștergeri
  1. 181 0
      alien-dining/docs/通用价目点餐接口说明.md
  2. 10 0
      alien-dining/src/main/java/shop/alien/dining/config/BaseRedisService.java
  3. 28 0
      alien-dining/src/main/java/shop/alien/dining/constants/OrderMenuConstants.java
  4. 3 3
      alien-dining/src/main/java/shop/alien/dining/controller/DiningController.java
  5. 249 0
      alien-dining/src/main/java/shop/alien/dining/controller/GenericDiningController.java
  6. 206 0
      alien-dining/src/main/java/shop/alien/dining/controller/GenericStoreOrderController.java
  7. 17 12
      alien-dining/src/main/java/shop/alien/dining/controller/StoreInfoController.java
  8. 2 2
      alien-dining/src/main/java/shop/alien/dining/controller/StoreOrderController.java
  9. 38 0
      alien-dining/src/main/java/shop/alien/dining/service/GenericCartService.java
  10. 21 0
      alien-dining/src/main/java/shop/alien/dining/service/GenericDiningService.java
  11. 15 11
      alien-dining/src/main/java/shop/alien/dining/service/StoreInfoService.java
  12. 7 1
      alien-dining/src/main/java/shop/alien/dining/service/StoreOrderService.java
  13. 60 99
      alien-dining/src/main/java/shop/alien/dining/service/impl/CartServiceImpl.java
  14. 30 38
      alien-dining/src/main/java/shop/alien/dining/service/impl/DiningServiceImpl.java
  15. 1022 0
      alien-dining/src/main/java/shop/alien/dining/service/impl/GenericCartServiceImpl.java
  16. 395 0
      alien-dining/src/main/java/shop/alien/dining/service/impl/GenericDiningServiceImpl.java
  17. 24 25
      alien-dining/src/main/java/shop/alien/dining/service/impl/OrderLockServiceImpl.java
  18. 38 16
      alien-dining/src/main/java/shop/alien/dining/service/impl/SseServiceImpl.java
  19. 132 16
      alien-dining/src/main/java/shop/alien/dining/service/impl/StoreInfoServiceImpl.java
  20. 145 34
      alien-dining/src/main/java/shop/alien/dining/service/impl/StoreOrderServiceImpl.java
  21. 1 1
      alien-dining/src/main/java/shop/alien/dining/strategy/payment/impl/WeChatPaymentMininProgramStrategyImpl.java
  22. 35 0
      alien-dining/src/main/java/shop/alien/dining/support/DiningFirstVisitDinerCount.java
  23. 57 1
      alien-dining/src/main/java/shop/alien/dining/support/DiningMenuPricing.java
  24. 83 0
      alien-dining/src/main/java/shop/alien/dining/support/DiningOrderingTableGuard.java
  25. 27 0
      alien-dining/src/main/java/shop/alien/dining/support/DiningSearchText.java
  26. 43 0
      alien-dining/src/main/java/shop/alien/dining/support/StoreBusinessSectionOrdering.java
  27. 23 0
      alien-dining/src/main/java/shop/alien/dining/support/StoreCartMenuFilters.java
  28. 14 0
      alien-dining/src/main/resources/db/generic_ordering_alter.sql
  29. 21 0
      alien-entity/src/main/java/shop/alien/entity/store/PrinterConfig.java
  30. 18 0
      alien-entity/src/main/java/shop/alien/entity/store/Receipt.java
  31. 4 0
      alien-entity/src/main/java/shop/alien/entity/store/StoreBookingBusinessHours.java
  32. 4 0
      alien-entity/src/main/java/shop/alien/entity/store/StoreCart.java
  33. 4 0
      alien-entity/src/main/java/shop/alien/entity/store/StoreCuisineCategory.java
  34. 9 1
      alien-entity/src/main/java/shop/alien/entity/store/StoreOrder.java
  35. 4 0
      alien-entity/src/main/java/shop/alien/entity/store/StoreOrderDetail.java
  36. 4 0
      alien-entity/src/main/java/shop/alien/entity/store/StoreProductDiscountRule.java
  37. 73 0
      alien-entity/src/main/java/shop/alien/entity/store/StoreReceiptTemplateConfig.java
  38. 1 1
      alien-entity/src/main/java/shop/alien/entity/store/StoreTable.java
  39. 38 0
      alien-entity/src/main/java/shop/alien/entity/store/constants/StoreMenuType.java
  40. 7 4
      alien-entity/src/main/java/shop/alien/entity/store/dto/CartItemDTO.java
  41. 3 0
      alien-entity/src/main/java/shop/alien/entity/store/dto/StoreBookingBusinessHoursDTO.java
  42. 3 0
      alien-entity/src/main/java/shop/alien/entity/store/dto/StoreCuisineCategoryDTO.java
  43. 3 0
      alien-entity/src/main/java/shop/alien/entity/store/dto/StoreProductDiscountRuleSaveDto.java
  44. 19 0
      alien-entity/src/main/java/shop/alien/entity/store/dto/StoreReceiptTemplateCreateDTO.java
  45. 16 0
      alien-entity/src/main/java/shop/alien/entity/store/dto/StoreReceiptTemplateDeleteDTO.java
  46. 31 0
      alien-entity/src/main/java/shop/alien/entity/store/dto/StoreReceiptTemplateEditDTO.java
  47. 19 0
      alien-entity/src/main/java/shop/alien/entity/store/dto/StoreReceiptTemplateResetDTO.java
  48. 28 0
      alien-entity/src/main/java/shop/alien/entity/store/dto/StoreReceiptTemplateSaveDTO.java
  49. 35 0
      alien-entity/src/main/java/shop/alien/entity/store/vo/CategoryMenuGroupVO.java
  50. 2 6
      alien-entity/src/main/java/shop/alien/entity/store/vo/CategoryWithCuisinesVO.java
  51. 6 0
      alien-entity/src/main/java/shop/alien/entity/store/vo/OrderInfoVO.java
  52. 27 0
      alien-entity/src/main/java/shop/alien/entity/store/vo/StorePriceWithPricesVO.java
  53. 28 0
      alien-entity/src/main/java/shop/alien/entity/store/vo/TurnoverBreakdownItemVO.java
  54. 19 0
      alien-entity/src/main/java/shop/alien/entity/store/vo/TurnoverDetailByDateVO.java
  55. 37 0
      alien-entity/src/main/java/shop/alien/entity/store/vo/TurnoverDetailItemVO.java
  56. 29 0
      alien-entity/src/main/java/shop/alien/entity/store/vo/TurnoverSummaryVO.java
  57. 7 0
      alien-entity/src/main/java/shop/alien/mapper/StoreReceiptTemplateConfigMapper.java
  58. 5 7
      alien-entity/src/main/java/shop/alien/mapper/UserReservationMapper.java
  59. 3 0
      alien-entity/src/main/resources/db/migration/store_cuisine_category_menu_type.sql
  60. 1 1
      alien-entity/src/main/resources/db/migration/store_table_app_qrcode_url.sql
  61. 2 1
      alien-entity/src/main/resources/mapper/StoreBookingBusinessHoursMapper.xml
  62. 1 0
      alien-entity/src/main/resources/mapper/StoreReservationMapper.xml
  63. 70 63
      alien-entity/src/main/resources/mapper/UserReservationMapper.xml
  64. 26 0
      alien-store/pom.xml
  65. 8 7
      alien-store/src/main/java/shop/alien/store/controller/DiningServiceController.java
  66. 2 0
      alien-store/src/main/java/shop/alien/store/controller/StoreBookingBusinessHoursController.java
  67. 1 1
      alien-store/src/main/java/shop/alien/store/controller/StoreBookingCategoryController.java
  68. 1 43
      alien-store/src/main/java/shop/alien/store/controller/StoreBookingSettingsController.java
  69. 1 1
      alien-store/src/main/java/shop/alien/store/controller/StoreBookingTableController.java
  70. 10 6
      alien-store/src/main/java/shop/alien/store/controller/StoreCuisineCategoryController.java
  71. 126 0
      alien-store/src/main/java/shop/alien/store/controller/StoreReceiptTemplateConfigController.java
  72. 63 0
      alien-store/src/main/java/shop/alien/store/controller/StoreTurnoverController.java
  73. 198 0
      alien-store/src/main/java/shop/alien/store/controller/dining/StoreGenericDiningPathProxyController.java
  74. 150 0
      alien-store/src/main/java/shop/alien/store/controller/dining/StoreGenericOrderPathProxyController.java
  75. 11 8
      alien-store/src/main/java/shop/alien/store/controller/dining/StoreInfoDiningPathProxyController.java
  76. 128 8
      alien-store/src/main/java/shop/alien/store/feign/DiningServiceFeign.java
  77. 11 0
      alien-store/src/main/java/shop/alien/store/service/ReceiptPrinter.java
  78. 1 1
      alien-store/src/main/java/shop/alien/store/service/StoreBookingCategoryService.java
  79. 1 1
      alien-store/src/main/java/shop/alien/store/service/StoreBookingTableService.java
  80. 20 10
      alien-store/src/main/java/shop/alien/store/service/StoreCuisineCategoryService.java
  81. 26 0
      alien-store/src/main/java/shop/alien/store/service/StoreReceiptTemplateConfigService.java
  82. 28 0
      alien-store/src/main/java/shop/alien/store/service/StoreTurnoverService.java
  83. 13 5
      alien-store/src/main/java/shop/alien/store/service/TableAppQrCodeService.java
  84. 13 1
      alien-store/src/main/java/shop/alien/store/service/WeChatMiniProgramQrCodeService.java
  85. 12 1
      alien-store/src/main/java/shop/alien/store/service/dining/DiningSseProxyService.java
  86. 66 0
      alien-store/src/main/java/shop/alien/store/service/impl/NetworkReceiptPrinter.java
  87. 1 0
      alien-store/src/main/java/shop/alien/store/service/impl/StoreBookingBusinessHoursServiceImpl.java
  88. 1 3
      alien-store/src/main/java/shop/alien/store/service/impl/StoreBookingCategoryServiceImpl.java
  89. 2 0
      alien-store/src/main/java/shop/alien/store/service/impl/StoreBookingSettingsServiceImpl.java
  90. 1 3
      alien-store/src/main/java/shop/alien/store/service/impl/StoreBookingTableServiceImpl.java
  91. 31 13
      alien-store/src/main/java/shop/alien/store/service/impl/StoreCuisineCategoryServiceImpl.java
  92. 23 2
      alien-store/src/main/java/shop/alien/store/service/impl/StoreProductDiscountServiceImpl.java
  93. 452 0
      alien-store/src/main/java/shop/alien/store/service/impl/StoreReceiptTemplateConfigServiceImpl.java
  94. 9 89
      alien-store/src/main/java/shop/alien/store/service/impl/StoreReservationServiceImpl.java
  95. 98 32
      alien-store/src/main/java/shop/alien/store/service/impl/StoreServiceFeeRuleServiceImpl.java
  96. 37 4
      alien-store/src/main/java/shop/alien/store/service/impl/StoreTableServiceImpl.java
  97. 216 0
      alien-store/src/main/java/shop/alien/store/service/impl/StoreTurnoverServiceImpl.java
  98. 11 11
      alien-store/src/main/java/shop/alien/store/service/impl/TableAppQrCodeServiceImpl.java
  99. 7 10
      alien-store/src/main/java/shop/alien/store/service/impl/WeChatMiniProgramQrCodeServiceImpl.java
  100. 26 0
      alien-store/src/main/java/shop/alien/store/util/PrinterFactory.java

+ 181 - 0
alien-dining/docs/通用价目点餐接口说明.md

@@ -0,0 +1,181 @@
+# 通用价目点餐(与美食点餐并存)说明文档
+
+本文描述 **通用价目表(`store_price`)** 点餐能力:与原有 **美食菜品(`store_cuisine`)** 共用订单/明细/购物车等表结构,通过 **`menu_type`、`line_type`、`store_table.type`** 等字段区分;**未删除或替换** 原 `/store/dining`、`/store/order` 接口。
+
+---
+
+## 1. 概念与约束
+
+| 项目 | 美食点餐 | 通用价目点餐 |
+|------|----------|----------------|
+| 商品主数据 | `store_cuisine` | `store_price` |
+| 桌台 `store_table.type` | `1` 或 `NULL`(按美食处理) | 必须为 **`2`** |
+| 购物车隔离 | Redis/DB:`menu_type = 1` | Redis 前缀 + DB:`menu_type = 2` |
+| 订单 `store_order.menu_type` | `1` | `2` |
+| 明细 `store_order_detail.line_type` | `1`,`cuisine_id` = 菜品 id | `2`,`cuisine_id` 存 **`store_price.id`** |
+| 优惠规则 `rule_product_type` | `1` 或 `NULL`(指向菜品) | `2`(`product_id` 指向 **`store_price.id`**) |
+
+**前端约定:** 通用流程里凡名为 `cuisineId` 的入参(如加购 DTO),语义为 **`store_price.id`**,不是 `store_cuisine.id`。
+
+---
+
+## 2. 数据库变更
+
+执行脚本(需在目标库执行一次):
+
+`alien-dining/src/main/resources/db/generic_ordering_alter.sql`
+
+主要包括:`store_order.menu_type`、`store_order_detail.line_type`、`store_cart.menu_type`、`store_product_discount_rule.rule_product_type` 及索引。
+
+**注意:** 未执行脚本而代码已包含对应实体字段时,读写可能报列不存在。**历史 `store_cart` 行** 建议将美食购物车统一为 `menu_type = 1`(或与脚本默认值一致),否则可能影响美食购物车查询。
+
+---
+
+## 3. 新接口一览(通用价目)
+
+网关若有统一前缀(如 `/api`),请在下列路径前自行拼接。
+
+**alien-store 透传:** 与美食点餐相同,在 store 侧增加 Feign + 代理控制器后,APP/统一网关可使用 **与 dining 完全一致** 的路径调用:
+
+- `GET/POST .../store/generic-dining/...` → `DiningServiceFeign` 透传 alien-dining  
+- `GET/POST .../store/generic-order/...` → 同上;**SSE**:`GET .../store/generic-order/sse/{tableId}`(`DiningSseProxyService.proxyGenericOrderSse`)
+
+支付、订单详情等仍用 **`/store/order/...`**(与美食共用)。
+
+### 3.1 点餐页 / 券 / 锁单 / 结算预览
+
+**前缀:** `/store/generic-dining`
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/page-info` | 开桌/人数,同美食逻辑;**仅 `type=2` 桌** |
+| GET | `/search` | 按名称搜 `store_price` |
+| GET | `/cuisines` | 分页列表;可选 `categoryId`(`category_ids` 为 JSON 时用 `JSON_CONTAINS`) |
+| GET | `/cuisine/{priceItemId}` | 详情,`priceItemId` = `store_price.id` |
+| GET | `/order/confirm` | 确认页;购物车为通用价目 |
+| GET | `/coupons/available` | 可领券(与美食共用门店券逻辑) |
+| POST | `/coupon/receive` | 领券 |
+| POST | `/order/lock` | 锁单(Redis 键与美食相同:`order:lock:table:{tableId}`) |
+| POST | `/order/unlock` | 解锁 |
+| GET | `/order/check-lock` | 查询持锁用户 |
+| GET | `/order/settlement` | 结算页信息(按 `orderId`) |
+| POST | `/order/settlement/lock` | 锁结算 |
+| POST | `/order/settlement/unlock` | 解结算锁 |
+
+以下接口 **仅在美食控制器** 提供,通用桌如需相同能力可继续调旧路径(与桌台业务相关,未复制到 generic):
+
+- `/store/dining/walk-in/reservation`
+- `/store/dining/reservation/detail-by-store-table-record`
+- `/store/dining/table-dining-status`
+- `/store/dining/service-fee/estimate`
+
+### 3.2 购物车 / 下单 / SSE
+
+**前缀:** `/store/generic-order`
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/cart/{tableId}` | 获取通用购物车 |
+| POST | `/cart/add` | 加购,`AddCartItemDTO.cuisineId` = `store_price.id` |
+| PUT | `/cart/update` | `tableId`、`cuisineId`(价目 id)、`quantity` |
+| DELETE | `/cart/remove` | 删除一行 |
+| DELETE | `/cart/clear` | 清空(保留逻辑与实现一致,参见代码) |
+| POST | `/cart/set-diner-count` | 用餐人数 |
+| PUT | `/cart/update-tableware` | 餐具数量 |
+| POST | `/create` | 创建订单,对应服务方法 **`createGenericOrder`**;须先锁单成功 |
+| GET | `/sse/{tableId}` | SSE;与美食共用「按桌台」通道,推送内容随业务为购物车或订单事件 |
+
+**换桌:** 当前美食侧 `change-table` 未一并迁移通用 Redis 购物车;通用换桌如需与美食一致行为,需在后续迭代扩展(`GenericCartService#migrateCart` 现状为不支持)。
+
+---
+
+## 4. 与旧接口对照(美食 → 通用)
+
+同一语义的新旧路径对比如下(**旧** 仍保留,并非废弃)。
+
+### 4.1 点餐域
+
+| 旧(美食) | 新(通用价目) |
+|------------|----------------|
+| `GET /store/dining/page-info` | `GET /store/generic-dining/page-info` |
+| `GET /store/dining/search` | `GET /store/generic-dining/search` |
+| `GET /store/dining/cuisines` | `GET /store/generic-dining/cuisines` |
+| `GET /store/dining/cuisine/{cuisineId}` | `GET /store/generic-dining/cuisine/{priceItemId}` |
+| `GET /store/dining/order/confirm` | `GET /store/generic-dining/order/confirm` |
+| `GET /store/dining/coupons/available` | `GET /store/generic-dining/coupons/available` |
+| `POST /store/dining/coupon/receive` | `POST /store/generic-dining/coupon/receive` |
+| `POST /store/dining/order/lock` | `POST /store/generic-dining/order/lock` |
+| `POST /store/dining/order/unlock` | `POST /store/generic-dining/order/unlock` |
+| `GET /store/dining/order/check-lock` | `GET /store/generic-dining/order/check-lock` |
+| `GET /store/dining/order/settlement` | `GET /store/generic-dining/order/settlement` |
+| `POST /store/dining/order/settlement/lock` | `POST /store/generic-dining/order/settlement/lock` |
+| `POST /store/dining/order/settlement/unlock` | `POST /store/generic-dining/order/settlement/unlock` |
+
+### 4.2 订单域(购物车与创建)
+
+| 旧(美食) | 新(通用价目) |
+|------------|----------------|
+| `GET /store/order/cart/{tableId}` | `GET /store/generic-order/cart/{tableId}` |
+| `POST /store/order/cart/add` | `POST /store/generic-order/cart/add` |
+| `PUT /store/order/cart/update` | `PUT /store/generic-order/cart/update` |
+| `DELETE /store/order/cart/remove` | `DELETE /store/generic-order/cart/remove` |
+| `DELETE /store/order/cart/clear` | `DELETE /store/generic-order/cart/clear` |
+| `POST /store/order/cart/set-diner-count` | `POST /store/generic-order/cart/set-diner-count` |
+| `PUT /store/order/cart/update-tableware` | `PUT /store/generic-order/cart/update-tableware` |
+| `POST /store/order/create` | `POST /store/generic-order/create` |
+| `GET /store/order/sse/{tableId}` | `GET /store/generic-order/sse/{tableId}` |
+
+---
+
+## 5. 仍仅使用旧路径的订单能力
+
+以下接口 **未** 提供 `generic-order` 副本,通用价目订单创建后仍通过 **`/store/order/...`** 使用(订单记录上带有 `menu_type=2`):
+
+- 支付:`POST /store/order/pay/{orderId}`
+- 取消:`POST /store/order/cancel/{orderId}`
+- 详情 / 明细 / 变更记录:`/store/order/detail/...`、`detail/list`、`info`、`change-log`
+- 分页与我的订单:`/store/order/page`、`/store/order/my-orders`
+- 换桌:`POST /store/order/change-table`(美食购物车迁移逻辑为主;通用购物车参见上文说明)
+- 完成订单、商家完成、改券、管理员重置桌等
+
+具体以 `StoreOrderController` 为准。
+
+---
+
+## 6. 商家后台:创建餐桌、价目进分类(要不要改接口?)
+
+### 6.1 价目表绑定分类
+
+- **一般不需要新接口。** `store_price.category_ids` 存 **JSON 数组字符串**(如 `[1,2,3]`),与 `store_booking_category` 等分类 id 对应即可。
+- 使用门店侧已有 **`POST /store/price/save`**、**`POST /store/price/update`**(`StorePriceController`),在 body 里带上 **`categoryIds`**(或实体映射字段)即可。
+- 小程序通用点餐列表按分类筛选依赖该字段为 **合法 JSON**;非法 JSON 可能导致 `JSON_CONTAINS` 查询异常。
+
+### 6.2 创建餐桌(`store_table.type`)
+
+- **`POST /store/booking/table/add`**(`StoreBookingTableController`,body:`StoreBookingTableBatchDTO`)**已支持**字段 **`type`**:**`1` = 美食**,**`2` = 通用**;不传默认 **`1`**。建通用价目桌时传 **`type: 2`** 即可,**无需再单独加接口**。
+- 若仍使用 **`StoreTableController` 的批量建桌**(如 **`batchCreateTables`** → `StoreTableServiceImpl.batchCreateTables`),当前实现 **不会写入 `type`**,新桌为 **`NULL`**,下单侧会按 **美食桌** 处理。若必须通过该入口建 **通用桌**,需要 **扩展 DTO + 服务**,在落库时设置 **`type = 2`**(或统一改为走预订桌 **`/store/booking/table/add`**)。
+
+---
+
+## 7. 兼容性说明(旧代码是否仍可用)
+
+- **旧 URL 未被移除**:继续使用 `/store/dining`、`/store/order` 的美食流程在设计上仍支持。
+- **前提:** 已执行 DDL;美食桌 **`store_table.type` 不为 `2`**(`NULL` 一般仍走美食)。
+- **桌台为 `type=2` 时**:美食下单接口会按业务拒绝,须改用 **`/store/generic-order/create`** 等通用入口。
+
+---
+
+## 8. 关键代码位置(便于维护)
+
+| 内容 | 路径 |
+|------|------|
+| 常量 | `alien-dining/.../constants/OrderMenuConstants.java` |
+| DDL | `alien-dining/src/main/resources/db/generic_ordering_alter.sql` |
+| 通用浏览 | `GenericDiningController`、`GenericDiningServiceImpl` |
+| 通用购物车 | `GenericCartService`、`GenericCartServiceImpl` |
+| 通用下单 | `StoreOrderService#createGenericOrder`、`StoreOrderServiceImpl` |
+| 美食购物车过滤等 | `CartServiceImpl`、`StoreCartMenuFilters` |
+
+---
+
+*文档版本:与仓库中通用价目点餐实现同步整理,如有接口增减请以 Swagger / 源码为准。*

+ 10 - 0
alien-dining/src/main/java/shop/alien/dining/config/BaseRedisService.java

@@ -134,6 +134,16 @@ public class BaseRedisService {
     }
 
     /**
+     * 仅当 key 不存在时写入字符串并设置过期时间(SET NX EX),用于分布式锁等场景。
+     *
+     * @return true 表示写入成功(获得锁),false 表示 key 已存在
+     */
+    public boolean setStringIfAbsent(String key, String value, long timeoutSeconds) {
+        Boolean ok = stringRedisTemplate.opsForValue().setIfAbsent(key, value, timeoutSeconds, TimeUnit.SECONDS);
+        return Boolean.TRUE.equals(ok);
+    }
+
+    /**
      * 设置超时时间
      *
      * @param key     键

+ 28 - 0
alien-dining/src/main/java/shop/alien/dining/constants/OrderMenuConstants.java

@@ -0,0 +1,28 @@
+package shop.alien.dining.constants;
+
+/**
+ * 点餐菜单维度:美食价目(store_cuisine)与通用价目(store_price)。
+ */
+public final class OrderMenuConstants {
+
+    private OrderMenuConstants() {
+    }
+
+    /** 美食 / store_cuisine */
+    public static final int MENU_TYPE_CUISINE = 1;
+    /** 通用价目 / store_price */
+    public static final int MENU_TYPE_GENERIC_PRICE = 2;
+
+    /** 订单明细:行来自 store_cuisine */
+    public static final int LINE_TYPE_CUISINE = 1;
+    /** 订单明细:行来自 store_price(cuisine_id 列存 store_price.id) */
+    public static final int LINE_TYPE_GENERIC_PRICE = 2;
+
+    /** 优惠规则:product_id 指向 store_cuisine.id */
+    public static final int RULE_PRODUCT_CUISINE = 1;
+    /** 优惠规则:product_id 指向 store_price.id */
+    public static final int RULE_PRODUCT_GENERIC_PRICE = 2;
+
+    public static final String GENERIC_CART_REDIS_PREFIX = "generic-cart:table:";
+    public static final String GENERIC_COUPON_REDIS_PREFIX = "generic-coupon:used:table:";
+}

+ 3 - 3
alien-dining/src/main/java/shop/alien/dining/controller/DiningController.java

@@ -36,7 +36,7 @@ public class DiningController {
     private final DiningWalkInReservationService diningWalkInReservationService;
     private final AlienStoreFeign alienStoreFeign;
 
-    @ApiOperation(value = "提交到店就餐信息", notes = "选完人数并填写姓名/电话/时段后调用:写入 user_reservation,状态为已到店(2),并关联当前桌;同时将 store_table 置为就餐中(1)、写入 diner_count(与 page-info 首客传人数一致),便于 /table-dining-status。返回预约ID。下单时可不传 userReservationId,后端将按 tableId 解析本桌当日有效预约。startTime、endTime(若传)必须为 yyyy-MM-dd HH:mm(两位时分、无秒);不传 endTime 由后端推算。可选 reservationDate 须与 startTime 日期一致。")
+    @ApiOperation(value = "提交到店就餐信息", notes = "选完人数并填写姓名/电话/时段后调用:写入 user_reservation,状态为已到店(2),并关联当前桌;此时将 store_table 置为就餐中(1)、并写入 diner_count(此前 page-info 仅保存人数不会开台)。便于 /table-dining-status。返回预约ID。下单时可不传 userReservationId,后端将按 tableId 解析本桌当日有效预约。startTime、endTime(若传)必须为 yyyy-MM-dd HH:mm(两位时分、无秒);不传 endTime 由后端推算。可选 reservationDate 须与 startTime 日期一致。")
     @PostMapping("/walk-in/reservation")
     public R<Integer> createWalkInReservation(@Valid @RequestBody DiningWalkInReservationDTO dto) {
         log.info("DiningController.createWalkInReservation?tableId={}", dto != null ? dto.getTableId() : null);
@@ -108,11 +108,11 @@ public class DiningController {
         }
     }
 
-    @ApiOperation(value = "获取点餐页面信息", notes = "首客选桌时传就餐人数,餐桌置为就餐中并保存人数;后续用户选同一桌可不传就餐人数,将使用表中已保存人数")
+    @ApiOperation(value = "获取点餐页面信息", notes = "首客传就餐人数时仅保存人数不把餐桌置为就餐中;提交到店/用餐信息(walk-in)后才会开台。后续同一桌可不传人数,用表中已保存人数")
     @GetMapping("/page-info")
     public R<DiningPageInfoVO> getDiningPageInfo(
             @ApiParam(value = "桌号ID", required = true) @RequestParam Integer tableId,
-            @ApiParam(value = "就餐人数(首客必传;餐桌已就餐中时可省略,将使用已保存人数)") @RequestParam(required = false) Integer dinerCount) {
+            @ApiParam(value = "就餐人数(首客必传;若表中已有已保存人数可省略)") @RequestParam(required = false) Integer dinerCount) {
         log.info("DiningController.getDiningPageInfo?tableId={}, dinerCount={}", tableId, dinerCount);
         try {
             // 从 token 获取用户信息

+ 249 - 0
alien-dining/src/main/java/shop/alien/dining/controller/GenericDiningController.java

@@ -0,0 +1,249 @@
+package shop.alien.dining.controller;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiParam;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.util.StringUtils;
+import org.springframework.web.bind.annotation.*;
+import shop.alien.dining.service.DiningService;
+import shop.alien.dining.service.GenericDiningService;
+import shop.alien.dining.util.TokenUtil;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.vo.*;
+
+import java.util.List;
+
+/**
+ * 小程序-通用价目(store_price)点餐页,路径与 {@link DiningController} 区分,仅允许 type=2 桌台。
+ */
+@Slf4j
+@Api(tags = {"小程序-通用价目点餐"})
+@CrossOrigin
+@RestController
+@RequestMapping("/store/generic-dining")
+@RequiredArgsConstructor
+public class GenericDiningController {
+
+    private final GenericDiningService genericDiningService;
+    private final DiningService diningService;
+
+    @ApiOperation(value = "获取点餐页面信息", notes = "同美食 page-info:人数只落库,开台在 walk-in;仅 type=2 桌可用")
+    @GetMapping("/page-info")
+    public R<DiningPageInfoVO> getDiningPageInfo(
+            @ApiParam(value = "桌号ID", required = true) @RequestParam Integer tableId,
+            @ApiParam(value = "就餐人数(首客必传;若表中已有已保存人数可省略)") @RequestParam(required = false) Integer dinerCount) {
+        log.info("GenericDiningController.getDiningPageInfo?tableId={}, dinerCount={}", tableId, dinerCount);
+        try {
+            Integer userId = TokenUtil.getCurrentUserId();
+            if (userId == null) {
+                return R.fail("用户未登录");
+            }
+            return R.data(genericDiningService.getDiningPageInfo(tableId, dinerCount));
+        } catch (Exception e) {
+            log.error("通用价目-获取点餐页面信息失败: {}", e.getMessage(), e);
+            return R.fail("获取点餐页面信息失败: " + e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "搜索价目", notes = "按名称模糊搜索 store_price,关键词限10字")
+    @GetMapping("/search")
+    public R<List<CuisineListVO>> search(
+            @ApiParam(value = "门店ID", required = true) @RequestParam Integer storeId,
+            @ApiParam(value = "关键词") @RequestParam(required = false) String keyword,
+            @ApiParam(value = "桌号ID", required = true) @RequestParam Integer tableId) {
+        log.info("GenericDiningController.search?storeId={}, keyword={}, tableId={}", storeId, keyword, tableId);
+        try {
+            if (!TokenUtil.hasValidToken()) {
+                return R.fail("用户未登录");
+            }
+            if (StringUtils.hasText(keyword) && keyword.length() > 10) {
+                keyword = keyword.substring(0, 10);
+            }
+            return R.data(genericDiningService.searchCuisines(storeId, keyword, tableId));
+        } catch (Exception e) {
+            log.error("通用价目-搜索失败: {}", e.getMessage(), e);
+            return R.fail("搜索失败: " + e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "价目列表", notes = "分页;categoryId 可选,对应 store_price.category_ids JSON 数组")
+    @GetMapping("/cuisines")
+    public R<List<CuisineListVO>> cuisines(
+            @ApiParam(value = "门店ID", required = true) @RequestParam Integer storeId,
+            @ApiParam(value = "分类ID") @RequestParam(required = false) Integer categoryId,
+            @ApiParam(value = "桌号ID", required = true) @RequestParam Integer tableId,
+            @ApiParam(value = "页码") @RequestParam(defaultValue = "1") Integer page,
+            @ApiParam(value = "每页数量") @RequestParam(defaultValue = "12") Integer size) {
+        log.info("GenericDiningController.cuisines?storeId={}, categoryId={}, tableId={}, page={}, size={}", storeId, categoryId, tableId, page, size);
+        try {
+            if (!TokenUtil.hasValidToken()) {
+                return R.fail("用户未登录");
+            }
+            return R.data(genericDiningService.getCuisinesByCategory(storeId, categoryId, tableId, page, size));
+        } catch (Exception e) {
+            log.error("通用价目-列表失败: {}", e.getMessage(), e);
+            return R.fail("获取列表失败: " + e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "价目详情", notes = "cuisineId 实为 store_price.id;加购时 AddCartItemDTO.cuisineId 传此 id")
+    @GetMapping("/cuisine/{priceItemId}")
+    public R<CuisineDetailVO> detail(
+            @ApiParam(value = "价目ID(store_price.id)", required = true) @PathVariable Integer priceItemId,
+            @ApiParam(value = "桌号ID", required = true) @RequestParam Integer tableId) {
+        log.info("GenericDiningController.detail?priceItemId={}, tableId={}", priceItemId, tableId);
+        try {
+            return R.data(genericDiningService.getCuisineDetail(priceItemId, tableId));
+        } catch (Exception e) {
+            log.error("通用价目-详情失败: {}", e.getMessage(), e);
+            return R.fail("获取详情失败: " + e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "订单确认页信息", notes = "购物车为通用价目 Redis/DB;优惠券与美食共用门店券接口")
+    @GetMapping("/order/confirm")
+    public R<OrderConfirmVO> orderConfirm(
+            @ApiParam(value = "桌号ID", required = true) @RequestParam Integer tableId,
+            @ApiParam(value = "就餐人数", required = true) @RequestParam Integer dinerCount) {
+        log.info("GenericDiningController.orderConfirm?tableId={}, dinerCount={}", tableId, dinerCount);
+        try {
+            Integer userId = TokenUtil.getCurrentUserId();
+            if (userId == null) {
+                return R.fail("用户未登录");
+            }
+            return R.data(genericDiningService.getOrderConfirmInfo(tableId, dinerCount, userId));
+        } catch (Exception e) {
+            log.error("通用价目-确认页失败: {}", e.getMessage(), e);
+            return R.fail("获取订单确认信息失败: " + e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "可领取优惠券")
+    @GetMapping("/coupons/available")
+    public R<List<AvailableCouponVO>> availableCoupons(@ApiParam(value = "门店ID", required = true) @RequestParam Integer storeId) {
+        try {
+            Integer userId = TokenUtil.getCurrentUserId();
+            if (userId == null) {
+                return R.fail("用户未登录");
+            }
+            return R.data(diningService.getAvailableCoupons(storeId, userId));
+        } catch (Exception e) {
+            log.error("通用价目-可领券列表失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "领取优惠券")
+    @PostMapping("/coupon/receive")
+    public R<Boolean> receiveCoupon(@ApiParam(value = "优惠券ID", required = true) @RequestParam Integer couponId) {
+        try {
+            Integer userId = TokenUtil.getCurrentUserId();
+            if (userId == null) {
+                return R.fail("用户未登录");
+            }
+            return R.data(diningService.receiveCoupon(couponId, userId));
+        } catch (Exception e) {
+            log.error("通用价目-领券失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "锁定订单", notes = "与美食共用 Redis 锁键 order:lock:table:{tableId}")
+    @PostMapping("/order/lock")
+    public R<Boolean> lockOrder(@ApiParam(value = "桌号ID", required = true) @RequestParam Integer tableId) {
+        try {
+            Integer userId = TokenUtil.getCurrentUserId();
+            if (userId == null) {
+                return R.fail("用户未登录");
+            }
+            if (!diningService.lockOrder(tableId, userId)) {
+                return R.fail("订单已被其他用户锁定,无法下单");
+            }
+            return R.data(true);
+        } catch (Exception e) {
+            log.error("通用价目-锁单失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "解锁订单")
+    @PostMapping("/order/unlock")
+    public R<Boolean> unlockOrder(@ApiParam(value = "桌号ID", required = true) @RequestParam Integer tableId) {
+        try {
+            Integer userId = TokenUtil.getCurrentUserId();
+            if (userId == null) {
+                return R.fail("用户未登录");
+            }
+            diningService.unlockOrder(tableId, userId);
+            return R.data(true);
+        } catch (Exception e) {
+            log.error("通用价目-解锁失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "查询订单锁")
+    @GetMapping("/order/check-lock")
+    public R<Integer> checkLock(@ApiParam(value = "桌号ID", required = true) @RequestParam Integer tableId) {
+        try {
+            if (!TokenUtil.hasValidToken()) {
+                return R.fail("用户未登录");
+            }
+            return R.data(diningService.checkOrderLock(tableId));
+        } catch (Exception e) {
+            log.error("通用价目-查锁失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "结算页信息")
+    @GetMapping("/order/settlement")
+    public R<OrderSettlementVO> settlement(@ApiParam(value = "订单ID", required = true) @RequestParam Integer orderId) {
+        try {
+            Integer userId = TokenUtil.getCurrentUserId();
+            if (userId == null) {
+                return R.fail("用户未登录");
+            }
+            return R.data(diningService.getOrderSettlementInfo(orderId, userId));
+        } catch (Exception e) {
+            log.error("通用价目-结算页失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "锁定结算")
+    @PostMapping("/order/settlement/lock")
+    public R<Boolean> lockSettlement(@ApiParam(value = "订单ID", required = true) @RequestParam Integer orderId) {
+        try {
+            Integer userId = TokenUtil.getCurrentUserId();
+            if (userId == null) {
+                return R.fail("用户未登录");
+            }
+            if (!diningService.lockSettlement(orderId, userId)) {
+                return R.fail("订单已被其他用户锁定,无法结算");
+            }
+            return R.data(true);
+        } catch (Exception e) {
+            log.error("通用价目-锁结算失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "解锁结算")
+    @PostMapping("/order/settlement/unlock")
+    public R<Boolean> unlockSettlement(@ApiParam(value = "订单ID", required = true) @RequestParam Integer orderId) {
+        try {
+            Integer userId = TokenUtil.getCurrentUserId();
+            if (userId == null) {
+                return R.fail("用户未登录");
+            }
+            diningService.unlockSettlement(orderId, userId);
+            return R.data(true);
+        } catch (Exception e) {
+            log.error("通用价目-解锁结算失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+}

+ 206 - 0
alien-dining/src/main/java/shop/alien/dining/controller/GenericStoreOrderController.java

@@ -0,0 +1,206 @@
+package shop.alien.dining.controller;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+import shop.alien.dining.service.GenericCartService;
+import shop.alien.dining.service.SseService;
+import shop.alien.dining.service.StoreOrderService;
+import shop.alien.dining.util.TokenUtil;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.StoreInfo;
+import shop.alien.entity.store.StoreOrder;
+import shop.alien.entity.store.dto.AddCartItemDTO;
+import shop.alien.entity.store.dto.CartDTO;
+import shop.alien.entity.store.dto.CreateOrderDTO;
+import shop.alien.entity.store.vo.OrderSuccessVO;
+import shop.alien.mapper.StoreInfoMapper;
+
+import javax.validation.Valid;
+
+/**
+ * 小程序-通用价目购物车与下单({@link shop.alien.dining.controller.StoreOrderController} 平行入口)。
+ */
+@Slf4j
+@Api(tags = {"小程序-通用价目订单"})
+@CrossOrigin
+@RestController
+@RequestMapping("/store/generic-order")
+@RequiredArgsConstructor
+public class GenericStoreOrderController {
+
+    private final StoreOrderService orderService;
+    private final GenericCartService genericCartService;
+    private final SseService sseService;
+    private final StoreInfoMapper storeInfoMapper;
+
+    @ApiOperation(value = "获取通用价目购物车")
+    @GetMapping("/cart/{tableId}")
+    public R<CartDTO> getCart(@PathVariable Integer tableId) {
+        try {
+            if (!TokenUtil.hasValidToken()) {
+                return R.fail("用户未登录");
+            }
+            return R.data(genericCartService.getCart(tableId));
+        } catch (Exception e) {
+            log.error("通用价目-获取购物车失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "加购", notes = "AddCartItemDTO.cuisineId 传 store_price.id")
+    @PostMapping("/cart/add")
+    public R<CartDTO> addCart(@Valid @RequestBody AddCartItemDTO dto) {
+        try {
+            if (!TokenUtil.hasValidToken()) {
+                return R.fail("用户未登录");
+            }
+            CartDTO cart = genericCartService.addItem(dto);
+            sseService.pushCartUpdate(dto.getTableId(), cart);
+            return R.data(cart);
+        } catch (Exception e) {
+            log.error("通用价目-加购失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "更新数量", notes = "cuisineId 为 store_price.id")
+    @PutMapping("/cart/update")
+    public R<CartDTO> updateCart(
+            @RequestParam Integer tableId,
+            @RequestParam Integer cuisineId,
+            @RequestParam Integer quantity) {
+        try {
+            if (!TokenUtil.hasValidToken()) {
+                return R.fail("用户未登录");
+            }
+            CartDTO cart = genericCartService.updateItemQuantity(tableId, cuisineId, quantity);
+            sseService.pushCartUpdate(tableId, cart);
+            return R.data(cart);
+        } catch (Exception e) {
+            log.error("通用价目-更新购物车失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "删除行")
+    @DeleteMapping("/cart/remove")
+    public R<CartDTO> remove(
+            @RequestParam Integer tableId,
+            @RequestParam Integer cuisineId) {
+        try {
+            if (!TokenUtil.hasValidToken()) {
+                return R.fail("用户未登录");
+            }
+            CartDTO cart = genericCartService.removeItem(tableId, cuisineId);
+            sseService.pushCartUpdate(tableId, cart);
+            return R.data(cart);
+        } catch (Exception e) {
+            log.error("通用价目-删除购物车行失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "清空购物车")
+    @DeleteMapping("/cart/clear")
+    public R<CartDTO> clear(@RequestParam Integer tableId) {
+        try {
+            if (!TokenUtil.hasValidToken()) {
+                return R.fail("用户未登录");
+            }
+            genericCartService.clearCart(tableId);
+            CartDTO cart = genericCartService.getCart(tableId);
+            sseService.pushCartUpdate(tableId, cart);
+            return R.data(cart);
+        } catch (Exception e) {
+            log.error("通用价目-清空购物车失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "设置用餐人数")
+    @PostMapping("/cart/set-diner-count")
+    public R<CartDTO> setDinerCount(@RequestParam Integer tableId, @RequestParam Integer dinerCount) {
+        try {
+            if (!TokenUtil.hasValidToken()) {
+                return R.fail("用户未登录");
+            }
+            if (dinerCount == null || dinerCount <= 0) {
+                return R.fail("用餐人数必须大于0");
+            }
+            CartDTO cart = genericCartService.setDinerCount(tableId, dinerCount);
+            sseService.pushCartUpdate(tableId, cart);
+            return R.data(cart);
+        } catch (Exception e) {
+            log.error("通用价目-设置人数失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "更新餐具数量")
+    @PutMapping("/cart/update-tableware")
+    public R<CartDTO> updateTableware(@RequestParam Integer tableId, @RequestParam(required = false) Integer quantity) {
+        try {
+            if (!TokenUtil.hasValidToken()) {
+                return R.fail("用户未登录");
+            }
+            if (quantity != null && quantity < 0) {
+                return R.fail("餐具数量不能小于0");
+            }
+            if (quantity == null) {
+                CartDTO cart = genericCartService.getCart(tableId);
+                sseService.pushCartUpdate(tableId, cart);
+                return R.data(cart);
+            }
+            CartDTO cart = genericCartService.updateTablewareQuantity(tableId, quantity);
+            sseService.pushCartUpdate(tableId, cart);
+            return R.data(cart);
+        } catch (Exception e) {
+            log.error("通用价目-餐具数量失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "创建订单", notes = "须先锁定订单;桌台须为通用价目桌。逻辑见 StoreOrderService.createGenericOrder")
+    @PostMapping("/create")
+    public R<OrderSuccessVO> create(@Valid @RequestBody CreateOrderDTO dto) {
+        try {
+            if (!TokenUtil.hasValidToken()) {
+                return R.fail("用户未登录");
+            }
+            if (dto.getRemark() != null && dto.getRemark().length() > 30) {
+                dto.setRemark(dto.getRemark().substring(0, 30));
+            }
+            dto.setImmediatePay(0);
+            StoreOrder order = orderService.createGenericOrder(dto);
+
+            OrderSuccessVO vo = new OrderSuccessVO();
+            vo.setOrderId(order.getId());
+            vo.setOrderNo(order.getOrderNo());
+            vo.setTableNumber(order.getTableNumber());
+            vo.setDinerCount(order.getDinerCount());
+            vo.setOrderStatus(order.getOrderStatus());
+            StoreInfo storeInfo = storeInfoMapper.selectById(order.getStoreId());
+            vo.setStoreName(storeInfo != null ? storeInfo.getStoreName() : null);
+
+            sseService.pushCartUpdate(dto.getTableId(), order);
+            return R.data(vo);
+        } catch (Exception e) {
+            log.error("通用价目-下单失败: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "SSE", notes = "与美食同通道,按 tableId;推送内容为通用购物车或订单事件")
+    @GetMapping(value = "/sse/{tableId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+    public SseEmitter sse(@PathVariable Integer tableId) {
+        if (!TokenUtil.hasValidToken()) {
+            return null;
+        }
+        return sseService.createConnection(tableId);
+    }
+}

+ 17 - 12
alien-dining/src/main/java/shop/alien/dining/controller/StoreInfoController.java

@@ -9,7 +9,7 @@ import shop.alien.entity.store.StoreCuisine;
 import shop.alien.entity.store.StoreCuisineCategory;
 import shop.alien.entity.store.StoreTable;
 import shop.alien.entity.store.dto.StoreInfoWithHomepageCuisinesDTO;
-import shop.alien.entity.store.vo.CategoryWithCuisinesVO;
+import shop.alien.entity.store.vo.CategoryMenuGroupVO;
 import shop.alien.dining.service.StoreInfoService;
 
 import java.util.List;
@@ -47,16 +47,17 @@ public class StoreInfoController {
         }
     }
 
-    @ApiOperation(value = "根据门店ID查询菜品种类列表", notes = "查询指定门店下的所有菜品种类")
+    @ApiOperation(value = "根据门店ID查询分类列表", notes = "默认美食分类;传 menuType=2 可拉取通用价目分类(与 store_price.category_ids 对应)")
     @GetMapping("/categories")
     public R<List<StoreCuisineCategory>> getCategoriesByStoreId(
-            @ApiParam(value = "门店ID", required = true) @RequestParam Integer storeId) {
-        log.info("StoreInfoController.getCategoriesByStoreId?storeId={}", storeId);
+            @ApiParam(value = "门店ID", required = true) @RequestParam Integer storeId,
+            @ApiParam(value = "可选 1=美食 2=通用价目,默认 1") @RequestParam(required = false) Integer menuType) {
+        log.info("StoreInfoController.getCategoriesByStoreId?storeId={}, menuType={}", storeId, menuType);
         try {
             if (storeId == null) {
                 return R.fail("门店ID不能为空");
             }
-            List<StoreCuisineCategory> categories = storeInfoService.getCategoriesByStoreId(storeId);
+            List<StoreCuisineCategory> categories = storeInfoService.getCategoriesByStoreId(storeId, menuType);
             return R.data(categories);
         } catch (Exception e) {
             log.error("查询菜品种类列表失败: {}", e.getMessage(), e);
@@ -64,21 +65,25 @@ public class StoreInfoController {
         }
     }
 
-    @ApiOperation(value = "根据门店ID查询菜品种类及各类别下菜品", notes = "一次返回所有菜品种类及每个分类下的菜品列表;可选 keyword 按菜品名称模糊查询。category 与 /store/info/categories 单条一致;cuisines 每项为 StoreCuisine 全字段,并含 originalPrice、currentPrice、hasActiveDiscount(按 store_product_discount_rule 当前时刻命中,与门店优惠配置一致)。")
+    @ApiOperation(value = "分类树(美食或通用价目)", notes = "同一接口:menuType=1(默认)返回 category+cuisines;menuType=2 返回 category+prices(store_price)。keyword 可选,模糊名称。空侧字段不序列化,默认与历史美食 JSON 接近。")
     @GetMapping("/categories-with-cuisines")
-    public R<List<CategoryWithCuisinesVO>> getCategoriesWithCuisinesByStoreId(
+    public R<List<CategoryMenuGroupVO>> getCategoriesWithMenuGroupsByStoreId(
             @ApiParam(value = "门店ID", required = true) @RequestParam Integer storeId,
-            @ApiParam(value = "菜品名称模糊查询关键词(可选)") @RequestParam(required = false) String keyword) {
-        log.info("StoreInfoController.getCategoriesWithCuisinesByStoreId?storeId={}, keyword={}", storeId, keyword);
+            @ApiParam(value = "名称模糊查询(可选)") @RequestParam(required = false) String keyword,
+            @ApiParam(value = "1=美食 2=通用价目,默认 1") @RequestParam(required = false) Integer menuType) {
+        log.info("StoreInfoController.getCategoriesWithMenuGroupsByStoreId?storeId={}, keyword={}, menuType={}",
+                storeId, keyword, menuType);
         try {
             if (storeId == null) {
                 return R.fail("门店ID不能为空");
             }
-            List<CategoryWithCuisinesVO> list = storeInfoService.getCategoriesWithCuisinesByStoreId(storeId, keyword);
+            List<CategoryMenuGroupVO> list = storeInfoService.getCategoriesWithMenuGroupsByStoreId(storeId, keyword, menuType);
             return R.data(list);
+        } catch (IllegalArgumentException e) {
+            return R.fail(e.getMessage());
         } catch (Exception e) {
-            log.error("查询菜品种类及菜品失败: {}", e.getMessage(), e);
-            return R.fail("查询菜品种类及菜品失败: " + e.getMessage());
+            log.error("查询分类树失败: {}", e.getMessage(), e);
+            return R.fail("查询分类树失败: " + e.getMessage());
         }
     }
 

+ 2 - 2
alien-dining/src/main/java/shop/alien/dining/controller/StoreOrderController.java

@@ -229,7 +229,7 @@ public class StoreOrderController {
         }
     }
 
-    @ApiOperation(value = "创建订单(下单)", notes = "从购物车创建订单,不立即支付。绑定的预约须为「已到店(2)」且当前桌在其占用桌位中;userReservationId 可选,不传则按 tableId 解析本桌当日已到店预约(不必与预约人为同一登录账号,便于同伴下单)。备注创建/更新传入,加餐更新时覆盖。"
+    @ApiOperation(value = "创建订单(下单)", notes = "从购物车创建订单,不立即支付。**须先调用「锁定订单」接口且当前用户持有锁(锁 5 分钟内有效),否则将拒绝下单。**绑定的预约须为「已到店(2)」且当前桌在其占用桌位中;userReservationId 可选,不传则按 tableId 解析本桌当日已到店预约(不必与预约人为同一登录账号,便于同伴下单)。备注创建/更新传入,加餐更新时覆盖。使用优惠券时,券须为**当前登录用户已领取**且待使用、未过期且在券活动有效期内。"
             + "订单总金额、餐具费、服务费、优惠金额、实付金额均由前端传入,后端不做金额校验。")
     @PostMapping("/create")
     public R<shop.alien.entity.store.vo.OrderSuccessVO> createOrder(@Valid @RequestBody CreateOrderDTO dto) {
@@ -369,7 +369,7 @@ public class StoreOrderController {
         }
     }
 
-    @ApiOperation(value = "查询订单信息", notes = "根据订单ID查询订单完整信息(包含订单基本信息、菜品清单、价格明细)")
+    @ApiOperation(value = "查询订单信息", notes = "根据订单ID查询订单完整信息(包含订单基本信息、tableId、menuType、菜品清单、价格明细)")
     @GetMapping("/info/{orderId}")
     public R<OrderInfoVO> getOrderInfo(@ApiParam(value = "订单ID", required = true) @PathVariable Integer orderId) {
         log.info("StoreOrderController.getOrderInfo?orderId={}", orderId);

+ 38 - 0
alien-dining/src/main/java/shop/alien/dining/service/GenericCartService.java

@@ -0,0 +1,38 @@
+package shop.alien.dining.service;
+
+import shop.alien.entity.store.dto.AddCartItemDTO;
+import shop.alien.entity.store.dto.CartDTO;
+
+/**
+ * 通用价目表(store_price)购物车,与美食购物车 Redis/DB 隔离。
+ */
+public interface GenericCartService {
+
+    CartDTO getCart(Integer tableId);
+
+    CartDTO addItem(AddCartItemDTO dto);
+
+    CartDTO updateItemQuantity(Integer tableId, Integer priceItemId, Integer quantity);
+
+    CartDTO removeItem(Integer tableId, Integer priceItemId);
+
+    void clearCart(Integer tableId);
+
+    default CartDTO migrateCart(Integer fromTableId, Integer toTableId) {
+        throw new UnsupportedOperationException("通用价目购物车暂不支持换桌迁移,请后续扩展");
+    }
+
+    boolean hasUsedCoupon(Integer tableId);
+
+    void markCouponUsed(Integer tableId, Integer couponId);
+
+    void clearCouponUsed(Integer tableId);
+
+    CartDTO setDinerCount(Integer tableId, Integer dinerCount);
+
+    CartDTO updateTablewareQuantity(Integer tableId, Integer quantity);
+
+    CartDTO lockCartItems(Integer tableId);
+
+    CartDTO unlockCartItems(Integer tableId);
+}

+ 21 - 0
alien-dining/src/main/java/shop/alien/dining/service/GenericDiningService.java

@@ -0,0 +1,21 @@
+package shop.alien.dining.service;
+
+import shop.alien.entity.store.vo.*;
+
+import java.util.List;
+
+/**
+ * 通用价目(store_price)点餐页:列表/详情/确认页,仅允许 type=2 桌台。
+ */
+public interface GenericDiningService {
+
+    DiningPageInfoVO getDiningPageInfo(Integer tableId, Integer dinerCount);
+
+    List<CuisineListVO> searchCuisines(Integer storeId, String keyword, Integer tableId);
+
+    List<CuisineListVO> getCuisinesByCategory(Integer storeId, Integer categoryId, Integer tableId, Integer page, Integer size);
+
+    CuisineDetailVO getCuisineDetail(Integer priceItemId, Integer tableId);
+
+    OrderConfirmVO getOrderConfirmInfo(Integer tableId, Integer dinerCount, Integer userId);
+}

+ 15 - 11
alien-dining/src/main/java/shop/alien/dining/service/StoreInfoService.java

@@ -3,8 +3,9 @@ package shop.alien.dining.service;
 import shop.alien.entity.store.StoreCuisine;
 import shop.alien.entity.store.StoreCuisineCategory;
 import shop.alien.entity.store.StoreTable;
+import shop.alien.entity.store.constants.StoreMenuType;
 import shop.alien.entity.store.dto.StoreInfoWithHomepageCuisinesDTO;
-import shop.alien.entity.store.vo.CategoryWithCuisinesVO;
+import shop.alien.entity.store.vo.CategoryMenuGroupVO;
 
 import java.util.List;
 
@@ -25,12 +26,16 @@ public interface StoreInfoService {
     List<StoreTable> getTablesByStoreId(Integer storeId);
 
     /**
-     * 根据门店ID查询菜品种类列表
-     *
-     * @param storeId 门店ID
-     * @return 菜品种类列表
+     * 查询分类列表(默认仅美食分类,与历史一致)
+     */
+    default List<StoreCuisineCategory> getCategoriesByStoreId(Integer storeId) {
+        return getCategoriesByStoreId(storeId, StoreMenuType.CUISINE);
+    }
+
+    /**
+     * @param menuType {@link StoreMenuType},null 视作美食
      */
-    List<StoreCuisineCategory> getCategoriesByStoreId(Integer storeId);
+    List<StoreCuisineCategory> getCategoriesByStoreId(Integer storeId, Integer menuType);
 
     /**
      * 根据菜品种类ID查询菜品信息列表
@@ -41,13 +46,12 @@ public interface StoreInfoService {
     List<StoreCuisine> getCuisinesByCategoryId(Integer categoryId);
 
     /**
-     * 根据门店ID查询菜品种类及每个分类下的菜品列表(一次返回种类+菜品)
+     * 分类树:{@code menuType=1}(默认)美食 + store_cuisine;{@code menuType=2} 通用价目 + store_price。
+     * 路径统一为 GET /store/info/categories-with-cuisines。
      *
-     * @param storeId 门店ID
-     * @param keyword 菜品名称模糊查询关键词(可选,为空则不按名称筛选)
-     * @return 菜品种类及下属菜品列表
+     * @param menuType {@link shop.alien.entity.store.constants.StoreMenuType},null 视为 1
      */
-    List<CategoryWithCuisinesVO> getCategoriesWithCuisinesByStoreId(Integer storeId, String keyword);
+    List<CategoryMenuGroupVO> getCategoriesWithMenuGroupsByStoreId(Integer storeId, String keyword, Integer menuType);
 
     /**
      * 删除菜品种类:仅逻辑删除分类并解除菜品与该分类的绑定关系,价目表(菜品)本身不改动

+ 7 - 1
alien-dining/src/main/java/shop/alien/dining/service/StoreOrderService.java

@@ -29,6 +29,11 @@ public interface StoreOrderService extends IService<StoreOrder> {
     StoreOrder createOrder(CreateOrderDTO dto);
 
     /**
+     * 通用价目表(store_price)下单,与 {@link #createOrder} 共用订单表,menu_type=2。
+     */
+    StoreOrder createGenericOrder(CreateOrderDTO dto);
+
+    /**
      * 支付订单
      *
      * @param orderId 订单ID
@@ -147,8 +152,9 @@ public interface StoreOrderService extends IService<StoreOrder> {
      * 支付完成后重置餐桌(保留订单数据,只重置餐桌绑定关系)
      *
      * @param tableId 餐桌ID
+     * @param orderMenuType 订单 {@code menu_type},用于仅清理对应类型购物车;可为 null 表示清理美食+未区分的历史行
      */
-    void resetTableAfterPayment(Integer tableId);
+    void resetTableAfterPayment(Integer tableId, Integer orderMenuType);
 
     /**
      * 查询我的订单(通过标识查询未支付订单或历史订单)

+ 60 - 99
alien-dining/src/main/java/shop/alien/dining/service/impl/CartServiceImpl.java

@@ -24,6 +24,9 @@ import shop.alien.mapper.StoreInfoMapper;
 import shop.alien.mapper.StoreProductDiscountRuleMapper;
 import shop.alien.mapper.StoreTableMapper;
 import shop.alien.dining.support.DiningMenuPricing;
+import shop.alien.dining.support.DiningOrderingTableGuard;
+import shop.alien.dining.support.StoreCartMenuFilters;
+import shop.alien.dining.constants.OrderMenuConstants;
 import shop.alien.dining.util.TokenUtil;
 
 import java.math.BigDecimal;
@@ -68,6 +71,7 @@ public class CartServiceImpl implements CartService {
     private final StoreCouponUsageMapper storeCouponUsageMapper;
     private final StoreInfoMapper storeInfoMapper;
     private final StoreProductDiscountRuleMapper storeProductDiscountRuleMapper;
+    private final DiningOrderingTableGuard diningOrderingTableGuard;
 
     @Override
     public CartDTO getCart(Integer tableId) {
@@ -149,7 +153,9 @@ public class CartServiceImpl implements CartService {
                 it.setOriginalUnitPrice(p);
                 it.setCurrentUnitPrice(p);
                 it.setHasActiveDiscount(Boolean.FALSE);
-                BigDecimal newSub = p.multiply(BigDecimal.valueOf(qty)).setScale(2, RoundingMode.HALF_UP);
+                BigDecimal origSub = p.multiply(BigDecimal.valueOf(qty)).setScale(2, RoundingMode.HALF_UP);
+                BigDecimal newSub = origSub;
+                it.setOriginalSubtotalAmount(origSub);
                 if (!Objects.equals(it.getUnitPrice(), p) || !Objects.equals(it.getSubtotalAmount(), newSub)) {
                     changed = true;
                 }
@@ -162,8 +168,11 @@ public class CartServiceImpl implements CartService {
             it.setOriginalUnitPrice(list);
             it.setCurrentUnitPrice(sale);
             it.setHasActiveDiscount(list.compareTo(sale) != 0);
+            BigDecimal origSub = list.multiply(BigDecimal.valueOf(qty)).setScale(2, RoundingMode.HALF_UP);
             BigDecimal newSub = sale.multiply(BigDecimal.valueOf(qty)).setScale(2, RoundingMode.HALF_UP);
-            if (!Objects.equals(it.getUnitPrice(), sale) || !Objects.equals(it.getSubtotalAmount(), newSub)) {
+            it.setOriginalSubtotalAmount(origSub);
+            if (!Objects.equals(it.getUnitPrice(), sale) || !Objects.equals(it.getSubtotalAmount(), newSub)
+                    || !Objects.equals(it.getOriginalSubtotalAmount(), origSub)) {
                 changed = true;
             }
             it.setUnitPrice(sale);
@@ -202,6 +211,7 @@ public class CartServiceImpl implements CartService {
         LambdaQueryWrapper<StoreCart> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(StoreCart::getTableId, tableId);
         wrapper.eq(StoreCart::getDeleteFlag, 0);
+        StoreCartMenuFilters.applyCuisineCart(wrapper);
         List<StoreCart> cartList = storeCartMapper.selectList(wrapper);
 
         if (cartList != null && !cartList.isEmpty()) {
@@ -245,12 +255,16 @@ public class CartServiceImpl implements CartService {
         if (table == null) {
             throw new RuntimeException("桌号不存在");
         }
+        diningOrderingTableGuard.assertCuisineMenuAllowed(table);
 
         // 验证菜品
         StoreCuisine cuisine = storeCuisineMapper.selectById(dto.getCuisineId());
         if (cuisine == null) {
             throw new RuntimeException("菜品不存在");
         }
+        if (!Objects.equals(cuisine.getStoreId(), table.getStoreId())) {
+            throw new RuntimeException("菜品与桌台门店不匹配");
+        }
         if (cuisine.getShelfStatus() != 1) {
             throw new RuntimeException("菜品已下架");
         }
@@ -303,17 +317,7 @@ public class CartServiceImpl implements CartService {
             items.add(newItem);
         }
 
-        // 重新计算总金额和总数量
-        BigDecimal totalAmount = items.stream()
-                .map(CartItemDTO::getSubtotalAmount)
-                .reduce(BigDecimal.ZERO, BigDecimal::add);
-        Integer totalQuantity = items.stream()
-                .mapToInt(CartItemDTO::getQuantity)
-                .sum();
-        cart.setTotalAmount(totalAmount);
-        cart.setTotalQuantity(totalQuantity);
-
-        // 保存到Redis和数据库(双写策略)
+        applyRealtimeMenuPricing(cart);
         saveCart(cart);
 
         return cart;
@@ -350,26 +354,26 @@ public class CartServiceImpl implements CartService {
             item.setSubtotalAmount(item.getUnitPrice()
                     .multiply(BigDecimal.valueOf(quantity)));
 
-            // 重新计算总金额和总数量
-            BigDecimal totalAmount = items.stream()
-                    .map(CartItemDTO::getSubtotalAmount)
-                    .reduce(BigDecimal.ZERO, BigDecimal::add);
-            Integer totalQuantity = items.stream()
-                    .mapToInt(CartItemDTO::getQuantity)
-                    .sum();
-            cart.setTotalAmount(totalAmount);
-            cart.setTotalQuantity(totalQuantity);
-
+            applyRealtimeMenuPricing(cart);
             saveCart(cart);
         } else {
             // 商品不存在,自动添加
             log.info("商品不在购物车中,自动添加, tableId={}, cuisineId={}, quantity={}", tableId, cuisineId, quantity);
-            
+
+            StoreTable table = storeTableMapper.selectById(tableId);
+            if (table == null) {
+                throw new RuntimeException("桌号不存在");
+            }
+            diningOrderingTableGuard.assertCuisineMenuAllowed(table);
+
             // 验证菜品
             StoreCuisine cuisine = storeCuisineMapper.selectById(cuisineId);
             if (cuisine == null) {
                 throw new RuntimeException("菜品不存在");
             }
+            if (!Objects.equals(cuisine.getStoreId(), table.getStoreId())) {
+                throw new RuntimeException("菜品与桌台门店不匹配");
+            }
             if (cuisine.getShelfStatus() != 1) {
                 throw new RuntimeException("菜品已下架");
             }
@@ -392,17 +396,7 @@ public class CartServiceImpl implements CartService {
             newItem.setAddUserPhone(userPhone);
             items.add(newItem);
 
-            // 重新计算总金额和总数量
-            BigDecimal totalAmount = items.stream()
-                    .map(CartItemDTO::getSubtotalAmount)
-                    .reduce(BigDecimal.ZERO, BigDecimal::add);
-            Integer totalQuantity = items.stream()
-                    .mapToInt(CartItemDTO::getQuantity)
-                    .sum();
-            cart.setTotalAmount(totalAmount);
-            cart.setTotalQuantity(totalQuantity);
-
-            // 保存到Redis和数据库(双写策略)
+            applyRealtimeMenuPricing(cart);
             saveCart(cart);
             log.info("商品已自动添加到购物车, tableId={}, cuisineId={}, quantity={}", tableId, cuisineId, quantity);
         }
@@ -431,17 +425,14 @@ public class CartServiceImpl implements CartService {
         
         items.removeIf(i -> i.getCuisineId().equals(cuisineId));
 
-        // 重新计算总金额和总数量
-        BigDecimal totalAmount = items.stream()
-                .map(CartItemDTO::getSubtotalAmount)
-                .reduce(BigDecimal.ZERO, BigDecimal::add);
-        Integer totalQuantity = items.stream()
-                .mapToInt(CartItemDTO::getQuantity)
-                .sum();
-        cart.setTotalAmount(totalAmount);
-        cart.setTotalQuantity(totalQuantity);
-
-        saveCart(cart);
+        if (items.isEmpty()) {
+            cart.setTotalAmount(BigDecimal.ZERO);
+            cart.setTotalQuantity(0);
+            saveCart(cart);
+        } else {
+            applyRealtimeMenuPricing(cart);
+            saveCart(cart);
+        }
         return cart;
     }
 
@@ -515,15 +506,12 @@ public class CartServiceImpl implements CartService {
         if (hasChanges) {
             // 1. 更新购物车(删除未下单商品,已下单商品数量已恢复)
             cart.setItems(orderedItems);
-            // 重新计算总金额和总数量(只计算保留的商品,数量已恢复为已下单数量)
-            BigDecimal totalAmount = orderedItems.stream()
-                    .map(CartItemDTO::getSubtotalAmount)
-                    .reduce(BigDecimal.ZERO, BigDecimal::add);
-            Integer totalQuantity = orderedItems.stream()
-                    .mapToInt(CartItemDTO::getQuantity)
-                    .sum();
-            cart.setTotalAmount(totalAmount);
-            cart.setTotalQuantity(totalQuantity);
+            if (!orderedItems.isEmpty()) {
+                applyRealtimeMenuPricing(cart);
+            } else {
+                cart.setTotalAmount(BigDecimal.ZERO);
+                cart.setTotalQuantity(0);
+            }
             
             // 更新Redis(保留已下单的商品,数量已恢复)
             if (orderedItems.isEmpty()) {
@@ -540,6 +528,7 @@ public class CartServiceImpl implements CartService {
                 LambdaQueryWrapper<StoreCart> wrapper = new LambdaQueryWrapper<>();
                 wrapper.eq(StoreCart::getTableId, tableId);
                 wrapper.eq(StoreCart::getDeleteFlag, 0);
+                StoreCartMenuFilters.applyCuisineCart(wrapper);
                 // 排除餐具(cuisineId = -1)
                 wrapper.ne(StoreCart::getCuisineId, TABLEWARE_CUISINE_ID);
                 if (!orderedCuisineIds.isEmpty()) {
@@ -566,8 +555,8 @@ public class CartServiceImpl implements CartService {
             // 4. 更新桌号表的购物车统计
             StoreTable table = storeTableMapper.selectById(tableId);
             if (table != null) {
-                table.setCartItemCount(totalQuantity);
-                table.setCartTotalAmount(totalAmount);
+                table.setCartItemCount(cart.getTotalQuantity());
+                table.setCartTotalAmount(cart.getTotalAmount());
                 storeTableMapper.updateById(table);
             }
             
@@ -589,6 +578,7 @@ public class CartServiceImpl implements CartService {
         if (toTable == null) {
             throw new RuntimeException("目标桌号不存在");
         }
+        diningOrderingTableGuard.assertCuisineMenuAllowed(toTable);
 
         // 获取目标购物车
         CartDTO toCart = getCart(toTableId);
@@ -622,17 +612,7 @@ public class CartServiceImpl implements CartService {
         toCart.setTableNumber(toTable.getTableNumber());
         toCart.setStoreId(toTable.getStoreId());
 
-        // 重新计算总金额和总数量
-        BigDecimal totalAmount = mergedItems.stream()
-                .map(CartItemDTO::getSubtotalAmount)
-                .reduce(BigDecimal.ZERO, BigDecimal::add);
-        Integer totalQuantity = mergedItems.stream()
-                .mapToInt(CartItemDTO::getQuantity)
-                .sum();
-        toCart.setTotalAmount(totalAmount);
-        toCart.setTotalQuantity(totalQuantity);
-
-        // 保存目标购物车
+        applyRealtimeMenuPricing(toCart);
         saveCart(toCart);
 
         // 清空原购物车
@@ -836,17 +816,7 @@ public class CartServiceImpl implements CartService {
             }
         }
 
-        // 重新计算总金额和总数量
-        BigDecimal totalAmount = items.stream()
-                .map(CartItemDTO::getSubtotalAmount)
-                .reduce(BigDecimal.ZERO, BigDecimal::add);
-        Integer totalQuantity = items.stream()
-                .mapToInt(CartItemDTO::getQuantity)
-                .sum();
-        cart.setTotalAmount(totalAmount);
-        cart.setTotalQuantity(totalQuantity);
-
-        // 保存到Redis和数据库(双写策略)
+        applyRealtimeMenuPricing(cart);
         saveCart(cart);
 
         return cart;
@@ -888,15 +858,14 @@ public class CartServiceImpl implements CartService {
                     .orElse(null);
             if (existing != null) {
                 items.remove(existing);
-                BigDecimal totalAmount = items.stream()
-                        .map(CartItemDTO::getSubtotalAmount)
-                        .reduce(BigDecimal.ZERO, BigDecimal::add);
-                Integer totalQuantity = items.stream()
-                        .mapToInt(CartItemDTO::getQuantity)
-                        .sum();
-                cart.setTotalAmount(totalAmount);
-                cart.setTotalQuantity(totalQuantity);
-                saveCart(cart);
+                if (items.isEmpty()) {
+                    cart.setTotalAmount(BigDecimal.ZERO);
+                    cart.setTotalQuantity(0);
+                    saveCart(cart);
+                } else {
+                    applyRealtimeMenuPricing(cart);
+                    saveCart(cart);
+                }
             }
             return cart;
         }
@@ -935,17 +904,7 @@ public class CartServiceImpl implements CartService {
         tablewareItem.setQuantity(quantity);
         tablewareItem.setSubtotalAmount(tablewareUnitPrice.multiply(BigDecimal.valueOf(quantity)));
 
-        // 重新计算总金额和总数量
-        BigDecimal totalAmount = items.stream()
-                .map(CartItemDTO::getSubtotalAmount)
-                .reduce(BigDecimal.ZERO, BigDecimal::add);
-        Integer totalQuantity = items.stream()
-                .mapToInt(CartItemDTO::getQuantity)
-                .sum();
-        cart.setTotalAmount(totalAmount);
-        cart.setTotalQuantity(totalQuantity);
-
-        // 保存到Redis和数据库(双写策略)
+        applyRealtimeMenuPricing(cart);
         saveCart(cart);
 
         return cart;
@@ -1070,6 +1029,7 @@ public class CartServiceImpl implements CartService {
             LambdaQueryWrapper<StoreCart> queryWrapper = new LambdaQueryWrapper<>();
             queryWrapper.eq(StoreCart::getTableId, cart.getTableId())
                     .eq(StoreCart::getDeleteFlag, 0);
+            StoreCartMenuFilters.applyCuisineCart(queryWrapper);
             List<StoreCart> existingCartList = storeCartMapper.selectList(queryWrapper);
             if (existingCartList != null && !existingCartList.isEmpty()) {
                 List<Integer> cartIds = existingCartList.stream()
@@ -1097,6 +1057,7 @@ public class CartServiceImpl implements CartService {
                     storeCart.setAddUserPhone(item.getAddUserPhone());
                     storeCart.setRemark(item.getRemark());
                     storeCart.setDeleteFlag(0);
+                    storeCart.setMenuType(OrderMenuConstants.MENU_TYPE_CUISINE);
                     storeCart.setCreatedTime(now);
                     storeCart.setCreatedUserId(userId);
                     storeCart.setUpdatedTime(now);

+ 30 - 38
alien-dining/src/main/java/shop/alien/dining/service/impl/DiningServiceImpl.java

@@ -11,10 +11,12 @@ import shop.alien.entity.store.dto.CartDTO;
 import shop.alien.entity.store.dto.CartItemDTO;
 import shop.alien.entity.store.vo.*;
 import shop.alien.mapper.*;
-import shop.alien.dining.config.BaseRedisService;
 import shop.alien.dining.service.CartService;
 import shop.alien.dining.service.DiningService;
 import shop.alien.dining.support.DiningOrderServiceFeeCalculator;
+import shop.alien.dining.support.DiningFirstVisitDinerCount;
+import shop.alien.dining.support.DiningOrderingTableGuard;
+import shop.alien.dining.support.DiningSearchText;
 
 import java.math.BigDecimal;
 import java.time.LocalDate;
@@ -35,30 +37,23 @@ public class DiningServiceImpl implements DiningService {
     private final StoreTableMapper storeTableMapper;
     private final StoreInfoMapper storeInfoMapper;
     private final StoreCuisineMapper storeCuisineMapper;
-    private final StoreCuisineCategoryMapper storeCuisineCategoryMapper;
     private final StoreCuisineComboMapper storeCuisineComboMapper;
     private final StoreOrderDetailMapper storeOrderDetailMapper;
     private final LifeDiscountCouponMapper lifeDiscountCouponMapper;
     private final LifeDiscountCouponUserMapper lifeDiscountCouponUserMapper;
     private final CartService cartService;
-    private final BaseRedisService baseRedisService;
     private final shop.alien.dining.service.StoreOrderService storeOrderService;
-    private final shop.alien.mapper.StoreOrderMapper storeOrderMapper;
     private final shop.alien.dining.service.OrderLockService orderLockService;
     private final DiningOrderServiceFeeCalculator diningOrderServiceFeeCalculator;
+    private final DiningOrderingTableGuard diningOrderingTableGuard;
 
     @Override
     public DiningServiceFeeEstimateVO estimateServiceFee(Integer storeId, Integer tableId, Integer dinerCount, BigDecimal goodsSubtotal) {
         if (storeId == null || tableId == null) {
             throw new RuntimeException("门店ID与桌号ID不能为空");
         }
-        StoreTable table = storeTableMapper.selectById(tableId);
-        if (table == null) {
-            throw new RuntimeException("桌号不存在");
-        }
-        if (table.getStoreId() == null || !table.getStoreId().equals(storeId)) {
-            throw new RuntimeException("桌号与门店不匹配");
-        }
+        StoreTable table = diningOrderingTableGuard.requireTable(tableId);
+        diningOrderingTableGuard.assertTableBelongsToStore(table, storeId);
         return diningOrderServiceFeeCalculator.estimate(storeId, tableId, new Date(), dinerCount, goodsSubtotal);
     }
 
@@ -86,37 +81,23 @@ public class DiningServiceImpl implements DiningService {
     @Override
     public DiningPageInfoVO getDiningPageInfo(Integer tableId, Integer dinerCount) {
         log.info("获取点餐页面信息, tableId={}, dinerCount={}", tableId, dinerCount);
-        StoreTable table = storeTableMapper.selectById(tableId);
-        if (table == null) {
-            throw new RuntimeException("桌号不存在");
-        }
-
-        StoreInfo storeInfo = storeInfoMapper.selectById(table.getStoreId());
-        if (storeInfo == null) {
-            throw new RuntimeException("门店不存在");
-        }
+        StoreTable table = diningOrderingTableGuard.requireTable(tableId);
+        StoreInfo storeInfo = diningOrderingTableGuard.requireStore(table);
+        diningOrderingTableGuard.assertCuisineMenuAllowed(storeInfo, table);
 
-        // 第一个用户:传入用餐人数时,将餐桌置为就餐中并保存就餐人数
-        if (dinerCount != null && dinerCount > 0) {
-            table.setStatus(1); // 就餐中
-            table.setDinerCount(dinerCount);
+        int resolvedDinerCount = DiningFirstVisitDinerCount.resolveOrThrow(table, dinerCount);
+        if (DiningFirstVisitDinerCount.shouldPersistToDatabase(dinerCount)) {
             storeTableMapper.updateById(table);
-            log.info("首客选桌并填写用餐人数, tableId={}, dinerCount={}, 餐桌状态已置为就餐中", tableId, dinerCount);
+            log.info("首客填写用餐人数(餐桌状态不变,未开台), tableId={}, dinerCount={}", tableId, resolvedDinerCount);
         } else {
-            // 第二个及后续用户:若餐桌已就餐中且表内已有就餐人数,直接使用
-            if (Integer.valueOf(1).equals(table.getStatus()) && table.getDinerCount() != null && table.getDinerCount() > 0) {
-                dinerCount = table.getDinerCount();
-                log.info("餐桌已就餐中,使用已保存的就餐人数, tableId={}, dinerCount={}", tableId, dinerCount);
-            } else {
-                // 餐桌空闲且未传就餐人数时要求必填
-                throw new RuntimeException("请选择用餐人数");
-            }
+            log.info("使用已保存的就餐人数, tableId={}, dinerCount={}, tableStatus={}",
+                    tableId, resolvedDinerCount, table.getStatus());
         }
 
         DiningPageInfoVO vo = new DiningPageInfoVO();
         vo.setStoreName(storeInfo.getStoreName());
         vo.setTableNumber(table.getTableNumber());
-        vo.setDinerCount(dinerCount);
+        vo.setDinerCount(resolvedDinerCount);
         vo.setStoreId(table.getStoreId());
         vo.setTableId(tableId);
 
@@ -127,10 +108,11 @@ public class DiningServiceImpl implements DiningService {
     public List<CuisineListVO> searchCuisines(Integer storeId, String keyword, Integer tableId) {
         log.info("搜索菜品, storeId={}, keyword={}, tableId={}", storeId, keyword, tableId);
 
-        // 限制搜索关键词长度
-        if (StringUtils.hasText(keyword) && keyword.length() > 10) {
-            keyword = keyword.substring(0, 10);
-        }
+        StoreTable table = diningOrderingTableGuard.requireTable(tableId);
+        diningOrderingTableGuard.assertTableBelongsToStore(table, storeId);
+        diningOrderingTableGuard.assertCuisineMenuAllowed(table);
+
+        keyword = DiningSearchText.clampKeyword(keyword);
 
         LambdaQueryWrapper<StoreCuisine> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(StoreCuisine::getStoreId, storeId);
@@ -149,6 +131,10 @@ public class DiningServiceImpl implements DiningService {
     public List<CuisineListVO> getCuisinesByCategory(Integer storeId, Integer categoryId, Integer tableId, Integer page, Integer size) {
         log.info("根据分类获取菜品列表, storeId={}, categoryId={}, tableId={}, page={}, size={}", storeId, categoryId, tableId, page, size);
 
+        StoreTable table = diningOrderingTableGuard.requireTable(tableId);
+        diningOrderingTableGuard.assertTableBelongsToStore(table, storeId);
+        diningOrderingTableGuard.assertCuisineMenuAllowed(table);
+
         if (page == null || page < 1) {
             page = 1;
         }
@@ -183,6 +169,12 @@ public class DiningServiceImpl implements DiningService {
             throw new RuntimeException("菜品不存在");
         }
 
+        StoreTable table = diningOrderingTableGuard.requireTable(tableId);
+        if (!Objects.equals(cuisine.getStoreId(), table.getStoreId())) {
+            throw new RuntimeException("菜品与桌台门店不匹配");
+        }
+        diningOrderingTableGuard.assertCuisineMenuAllowed(table);
+
         CuisineDetailVO vo = new CuisineDetailVO();
         // 手动映射所有字段,确保所有属性都被正确复制
         vo.setId(cuisine.getId());

+ 1022 - 0
alien-dining/src/main/java/shop/alien/dining/service/impl/GenericCartServiceImpl.java

@@ -0,0 +1,1022 @@
+package shop.alien.dining.service.impl;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+import shop.alien.dining.config.BaseRedisService;
+import shop.alien.dining.constants.OrderMenuConstants;
+import shop.alien.dining.service.GenericCartService;
+import shop.alien.entity.store.StoreCart;
+import shop.alien.entity.store.StoreCouponUsage;
+import shop.alien.entity.store.StoreInfo;
+import shop.alien.entity.store.StorePrice;
+import shop.alien.entity.store.StoreTable;
+import shop.alien.entity.store.dto.AddCartItemDTO;
+import shop.alien.entity.store.dto.CartDTO;
+import shop.alien.entity.store.dto.CartItemDTO;
+import shop.alien.mapper.StoreCartMapper;
+import shop.alien.mapper.StoreCouponUsageMapper;
+import shop.alien.mapper.StorePriceMapper;
+import shop.alien.mapper.StoreInfoMapper;
+import shop.alien.mapper.StoreProductDiscountRuleMapper;
+import shop.alien.mapper.StoreTableMapper;
+import shop.alien.dining.support.DiningMenuPricing;
+import shop.alien.dining.support.DiningOrderingTableGuard;
+import shop.alien.dining.support.StoreCartMenuFilters;
+import shop.alien.dining.util.TokenUtil;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.stream.Collectors;
+
+/**
+ * 通用价目表(store_price)购物车:Redis/DB 与美食购物车隔离(menu_type=2)。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class GenericCartServiceImpl implements GenericCartService {
+
+    private static final String CART_KEY_PREFIX = OrderMenuConstants.GENERIC_CART_REDIS_PREFIX;
+    private static final String COUPON_USED_KEY_PREFIX = OrderMenuConstants.GENERIC_COUPON_REDIS_PREFIX;
+    private static final int CART_EXPIRE_SECONDS = 24 * 60 * 60; // 24小时过期
+
+    // 异步写入数据库的线程池(专门用于购物车数据库写入)
+    private static final ExecutorService CART_DB_WRITE_EXECUTOR = Executors.newFixedThreadPool(5, r -> {
+        Thread t = new Thread(r, "generic-cart-db-write-" + System.currentTimeMillis());
+        t.setDaemon(true);
+        return t;
+    });
+
+    private final BaseRedisService baseRedisService;
+    private final StoreTableMapper storeTableMapper;
+    private final StorePriceMapper storePriceMapper;
+    private final StoreCartMapper storeCartMapper;
+    private final StoreCouponUsageMapper storeCouponUsageMapper;
+    private final StoreInfoMapper storeInfoMapper;
+    private final StoreProductDiscountRuleMapper storeProductDiscountRuleMapper;
+    private final DiningOrderingTableGuard diningOrderingTableGuard;
+
+    @Override
+    public CartDTO getCart(Integer tableId) {
+        log.info("获取通用价目购物车, tableId={}", tableId);
+        String cartKey = CART_KEY_PREFIX + tableId;
+        String cartJson = baseRedisService.getString(cartKey);
+
+        CartDTO cart = new CartDTO();
+        cart.setTableId(tableId);
+
+        // 查询桌号信息
+        StoreTable table = storeTableMapper.selectById(tableId);
+        if (table != null) {
+            cart.setTableNumber(table.getTableNumber());
+            cart.setStoreId(table.getStoreId());
+        }
+
+        if (StringUtils.hasText(cartJson)) {
+            try {
+                JSONObject cartObj = JSON.parseObject(cartJson);
+                List<CartItemDTO> items = cartObj.getList("items", CartItemDTO.class);
+                if (items != null) {
+                    cart.setItems(items);
+                    // 计算总金额和总数量
+                    BigDecimal totalAmount = items.stream()
+                            .map(CartItemDTO::getSubtotalAmount)
+                            .reduce(BigDecimal.ZERO, BigDecimal::add);
+                    Integer totalQuantity = items.stream()
+                            .mapToInt(CartItemDTO::getQuantity)
+                            .sum();
+                    cart.setTotalAmount(totalAmount);
+                    cart.setTotalQuantity(totalQuantity);
+                } else {
+                    cart.setItems(new ArrayList<>());
+                    cart.setTotalAmount(BigDecimal.ZERO);
+                    cart.setTotalQuantity(0);
+                }
+            } catch (Exception e) {
+                log.error("解析购物车数据失败: {}", e.getMessage(), e);
+                cart.setItems(new ArrayList<>());
+                cart.setTotalAmount(BigDecimal.ZERO);
+                cart.setTotalQuantity(0);
+            }
+        } else {
+            // Redis中没有,尝试从数据库加载
+            cart = loadCartFromDatabase(tableId);
+        }
+
+        applyRealtimeMenuPricing(cart);
+        return cart;
+    }
+
+    /**
+     * 按门店标价 + {@code store_product_discount_rule} 刷新行单价/小计/总价,并写回 Redis+DB(与下单明细口径一致)。
+     */
+    private void applyRealtimeMenuPricing(CartDTO cart) {
+        if (cart == null || cart.getStoreId() == null || cart.getItems() == null || cart.getItems().isEmpty()) {
+            return;
+        }
+        List<CartItemDTO> items = cart.getItems();
+        Set<Integer> cuisineIds = items.stream()
+                .map(CartItemDTO::getCuisineId)
+                .filter(Objects::nonNull)
+                .filter(id -> id > 0)
+                .collect(Collectors.toSet());
+        Map<Integer, BigDecimal> listByIdReal = cuisineIds.isEmpty()
+                ? java.util.Collections.emptyMap()
+                : DiningMenuPricing.resolveListUnitPriceByGenericPriceId(cuisineIds, storePriceMapper);
+        Map<Integer, BigDecimal> saleById = cuisineIds.isEmpty()
+                ? java.util.Collections.emptyMap()
+                : DiningMenuPricing.resolveSaleUnitPriceForGenericPrice(cart.getStoreId(), listByIdReal, storeProductDiscountRuleMapper);
+
+        boolean changed = false;
+        for (CartItemDTO it : items) {
+            Integer cid = it.getCuisineId();
+            int qty = it.getQuantity() != null ? it.getQuantity() : 0;
+            if (cid == null || cid <= 0) {
+                BigDecimal p = it.getUnitPrice() != null ? it.getUnitPrice() : BigDecimal.ZERO;
+                it.setOriginalUnitPrice(p);
+                it.setCurrentUnitPrice(p);
+                it.setHasActiveDiscount(Boolean.FALSE);
+                BigDecimal origSub = p.multiply(BigDecimal.valueOf(qty)).setScale(2, RoundingMode.HALF_UP);
+                BigDecimal newSub = origSub;
+                it.setOriginalSubtotalAmount(origSub);
+                if (!Objects.equals(it.getUnitPrice(), p) || !Objects.equals(it.getSubtotalAmount(), newSub)) {
+                    changed = true;
+                }
+                it.setUnitPrice(p);
+                it.setSubtotalAmount(newSub);
+                continue;
+            }
+            BigDecimal list = listByIdReal.getOrDefault(cid, BigDecimal.ZERO);
+            BigDecimal sale = saleById.getOrDefault(cid, list);
+            it.setOriginalUnitPrice(list);
+            it.setCurrentUnitPrice(sale);
+            it.setHasActiveDiscount(list.compareTo(sale) != 0);
+            BigDecimal origSub = list.multiply(BigDecimal.valueOf(qty)).setScale(2, RoundingMode.HALF_UP);
+            BigDecimal newSub = sale.multiply(BigDecimal.valueOf(qty)).setScale(2, RoundingMode.HALF_UP);
+            it.setOriginalSubtotalAmount(origSub);
+            if (!Objects.equals(it.getUnitPrice(), sale) || !Objects.equals(it.getSubtotalAmount(), newSub)
+                    || !Objects.equals(it.getOriginalSubtotalAmount(), origSub)) {
+                changed = true;
+            }
+            it.setUnitPrice(sale);
+            it.setSubtotalAmount(newSub);
+        }
+        BigDecimal total = items.stream()
+                .map(CartItemDTO::getSubtotalAmount)
+                .filter(Objects::nonNull)
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+        cart.setTotalAmount(total);
+        cart.setTotalQuantity(items.stream().mapToInt(i -> i.getQuantity() != null ? i.getQuantity() : 0).sum());
+        if (changed) {
+            saveCart(cart);
+        }
+    }
+
+    /**
+     * 从数据库加载购物车
+     */
+    private CartDTO loadCartFromDatabase(Integer tableId) {
+        log.info("从数据库加载购物车, tableId={}", tableId);
+        CartDTO cart = new CartDTO();
+        cart.setTableId(tableId);
+        cart.setItems(new ArrayList<>());
+        cart.setTotalAmount(BigDecimal.ZERO);
+        cart.setTotalQuantity(0);
+
+        // 查询桌号信息
+        StoreTable table = storeTableMapper.selectById(tableId);
+        if (table != null) {
+            cart.setTableNumber(table.getTableNumber());
+            cart.setStoreId(table.getStoreId());
+        }
+
+        // 从数据库查询购物车数据
+        LambdaQueryWrapper<StoreCart> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(StoreCart::getTableId, tableId);
+        wrapper.eq(StoreCart::getDeleteFlag, 0);
+        StoreCartMenuFilters.applyGenericCart(wrapper);
+        List<StoreCart> cartList = storeCartMapper.selectList(wrapper);
+
+        if (cartList != null && !cartList.isEmpty()) {
+            List<CartItemDTO> items = cartList.stream().map(cartItem -> {
+                CartItemDTO item = new CartItemDTO();
+                item.setCuisineId(cartItem.getCuisineId());
+                item.setCuisineName(cartItem.getCuisineName());
+                item.setCuisineImage(cartItem.getCuisineImage());
+                item.setUnitPrice(cartItem.getUnitPrice());
+                item.setQuantity(cartItem.getQuantity());
+                item.setLockedQuantity(cartItem.getLockedQuantity());
+                item.setSubtotalAmount(cartItem.getSubtotalAmount());
+                item.setAddUserId(cartItem.getAddUserId());
+                item.setAddUserPhone(cartItem.getAddUserPhone());
+                item.setRemark(cartItem.getRemark());
+                return item;
+            }).collect(Collectors.toList());
+
+            cart.setItems(items);
+            BigDecimal totalAmount = items.stream()
+                    .map(CartItemDTO::getSubtotalAmount)
+                    .reduce(BigDecimal.ZERO, BigDecimal::add);
+            Integer totalQuantity = items.stream()
+                    .mapToInt(CartItemDTO::getQuantity)
+                    .sum();
+            cart.setTotalAmount(totalAmount);
+            cart.setTotalQuantity(totalQuantity);
+
+            // 同步到Redis
+            saveCartToRedis(cart);
+        }
+
+        return cart;
+    }
+
+    @Override
+    public CartDTO addItem(AddCartItemDTO dto) {
+        log.info("添加商品到购物车, dto={}", dto);
+        StoreTable table = storeTableMapper.selectById(dto.getTableId());
+        if (table == null) {
+            throw new RuntimeException("桌号不存在");
+        }
+        diningOrderingTableGuard.assertGenericMenuAllowed(table);
+
+        // dto.cuisineId 此处表示 store_price.id
+        StorePrice price = storePriceMapper.selectById(dto.getCuisineId());
+        if (price == null) {
+            throw new RuntimeException("价目项不存在");
+        }
+        if (price.getShelfStatus() == null || price.getShelfStatus() != 1) {
+            throw new RuntimeException("价目项已下架");
+        }
+        if (price.getStatus() == null || price.getStatus() != 1) {
+            throw new RuntimeException("价目项未审核通过");
+        }
+
+        // 获取当前用户信息
+        Integer userId = TokenUtil.getCurrentUserId();
+        String userPhone = TokenUtil.getCurrentUserPhone();
+
+        // 获取购物车
+        CartDTO cart = getCart(dto.getTableId());
+
+        // 查找是否已存在该商品
+        List<CartItemDTO> items = cart.getItems();
+        CartItemDTO existingItem = items.stream()
+                .filter(item -> item.getCuisineId().equals(dto.getCuisineId()))
+                .findFirst()
+                .orElse(null);
+
+        if (existingItem != null) {
+            // 商品已存在
+            Integer lockedQuantity = existingItem.getLockedQuantity();
+            if (lockedQuantity != null && lockedQuantity > 0) {
+                // 如果商品有已下单数量,将新数量叠加到当前数量和已下单数量上
+                Integer newQuantity = existingItem.getQuantity() + dto.getQuantity();
+                Integer newLockedQuantity = lockedQuantity + dto.getQuantity();
+                existingItem.setQuantity(newQuantity);
+                existingItem.setLockedQuantity(newLockedQuantity);
+                existingItem.setSubtotalAmount(existingItem.getUnitPrice()
+                        .multiply(BigDecimal.valueOf(newQuantity)));
+                log.info("商品已存在且有已下单数量,叠加数量, priceId={}, oldQuantity={}, newQuantity={}, oldOrderedQuantity={}, newOrderedQuantity={}",
+                        dto.getCuisineId(), existingItem.getQuantity() - dto.getQuantity(), newQuantity, lockedQuantity, newLockedQuantity);
+            } else {
+                // 商品已存在但没有已下单数量,不允许重复添加
+                throw new RuntimeException("已添加过本商品");
+            }
+        } else {
+            // 添加新商品
+            CartItemDTO newItem = new CartItemDTO();
+            newItem.setCuisineId(price.getId());
+            newItem.setCuisineName(price.getName());
+            newItem.setCuisineType(1);
+            newItem.setCuisineImage(price.getImages());
+            newItem.setUnitPrice(price.getTotalPrice());
+            newItem.setQuantity(dto.getQuantity());
+            newItem.setSubtotalAmount(price.getTotalPrice()
+                    .multiply(BigDecimal.valueOf(dto.getQuantity())));
+            newItem.setAddUserId(userId);
+            newItem.setAddUserPhone(userPhone);
+            newItem.setRemark(dto.getRemark());
+            items.add(newItem);
+        }
+
+        applyRealtimeMenuPricing(cart);
+        saveCart(cart);
+
+        return cart;
+    }
+
+    @Override
+    public CartDTO updateItemQuantity(Integer tableId, Integer cuisineId, Integer quantity) {
+        log.info("更新购物车商品数量, tableId={}, cuisineId={}, quantity={}", tableId, cuisineId, quantity);
+        
+        // 如果数量为0或小于0,删除该商品
+        if (quantity == null || quantity <= 0) {
+            log.info("商品数量为0或小于0,删除商品, tableId={}, cuisineId={}", tableId, cuisineId);
+            return removeItem(tableId, cuisineId);
+        }
+
+        CartDTO cart = getCart(tableId);
+        List<CartItemDTO> items = cart.getItems();
+        CartItemDTO item = items.stream()
+                .filter(i -> i.getCuisineId().equals(cuisineId))
+                .findFirst()
+                .orElse(null);
+
+        if (item != null) {
+            // 商品已存在,更新数量
+            // 检查已下单数量:不允许将数量减少到小于已下单数量
+            Integer lockedQuantity = item.getLockedQuantity();
+            if (lockedQuantity != null && lockedQuantity > 0) {
+                if (quantity < lockedQuantity) {
+                    throw new RuntimeException("商品数量不能少于已下单数量(" + lockedQuantity + "),该数量已下单");
+                }
+            }
+            
+            item.setQuantity(quantity);
+            item.setSubtotalAmount(item.getUnitPrice()
+                    .multiply(BigDecimal.valueOf(quantity)));
+
+            applyRealtimeMenuPricing(cart);
+            saveCart(cart);
+        } else {
+            log.info("商品不在购物车中,自动添加, tableId={}, priceId={}, quantity={}", tableId, cuisineId, quantity);
+            StoreTable t = storeTableMapper.selectById(tableId);
+            if (t == null) {
+                throw new RuntimeException("桌号不存在");
+            }
+            diningOrderingTableGuard.assertGenericMenuAllowed(t);
+            StorePrice price = storePriceMapper.selectById(cuisineId);
+            if (price == null) {
+                throw new RuntimeException("价目项不存在");
+            }
+            if (price.getShelfStatus() == null || price.getShelfStatus() != 1) {
+                throw new RuntimeException("价目项已下架");
+            }
+            Integer userId = TokenUtil.getCurrentUserId();
+            String userPhone = TokenUtil.getCurrentUserPhone();
+            CartItemDTO newItem = new CartItemDTO();
+            newItem.setCuisineId(price.getId());
+            newItem.setCuisineName(price.getName());
+            newItem.setCuisineType(1);
+            newItem.setCuisineImage(price.getImages());
+            newItem.setUnitPrice(price.getTotalPrice());
+            newItem.setQuantity(quantity);
+            newItem.setSubtotalAmount(price.getTotalPrice()
+                    .multiply(BigDecimal.valueOf(quantity)));
+            newItem.setAddUserId(userId);
+            newItem.setAddUserPhone(userPhone);
+            items.add(newItem);
+
+            applyRealtimeMenuPricing(cart);
+            saveCart(cart);
+            log.info("商品已自动添加到购物车, tableId={}, priceId={}, quantity={}", tableId, cuisineId, quantity);
+        }
+
+        return cart;
+    }
+
+    @Override
+    public CartDTO removeItem(Integer tableId, Integer cuisineId) {
+        log.info("删除购物车商品, tableId={}, cuisineId={}", tableId, cuisineId);
+        CartDTO cart = getCart(tableId);
+        List<CartItemDTO> items = cart.getItems();
+        
+        // 检查是否有已下单数量,如果有则不允许删除
+        CartItemDTO item = items.stream()
+                .filter(i -> i.getCuisineId().equals(cuisineId))
+                .findFirst()
+                .orElse(null);
+        
+        if (item != null) {
+            Integer lockedQuantity = item.getLockedQuantity();
+            if (lockedQuantity != null && lockedQuantity > 0) {
+                throw new RuntimeException("商品已下单,已下单数量为 " + lockedQuantity + ",不允许删除");
+            }
+        }
+        
+        items.removeIf(i -> i.getCuisineId().equals(cuisineId));
+
+        if (items.isEmpty()) {
+            cart.setTotalAmount(BigDecimal.ZERO);
+            cart.setTotalQuantity(0);
+            saveCart(cart);
+        } else {
+            applyRealtimeMenuPricing(cart);
+            saveCart(cart);
+        }
+        return cart;
+    }
+
+    @Override
+    public void clearCart(Integer tableId) {
+        log.info("清空购物车(保留已下单商品), tableId={}", tableId);
+        
+        // 获取购物车
+        CartDTO cart = getCart(tableId);
+        List<CartItemDTO> items = cart.getItems();
+        
+        if (items == null || items.isEmpty()) {
+            log.info("购物车为空,无需清空, tableId={}", tableId);
+            return;
+        }
+        
+        // 分离已下单的商品、未下单的商品和餐具
+        List<CartItemDTO> orderedItems = new ArrayList<>(); // 已下单的商品(保留,数量恢复为已下单数量)
+        List<Integer> orderedCuisineIds = new ArrayList<>(); // 已下单的商品ID列表
+        List<CartItemDTO> unorderedItems = new ArrayList<>(); // 未下单的商品(删除)
+        CartItemDTO tablewareItem = null; // 餐具项(始终保留)
+        boolean hasChanges = false; // 是否有变化(需要更新)
+        
+        for (CartItemDTO item : items) {
+            // 餐具始终保留,不清空
+            if (TABLEWARE_CUISINE_ID.equals(item.getCuisineId())) {
+                tablewareItem = item;
+                continue;
+            }
+            
+            Integer lockedQuantity = item.getLockedQuantity();
+            if (lockedQuantity != null && lockedQuantity > 0) {
+                // 有已下单数量,保留该商品,但将当前数量恢复为已下单数量
+                Integer currentQuantity = item.getQuantity();
+                if (currentQuantity != null && !currentQuantity.equals(lockedQuantity)) {
+                    // 当前数量不等于已下单数量,需要恢复
+                    item.setQuantity(lockedQuantity);
+                    item.setSubtotalAmount(item.getUnitPrice().multiply(BigDecimal.valueOf(lockedQuantity)));
+                    hasChanges = true;
+                    log.info("恢复已下单商品数量, cuisineId={}, oldQuantity={}, orderedQuantity={}", 
+                            item.getCuisineId(), currentQuantity, lockedQuantity);
+                }
+                orderedItems.add(item);
+                orderedCuisineIds.add(item.getCuisineId());
+            } else {
+                // 没有已下单数量,标记为删除
+                unorderedItems.add(item);
+                hasChanges = true;
+            }
+        }
+        
+        // 将餐具项添加到保留列表中,若有已下单数量则恢复为已下单数量(与菜品逻辑一致)
+        if (tablewareItem != null) {
+            Integer tablewareLocked = tablewareItem.getLockedQuantity();
+            if (tablewareLocked != null && tablewareLocked > 0) {
+                Integer currentQty = tablewareItem.getQuantity();
+                if (currentQty == null || !currentQty.equals(tablewareLocked)) {
+                    tablewareItem.setQuantity(tablewareLocked);
+                    tablewareItem.setSubtotalAmount(tablewareItem.getUnitPrice().multiply(BigDecimal.valueOf(tablewareLocked)));
+                    hasChanges = true;
+                    log.info("恢复餐具数量为已下单数量, cuisineId={}, oldQuantity={}, orderedQuantity={}",
+                            tablewareItem.getCuisineId(), currentQty, tablewareLocked);
+                }
+            }
+            orderedItems.add(tablewareItem);
+            orderedCuisineIds.add(tablewareItem.getCuisineId());
+            log.info("保留餐具项, cuisineId={}, quantity={}", tablewareItem.getCuisineId(), tablewareItem.getQuantity());
+        }
+        
+        // 如果有变化(有未下单的商品需要删除,或者已下单商品数量需要恢复),进行更新
+        if (hasChanges) {
+            // 1. 更新购物车(删除未下单商品,已下单商品数量已恢复)
+            cart.setItems(orderedItems);
+            if (!orderedItems.isEmpty()) {
+                applyRealtimeMenuPricing(cart);
+            } else {
+                cart.setTotalAmount(BigDecimal.ZERO);
+                cart.setTotalQuantity(0);
+            }
+            
+            // 更新Redis(保留已下单的商品,数量已恢复)
+            if (orderedItems.isEmpty()) {
+                // 如果所有商品都未下单,清空Redis
+                String cartKey = CART_KEY_PREFIX + tableId;
+                baseRedisService.delete(cartKey);
+            } else {
+                // 保存更新后的购物车到Redis(已下单商品数量已恢复为已下单数量)
+                saveCartToRedis(cart);
+            }
+            
+            // 2. 从数据库中逻辑删除未下单的商品(排除餐具)
+            if (!unorderedItems.isEmpty()) {
+                LambdaQueryWrapper<StoreCart> wrapper = new LambdaQueryWrapper<>();
+                wrapper.eq(StoreCart::getTableId, tableId);
+                wrapper.eq(StoreCart::getDeleteFlag, 0);
+                StoreCartMenuFilters.applyGenericCart(wrapper);
+                // 排除餐具(cuisineId = -1)
+                wrapper.ne(StoreCart::getCuisineId, TABLEWARE_CUISINE_ID);
+                if (!orderedCuisineIds.isEmpty()) {
+                    // 排除已下单的商品ID(包括餐具)
+                    wrapper.notIn(StoreCart::getCuisineId, orderedCuisineIds);
+                }
+                List<StoreCart> cartListToDelete = storeCartMapper.selectList(wrapper);
+                if (cartListToDelete != null && !cartListToDelete.isEmpty()) {
+                    List<Integer> cartIds = cartListToDelete.stream()
+                            .map(StoreCart::getId)
+                            .collect(Collectors.toList());
+                    // 使用 deleteBatchIds 进行逻辑删除(MyBatis-Plus 会自动处理 @TableLogic)
+                    storeCartMapper.deleteBatchIds(cartIds);
+                    log.info("删除未下单商品(已排除餐具), tableId={}, count={}", tableId, cartIds.size());
+                }
+            }
+            
+            // 3. 更新数据库中已下单商品的数量(恢复为已下单数量)
+            if (!orderedItems.isEmpty()) {
+                // 保存更新后的购物车到数据库(会更新已下单商品的数量)
+                saveCartToDatabase(cart);
+            }
+            
+            // 4. 更新桌号表的购物车统计
+            StoreTable table = storeTableMapper.selectById(tableId);
+            if (table != null) {
+                table.setCartItemCount(cart.getTotalQuantity());
+                table.setCartTotalAmount(cart.getTotalAmount());
+                storeTableMapper.updateById(table);
+            }
+            
+            log.info("清空购物车完成(保留已下单商品和餐具,数量恢复为已下单数量), tableId={}, 删除商品数={}, 保留商品数={}", 
+                    tableId, unorderedItems.size(), orderedItems.size());
+        } else {
+            log.info("购物车无需更新, tableId={}", tableId);
+        }
+    }
+
+    @Override
+    public boolean hasUsedCoupon(Integer tableId) {
+        // 先查Redis
+        String couponUsedKey = COUPON_USED_KEY_PREFIX + tableId;
+        String couponId = baseRedisService.getString(couponUsedKey);
+        if (StringUtils.hasText(couponId)) {
+            return true;
+        }
+
+        // Redis中没有,查数据库
+        LambdaQueryWrapper<StoreCouponUsage> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(StoreCouponUsage::getTableId, tableId);
+        wrapper.eq(StoreCouponUsage::getDeleteFlag, 0);
+        wrapper.in(StoreCouponUsage::getUsageStatus, 0, 1, 2); // 已标记使用、已下单、已支付
+        wrapper.orderByDesc(StoreCouponUsage::getCreatedTime);
+        wrapper.last("LIMIT 1");
+        StoreCouponUsage usage = storeCouponUsageMapper.selectOne(wrapper);
+        return usage != null;
+    }
+
+    @Override
+    public void markCouponUsed(Integer tableId, Integer couponId) {
+        // 保存到Redis
+        String couponUsedKey = COUPON_USED_KEY_PREFIX + tableId;
+        baseRedisService.setString(couponUsedKey, String.valueOf(couponId), (long) CART_EXPIRE_SECONDS);
+
+        // 保存到数据库
+        StoreTable table = storeTableMapper.selectById(tableId);
+        if (table == null) {
+            log.warn("桌号不存在, tableId={}", tableId);
+            return;
+        }
+
+        // 检查是否已存在
+        LambdaQueryWrapper<StoreCouponUsage> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(StoreCouponUsage::getTableId, tableId);
+        wrapper.eq(StoreCouponUsage::getCouponId, couponId);
+        wrapper.eq(StoreCouponUsage::getDeleteFlag, 0);
+        StoreCouponUsage existing = storeCouponUsageMapper.selectOne(wrapper);
+
+        if (existing == null) {
+            Date now = new Date();
+            StoreCouponUsage usage = new StoreCouponUsage();
+            usage.setTableId(tableId);
+            usage.setStoreId(table.getStoreId());
+            usage.setCouponId(couponId);
+            usage.setUsageStatus(0); // 已标记使用
+            usage.setCreatedTime(now);
+            usage.setUpdatedTime(now); // 设置更新时间,避免数据库约束错误
+            Integer userId = TokenUtil.getCurrentUserId();
+            if (userId != null) {
+                usage.setCreatedUserId(userId);
+            }
+            storeCouponUsageMapper.insert(usage);
+        }
+
+        // 更新桌号表的优惠券ID
+        table.setCurrentCouponId(couponId);
+        storeTableMapper.updateById(table);
+    }
+
+    @Override
+    public void clearCouponUsed(Integer tableId) {
+        // 清空Redis
+        String couponUsedKey = COUPON_USED_KEY_PREFIX + tableId;
+        baseRedisService.delete(couponUsedKey);
+
+        // 更新数据库(逻辑删除未下单的记录,使用 MyBatis-Plus 的 deleteBatchIds)
+        LambdaQueryWrapper<StoreCouponUsage> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(StoreCouponUsage::getTableId, tableId);
+        wrapper.eq(StoreCouponUsage::getDeleteFlag, 0);
+        wrapper.eq(StoreCouponUsage::getUsageStatus, 0); // 只删除已标记使用但未下单的
+        List<StoreCouponUsage> usageList = storeCouponUsageMapper.selectList(wrapper);
+        if (usageList != null && !usageList.isEmpty()) {
+            List<Integer> usageIds = usageList.stream()
+                    .map(StoreCouponUsage::getId)
+                    .collect(Collectors.toList());
+            // 使用 deleteBatchIds 进行逻辑删除(MyBatis-Plus 会自动处理 @TableLogic)
+            storeCouponUsageMapper.deleteBatchIds(usageIds);
+        }
+
+        // 更新桌号表的优惠券ID
+        StoreTable table = storeTableMapper.selectById(tableId);
+        if (table != null) {
+            table.setCurrentCouponId(null);
+            storeTableMapper.updateById(table);
+        }
+    }
+
+    /**
+     * 餐具的特殊ID(用于标识餐具项)
+     */
+    private static final Integer TABLEWARE_CUISINE_ID = -1;
+    private static final String TABLEWARE_NAME = "餐具费";
+
+    /**
+     * 获取餐具单价(从 store_info 表获取)
+     *
+     * @param storeId 门店ID
+     * @return 餐具单价(BigDecimal),如果门店不存在或未设置餐具费,返回 0.00
+     */
+    private BigDecimal getTablewareUnitPrice(Integer storeId) {
+        if (storeId == null) {
+            log.warn("门店ID为空,返回默认餐具单价 0.00");
+            return BigDecimal.ZERO;
+        }
+        StoreInfo storeInfo = storeInfoMapper.selectById(storeId);
+        if (storeInfo == null) {
+            log.warn("门店不存在, storeId={},返回默认餐具单价 0.00", storeId);
+            return BigDecimal.ZERO;
+        }
+        Integer tablewareFee = storeInfo.getTablewareFee();
+        if (tablewareFee == null || tablewareFee < 0) {
+            log.warn("门店餐具费未设置或无效, storeId={}, tablewareFee={},返回默认餐具单价 0.00", storeId, tablewareFee);
+            return BigDecimal.ZERO;
+        }
+        return BigDecimal.valueOf(tablewareFee);
+    }
+
+    @Override
+    public CartDTO setDinerCount(Integer tableId, Integer dinerCount) {
+        log.info("设置用餐人数, tableId={}, dinerCount={}", tableId, dinerCount);
+        
+        if (dinerCount == null || dinerCount <= 0) {
+            throw new RuntimeException("用餐人数必须大于0");
+        }
+
+        // 获取购物车
+        CartDTO cart = getCart(tableId);
+        List<CartItemDTO> items = cart.getItems();
+
+        // 获取门店ID和餐具单价
+        Integer storeId = cart.getStoreId();
+        if (storeId == null) {
+            // 如果购物车中没有门店ID,从桌号获取
+            StoreTable table = storeTableMapper.selectById(tableId);
+            if (table != null) {
+                storeId = table.getStoreId();
+            }
+        }
+        BigDecimal tablewareUnitPrice = getTablewareUnitPrice(storeId);
+
+        // 商铺未设置餐具费时,不往购物车加餐具;若已有餐具项则移除
+        if (tablewareUnitPrice == null || tablewareUnitPrice.compareTo(BigDecimal.ZERO) <= 0) {
+            log.info("门店未设置餐具费, storeId={},设置就餐人数时不添加餐具", storeId);
+            CartItemDTO existing = items.stream()
+                    .filter(item -> TABLEWARE_CUISINE_ID.equals(item.getCuisineId()))
+                    .findFirst()
+                    .orElse(null);
+            if (existing != null) {
+                items.remove(existing);
+            }
+        } else {
+            // 查找是否已存在餐具项
+            CartItemDTO tablewareItem = items.stream()
+                    .filter(item -> TABLEWARE_CUISINE_ID.equals(item.getCuisineId()))
+                    .findFirst()
+                    .orElse(null);
+
+            Integer userId = TokenUtil.getCurrentUserId();
+            String userPhone = TokenUtil.getCurrentUserPhone();
+
+            if (tablewareItem != null) {
+                Integer lockedQuantity = tablewareItem.getLockedQuantity();
+                if (lockedQuantity != null && lockedQuantity > 0 && dinerCount < lockedQuantity) {
+                    throw new RuntimeException("餐具数量不能少于已下单数量(" + lockedQuantity + ")");
+                }
+                tablewareItem.setQuantity(dinerCount);
+                tablewareItem.setUnitPrice(tablewareUnitPrice);
+                tablewareItem.setSubtotalAmount(tablewareUnitPrice.multiply(BigDecimal.valueOf(dinerCount)));
+            } else {
+                CartItemDTO newTablewareItem = new CartItemDTO();
+                newTablewareItem.setCuisineId(TABLEWARE_CUISINE_ID);
+                newTablewareItem.setCuisineName(TABLEWARE_NAME);
+                newTablewareItem.setCuisineType(0);
+                newTablewareItem.setCuisineImage("");
+                newTablewareItem.setUnitPrice(tablewareUnitPrice);
+                newTablewareItem.setQuantity(dinerCount);
+                newTablewareItem.setSubtotalAmount(tablewareUnitPrice.multiply(BigDecimal.valueOf(dinerCount)));
+                newTablewareItem.setAddUserId(userId);
+                newTablewareItem.setAddUserPhone(userPhone);
+                items.add(newTablewareItem);
+            }
+        }
+
+        applyRealtimeMenuPricing(cart);
+        saveCart(cart);
+
+        return cart;
+    }
+
+    @Override
+    public CartDTO updateTablewareQuantity(Integer tableId, Integer quantity) {
+        log.info("更新餐具数量, tableId={}, quantity={}", tableId, quantity);
+        
+        if (quantity == null || quantity < 0) {
+            throw new RuntimeException("餐具数量不能小于0");
+        }
+
+        if (quantity == 0) {
+            // 数量为0时,删除餐具项
+            return removeItem(tableId, TABLEWARE_CUISINE_ID);
+        }
+
+        // 获取购物车
+        CartDTO cart = getCart(tableId);
+        List<CartItemDTO> items = cart.getItems();
+
+        // 获取门店ID和餐具单价
+        Integer storeId = cart.getStoreId();
+        if (storeId == null) {
+            StoreTable table = storeTableMapper.selectById(tableId);
+            if (table != null) {
+                storeId = table.getStoreId();
+            }
+        }
+        BigDecimal tablewareUnitPrice = getTablewareUnitPrice(storeId);
+
+        // 商铺未设置餐具费(单价为0或未配置)时,不往购物车加餐具;若已有餐具项则移除
+        if (tablewareUnitPrice == null || tablewareUnitPrice.compareTo(BigDecimal.ZERO) <= 0) {
+            log.info("门店未设置餐具费, storeId={},不添加餐具到购物车", storeId);
+            CartItemDTO existing = items.stream()
+                    .filter(item -> TABLEWARE_CUISINE_ID.equals(item.getCuisineId()))
+                    .findFirst()
+                    .orElse(null);
+            if (existing != null) {
+                items.remove(existing);
+                if (items.isEmpty()) {
+                    cart.setTotalAmount(BigDecimal.ZERO);
+                    cart.setTotalQuantity(0);
+                    saveCart(cart);
+                } else {
+                    applyRealtimeMenuPricing(cart);
+                    saveCart(cart);
+                }
+            }
+            return cart;
+        }
+
+        // 查找餐具项
+        CartItemDTO tablewareItem = items.stream()
+                .filter(item -> TABLEWARE_CUISINE_ID.equals(item.getCuisineId()))
+                .findFirst()
+                .orElse(null);
+
+        if (tablewareItem == null) {
+            // 如果不存在餐具项,创建一个
+            Integer userId = TokenUtil.getCurrentUserId();
+            String userPhone = TokenUtil.getCurrentUserPhone();
+            
+            tablewareItem = new CartItemDTO();
+            tablewareItem.setCuisineId(TABLEWARE_CUISINE_ID);
+            tablewareItem.setCuisineName(TABLEWARE_NAME);
+            tablewareItem.setCuisineType(0); // 0表示餐具
+            tablewareItem.setCuisineImage("");
+            tablewareItem.setUnitPrice(tablewareUnitPrice);
+            tablewareItem.setAddUserId(userId);
+            tablewareItem.setAddUserPhone(userPhone);
+            items.add(tablewareItem);
+        } else {
+            // 下单后只能增不能减:已有已下单数量时,数量不能少于已下单数量
+            Integer lockedQuantity = tablewareItem.getLockedQuantity();
+            if (lockedQuantity != null && lockedQuantity > 0 && quantity < lockedQuantity) {
+                throw new RuntimeException("餐具数量不能少于已下单数量(" + lockedQuantity + ")");
+            }
+            // 如果已存在,更新单价(可能门店修改了餐具费)
+            tablewareItem.setUnitPrice(tablewareUnitPrice);
+        }
+
+        // 更新数量
+        tablewareItem.setQuantity(quantity);
+        tablewareItem.setSubtotalAmount(tablewareUnitPrice.multiply(BigDecimal.valueOf(quantity)));
+
+        applyRealtimeMenuPricing(cart);
+        saveCart(cart);
+
+        return cart;
+    }
+
+    @Override
+    public CartDTO lockCartItems(Integer tableId) {
+        log.info("锁定购物车商品数量(设置已下单数量), tableId={}", tableId);
+        
+        // 获取购物车
+        CartDTO cart = getCart(tableId);
+        List<CartItemDTO> items = cart.getItems();
+        
+        if (items == null || items.isEmpty()) {
+            log.warn("购物车为空,无需锁定, tableId={}", tableId);
+            return cart;
+        }
+        
+        // 遍历所有商品,将当前数量设置为已下单数量
+        boolean hasChanges = false;
+        for (CartItemDTO item : items) {
+            Integer currentQuantity = item.getQuantity();
+            Integer lockedQuantity = item.getLockedQuantity();
+            
+            if (currentQuantity != null && currentQuantity > 0) {
+                if (lockedQuantity == null || lockedQuantity == 0) {
+                    // 如果还没有已下单数量,将当前数量设置为已下单数量
+                    item.setLockedQuantity(currentQuantity);
+                    hasChanges = true;
+                    log.info("设置商品已下单数量, cuisineId={}, orderedQuantity={}", item.getCuisineId(), currentQuantity);
+                } else if (currentQuantity > lockedQuantity) {
+                    // 如果已有已下单数量,且当前数量大于已下单数量(再次下单的情况),将新增数量累加到已下单数量
+                    Integer newLockedQuantity = lockedQuantity + (currentQuantity - lockedQuantity);
+                    item.setLockedQuantity(newLockedQuantity);
+                    hasChanges = true;
+                    log.info("更新商品已下单数量, cuisineId={}, oldOrderedQuantity={}, newOrderedQuantity={}", 
+                            item.getCuisineId(), lockedQuantity, newLockedQuantity);
+                }
+            }
+        }
+        
+        // 如果有变化,保存购物车
+        if (hasChanges) {
+            saveCart(cart);
+        }
+        
+        return cart;
+    }
+
+    @Override
+    public CartDTO unlockCartItems(Integer tableId) {
+        log.info("解锁购物车商品数量(清除已下单数量), tableId={}", tableId);
+        
+        // 获取购物车
+        CartDTO cart = getCart(tableId);
+        List<CartItemDTO> items = cart.getItems();
+        
+        if (items == null || items.isEmpty()) {
+            log.info("购物车为空,无需解锁, tableId={}", tableId);
+            return cart;
+        }
+        
+        // 遍历所有商品,清除已下单数量(lockedQuantity)
+        boolean hasChanges = false;
+        for (CartItemDTO item : items) {
+            if (item.getLockedQuantity() != null && item.getLockedQuantity() > 0) {
+                // 清除已下单数量,允许重新下单
+                item.setLockedQuantity(null);
+                hasChanges = true;
+                log.info("清除商品已下单数量, cuisineId={}", item.getCuisineId());
+            }
+        }
+        
+        // 如果有变化,保存购物车
+        if (hasChanges) {
+            saveCart(cart);
+            log.info("解锁购物车商品数量完成, tableId={}", tableId);
+        } else {
+            log.info("购物车无需解锁, tableId={}", tableId);
+        }
+        
+        return cart;
+    }
+
+    /**
+     * 保存购物车到Redis和数据库(优化后的双写策略)
+     * Redis同步写入(保证实时性),数据库异步批量写入(提高性能)
+     */
+    private void saveCart(CartDTO cart) {
+        // 1. 同步保存到Redis(保证实时性)
+        saveCartToRedis(cart);
+
+        // 2. 异步保存到数据库(不阻塞主流程,提高性能)
+        CompletableFuture.runAsync(() -> {
+            try {
+                saveCartToDatabase(cart);
+            } catch (Exception e) {
+                log.error("异步保存购物车到数据库失败, tableId={}, error={}", cart.getTableId(), e.getMessage(), e);
+            }
+        }, CART_DB_WRITE_EXECUTOR);
+    }
+
+    /**
+     * 保存购物车到Redis
+     */
+    private void saveCartToRedis(CartDTO cart) {
+        String cartKey = CART_KEY_PREFIX + cart.getTableId();
+        String cartJson = JSON.toJSONString(cart);
+        baseRedisService.setString(cartKey, cartJson, (long) CART_EXPIRE_SECONDS);
+    }
+
+    /**
+     * 保存购物车到数据库(优化后的批量操作版本)
+     * 使用批量逻辑删除和批量插入,提高性能
+     */
+    private void saveCartToDatabase(CartDTO cart) {
+        try {
+            Date now = new Date();
+            Integer userId = TokenUtil.getCurrentUserId();
+
+            // 1. 批量逻辑删除该桌号的所有购物车记录(使用 MyBatis-Plus 的 deleteBatchIds)
+            LambdaQueryWrapper<StoreCart> queryWrapper = new LambdaQueryWrapper<>();
+            queryWrapper.eq(StoreCart::getTableId, cart.getTableId())
+                    .eq(StoreCart::getDeleteFlag, 0);
+            StoreCartMenuFilters.applyGenericCart(queryWrapper);
+            List<StoreCart> existingCartList = storeCartMapper.selectList(queryWrapper);
+            if (existingCartList != null && !existingCartList.isEmpty()) {
+                List<Integer> cartIds = existingCartList.stream()
+                        .map(StoreCart::getId)
+                        .collect(Collectors.toList());
+                // 使用 deleteBatchIds 进行逻辑删除(MyBatis-Plus 会自动处理 @TableLogic)
+                storeCartMapper.deleteBatchIds(cartIds);
+            }
+
+            // 2. 批量插入新的购物车记录
+            if (cart.getItems() != null && !cart.getItems().isEmpty()) {
+                List<StoreCart> cartList = new ArrayList<>(cart.getItems().size());
+                for (CartItemDTO item : cart.getItems()) {
+                    StoreCart storeCart = new StoreCart();
+                    storeCart.setTableId(cart.getTableId());
+                    storeCart.setStoreId(cart.getStoreId());
+                    storeCart.setCuisineId(item.getCuisineId());
+                    storeCart.setCuisineName(item.getCuisineName());
+                    storeCart.setCuisineImage(item.getCuisineImage());
+                    storeCart.setUnitPrice(item.getUnitPrice());
+                    storeCart.setQuantity(item.getQuantity());
+                    storeCart.setLockedQuantity(item.getLockedQuantity());
+                    storeCart.setSubtotalAmount(item.getSubtotalAmount());
+                    storeCart.setAddUserId(item.getAddUserId());
+                    storeCart.setAddUserPhone(item.getAddUserPhone());
+                    storeCart.setRemark(item.getRemark());
+                    storeCart.setDeleteFlag(0);
+                    storeCart.setMenuType(OrderMenuConstants.MENU_TYPE_GENERIC_PRICE);
+                    storeCart.setCreatedTime(now);
+                    storeCart.setCreatedUserId(userId);
+                    storeCart.setUpdatedTime(now);
+                    cartList.add(storeCart);
+                }
+
+                // 批量插入(如果数量较少,直接循环插入;如果数量较多,可以考虑分批插入)
+                if (cartList.size() <= 50) {
+                    // 小批量直接插入
+                    for (StoreCart storeCart : cartList) {
+                        storeCartMapper.insert(storeCart);
+                    }
+                } else {
+                    // 大批量分批插入(每批50条)
+                    int batchSize = 50;
+                    for (int i = 0; i < cartList.size(); i += batchSize) {
+                        int end = Math.min(i + batchSize, cartList.size());
+                        List<StoreCart> batch = cartList.subList(i, end);
+                        for (StoreCart storeCart : batch) {
+                            storeCartMapper.insert(storeCart);
+                        }
+                    }
+                }
+            }
+
+            // 3. 更新桌号表的购物车统计
+            StoreTable table = storeTableMapper.selectById(cart.getTableId());
+            if (table != null) {
+                table.setCartItemCount(cart.getTotalQuantity());
+                table.setCartTotalAmount(cart.getTotalAmount());
+                storeTableMapper.updateById(table);
+            }
+
+            log.debug("购物车数据已异步保存到数据库, tableId={}, itemCount={}", 
+                    cart.getTableId(), cart.getItems() != null ? cart.getItems().size() : 0);
+        } catch (Exception e) {
+            log.error("保存购物车到数据库失败, tableId={}, error={}", cart.getTableId(), e.getMessage(), e);
+            // 数据库保存失败不影响Redis,继续执行
+        }
+    }
+}

+ 395 - 0
alien-dining/src/main/java/shop/alien/dining/service/impl/GenericDiningServiceImpl.java

@@ -0,0 +1,395 @@
+package shop.alien.dining.service.impl;
+
+import com.alibaba.fastjson2.JSON;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+import shop.alien.dining.constants.OrderMenuConstants;
+import shop.alien.dining.service.DiningService;
+import shop.alien.dining.service.GenericCartService;
+import shop.alien.dining.service.GenericDiningService;
+import shop.alien.dining.service.OrderLockService;
+import shop.alien.dining.support.DiningFirstVisitDinerCount;
+import shop.alien.dining.support.DiningOrderingTableGuard;
+import shop.alien.dining.support.DiningSearchText;
+import shop.alien.entity.store.LifeDiscountCoupon;
+import shop.alien.entity.store.StoreInfo;
+import shop.alien.entity.store.StoreOrderDetail;
+import shop.alien.entity.store.StorePrice;
+import shop.alien.entity.store.StoreTable;
+import shop.alien.entity.store.dto.CartDTO;
+import shop.alien.entity.store.dto.CartItemDTO;
+import shop.alien.entity.store.vo.AvailableCouponVO;
+import shop.alien.entity.store.vo.CuisineDetailVO;
+import shop.alien.entity.store.vo.CuisineListVO;
+import shop.alien.entity.store.vo.DiningPageInfoVO;
+import shop.alien.entity.store.vo.OrderConfirmVO;
+import shop.alien.mapper.LifeDiscountCouponMapper;
+import shop.alien.mapper.StoreOrderDetailMapper;
+import shop.alien.mapper.StorePriceMapper;
+import shop.alien.mapper.StoreTableMapper;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class GenericDiningServiceImpl implements GenericDiningService {
+
+    private final StoreTableMapper storeTableMapper;
+    private final StorePriceMapper storePriceMapper;
+    private final GenericCartService genericCartService;
+    private final OrderLockService orderLockService;
+    private final LifeDiscountCouponMapper lifeDiscountCouponMapper;
+    private final StoreOrderDetailMapper storeOrderDetailMapper;
+    private final DiningService diningService;
+    private final DiningOrderingTableGuard diningOrderingTableGuard;
+
+    @Override
+    public DiningPageInfoVO getDiningPageInfo(Integer tableId, Integer dinerCount) {
+        log.info("通用价目-获取点餐页面信息, tableId={}, dinerCount={}", tableId, dinerCount);
+        StoreTable table = diningOrderingTableGuard.requireTable(tableId);
+        StoreInfo storeInfo = diningOrderingTableGuard.requireStore(table);
+        diningOrderingTableGuard.assertGenericMenuAllowed(storeInfo, table);
+
+        int resolvedDinerCount = DiningFirstVisitDinerCount.resolveOrThrow(table, dinerCount);
+        if (DiningFirstVisitDinerCount.shouldPersistToDatabase(dinerCount)) {
+            storeTableMapper.updateById(table);
+            log.info("通用价目-首客填写用餐人数(餐桌状态不变,未开台), tableId={}, dinerCount={}",
+                    tableId, resolvedDinerCount);
+        } else {
+            log.info("通用价目-使用已保存人数, tableId={}, dinerCount={}, tableStatus={}",
+                    tableId, resolvedDinerCount, table.getStatus());
+        }
+
+        DiningPageInfoVO vo = new DiningPageInfoVO();
+        vo.setStoreName(storeInfo.getStoreName());
+        vo.setTableNumber(table.getTableNumber());
+        vo.setDinerCount(resolvedDinerCount);
+        vo.setStoreId(table.getStoreId());
+        vo.setTableId(tableId);
+        return vo;
+    }
+
+    private LambdaQueryWrapper<StorePrice> baseOnShelfApproved(Integer storeId) {
+        LambdaQueryWrapper<StorePrice> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(StorePrice::getStoreId, storeId);
+        wrapper.eq(StorePrice::getShelfStatus, 1);
+        wrapper.eq(StorePrice::getStatus, 1);
+        wrapper.orderByDesc(StorePrice::getCreatedTime);
+        return wrapper;
+    }
+
+    @Override
+    public List<CuisineListVO> searchCuisines(Integer storeId, String keyword, Integer tableId) {
+        log.info("通用价目-搜索, storeId={}, keyword={}, tableId={}", storeId, keyword, tableId);
+        StoreTable table = diningOrderingTableGuard.requireTable(tableId);
+        diningOrderingTableGuard.assertTableBelongsToStore(table, storeId);
+        diningOrderingTableGuard.assertGenericMenuAllowed(table);
+
+        keyword = DiningSearchText.clampKeyword(keyword);
+
+        LambdaQueryWrapper<StorePrice> wrapper = baseOnShelfApproved(storeId);
+        if (StringUtils.hasText(keyword)) {
+            wrapper.like(StorePrice::getName, keyword);
+        }
+        List<StorePrice> list = storePriceMapper.selectList(wrapper);
+        return toCuisineListVO(list, tableId);
+    }
+
+    @Override
+    public List<CuisineListVO> getCuisinesByCategory(Integer storeId, Integer categoryId, Integer tableId, Integer page, Integer size) {
+        log.info("通用价目-分类列表, storeId={}, categoryId={}, tableId={}, page={}, size={}", storeId, categoryId, tableId, page, size);
+        StoreTable table = diningOrderingTableGuard.requireTable(tableId);
+        diningOrderingTableGuard.assertTableBelongsToStore(table, storeId);
+        diningOrderingTableGuard.assertGenericMenuAllowed(table);
+
+        if (page == null || page < 1) {
+            page = 1;
+        }
+        if (size == null || size < 1) {
+            size = 12;
+        }
+        int offset = (page - 1) * size;
+
+        LambdaQueryWrapper<StorePrice> wrapper = baseOnShelfApproved(storeId);
+        if (categoryId != null) {
+            wrapper.apply("JSON_CONTAINS(CAST(category_ids AS JSON), CAST({0} AS JSON))", categoryId);
+        }
+        wrapper.last("LIMIT " + offset + ", " + size);
+        List<StorePrice> list = storePriceMapper.selectList(wrapper);
+        return toCuisineListVO(list, tableId);
+    }
+
+    @Override
+    public CuisineDetailVO getCuisineDetail(Integer priceItemId, Integer tableId) {
+        log.info("通用价目-详情, priceItemId={}, tableId={}", priceItemId, tableId);
+        StoreTable table = diningOrderingTableGuard.requireTable(tableId);
+        diningOrderingTableGuard.assertGenericMenuAllowed(table);
+
+        StorePrice price = storePriceMapper.selectById(priceItemId);
+        if (price == null || !Objects.equals(price.getStoreId(), table.getStoreId())) {
+            throw new RuntimeException("价目不存在");
+        }
+
+        CuisineDetailVO vo = new CuisineDetailVO();
+        vo.setId(price.getId());
+        vo.setStoreId(price.getStoreId());
+        vo.setName(price.getName());
+        vo.setTotalPrice(price.getTotalPrice());
+        vo.setCuisineType(1);
+        vo.setCategoryIds(price.getCategoryIds());
+        vo.setTags(formatTagsForDetail(price.getTags()));
+        vo.setDishReview(price.getDishReview());
+        vo.setDescription(price.getDescription());
+        vo.setDetailContent(price.getDetailContent());
+        vo.setImageContent(price.getImageContent());
+        vo.setExtraNote(price.getExtraNote());
+        vo.setNeedReserve(price.getNeedReserve());
+        vo.setReserveRule(price.getReserveRule());
+        vo.setPeopleLimit(price.getPeopleLimit());
+        vo.setUsageRule(price.getUsageRule());
+        vo.setComboItems(null);
+
+        if (StringUtils.hasText(price.getImages())) {
+            vo.setImages(java.util.Arrays.asList(price.getImages().split(",")));
+        } else {
+            vo.setImages(new ArrayList<>());
+        }
+
+        vo.setMonthlySales(monthlySalesForGenericPrice(priceItemId));
+
+        CartDTO cart = genericCartService.getCart(tableId);
+        if (cart.getItems() != null) {
+            Integer qty = cart.getItems().stream()
+                    .filter(item -> priceItemId.equals(item.getCuisineId()))
+                    .map(CartItemDTO::getQuantity)
+                    .filter(Objects::nonNull)
+                    .findFirst()
+                    .orElse(0);
+            vo.setCartQuantity(qty);
+        } else {
+            vo.setCartQuantity(0);
+        }
+        return vo;
+    }
+
+    @Override
+    public OrderConfirmVO getOrderConfirmInfo(Integer tableId, Integer dinerCount, Integer userId) {
+        log.info("通用价目-确认页, tableId={}, dinerCount={}, userId={}", tableId, dinerCount, userId);
+
+        DiningPageInfoVO pageInfo = getDiningPageInfo(tableId, dinerCount);
+        int effectiveDinerCount = pageInfo.getDinerCount() != null && pageInfo.getDinerCount() > 0
+                ? pageInfo.getDinerCount() : (dinerCount != null ? dinerCount : 0);
+
+        CartDTO cart = genericCartService.getCart(tableId);
+        List<CartItemDTO> items = cart.getItems();
+        if (items != null && !items.isEmpty()) {
+            Set<Integer> priceIds = items.stream()
+                    .map(CartItemDTO::getCuisineId)
+                    .filter(Objects::nonNull)
+                    .collect(Collectors.toSet());
+            if (!priceIds.isEmpty()) {
+                List<StorePrice> prices = storePriceMapper.selectBatchIds(new ArrayList<>(priceIds));
+                Map<Integer, String> tagsMap = new HashMap<>();
+                if (prices != null) {
+                    for (StorePrice p : prices) {
+                        if (p.getTags() != null) {
+                            tagsMap.put(p.getId(), formatTagsForDetail(p.getTags()));
+                        }
+                    }
+                }
+                for (CartItemDTO item : items) {
+                    if (item.getCuisineId() != null && item.getTags() == null) {
+                        item.setTags(tagsMap.get(item.getCuisineId()));
+                    }
+                }
+            }
+        }
+
+        Integer lockUserId = orderLockService.checkOrderLock(tableId);
+        boolean isLocked = lockUserId != null && !lockUserId.equals(userId);
+
+        OrderConfirmVO vo = new OrderConfirmVO();
+        vo.setStoreName(pageInfo.getStoreName());
+        vo.setTableNumber(pageInfo.getTableNumber());
+        vo.setDinerCount(pageInfo.getDinerCount() != null ? pageInfo.getDinerCount() : dinerCount);
+        vo.setItems(items != null ? items : cart.getItems());
+        vo.setTotalAmount(cart.getTotalAmount());
+        vo.setIsLocked(isLocked);
+        vo.setLockUserId(lockUserId);
+
+        BigDecimal tablewareUnitPrice = new BigDecimal("1.00");
+        BigDecimal tablewareFee = BigDecimal.ZERO;
+        if (effectiveDinerCount > 0) {
+            tablewareFee = tablewareUnitPrice.multiply(BigDecimal.valueOf(effectiveDinerCount));
+        }
+        vo.setTablewareFee(tablewareFee);
+        vo.setTablewareUnitPrice(tablewareUnitPrice);
+        vo.setServiceFee(BigDecimal.ZERO);
+
+        BigDecimal totalWithTableware = cart.getTotalAmount().add(tablewareFee);
+
+        List<AvailableCouponVO> availableCoupons = new ArrayList<>();
+        if (userId != null && cart.getTotalAmount().compareTo(BigDecimal.ZERO) > 0) {
+            availableCoupons = diningService.getAvailableCoupons(pageInfo.getStoreId(), userId);
+            List<Integer> couponIds = availableCoupons.stream()
+                    .filter(c -> Boolean.TRUE.equals(c.getIsReceived()) && Boolean.TRUE.equals(c.getIsAvailable()))
+                    .filter(c -> totalWithTableware.compareTo(c.getMinimumSpendingAmount() != null ? c.getMinimumSpendingAmount() : BigDecimal.ZERO) >= 0)
+                    .map(AvailableCouponVO::getId)
+                    .collect(Collectors.toList());
+
+            Map<Integer, LifeDiscountCoupon> couponMap = new HashMap<>();
+            if (!couponIds.isEmpty()) {
+                List<LifeDiscountCoupon> couponDetails = lifeDiscountCouponMapper.selectBatchIds(couponIds);
+                couponMap = couponDetails.stream().collect(Collectors.toMap(LifeDiscountCoupon::getId, c -> c));
+            }
+
+            final BigDecimal finalTotal = totalWithTableware;
+            final Map<Integer, LifeDiscountCoupon> finalCouponMap = couponMap;
+            List<AvailableCouponVO> usableCoupons = availableCoupons.stream()
+                    .filter(c -> Boolean.TRUE.equals(c.getIsReceived()) && Boolean.TRUE.equals(c.getIsAvailable()))
+                    .filter(c -> totalWithTableware.compareTo(c.getMinimumSpendingAmount() != null ? c.getMinimumSpendingAmount() : BigDecimal.ZERO) >= 0)
+                    .sorted((a, b) -> {
+                        BigDecimal discountA = calculateDiscountAmountForVO(a, finalCouponMap, finalTotal);
+                        BigDecimal discountB = calculateDiscountAmountForVO(b, finalCouponMap, finalTotal);
+                        return discountB.compareTo(discountA);
+                    })
+                    .collect(Collectors.toList());
+
+            if (!usableCoupons.isEmpty()) {
+                AvailableCouponVO bestCoupon = usableCoupons.get(0);
+                vo.setCouponId(bestCoupon.getId());
+                vo.setCouponName(bestCoupon.getName());
+                LifeDiscountCoupon couponDetail = lifeDiscountCouponMapper.selectById(bestCoupon.getId());
+                BigDecimal discountAmount = calculateDiscountAmount(couponDetail, totalWithTableware);
+                vo.setDiscountAmount(discountAmount);
+                vo.setPayAmount(totalWithTableware.subtract(discountAmount));
+            } else {
+                vo.setPayAmount(totalWithTableware);
+            }
+        } else {
+            vo.setPayAmount(totalWithTableware);
+        }
+        vo.setAvailableCoupons(availableCoupons);
+        return vo;
+    }
+
+    private List<CuisineListVO> toCuisineListVO(List<StorePrice> prices, Integer tableId) {
+        CartDTO cart = genericCartService.getCart(tableId);
+        Map<Integer, Integer> cartQty = new HashMap<>();
+        if (cart.getItems() != null) {
+            cartQty = cart.getItems().stream()
+                    .collect(Collectors.toMap(CartItemDTO::getCuisineId, CartItemDTO::getQuantity, (a, b) -> b));
+        }
+        final Map<Integer, Integer> finalCartQty = cartQty;
+        return prices.stream().map(p -> {
+            CuisineListVO vo = new CuisineListVO();
+            vo.setId(p.getId());
+            vo.setName(p.getName());
+            vo.setPrice(p.getTotalPrice());
+            vo.setShortComment(p.getDishReview());
+            vo.setTags(formatTagsForDetail(p.getTags()));
+            vo.setMonthlySales(monthlySalesForGenericPrice(p.getId()));
+            vo.setCartQuantity(finalCartQty.getOrDefault(p.getId(), 0));
+            vo.setCuisineType(1);
+            vo.setShelfStatus(p.getShelfStatus());
+            if (StringUtils.hasText(p.getImages())) {
+                String[] images = p.getImages().split(",");
+                vo.setFirstImage(images.length > 0 ? images[0] : null);
+            }
+            return vo;
+        }).collect(Collectors.toList());
+    }
+
+    private int monthlySalesForGenericPrice(Integer priceId) {
+        try {
+            Calendar calendar = Calendar.getInstance();
+            calendar.set(Calendar.DAY_OF_MONTH, 1);
+            calendar.set(Calendar.HOUR_OF_DAY, 0);
+            calendar.set(Calendar.MINUTE, 0);
+            calendar.set(Calendar.SECOND, 0);
+            calendar.set(Calendar.MILLISECOND, 0);
+            Date monthStart = calendar.getTime();
+
+            LambdaQueryWrapper<StoreOrderDetail> wrapper = new LambdaQueryWrapper<>();
+            wrapper.eq(StoreOrderDetail::getCuisineId, priceId);
+            wrapper.eq(StoreOrderDetail::getLineType, OrderMenuConstants.LINE_TYPE_GENERIC_PRICE);
+            wrapper.eq(StoreOrderDetail::getDeleteFlag, 0);
+            wrapper.ge(StoreOrderDetail::getCreatedTime, monthStart);
+            wrapper.inSql(StoreOrderDetail::getOrderId,
+                    "SELECT id FROM store_order WHERE pay_status = 1 AND delete_flag = 0");
+
+            List<StoreOrderDetail> details = storeOrderDetailMapper.selectList(wrapper);
+            return details.stream().mapToInt(StoreOrderDetail::getQuantity).sum();
+        } catch (Exception e) {
+            log.warn("通用价目-月售统计失败 priceId={}", priceId, e);
+            return 0;
+        }
+    }
+
+    private static String formatTagsForDetail(String raw) {
+        if (!StringUtils.hasText(raw)) {
+            return null;
+        }
+        String t = raw.trim();
+        if (t.startsWith("[")) {
+            try {
+                List<String> arr = JSON.parseArray(t, String.class);
+                if (arr == null || arr.isEmpty()) {
+                    return null;
+                }
+                return String.join(",", arr);
+            } catch (Exception ignored) {
+                return raw;
+            }
+        }
+        return raw;
+    }
+
+    private BigDecimal calculateDiscountAmount(LifeDiscountCoupon coupon, BigDecimal totalBeforeDiscount) {
+        if (coupon == null || totalBeforeDiscount == null || totalBeforeDiscount.compareTo(BigDecimal.ZERO) <= 0) {
+            return BigDecimal.ZERO;
+        }
+        Integer couponType = coupon.getCouponType();
+        BigDecimal discountAmount = BigDecimal.ZERO;
+        if (couponType != null && couponType == 2) {
+            BigDecimal discountRate = coupon.getDiscountRate();
+            if (discountRate != null && discountRate.compareTo(BigDecimal.ZERO) > 0 && discountRate.compareTo(new BigDecimal(100)) <= 0) {
+                BigDecimal discountedAmount = totalBeforeDiscount.multiply(discountRate).divide(new BigDecimal(100), 2, BigDecimal.ROUND_HALF_UP);
+                discountAmount = totalBeforeDiscount.subtract(discountedAmount);
+            }
+        } else {
+            discountAmount = coupon.getNominalValue();
+            if (discountAmount == null) {
+                discountAmount = BigDecimal.ZERO;
+            }
+            if (discountAmount.compareTo(totalBeforeDiscount) > 0) {
+                discountAmount = totalBeforeDiscount;
+            }
+        }
+        return discountAmount;
+    }
+
+    private BigDecimal calculateDiscountAmountForVO(AvailableCouponVO vo, Map<Integer, LifeDiscountCoupon> couponMap, BigDecimal totalAmount) {
+        if (vo == null || totalAmount == null || totalAmount.compareTo(BigDecimal.ZERO) <= 0) {
+            return BigDecimal.ZERO;
+        }
+        LifeDiscountCoupon coupon = couponMap.get(vo.getId());
+        if (coupon == null) {
+            return vo.getNominalValue() != null ? vo.getNominalValue() : BigDecimal.ZERO;
+        }
+        return calculateDiscountAmount(coupon, totalAmount);
+    }
+}

+ 24 - 25
alien-dining/src/main/java/shop/alien/dining/service/impl/OrderLockServiceImpl.java

@@ -27,24 +27,23 @@ public class OrderLockServiceImpl implements OrderLockService {
     @Override
     public boolean lockOrder(Integer tableId, Integer userId) {
         log.info("锁定订单, tableId={}, userId={}", tableId, userId);
+        if (tableId == null || userId == null) {
+            return false;
+        }
 
         String lockKey = ORDER_LOCK_KEY_PREFIX + tableId;
+        String userIdStr = String.valueOf(userId);
+        // 原子抢占,避免并发下双方都认为无锁
+        if (baseRedisService.setStringIfAbsent(lockKey, userIdStr, ORDER_LOCK_EXPIRE_SECONDS)) {
+            return true;
+        }
+        // 已是本人持锁:续期
         String existingLock = baseRedisService.getString(lockKey);
-        if (StringUtils.hasText(existingLock)) {
-            // 已锁定,检查是否是当前用户
-            if (existingLock.equals(String.valueOf(userId))) {
-                // 刷新过期时间
-                baseRedisService.setString(lockKey, existingLock, (long) ORDER_LOCK_EXPIRE_SECONDS);
-                return true;
-            } else {
-                // 被其他用户锁定
-                return false;
-            }
+        if (userIdStr.equals(existingLock)) {
+            baseRedisService.setTimeOut(lockKey, (long) ORDER_LOCK_EXPIRE_SECONDS);
+            return true;
         }
-
-        // 设置锁定
-        baseRedisService.setString(lockKey, String.valueOf(userId), (long) ORDER_LOCK_EXPIRE_SECONDS);
-        return true;
+        return false;
     }
 
     @Override
@@ -75,20 +74,20 @@ public class OrderLockServiceImpl implements OrderLockService {
     @Override
     public boolean lockSettlement(Integer orderId, Integer userId) {
         log.info("锁定订单结算, orderId={}, userId={}", orderId, userId);
-
+        if (orderId == null || userId == null) {
+            return false;
+        }
         String lockKey = SETTLEMENT_LOCK_KEY_PREFIX + orderId;
+        String userIdStr = String.valueOf(userId);
+        if (baseRedisService.setStringIfAbsent(lockKey, userIdStr, ORDER_LOCK_EXPIRE_SECONDS)) {
+            return true;
+        }
         String existingLock = baseRedisService.getString(lockKey);
-        if (StringUtils.hasText(existingLock)) {
-            if (existingLock.equals(String.valueOf(userId))) {
-                baseRedisService.setString(lockKey, existingLock, (long) ORDER_LOCK_EXPIRE_SECONDS);
-                return true;
-            } else {
-                return false;
-            }
+        if (userIdStr.equals(existingLock)) {
+            baseRedisService.setTimeOut(lockKey, (long) ORDER_LOCK_EXPIRE_SECONDS);
+            return true;
         }
-
-        baseRedisService.setString(lockKey, String.valueOf(userId), (long) ORDER_LOCK_EXPIRE_SECONDS);
-        return true;
+        return false;
     }
 
     @Override

+ 38 - 16
alien-dining/src/main/java/shop/alien/dining/service/impl/SseServiceImpl.java

@@ -6,9 +6,12 @@ import org.springframework.stereotype.Service;
 import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
 
 import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.Executors;
 import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.TimeUnit;
 
 /**
@@ -27,6 +30,9 @@ public class SseServiceImpl implements shop.alien.dining.service.SseService {
     // 定时任务执行器,用于发送心跳
     private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(10);
 
+    /** 每个 SSE 连接对应的心跳任务,连接移除时必须 cancel,否则会对已 complete 的 emitter 继续 send 误报 ERROR */
+    private final ConcurrentHashMap<String, ScheduledFuture<?>> heartbeatTasks = new ConcurrentHashMap<>();
+
     @Override
     public SseEmitter createConnection(Integer tableId) {
         log.info("创建SSE连接, tableId={}", tableId);
@@ -109,14 +115,10 @@ public class SseServiceImpl implements shop.alien.dining.service.SseService {
         log.info("关闭SSE连接, tableId={}", tableId);
         ConcurrentHashMap<String, SseEmitter> tableConnections = connections.get(tableId);
         if (tableConnections != null) {
-            tableConnections.forEach((connectionId, emitter) -> {
-                try {
-                    emitter.complete();
-                } catch (Exception e) {
-                    log.error("关闭SSE连接失败, tableId={}, connectionId={}", tableId, connectionId, e);
-                }
-            });
-            connections.remove(tableId);
+            List<String> ids = new ArrayList<>(tableConnections.keySet());
+            for (String connectionId : ids) {
+                removeConnection(tableId, connectionId);
+            }
         }
     }
 
@@ -125,11 +127,19 @@ public class SseServiceImpl implements shop.alien.dining.service.SseService {
      */
     private boolean isClientDisconnect(Throwable ex) {
         if (ex == null) return false;
+        String cn = ex.getClass().getSimpleName();
+        if ("AsyncRequestNotUsableException".equals(cn) || "ClientAbortException".equals(cn)) {
+            return true;
+        }
         String msg = ex.getMessage();
         if (msg != null) {
             String lower = msg.toLowerCase();
             if (lower.contains("broken pipe") || lower.contains("connection reset")
-                    || lower.contains("connection closed") || lower.contains("an established connection was aborted")) {
+                    || lower.contains("connection closed") || lower.contains("an established connection was aborted")
+                    || lower.contains("connection abort") || lower.contains("abort") || lower.contains("stream closed")
+                    || lower.contains("closed channel") || lower.contains("already completed")
+                    || lower.contains("responsebodyemitter") || lower.contains("not usable")
+                    || msg.contains("中止") || msg.contains("已关闭")) {
                 return true;
             }
         }
@@ -140,6 +150,10 @@ public class SseServiceImpl implements shop.alien.dining.service.SseService {
      * 移除连接
      */
     private void removeConnection(Integer tableId, String connectionId) {
+        ScheduledFuture<?> hb = heartbeatTasks.remove(connectionId);
+        if (hb != null) {
+            hb.cancel(false);
+        }
         ConcurrentHashMap<String, SseEmitter> tableConnections = connections.get(tableId);
         if (tableConnections != null) {
             SseEmitter emitter = tableConnections.remove(connectionId);
@@ -160,13 +174,20 @@ public class SseServiceImpl implements shop.alien.dining.service.SseService {
      * 启动心跳任务
      */
     private void startHeartbeat(Integer tableId, String connectionId, SseEmitter emitter) {
-        scheduler.scheduleAtFixedRate(() -> {
-            try {
-                if (emitter != null) {
-                    emitter.send(SseEmitter.event()
-                            .name("heartbeat")
-                            .data("ping"));
+        final ScheduledFuture<?>[] holder = new ScheduledFuture<?>[1];
+        holder[0] = scheduler.scheduleAtFixedRate(() -> {
+            ConcurrentHashMap<String, SseEmitter> tableConnections = connections.get(tableId);
+            if (tableConnections == null || tableConnections.get(connectionId) != emitter) {
+                heartbeatTasks.remove(connectionId, holder[0]);
+                if (holder[0] != null) {
+                    holder[0].cancel(false);
                 }
+                return;
+            }
+            try {
+                emitter.send(SseEmitter.event()
+                        .name("heartbeat")
+                        .data("ping"));
             } catch (IOException e) {
                 if (isClientDisconnect(e)) {
                     log.debug("心跳时客户端已断开, tableId={}, connectionId={}", tableId, connectionId);
@@ -175,6 +196,7 @@ public class SseServiceImpl implements shop.alien.dining.service.SseService {
                 }
                 removeConnection(tableId, connectionId);
             }
-        }, 30, 30, TimeUnit.SECONDS); // 每30秒发送一次心跳
+        }, 30, 30, TimeUnit.SECONDS);
+        heartbeatTasks.put(connectionId, holder[0]);
     }
 }

+ 132 - 16
alien-dining/src/main/java/shop/alien/dining/service/impl/StoreInfoServiceImpl.java

@@ -9,16 +9,21 @@ import org.springframework.stereotype.Service;
 import shop.alien.entity.store.StoreCuisine;
 import shop.alien.entity.store.StoreCuisineCategory;
 import shop.alien.entity.store.StoreInfo;
+import shop.alien.entity.store.StorePrice;
+import shop.alien.entity.store.constants.StoreMenuType;
 import shop.alien.entity.store.StoreProductDiscountRule;
 import shop.alien.entity.store.StoreTable;
 import shop.alien.entity.store.dto.StoreInfoWithHomepageCuisinesDTO;
-import shop.alien.entity.store.vo.CategoryWithCuisinesVO;
+import shop.alien.entity.store.vo.CategoryMenuGroupVO;
 import shop.alien.entity.store.vo.StoreCuisineWithPricesVO;
+import shop.alien.entity.store.vo.StorePriceWithPricesVO;
 import shop.alien.mapper.StoreCuisineCategoryMapper;
 import shop.alien.mapper.StoreCuisineMapper;
 import shop.alien.mapper.StoreInfoMapper;
+import shop.alien.mapper.StorePriceMapper;
 import shop.alien.mapper.StoreProductDiscountRuleMapper;
 import shop.alien.mapper.StoreTableMapper;
+import shop.alien.dining.constants.OrderMenuConstants;
 import shop.alien.dining.service.StoreInfoService;
 import shop.alien.dining.support.ProductDiscountPricingSupport;
 import org.apache.commons.lang3.StringUtils;
@@ -48,6 +53,7 @@ public class StoreInfoServiceImpl implements StoreInfoService {
     private final StoreTableMapper storeTableMapper;
     private final StoreCuisineCategoryMapper storeCuisineCategoryMapper;
     private final StoreCuisineMapper storeCuisineMapper;
+    private final StorePriceMapper storePriceMapper;
     private final StoreInfoMapper storeInfoMapper;
     private final StoreProductDiscountRuleMapper storeProductDiscountRuleMapper;
 
@@ -64,15 +70,17 @@ public class StoreInfoServiceImpl implements StoreInfoService {
     }
 
     @Override
-    public List<StoreCuisineCategory> getCategoriesByStoreId(Integer storeId) {
-        log.info("根据门店ID查询菜品种类列表, storeId={}", storeId);
-        
+    public List<StoreCuisineCategory> getCategoriesByStoreId(Integer storeId, Integer menuType) {
+        int mt = StoreMenuType.normalizeOrCuisine(menuType);
+        log.info("根据门店ID查询分类列表, storeId={}, menuType={}", storeId, mt);
+
         LambdaQueryWrapper<StoreCuisineCategory> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(StoreCuisineCategory::getStoreId, storeId);
+        wrapper.eq(StoreCuisineCategory::getMenuType, mt);
         wrapper.eq(StoreCuisineCategory::getDeleteFlag, 0);
-        wrapper.eq(StoreCuisineCategory::getStatus, 1); // 只查询启用的分类
-        wrapper.orderByAsc(StoreCuisineCategory::getSort); // 按排序字段排序
-        
+        wrapper.eq(StoreCuisineCategory::getStatus, 1);
+        wrapper.orderByAsc(StoreCuisineCategory::getSort);
+
         return storeCuisineCategoryMapper.selectList(wrapper);
     }
 
@@ -86,8 +94,12 @@ public class StoreInfoServiceImpl implements StoreInfoService {
             log.warn("菜品种类不存在, categoryId={}", categoryId);
             return new java.util.ArrayList<>();
         }
-        
-        // 查询该门店下所有上架的菜品(与 getCategoriesWithCuisinesByStoreId 中 cuisines 查询条件、排序一致)
+        if (!StoreMenuType.isCuisine(category.getMenuType())) {
+            log.warn("该分类非美食分类,不返回 store_cuisine 列表, categoryId={}, menuType={}", categoryId, category.getMenuType());
+            return new java.util.ArrayList<>();
+        }
+
+        // 查询该门店下所有上架的菜品(与 getCategoriesWithMenuGroupsByStoreId(menuType=1) 中 cuisines 查询条件、排序一致)
         LambdaQueryWrapper<StoreCuisine> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(StoreCuisine::getStoreId, category.getStoreId());
         wrapper.eq(StoreCuisine::getDeleteFlag, 0);
@@ -119,13 +131,20 @@ public class StoreInfoServiceImpl implements StoreInfoService {
     }
 
     @Override
-    public List<CategoryWithCuisinesVO> getCategoriesWithCuisinesByStoreId(Integer storeId, String keyword) {
-        log.info("根据门店ID查询菜品种类及下属菜品, storeId={}, keyword={}", storeId, keyword);
+    public List<CategoryMenuGroupVO> getCategoriesWithMenuGroupsByStoreId(Integer storeId, String keyword, Integer menuType) {
+        int mt = StoreMenuType.normalizeOrCuisine(menuType);
+        log.info("根据门店ID查询分类树, storeId={}, keyword={}, menuType={}", storeId, keyword, mt);
+        if (mt == StoreMenuType.GENERIC_PRICE) {
+            return buildGenericCategoryMenuGroups(storeId, keyword);
+        }
+        return buildCuisineCategoryMenuGroups(storeId, keyword);
+    }
+
+    private List<CategoryMenuGroupVO> buildCuisineCategoryMenuGroups(Integer storeId, String keyword) {
         List<StoreCuisineCategory> categories = getCategoriesByStoreId(storeId);
         if (categories == null || categories.isEmpty()) {
             return new ArrayList<>();
         }
-        // 与 getCuisinesByCategoryId 相同的查询条件与排序,保证 cuisines 字段与 /store/info/cuisines 一致
         LambdaQueryWrapper<StoreCuisine> cuisineWrapper = new LambdaQueryWrapper<>();
         cuisineWrapper.eq(StoreCuisine::getStoreId, storeId);
         cuisineWrapper.eq(StoreCuisine::getDeleteFlag, 0);
@@ -148,7 +167,7 @@ public class StoreInfoServiceImpl implements StoreInfoService {
         }
         Map<Integer, BigDecimal> discountedByProductId = buildDiscountedPriceMap(storeId, flatForPricing);
 
-        List<CategoryWithCuisinesVO> result = new ArrayList<>();
+        List<CategoryMenuGroupVO> result = new ArrayList<>();
         for (StoreCuisineCategory category : categories) {
             Integer categoryId = category.getId();
             List<StoreCuisineWithPricesVO> cuisines = allCuisines.stream()
@@ -156,7 +175,47 @@ public class StoreInfoServiceImpl implements StoreInfoService {
                     .filter(c -> !filterByName || (c.getName() != null && c.getName().toLowerCase().contains(keywordLower)))
                     .map(c -> toCuisineWithPricesVo(c, discountedByProductId))
                     .collect(Collectors.toList());
-            result.add(new CategoryWithCuisinesVO(category, cuisines));
+            result.add(new CategoryMenuGroupVO(category, cuisines, null));
+        }
+        return result;
+    }
+
+    private List<CategoryMenuGroupVO> buildGenericCategoryMenuGroups(Integer storeId, String keyword) {
+        List<StoreCuisineCategory> categories = getCategoriesByStoreId(storeId, StoreMenuType.GENERIC_PRICE);
+        if (categories == null || categories.isEmpty()) {
+            return new ArrayList<>();
+        }
+        LambdaQueryWrapper<StorePrice> priceWrapper = new LambdaQueryWrapper<>();
+        priceWrapper.eq(StorePrice::getStoreId, storeId);
+        priceWrapper.eq(StorePrice::getShelfStatus, 1);
+        priceWrapper.eq(StorePrice::getStatus, 1);
+        priceWrapper.orderByAsc(StorePrice::getId);
+        List<StorePrice> allPrices = storePriceMapper.selectList(priceWrapper);
+        if (allPrices == null) {
+            allPrices = new ArrayList<>();
+        }
+        boolean filterByName = StringUtils.isNotBlank(keyword);
+        String keywordLower = filterByName ? keyword.trim().toLowerCase() : null;
+
+        List<StorePrice> flatForPricing = new ArrayList<>();
+        for (StoreCuisineCategory category : categories) {
+            Integer categoryId = category.getId();
+            allPrices.stream()
+                    .filter(p -> priceBelongsToCategory(p, categoryId))
+                    .filter(p -> !filterByName || (p.getName() != null && p.getName().toLowerCase().contains(keywordLower)))
+                    .forEach(flatForPricing::add);
+        }
+        Map<Integer, BigDecimal> discountedByProductId = buildDiscountedPriceMapForGeneric(storeId, flatForPricing);
+
+        List<CategoryMenuGroupVO> result = new ArrayList<>();
+        for (StoreCuisineCategory category : categories) {
+            Integer categoryId = category.getId();
+            List<StorePriceWithPricesVO> prices = allPrices.stream()
+                    .filter(p -> priceBelongsToCategory(p, categoryId))
+                    .filter(p -> !filterByName || (p.getName() != null && p.getName().toLowerCase().contains(keywordLower)))
+                    .map(p -> toPriceWithPricesVo(p, discountedByProductId))
+                    .collect(Collectors.toList());
+            result.add(new CategoryMenuGroupVO(category, null, prices));
         }
         return result;
     }
@@ -195,6 +254,39 @@ public class StoreInfoServiceImpl implements StoreInfoService {
                 rules);
     }
 
+    /** 通用价目:仅 rule_product_type=2 的规则参与计价 */
+    private Map<Integer, BigDecimal> buildDiscountedPriceMapForGeneric(Integer storeId, List<StorePrice> visiblePrices) {
+        if (visiblePrices == null || visiblePrices.isEmpty()) {
+            return new HashMap<>();
+        }
+        Set<Integer> idSet = new HashSet<>();
+        Map<Integer, BigDecimal> basePriceByProduct = new HashMap<>();
+        for (StorePrice p : visiblePrices) {
+            if (p.getId() == null) {
+                continue;
+            }
+            if (idSet.add(p.getId())) {
+                BigDecimal base = p.getTotalPrice() != null ? p.getTotalPrice() : BigDecimal.ZERO;
+                basePriceByProduct.put(p.getId(), base);
+            }
+        }
+        List<Integer> productIds = new ArrayList<>(idSet);
+        if (productIds.isEmpty()) {
+            return new HashMap<>();
+        }
+        List<StoreProductDiscountRule> rules = storeProductDiscountRuleMapper.selectList(
+                new LambdaQueryWrapper<StoreProductDiscountRule>()
+                        .eq(StoreProductDiscountRule::getStoreId, storeId)
+                        .eq(StoreProductDiscountRule::getStatus, 1)
+                        .eq(StoreProductDiscountRule::getRuleProductType, OrderMenuConstants.RULE_PRODUCT_GENERIC_PRICE)
+                        .in(StoreProductDiscountRule::getProductId, productIds));
+        return ProductDiscountPricingSupport.resolveDiscountedPrices(
+                productIds,
+                basePriceByProduct,
+                LocalDateTime.now(),
+                rules);
+    }
+
     private static StoreCuisineWithPricesVO toCuisineWithPricesVo(StoreCuisine cuisine, Map<Integer, BigDecimal> discountedByProductId) {
         Map<Integer, BigDecimal> disc = discountedByProductId == null ? Collections.emptyMap() : discountedByProductId;
         StoreCuisineWithPricesVO vo = new StoreCuisineWithPricesVO();
@@ -208,9 +300,29 @@ public class StoreInfoServiceImpl implements StoreInfoService {
         return vo;
     }
 
+    private static StorePriceWithPricesVO toPriceWithPricesVo(StorePrice price, Map<Integer, BigDecimal> discountedByProductId) {
+        Map<Integer, BigDecimal> disc = discountedByProductId == null ? Collections.emptyMap() : discountedByProductId;
+        StorePriceWithPricesVO vo = new StorePriceWithPricesVO();
+        BeanUtils.copyProperties(price, vo);
+        BigDecimal original = price.getTotalPrice() != null ? price.getTotalPrice() : BigDecimal.ZERO;
+        vo.setOriginalPrice(original);
+        boolean hasRule = price.getId() != null && disc.containsKey(price.getId());
+        BigDecimal current = hasRule ? disc.get(price.getId()) : original;
+        vo.setCurrentPrice(current);
+        vo.setHasActiveDiscount(hasRule);
+        return vo;
+    }
+
     private boolean belongsToCategory(StoreCuisine cuisine, Integer categoryId) {
-        String categoryIdsStr = cuisine.getCategoryIds();
-        if (StringUtils.isBlank(categoryIdsStr)) {
+        return categoryJsonContains(cuisine.getCategoryIds(), categoryId);
+    }
+
+    private boolean priceBelongsToCategory(StorePrice price, Integer categoryId) {
+        return categoryJsonContains(price.getCategoryIds(), categoryId);
+    }
+
+    private static boolean categoryJsonContains(String categoryIdsStr, Integer categoryId) {
+        if (StringUtils.isBlank(categoryIdsStr) || categoryId == null) {
             return false;
         }
         try {
@@ -233,6 +345,10 @@ public class StoreInfoServiceImpl implements StoreInfoService {
             log.warn("删除菜品种类失败:分类不存在, categoryId={}", categoryId);
             return false;
         }
+        if (!StoreMenuType.isCuisine(category.getMenuType())) {
+            log.warn("删除菜品种类失败:仅支持美食分类,通用价目分类请在商家端维护, categoryId={}", categoryId);
+            return false;
+        }
         Integer storeId = category.getStoreId();
         // 1. 解除绑定:将该分类ID从所有关联菜品的 category_ids 中移除,价目表菜品其它字段不变
         LambdaQueryWrapper<StoreCuisine> wrapper = new LambdaQueryWrapper<>();

+ 145 - 34
alien-dining/src/main/java/shop/alien/dining/service/impl/StoreOrderServiceImpl.java

@@ -14,7 +14,12 @@ import shop.alien.dining.config.BaseRedisService;
 import shop.alien.dining.service.CartService;
 import shop.alien.dining.service.StoreOrderService;
 import shop.alien.dining.support.DiningMenuPricing;
+import shop.alien.dining.support.DiningOrderingTableGuard;
 import shop.alien.dining.util.TokenUtil;
+import shop.alien.dining.constants.OrderMenuConstants;
+import shop.alien.dining.service.GenericCartService;
+import shop.alien.dining.support.StoreCartMenuFilters;
+import shop.alien.util.common.constant.DiscountCouponEnum;
 import shop.alien.entity.store.*;
 import shop.alien.entity.store.dto.CartDTO;
 import shop.alien.mapper.UserReservationMapper;
@@ -71,20 +76,33 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
     private final UserReservationMapper userReservationMapper;
     private final UserReservationTableMapper userReservationTableMapper;
     private final StoreProductDiscountRuleMapper storeProductDiscountRuleMapper;
+    private final LifeDiscountCouponUserMapper lifeDiscountCouponUserMapper;
+    private final GenericCartService genericCartService;
+    private final StorePriceMapper storePriceMapper;
+    private final DiningOrderingTableGuard diningOrderingTableGuard;
 
     @Override
     public StoreOrder createOrder(CreateOrderDTO dto) {
-        log.info("创建订单, dto={}", dto);
+        return createOrderWithMenuType(dto, OrderMenuConstants.MENU_TYPE_CUISINE);
+    }
+
+    @Override
+    public StoreOrder createGenericOrder(CreateOrderDTO dto) {
+        return createOrderWithMenuType(dto, OrderMenuConstants.MENU_TYPE_GENERIC_PRICE);
+    }
+
+    private StoreOrder createOrderWithMenuType(CreateOrderDTO dto, int menuType) {
+        log.info("创建订单, dto={}, menuType={}", dto, menuType);
 
         // 获取当前用户信息
         Object[] userInfo = getCurrentUserInfo();
         Integer userId = (Integer) userInfo[0];
         String userPhone = (String) userInfo[1];
 
-        // 检查订单锁定状态
+        // 须由当前用户先调用锁定接口且锁未过期,避免并发重复下单
         Integer lockUserId = orderLockService.checkOrderLock(dto.getTableId());
-        if (lockUserId != null && !lockUserId.equals(userId)) {
-            throw new RuntimeException("订单已被其他用户锁定,无法下单");
+        if (lockUserId == null || !lockUserId.equals(userId)) {
+            throw new RuntimeException("请先锁定订单后再下单(调用锁定订单接口成功后再提交)");
         }
 
         // 验证桌号
@@ -99,8 +117,12 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
             throw new RuntimeException("门店不存在");
         }
 
+        diningOrderingTableGuard.assertOrderMenuTypeAllowed(storeInfo, table, menuType);
+
         // 获取购物车
-        CartDTO cart = cartService.getCart(dto.getTableId());
+        CartDTO cart = menuType == OrderMenuConstants.MENU_TYPE_GENERIC_PRICE
+                ? genericCartService.getCart(dto.getTableId())
+                : cartService.getCart(dto.getTableId());
         if (cart.getItems() == null || cart.getItems().isEmpty()) {
             throw new RuntimeException("购物车为空");
         }
@@ -141,7 +163,10 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
         
         if (dto.getCouponId() != null) {
             // 检查桌号是否已使用优惠券,如果已使用则替换为新优惠券
-            if (cartService.hasUsedCoupon(dto.getTableId())) {
+            boolean couponUsed = menuType == OrderMenuConstants.MENU_TYPE_GENERIC_PRICE
+                    ? genericCartService.hasUsedCoupon(dto.getTableId())
+                    : cartService.hasUsedCoupon(dto.getTableId());
+            if (couponUsed) {
                 // 获取旧的优惠券使用记录
                 LambdaQueryWrapper<StoreCouponUsage> oldUsageWrapper = new LambdaQueryWrapper<>();
                 oldUsageWrapper.eq(StoreCouponUsage::getTableId, dto.getTableId());
@@ -168,7 +193,11 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
                 }
                 
                 // 清除旧的优惠券使用标记
-                cartService.clearCouponUsed(dto.getTableId());
+                if (menuType == OrderMenuConstants.MENU_TYPE_GENERIC_PRICE) {
+                    genericCartService.clearCouponUsed(dto.getTableId());
+                } else {
+                    cartService.clearCouponUsed(dto.getTableId());
+                }
                 log.info("替换优惠券:清除旧优惠券标记, tableId={}, newCouponId={}", dto.getTableId(), dto.getCouponId());
             }
 
@@ -186,8 +215,14 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
                 throw new RuntimeException("优惠券不属于该门店");
             }
 
+            assertCouponOwnedAndUsable(userId, coupon);
+
             // 标记桌号已使用新优惠券
-            cartService.markCouponUsed(dto.getTableId(), dto.getCouponId());
+            if (menuType == OrderMenuConstants.MENU_TYPE_GENERIC_PRICE) {
+                genericCartService.markCouponUsed(dto.getTableId(), dto.getCouponId());
+            } else {
+                cartService.markCouponUsed(dto.getTableId(), dto.getCouponId());
+            }
         } else {
             // 直接使用前端传入的值,如果为null则设为0
             if (discountAmount == null) {
@@ -204,12 +239,23 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
                 .filter(Objects::nonNull)
                 .filter(id -> id > 0)
                 .collect(Collectors.toSet());
-        Map<Integer, BigDecimal> listUnitByCuisine = cartCuisineIds.isEmpty()
-                ? Collections.emptyMap()
-                : DiningMenuPricing.resolveListUnitPriceByCuisineId(cartCuisineIds, storeCuisineMapper);
-        Map<Integer, BigDecimal> saleUnitByCuisine = cartCuisineIds.isEmpty()
-                ? Collections.emptyMap()
-                : DiningMenuPricing.resolveSaleUnitPrice(table.getStoreId(), listUnitByCuisine, storeProductDiscountRuleMapper);
+        final Map<Integer, BigDecimal> listUnitByProduct;
+        final Map<Integer, BigDecimal> saleUnitByProduct;
+        if (menuType == OrderMenuConstants.MENU_TYPE_GENERIC_PRICE) {
+            listUnitByProduct = cartCuisineIds.isEmpty()
+                    ? Collections.emptyMap()
+                    : DiningMenuPricing.resolveListUnitPriceByGenericPriceId(cartCuisineIds, storePriceMapper);
+            saleUnitByProduct = cartCuisineIds.isEmpty()
+                    ? Collections.emptyMap()
+                    : DiningMenuPricing.resolveSaleUnitPriceForGenericPrice(table.getStoreId(), listUnitByProduct, storeProductDiscountRuleMapper);
+        } else {
+            listUnitByProduct = cartCuisineIds.isEmpty()
+                    ? Collections.emptyMap()
+                    : DiningMenuPricing.resolveListUnitPriceByCuisineId(cartCuisineIds, storeCuisineMapper);
+            saleUnitByProduct = cartCuisineIds.isEmpty()
+                    ? Collections.emptyMap()
+                    : DiningMenuPricing.resolveSaleUnitPrice(table.getStoreId(), listUnitByProduct, storeProductDiscountRuleMapper);
+        }
 
         StoreOrder order = null;
         String orderNo = null;
@@ -223,6 +269,9 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
             StoreOrder existingOrder = this.getById(table.getCurrentOrderId());
             if (existingOrder != null && existingOrder.getOrderStatus() == 0 && existingOrder.getPayStatus() == 0) {
                 // 订单存在且是待支付状态,更新订单
+                if (existingOrder.getMenuType() != null && !existingOrder.getMenuType().equals(menuType)) {
+                    throw new RuntimeException("订单菜单类型与当前下单入口不一致");
+                }
                 isUpdate = true;
                 order = existingOrder;
                 previousCouponIdBeforeUpdate = order.getCouponId();
@@ -311,6 +360,7 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
             order.setPayStatus(0); // 未支付
             order.setRemark(dto.getRemark());
             order.setUserReservationId(userReservationId);
+            order.setMenuType(menuType);
             order.setCreatedUserId(userId);
             order.setUpdatedUserId(userId);
             // 手动设置创建时间和更新时间(临时方案,避免自动填充未生效导致 created_time 为 null)
@@ -375,6 +425,9 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
                     StoreOrderDetail detail = new StoreOrderDetail();
                     detail.setOrderId(finalOrder.getId());
                     detail.setOrderNo(finalOrderNo);
+                    detail.setLineType(menuType == OrderMenuConstants.MENU_TYPE_GENERIC_PRICE
+                            ? OrderMenuConstants.LINE_TYPE_GENERIC_PRICE
+                            : OrderMenuConstants.LINE_TYPE_CUISINE);
                     detail.setCuisineId(item.getCuisineId());
                     detail.setCuisineName(item.getCuisineName());
                     
@@ -384,6 +437,8 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
                         // 如果是餐具,设置为0;否则设置为1(默认单品)
                         if (TABLEWARE_CUISINE_ID.equals(item.getCuisineId())) {
                             cuisineType = 0; // 0表示餐具
+                        } else if (menuType == OrderMenuConstants.MENU_TYPE_GENERIC_PRICE) {
+                            cuisineType = 1;
                         } else {
                             // 尝试从菜品信息中获取,如果获取不到则默认为1(单品)
                             try {
@@ -410,10 +465,10 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
                         detail.setSubtotalAmount(p.multiply(BigDecimal.valueOf(lineQty)).setScale(2, RoundingMode.HALF_UP));
                     } else {
                         Integer cid = item.getCuisineId();
-                        java.math.BigDecimal listU = listUnitByCuisine.getOrDefault(cid,
+                        java.math.BigDecimal listU = listUnitByProduct.getOrDefault(cid,
                                 item.getOriginalUnitPrice() != null ? item.getOriginalUnitPrice()
                                         : (item.getUnitPrice() != null ? item.getUnitPrice() : BigDecimal.ZERO));
-                        java.math.BigDecimal saleU = saleUnitByCuisine.getOrDefault(cid,
+                        java.math.BigDecimal saleU = saleUnitByProduct.getOrDefault(cid,
                                 item.getCurrentUnitPrice() != null ? item.getCurrentUnitPrice()
                                         : (item.getUnitPrice() != null ? item.getUnitPrice() : listU));
                         detail.setListUnitPrice(listU);
@@ -478,7 +533,11 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
         }
 
         // 锁定购物车商品数量(禁止减少或删除已下单的商品)
-        cartService.lockCartItems(dto.getTableId());
+        if (menuType == OrderMenuConstants.MENU_TYPE_GENERIC_PRICE) {
+            genericCartService.lockCartItems(dto.getTableId());
+        } else {
+            cartService.lockCartItems(dto.getTableId());
+        }
 
         // 下单后不清空购物车,允许加餐(加餐时添加到同一订单)
         // 只有在支付完成后才清空购物车
@@ -553,7 +612,7 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
 
         // 支付完成后,清空购物车和重置餐桌状态(保留订单数据,不删除订单)
         // resetTableAfterPayment 方法会清空购物车和重置餐桌状态,但不会删除订单数据
-        resetTableAfterPayment(order.getTableId());
+        resetTableAfterPayment(order.getTableId(), order.getMenuType());
 
         // 支付订单成功后,自动解锁结算锁定
         try {
@@ -587,20 +646,25 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
         this.updateById(order);
 
         // 恢复购物车的已下单数量(允许重新下单)
-        cartService.unlockCartItems(order.getTableId());
-
-        // 清除优惠券使用标记
-        cartService.clearCouponUsed(order.getTableId());
+        Integer tableId = order.getTableId();
+        if (order.getMenuType() != null && order.getMenuType().equals(OrderMenuConstants.MENU_TYPE_GENERIC_PRICE)) {
+            genericCartService.unlockCartItems(tableId);
+            genericCartService.clearCouponUsed(tableId);
+        } else {
+            cartService.unlockCartItems(tableId);
+            cartService.clearCouponUsed(tableId);
+        }
 
         // 更新桌号状态:显式清空 currentOrderId(updateById 会忽略 null,必须用 LambdaUpdateWrapper)
-        Integer tableId = order.getTableId();
         StoreTable table = storeTableMapper.selectById(tableId);
         if (table != null) {
             LambdaUpdateWrapper<StoreTable> tableWrapper = new LambdaUpdateWrapper<>();
             tableWrapper.eq(StoreTable::getId, tableId)
                     .set(StoreTable::getCurrentOrderId, null)
                     .set(StoreTable::getUpdatedTime, new Date());
-            CartDTO cart = cartService.getCart(tableId);
+            CartDTO cart = order.getMenuType() != null && order.getMenuType().equals(OrderMenuConstants.MENU_TYPE_GENERIC_PRICE)
+                    ? genericCartService.getCart(tableId)
+                    : cartService.getCart(tableId);
             if (cart.getItems() == null || cart.getItems().isEmpty()) {
                 tableWrapper.set(StoreTable::getStatus, 0).set(StoreTable::getDinerCount, null);
             } else {
@@ -979,6 +1043,11 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
             if (!coupon.getStoreId().equals(String.valueOf(order.getStoreId()))) {
                 throw new RuntimeException("优惠券不属于该门店");
             }
+            Integer uid = TokenUtil.getCurrentUserId();
+            if (uid == null) {
+                throw new RuntimeException("用户未登录");
+            }
+            assertCouponOwnedAndUsable(uid, coupon);
         }
 
         BigDecimal discountAmount = dto.getDiscountAmount() != null
@@ -1204,13 +1273,14 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
             }
         }
         
-        // 5. 清空Redis中的购物车缓存
-        String cartKey = "cart:table:" + tableId;
-        baseRedisService.delete(cartKey);
+        // 5. 清空Redis中的购物车缓存(美食 + 通用)
+        baseRedisService.delete("cart:table:" + tableId);
+        baseRedisService.delete(OrderMenuConstants.GENERIC_CART_REDIS_PREFIX + tableId);
         log.info("清空Redis购物车缓存, tableId={}", tableId);
         
         // 6. 清除优惠券使用标记(Redis中的标记)
         cartService.clearCouponUsed(tableId);
+        genericCartService.clearCouponUsed(tableId);
         log.info("清除优惠券使用标记, tableId={}", tableId);
         
         // 7. 重置餐桌表(使用 LambdaUpdateWrapper 来显式设置 null 值)
@@ -1237,10 +1307,11 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
      * 支付完成后重置餐桌(保留订单数据,只重置餐桌绑定关系)
      *
      * @param tableId 餐桌ID
+     * @param orderMenuType 订单菜单类型,用于只清理对应 store_cart 与 Redis
      */
     @Override
-    public void resetTableAfterPayment(Integer tableId) {
-        log.info("支付完成后重置餐桌, tableId={}", tableId);
+    public void resetTableAfterPayment(Integer tableId, Integer orderMenuType) {
+        log.info("支付完成后重置餐桌, tableId={}, orderMenuType={}", tableId, orderMenuType);
 
         // 验证餐桌是否存在
         StoreTable table = storeTableMapper.selectById(tableId);
@@ -1250,10 +1321,14 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
         }
 
         // 1. 完全清空购物车(包括已下单的商品,因为订单已支付完成)
-        // 逻辑删除所有购物车数据
         LambdaQueryWrapper<StoreCart> cartWrapper = new LambdaQueryWrapper<>();
         cartWrapper.eq(StoreCart::getTableId, tableId);
         cartWrapper.eq(StoreCart::getDeleteFlag, 0);
+        if (orderMenuType != null && orderMenuType.equals(OrderMenuConstants.MENU_TYPE_GENERIC_PRICE)) {
+            StoreCartMenuFilters.applyGenericCart(cartWrapper);
+        } else {
+            StoreCartMenuFilters.applyCuisineCart(cartWrapper);
+        }
         List<StoreCart> cartList = storeCartMapper.selectList(cartWrapper);
         if (cartList != null && !cartList.isEmpty()) {
             List<Integer> cartIds = cartList.stream()
@@ -1263,13 +1338,17 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
             log.info("支付完成后删除购物车数据, tableId={}, count={}", tableId, cartList.size());
         }
 
-        // 2. 清空Redis中的购物车缓存
-        String cartKey = "cart:table:" + tableId;
-        baseRedisService.delete(cartKey);
+        // 2. 清空Redis中的购物车缓存(两套前缀都清,避免残留)
+        baseRedisService.delete("cart:table:" + tableId);
+        baseRedisService.delete(OrderMenuConstants.GENERIC_CART_REDIS_PREFIX + tableId);
         log.info("清空Redis购物车缓存, tableId={}", tableId);
 
         // 3. 清除优惠券使用标记
-        cartService.clearCouponUsed(tableId);
+        if (orderMenuType != null && orderMenuType.equals(OrderMenuConstants.MENU_TYPE_GENERIC_PRICE)) {
+            genericCartService.clearCouponUsed(tableId);
+        } else {
+            cartService.clearCouponUsed(tableId);
+        }
         log.info("清除优惠券使用标记, tableId={}", tableId);
 
         // 4. 重置餐桌表(使用 LambdaUpdateWrapper 来显式设置 null 值)
@@ -1525,7 +1604,9 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
         vo.setStoreBlurb(storeBlurb);
         vo.setStoreType(storeType);
         vo.setBusinessStatus(businessStatus);
+        vo.setTableId(order.getTableId());
         vo.setTableNumber(order.getTableNumber());
+        vo.setMenuType(order.getMenuType());
         vo.setUserReservationId(order.getUserReservationId());
         vo.setDinerCount(order.getDinerCount());
         vo.setContactPhone(order.getContactPhone());
@@ -2114,4 +2195,34 @@ public class StoreOrderServiceImpl extends ServiceImpl<StoreOrderMapper, StoreOr
         return new Object[]{userId, userPhone};
     }
 
+    /**
+     * 校验当前用户持有该券且为待使用、未过用户券有效期及券活动期。
+     */
+    private void assertCouponOwnedAndUsable(Integer userId, LifeDiscountCoupon coupon) {
+        if (userId == null || coupon == null || coupon.getId() == null) {
+            throw new RuntimeException("用户信息或优惠券无效");
+        }
+        int waiting = Integer.parseInt(DiscountCouponEnum.WAITING_USED.getValue());
+        LambdaQueryWrapper<LifeDiscountCouponUser> w = new LambdaQueryWrapper<>();
+        w.eq(LifeDiscountCouponUser::getUserId, userId);
+        w.eq(LifeDiscountCouponUser::getCouponId, coupon.getId());
+        w.eq(LifeDiscountCouponUser::getStatus, waiting);
+        w.orderByDesc(LifeDiscountCouponUser::getCreatedTime);
+        w.last("LIMIT 1");
+        LifeDiscountCouponUser cu = lifeDiscountCouponUserMapper.selectOne(w);
+        if (cu == null) {
+            throw new RuntimeException("您未持有该优惠券或券已使用/已作废");
+        }
+        LocalDate today = LocalDate.now(SHANGHAI);
+        if (cu.getExpirationTime() != null && today.isAfter(cu.getExpirationTime())) {
+            throw new RuntimeException("该优惠券已过期");
+        }
+        if (coupon.getStartDate() != null && today.isBefore(coupon.getStartDate())) {
+            throw new RuntimeException("该优惠券尚未开始");
+        }
+        if (coupon.getEndDate() != null && today.isAfter(coupon.getEndDate())) {
+            throw new RuntimeException("该优惠券活动已结束");
+        }
+    }
+
 }

+ 1 - 1
alien-dining/src/main/java/shop/alien/dining/strategy/payment/impl/WeChatPaymentMininProgramStrategyImpl.java

@@ -376,7 +376,7 @@ public class WeChatPaymentMininProgramStrategyImpl implements PaymentStrategy {
                         }
                         // 支付完成后,清空购物车和重置餐桌状态(保留订单数据,不删除订单)
                         try {
-                            storeOrderService.resetTableAfterPayment(storeOrder.getTableId());
+                            storeOrderService.resetTableAfterPayment(storeOrder.getTableId(), storeOrder.getMenuType());
                             log.info("支付完成后重置餐桌成功, tableId={}", storeOrder.getTableId());
                         } catch (Exception e) {
                             log.error("支付完成后重置餐桌失败, tableId={}, error={}", storeOrder.getTableId(), e.getMessage(), e);

+ 35 - 0
alien-dining/src/main/java/shop/alien/dining/support/DiningFirstVisitDinerCount.java

@@ -0,0 +1,35 @@
+package shop.alien.dining.support;
+
+import shop.alien.entity.store.StoreTable;
+
+/**
+ * 点餐页首访:填写或沿用桌台已保存的「就餐人数」(不落开台状态,仅更新 {@link StoreTable#getDinerCount} 可能待持久化字段)。
+ */
+public final class DiningFirstVisitDinerCount {
+
+    private DiningFirstVisitDinerCount() {
+    }
+
+    /**
+     * 本次请求是否带了有效人数并需要写回库(调用方在 true 时应对 table 执行 update)。
+     */
+    public static boolean shouldPersistToDatabase(Integer dinerCountFromRequest) {
+        return dinerCountFromRequest != null && dinerCountFromRequest > 0;
+    }
+
+    /**
+     * 解析最终就餐人数:请求有效则写入 table 暂存字段;否则沿用桌台已有正数人数;皆无则抛错。
+     *
+     * @return 用于展示与后续业务的正整数人数
+     */
+    public static int resolveOrThrow(StoreTable table, Integer dinerCountFromRequest) {
+        if (shouldPersistToDatabase(dinerCountFromRequest)) {
+            table.setDinerCount(dinerCountFromRequest);
+            return dinerCountFromRequest;
+        }
+        if (table.getDinerCount() != null && table.getDinerCount() > 0) {
+            return table.getDinerCount();
+        }
+        throw new RuntimeException("请选择用餐人数");
+    }
+}

+ 57 - 1
alien-dining/src/main/java/shop/alien/dining/support/DiningMenuPricing.java

@@ -2,9 +2,12 @@ package shop.alien.dining.support;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import shop.alien.entity.store.StoreCuisine;
+import shop.alien.entity.store.StorePrice;
 import shop.alien.entity.store.StoreProductDiscountRule;
 import shop.alien.mapper.StoreCuisineMapper;
+import shop.alien.mapper.StorePriceMapper;
 import shop.alien.mapper.StoreProductDiscountRuleMapper;
+import shop.alien.dining.constants.OrderMenuConstants;
 
 import java.math.BigDecimal;
 import java.time.LocalDateTime;
@@ -50,6 +53,27 @@ public final class DiningMenuPricing {
     }
 
     /**
+     * 通用价目标价(store_price.total_price)。
+     */
+    public static Map<Integer, BigDecimal> resolveListUnitPriceByGenericPriceId(Set<Integer> priceIds, StorePriceMapper priceMapper) {
+        if (priceIds == null || priceIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        List<StorePrice> list = priceMapper.selectList(
+                new LambdaQueryWrapper<StorePrice>().in(StorePrice::getId, priceIds));
+        Map<Integer, BigDecimal> m = new HashMap<>();
+        if (list != null) {
+            for (StorePrice p : list) {
+                m.put(p.getId(), p.getTotalPrice() != null ? p.getTotalPrice() : BigDecimal.ZERO);
+            }
+        }
+        for (Integer id : priceIds) {
+            m.putIfAbsent(id, BigDecimal.ZERO);
+        }
+        return m;
+    }
+
+    /**
      * 成交单价:有规则命中则用规则价,否则等于标价。
      */
     public static Map<Integer, BigDecimal> resolveSaleUnitPrice(
@@ -64,7 +88,9 @@ public final class DiningMenuPricing {
                 new LambdaQueryWrapper<StoreProductDiscountRule>()
                         .eq(StoreProductDiscountRule::getStoreId, storeId)
                         .eq(StoreProductDiscountRule::getStatus, 1)
-                        .in(StoreProductDiscountRule::getProductId, ids));
+                        .in(StoreProductDiscountRule::getProductId, ids)
+                        .and(w -> w.eq(StoreProductDiscountRule::getRuleProductType, OrderMenuConstants.RULE_PRODUCT_CUISINE)
+                                .or().isNull(StoreProductDiscountRule::getRuleProductType)));
         Map<Integer, BigDecimal> disc = ProductDiscountPricingSupport.resolveDiscountedPrices(
                 ids,
                 listUnitByCuisineId,
@@ -77,4 +103,34 @@ public final class DiningMenuPricing {
         }
         return sale;
     }
+
+    /**
+     * 通用价目成交单价(rule_product_type=2 的规则)。
+     */
+    public static Map<Integer, BigDecimal> resolveSaleUnitPriceForGenericPrice(
+            Integer storeId,
+            Map<Integer, BigDecimal> listUnitByPriceId,
+            StoreProductDiscountRuleMapper ruleMapper) {
+        if (storeId == null || listUnitByPriceId == null || listUnitByPriceId.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        List<Integer> ids = new ArrayList<>(listUnitByPriceId.keySet());
+        List<StoreProductDiscountRule> rules = ruleMapper.selectList(
+                new LambdaQueryWrapper<StoreProductDiscountRule>()
+                        .eq(StoreProductDiscountRule::getStoreId, storeId)
+                        .eq(StoreProductDiscountRule::getStatus, 1)
+                        .eq(StoreProductDiscountRule::getRuleProductType, OrderMenuConstants.RULE_PRODUCT_GENERIC_PRICE)
+                        .in(StoreProductDiscountRule::getProductId, ids));
+        Map<Integer, BigDecimal> disc = ProductDiscountPricingSupport.resolveDiscountedPrices(
+                ids,
+                listUnitByPriceId,
+                LocalDateTime.now(SHANGHAI),
+                rules != null ? rules : Collections.emptyList());
+        Map<Integer, BigDecimal> sale = new HashMap<>();
+        for (Integer id : ids) {
+            BigDecimal list = listUnitByPriceId.getOrDefault(id, BigDecimal.ZERO);
+            sale.put(id, disc.containsKey(id) ? disc.get(id) : list);
+        }
+        return sale;
+    }
 }

+ 83 - 0
alien-dining/src/main/java/shop/alien/dining/support/DiningOrderingTableGuard.java

@@ -0,0 +1,83 @@
+package shop.alien.dining.support;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+import shop.alien.dining.constants.OrderMenuConstants;
+import shop.alien.entity.store.StoreInfo;
+import shop.alien.entity.store.StoreTable;
+import shop.alien.mapper.StoreInfoMapper;
+import shop.alien.mapper.StoreTableMapper;
+
+import java.util.Objects;
+/**
+ * 点餐侧桌台与门店校验:桌是否存在、门店是否匹配、经营板块+桌类型是否允许美食或通用价目入口。
+ * <p>
+ * 集中异常文案与 {@link StoreBusinessSectionOrdering} 的调用,避免在多个 Service 中复制相同逻辑。
+ */
+@Component
+@RequiredArgsConstructor
+public class DiningOrderingTableGuard {
+
+    private final StoreTableMapper storeTableMapper;
+    private final StoreInfoMapper storeInfoMapper;
+
+    public StoreTable requireTable(Integer tableId) {
+        StoreTable table = storeTableMapper.selectById(tableId);
+        if (table == null) {
+            throw new RuntimeException("桌号不存在");
+        }
+        return table;
+    }
+
+    public StoreInfo requireStore(StoreTable table) {
+        StoreInfo storeInfo = storeInfoMapper.selectById(table.getStoreId());
+        if (storeInfo == null) {
+            throw new RuntimeException("门店不存在");
+        }
+        return storeInfo;
+    }
+
+    public void assertTableBelongsToStore(StoreTable table, Integer storeId) {
+        if (!Objects.equals(table.getStoreId(), storeId)) {
+            throw new RuntimeException("门店与桌台不匹配");
+        }
+    }
+
+    /** 美食 browsing / 购物车 / 下单:当前桌台须按规则走美食价目 */
+    public void assertCuisineMenuAllowed(StoreTable table) {
+        assertCuisineMenuAllowed(storeInfoForTable(table), table);
+    }
+
+    public void assertCuisineMenuAllowed(StoreInfo storeInfo, StoreTable table) {
+        if (!StoreBusinessSectionOrdering.allowsCuisinePricing(storeInfo, table)) {
+            throw new RuntimeException("当前门店适用通用价目点餐,请使用通用价目入口");
+        }
+    }
+
+    /** 通用价目 browsing / 购物车:当前桌台须按规则走 store_price */
+    public void assertGenericMenuAllowed(StoreTable table) {
+        assertGenericMenuAllowed(storeInfoForTable(table), table);
+    }
+
+    public void assertGenericMenuAllowed(StoreInfo storeInfo, StoreTable table) {
+        if (!StoreBusinessSectionOrdering.allowsGenericPricing(storeInfo, table)) {
+            throw new RuntimeException("当前门店/桌台不适用通用价目点餐,请使用美食点餐入口");
+        }
+    }
+
+    /** 创建订单前:请求的美食/通用类型须与门店+桌台解析出的有效类型一致 */
+    public void assertOrderMenuTypeAllowed(StoreInfo storeInfo, StoreTable table, int requestedMenuType) {
+        int effective = StoreBusinessSectionOrdering.resolveEffectiveMenuType(storeInfo, table);
+        if (requestedMenuType == OrderMenuConstants.MENU_TYPE_GENERIC_PRICE) {
+            if (effective != OrderMenuConstants.MENU_TYPE_GENERIC_PRICE) {
+                throw new RuntimeException("当前门店/桌台不适用通用价目点餐,请使用美食点餐入口");
+            }
+        } else if (effective != OrderMenuConstants.MENU_TYPE_CUISINE) {
+            throw new RuntimeException("当前门店/桌台请使用通用价目点餐接口下单");
+        }
+    }
+
+    private StoreInfo storeInfoForTable(StoreTable table) {
+        return storeInfoMapper.selectById(table.getStoreId());
+    }
+}

+ 27 - 0
alien-dining/src/main/java/shop/alien/dining/support/DiningSearchText.java

@@ -0,0 +1,27 @@
+package shop.alien.dining.support;
+
+import org.springframework.util.StringUtils;
+
+/**
+ * 点餐检索相关字符串处理(与具体 Service 解耦)。
+ */
+public final class DiningSearchText {
+
+    /** 与历史接口一致:模糊搜索关键词最多保留的字数 */
+    public static final int MAX_KEYWORD_LENGTH = 10;
+
+    private DiningSearchText() {
+    }
+
+    public static String clampKeyword(String keyword) {
+        return clampKeyword(keyword, MAX_KEYWORD_LENGTH);
+    }
+
+    public static String clampKeyword(String keyword, int maxLen) {
+        if (!StringUtils.hasText(keyword) || maxLen <= 0) {
+            return keyword;
+        }
+        // 与历史实现一致:仅截断长度,不对入参 trim(避免前后空格参与 like 条件的行为发生漂移)
+        return keyword.length() > maxLen ? keyword.substring(0, maxLen) : keyword;
+    }
+}

+ 43 - 0
alien-dining/src/main/java/shop/alien/dining/support/StoreBusinessSectionOrdering.java

@@ -0,0 +1,43 @@
+package shop.alien.dining.support;
+
+import shop.alien.entity.store.StoreInfo;
+import shop.alien.entity.store.StoreTable;
+
+/**
+ * 门店经营板块与桌台类型共同决定点餐维度(与桌码参数 m、alien-store 桌码生成逻辑一致):
+ * 优先 {@code store_info.business_section}——1 特色美食→美食(1);2 休闲娱乐、3 生活服务→通用价目(2);
+ * 板块未配置或非上述值时,再按 {@code store_table.type}(2→通用,否则→美食)。
+ */
+public final class StoreBusinessSectionOrdering {
+
+    private StoreBusinessSectionOrdering() {
+    }
+
+    /**
+     * @return 1 美食/store_cuisine;2 通用价目/store_price(见 {@link shop.alien.dining.constants.OrderMenuConstants})
+     */
+    public static int resolveEffectiveMenuType(StoreInfo storeInfo, StoreTable table) {
+        if (table == null) {
+            return 1;
+        }
+        if (storeInfo != null && storeInfo.getBusinessSection() != null) {
+            int sec = storeInfo.getBusinessSection();
+            if (sec == 1) {
+                return 1;
+            }
+            if (sec == 2 || sec == 3) {
+                return 2;
+            }
+        }
+        Integer t = table.getType();
+        return Integer.valueOf(2).equals(t) ? 2 : 1;
+    }
+
+    public static boolean allowsGenericPricing(StoreInfo storeInfo, StoreTable table) {
+        return resolveEffectiveMenuType(storeInfo, table) == 2;
+    }
+
+    public static boolean allowsCuisinePricing(StoreInfo storeInfo, StoreTable table) {
+        return resolveEffectiveMenuType(storeInfo, table) == 1;
+    }
+}

+ 23 - 0
alien-dining/src/main/java/shop/alien/dining/support/StoreCartMenuFilters.java

@@ -0,0 +1,23 @@
+package shop.alien.dining.support;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import shop.alien.dining.constants.OrderMenuConstants;
+import shop.alien.entity.store.StoreCart;
+
+/**
+ * store_cart 按 menu_type 区分美食/通用价目购物车。
+ */
+public final class StoreCartMenuFilters {
+
+    private StoreCartMenuFilters() {
+    }
+
+    public static void applyCuisineCart(LambdaQueryWrapper<StoreCart> w) {
+        w.and(x -> x.eq(StoreCart::getMenuType, OrderMenuConstants.MENU_TYPE_CUISINE)
+                .or().isNull(StoreCart::getMenuType));
+    }
+
+    public static void applyGenericCart(LambdaQueryWrapper<StoreCart> w) {
+        w.eq(StoreCart::getMenuType, OrderMenuConstants.MENU_TYPE_GENERIC_PRICE);
+    }
+}

+ 14 - 0
alien-dining/src/main/resources/db/generic_ordering_alter.sql

@@ -0,0 +1,14 @@
+-- 通用价目点餐扩展(执行前请自行在测试环境验证)
+-- 1. 订单:菜单类型(1=美食 2=通用价目,默认 1 兼容旧数据)
+ALTER TABLE store_order ADD COLUMN menu_type TINYINT NOT NULL DEFAULT 1 COMMENT '1=美食(store_cuisine) 2=通用价目(store_price)' AFTER table_number;
+
+-- 2. 订单明细:行类型(1=cuisine_id→store_cuisine 2=cuisine_id→store_price)
+ALTER TABLE store_order_detail ADD COLUMN line_type TINYINT NOT NULL DEFAULT 1 COMMENT '1=美食 2=通用价目' AFTER order_no;
+
+-- 3. 购物车:与美食购物车分行存储(默认 1)
+ALTER TABLE store_cart ADD COLUMN menu_type TINYINT NOT NULL DEFAULT 1 COMMENT '1=美食 2=通用价目' AFTER store_id;
+
+-- 4. 优惠规则:区分 product_id 指向美食还是通用价目(默认 1;历史 NULL 视作美食)
+ALTER TABLE store_product_discount_rule ADD COLUMN rule_product_type TINYINT NULL DEFAULT 1 COMMENT '1=store_cuisine 2=store_price' AFTER product_id;
+
+CREATE INDEX idx_store_cart_table_menu ON store_cart (table_id, menu_type, delete_flag);

+ 21 - 0
alien-entity/src/main/java/shop/alien/entity/store/PrinterConfig.java

@@ -0,0 +1,21 @@
+package shop.alien.entity.store;
+
+import lombok.Data;
+
+@Data
+public class PrinterConfig {
+
+    private Integer id;
+    private String printerCode;
+    private String printerName;
+    private String brand;
+    private String connectType;
+    private String ip;
+    private Integer port;
+    private String comPort;
+    private Integer paperWidth;
+    private Integer enabled;
+    private String remark;
+
+
+}

+ 18 - 0
alien-entity/src/main/java/shop/alien/entity/store/Receipt.java

@@ -0,0 +1,18 @@
+package shop.alien.entity.store;
+
+import lombok.Data;
+
+@Data
+public class Receipt {
+
+
+
+    private String orderNo;
+    private String createTime;
+    private double totalAmount;
+    private String payType;
+
+
+
+
+}

+ 4 - 0
alien-entity/src/main/java/shop/alien/entity/store/StoreBookingBusinessHours.java

@@ -54,6 +54,10 @@ public class StoreBookingBusinessHours {
     @TableField("end_time")
     private String endTime;
 
+    @ApiModelProperty(value = "营业时间(多段用英文分号分隔,varchar500)")
+    @TableField("business_hours_str")
+    private String businessHoursStr;
+
     @ApiModelProperty(value = "排序(用于同一门店多条记录的排序)")
     @TableField("sort")
     private Integer sort;

+ 4 - 0
alien-entity/src/main/java/shop/alien/entity/store/StoreCart.java

@@ -34,6 +34,10 @@ public class StoreCart {
     @TableField("store_id")
     private Integer storeId;
 
+    @ApiModelProperty(value = "菜单类型:1=美食 2=通用价目,默认1")
+    @TableField("menu_type")
+    private Integer menuType;
+
     @ApiModelProperty(value = "菜品ID")
     @TableField("cuisine_id")
     private Integer cuisineId;

+ 4 - 0
alien-entity/src/main/java/shop/alien/entity/store/StoreCuisineCategory.java

@@ -29,6 +29,10 @@ public class StoreCuisineCategory {
     @TableField("store_id")
     private Integer storeId;
 
+    @ApiModelProperty(value = "分类归属(1:美食/store_cuisine,2:通用价目/store_price),见 shop.alien.entity.store.constants.StoreMenuType")
+    @TableField("menu_type")
+    private Integer menuType;
+
     @ApiModelProperty(value = "分类名称")
     @TableField("category_name")
     private String categoryName;

+ 9 - 1
alien-entity/src/main/java/shop/alien/entity/store/StoreOrder.java

@@ -46,6 +46,10 @@ public class StoreOrder {
     @TableField("table_number")
     private String tableNumber;
 
+    @ApiModelProperty(value = "菜单类型:1=美食(store_cuisine) 2=通用价目(store_price),默认1")
+    @TableField("menu_type")
+    private Integer menuType;
+
     @ApiModelProperty(value = "就餐人数")
     @TableField("diner_count")
     private Integer dinerCount;
@@ -111,7 +115,7 @@ public class StoreOrder {
     @TableField("pay_amount")
     private BigDecimal payAmount;
 
-    @ApiModelProperty(value = "支付方式(1:微信, 2:支付宝, 3:现金)")
+    @ApiModelProperty(value = "支付方式(1:微信, 2:支付宝, 3:现金 ,4:银行卡)")
     @TableField("pay_type")
     private Integer payType;
 
@@ -200,4 +204,8 @@ public class StoreOrder {
     @ApiModelProperty(value = "修改人ID")
     @TableField("updated_user_id")
     private Integer updatedUserId;
+
+    @ApiModelProperty(value = "收款方式 1.手机支付 2.收银台")
+    @TableField("payment_method")
+    private Integer paymentMethod;
 }

+ 4 - 0
alien-entity/src/main/java/shop/alien/entity/store/StoreOrderDetail.java

@@ -34,6 +34,10 @@ public class StoreOrderDetail {
     @TableField("order_no")
     private String orderNo;
 
+    @ApiModelProperty(value = "行类型:1=美食 store_cuisine.id 2=通用价目 store_price.id(存在 cuisine_id)")
+    @TableField("line_type")
+    private Integer lineType;
+
     @ApiModelProperty(value = "菜品ID")
     @TableField("cuisine_id")
     private Integer cuisineId;

+ 4 - 0
alien-entity/src/main/java/shop/alien/entity/store/StoreProductDiscountRule.java

@@ -26,6 +26,10 @@ public class StoreProductDiscountRule {
 	@TableField("product_id")
 	private Integer productId;
 
+	@ApiModelProperty("1=美食(store_cuisine) 2=通用价目(store_price),默认1")
+	@TableField("rule_product_type")
+	private Integer ruleProductType;
+
 	@ApiModelProperty("规则名称")
 	@TableField("rule_name")
 	private String ruleName;

+ 73 - 0
alien-entity/src/main/java/shop/alien/entity/store/StoreReceiptTemplateConfig.java

@@ -0,0 +1,73 @@
+package shop.alien.entity.store;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.util.Date;
+
+@Data
+@JsonInclude
+@TableName("store_receipt_template_config")
+@ApiModel(value = "StoreReceiptTemplateConfig对象", description = "店铺票据模板配置")
+public class StoreReceiptTemplateConfig {
+
+    @ApiModelProperty(value = "主键ID")
+    @TableId(value = "id", type = IdType.AUTO)
+    private Integer id;
+
+    @ApiModelProperty(value = "门店ID")
+    @TableField("store_id")
+    private Integer storeId;
+
+    @ApiModelProperty(value = "票据类型(1:客单,2:结账单)")
+    @TableField("receipt_type")
+    private Integer receiptType;
+
+    @ApiModelProperty(value = "模板类型(1:默认模板,2:自定义模板)")
+    @TableField("template_type")
+    private Integer templateType;
+
+    @ApiModelProperty(value = "模板名称")
+    @TableField("template_name")
+    private String templateName;
+
+    @ApiModelProperty(value = "是否启用(0:否,1:是)")
+    @TableField("enabled")
+    private Integer enabled;
+
+    @ApiModelProperty(value = "模板配置JSON")
+    @TableField("template_config_json")
+    private String templateConfigJson;
+
+    @ApiModelProperty(value = "删除标记, 0:未删除, 1:已删除")
+    @TableField("delete_flag")
+    @TableLogic
+    private Integer deleteFlag;
+
+    @ApiModelProperty(value = "创建时间")
+    @TableField(value = "created_time", fill = FieldFill.INSERT)
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date createdTime;
+
+    @ApiModelProperty(value = "创建人ID")
+    @TableField("created_user_id")
+    private Integer createdUserId;
+
+    @ApiModelProperty(value = "修改时间")
+    @TableField(value = "updated_time", fill = FieldFill.UPDATE)
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date updatedTime;
+
+    @ApiModelProperty(value = "修改人ID")
+    @TableField("updated_user_id")
+    private Integer updatedUserId;
+}

+ 1 - 1
alien-entity/src/main/java/shop/alien/entity/store/StoreTable.java

@@ -61,7 +61,7 @@ public class StoreTable {
     @TableField("qrcode_url")
     private String qrcodeUrl;
 
-    @ApiModelProperty(value = "APP扫码二维码URL(内容为门店ID+桌号JSON,非微信太阳码)")
+    @ApiModelProperty(value = "APP扫码二维码URL(内容为 storeId+tableId+menuType 的 JSON,非微信太阳码)")
     @TableField("app_qrcode_url")
     private String appQrcodeUrl;
 

+ 38 - 0
alien-entity/src/main/java/shop/alien/entity/store/constants/StoreMenuType.java

@@ -0,0 +1,38 @@
+package shop.alien.entity.store.constants;
+
+/**
+ * 门店侧「菜单维度」:与 {@code store_order.menu_type}、桌台点餐类型等一致。
+ * <p>
+ * 用于 {@code store_cuisine_category.menu_type} 等字段。
+ */
+public final class StoreMenuType {
+
+    private StoreMenuType() {
+    }
+
+    /** 美食 / store_cuisine */
+    public static final int CUISINE = 1;
+    /** 通用价目 / store_price */
+    public static final int GENERIC_PRICE = 2;
+
+    /**
+     * 规范化入参:null 视作美食(兼容旧调用未传类型)。
+     */
+    public static int normalizeOrCuisine(Integer menuType) {
+        if (menuType == null) {
+            return CUISINE;
+        }
+        if (menuType == CUISINE || menuType == GENERIC_PRICE) {
+            return menuType;
+        }
+        throw new IllegalArgumentException("menuType 仅支持 1=美食 2=通用价目,当前值=" + menuType);
+    }
+
+    public static boolean isCuisine(Integer menuType) {
+        return menuType == null || menuType == CUISINE;
+    }
+
+    public static boolean isGenericPrice(Integer menuType) {
+        return menuType != null && menuType == GENERIC_PRICE;
+    }
+}

+ 7 - 4
alien-entity/src/main/java/shop/alien/entity/store/dto/CartItemDTO.java

@@ -28,15 +28,18 @@ public class CartItemDTO {
     @ApiModelProperty(value = "菜品图片")
     private String cuisineImage;
 
-    @ApiModelProperty(value = "单价(与现价一致,入库/展示均为成交用单价)")
+    @ApiModelProperty(value = "单价(与现价单价一致;计价逻辑以 currentUnitPrice 为准)")
     private BigDecimal unitPrice;
 
-    @ApiModelProperty(value = "原价单价(门店标价 store_cuisine.total_price,随规则对比展示)")
+    @ApiModelProperty(value = "原价单价(门店标价,对应菜单「原价」)")
     private BigDecimal originalUnitPrice;
 
-    @ApiModelProperty(value = "现价单价(命中 store_product_discount_rule 后与原价可能不同;无优惠时等于原价)")
+    @ApiModelProperty(value = "现价单价(含菜品优惠规则后的成交价,对应菜单「现价」;无优惠时等于原价)")
     private BigDecimal currentUnitPrice;
 
+    @ApiModelProperty(value = "原价小计(原价单价×数量,用于划线展示)")
+    private BigDecimal originalSubtotalAmount;
+
     @ApiModelProperty(value = "当前是否有生效中的菜品优惠")
     private Boolean hasActiveDiscount;
 
@@ -46,7 +49,7 @@ public class CartItemDTO {
     @ApiModelProperty(value = "已下单数量(下单时锁定的数量,不允许减少或删除)")
     private Integer lockedQuantity;
 
-    @ApiModelProperty(value = "小计金额")
+    @ApiModelProperty(value = "现价小计(现价单价×数量,与结算小计一致)")
     private BigDecimal subtotalAmount;
 
     @ApiModelProperty(value = "添加该菜品的用户ID")

+ 3 - 0
alien-entity/src/main/java/shop/alien/entity/store/dto/StoreBookingBusinessHoursDTO.java

@@ -42,6 +42,9 @@ public class StoreBookingBusinessHoursDTO {
     @ApiModelProperty(value = "结束时间(HH:mm格式,非全天时必填)")
     private String endTime;
 
+    @ApiModelProperty(value = "营业时间(多段用英文分号;分隔,最长500字符)")
+    private String businessHoursStr;
+
     @ApiModelProperty(value = "排序(用于同一门店多条记录的排序)")
     private Integer sort;
 

+ 3 - 0
alien-entity/src/main/java/shop/alien/entity/store/dto/StoreCuisineCategoryDTO.java

@@ -29,4 +29,7 @@ public class StoreCuisineCategoryDTO {
     @ApiModelProperty(value = "分类名称列表", notes = "批量创建分类时必填,多个分类名称用英文逗号分隔,如:热菜,水果,甜品")
     private String categoryNames;
 
+    @ApiModelProperty(value = "分类归属", notes = "1=美食 2=通用价目,默认 1;批量创建时写入各新行")
+    private Integer menuType;
+
 }

+ 3 - 0
alien-entity/src/main/java/shop/alien/entity/store/dto/StoreProductDiscountRuleSaveDto.java

@@ -19,6 +19,9 @@ public class StoreProductDiscountRuleSaveDto {
 	@ApiModelProperty(value = "菜品ID", required = true)
 	private Integer productId;
 
+	@ApiModelProperty(value = "规则商品类型: 1=美食(store_cuisine) 2=通用价目(store_price),默认1")
+	private Integer ruleProductType;
+
 	@ApiModelProperty(value = "规则名称", required = true)
 	private String ruleName;
 

+ 19 - 0
alien-entity/src/main/java/shop/alien/entity/store/dto/StoreReceiptTemplateCreateDTO.java

@@ -0,0 +1,19 @@
+package shop.alien.entity.store.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+@Data
+@ApiModel(value = "StoreReceiptTemplateCreateDTO对象", description = "创建票据模板参数")
+public class StoreReceiptTemplateCreateDTO {
+
+    @ApiModelProperty(value = "门店ID", required = true)
+    private Integer storeId;
+
+    @ApiModelProperty(value = "票据类型(1:客单,2:结账单)", required = true)
+    private Integer receiptType;
+
+    @ApiModelProperty(value = "模板名称", required = true)
+    private String templateName;
+}

+ 16 - 0
alien-entity/src/main/java/shop/alien/entity/store/dto/StoreReceiptTemplateDeleteDTO.java

@@ -0,0 +1,16 @@
+package shop.alien.entity.store.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+@Data
+@ApiModel(value = "StoreReceiptTemplateDeleteDTO对象", description = "按ID删除票据模板参数")
+public class StoreReceiptTemplateDeleteDTO {
+
+    @ApiModelProperty(value = "模板ID", required = true)
+    private Integer id;
+
+    @ApiModelProperty(value = "门店ID(可选,用于所有权校验)")
+    private Integer storeId;
+}

+ 31 - 0
alien-entity/src/main/java/shop/alien/entity/store/dto/StoreReceiptTemplateEditDTO.java

@@ -0,0 +1,31 @@
+package shop.alien.entity.store.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+@Data
+@ApiModel(value = "StoreReceiptTemplateEditDTO对象", description = "按ID编辑票据模板参数")
+public class StoreReceiptTemplateEditDTO {
+
+    @ApiModelProperty(value = "模板ID", required = true)
+    private Integer id;
+
+    @ApiModelProperty(value = "门店ID", required = true)
+    private Integer storeId;
+
+    @ApiModelProperty(value = "票据类型(1:客单,2:结账单)", required = true)
+    private Integer receiptType;
+
+    @ApiModelProperty(value = "模板类型(1:默认模板,2:自定义模板)", required = true)
+    private Integer templateType;
+
+    @ApiModelProperty(value = "模板名称")
+    private String templateName;
+
+    @ApiModelProperty(value = "是否启用(0:否,1:是)")
+    private Integer enabled;
+
+    @ApiModelProperty(value = "模板配置JSON", required = true)
+    private String templateConfigJson;
+}

+ 19 - 0
alien-entity/src/main/java/shop/alien/entity/store/dto/StoreReceiptTemplateResetDTO.java

@@ -0,0 +1,19 @@
+package shop.alien.entity.store.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+@Data
+@ApiModel(value = "StoreReceiptTemplateResetDTO对象", description = "恢复票据模板默认参数")
+public class StoreReceiptTemplateResetDTO {
+
+    @ApiModelProperty(value = "门店ID", required = true)
+    private Integer storeId;
+
+    @ApiModelProperty(value = "票据类型(1:客单,2:结账单)", required = true)
+    private Integer receiptType;
+
+    @ApiModelProperty(value = "模板类型(1:默认模板,2:自定义模板)", required = true)
+    private Integer templateType;
+}

+ 28 - 0
alien-entity/src/main/java/shop/alien/entity/store/dto/StoreReceiptTemplateSaveDTO.java

@@ -0,0 +1,28 @@
+package shop.alien.entity.store.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+@Data
+@ApiModel(value = "StoreReceiptTemplateSaveDTO对象", description = "保存票据模板配置参数")
+public class StoreReceiptTemplateSaveDTO {
+
+    @ApiModelProperty(value = "门店ID", required = true)
+    private Integer storeId;
+
+    @ApiModelProperty(value = "票据类型(1:客单,2:结账单)", required = true)
+    private Integer receiptType;
+
+    @ApiModelProperty(value = "模板类型(1:默认模板,2:自定义模板)", required = true)
+    private Integer templateType;
+
+    @ApiModelProperty(value = "模板名称")
+    private String templateName;
+
+    @ApiModelProperty(value = "是否启用(0:否,1:是)")
+    private Integer enabled;
+
+    @ApiModelProperty(value = "模板配置JSON", required = true)
+    private String templateConfigJson;
+}

+ 35 - 0
alien-entity/src/main/java/shop/alien/entity/store/vo/CategoryMenuGroupVO.java

@@ -0,0 +1,35 @@
+package shop.alien.entity.store.vo;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import shop.alien.entity.store.StoreCuisineCategory;
+
+import java.util.List;
+
+/**
+ * 门店分类树统一项:由 {@code GET /store/info/categories-with-cuisines} 的 {@code menuType} 决定填充哪一侧。
+ * <p>
+ * {@code menuType=1}(默认)仅填充 {@link #cuisines};{@code menuType=2} 仅填充 {@link #prices}。
+ * 空列表字段默认不参与序列化,便于与历史仅含 category+cuisines 的响应对齐。
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@ApiModel(value = "CategoryMenuGroupVO", description = "分类 + 美食菜品或通用价目项(由 menuType 决定哪侧有数据)")
+public class CategoryMenuGroupVO {
+
+    @ApiModelProperty(value = "分类信息(menu_type 与请求的 menuType 一致)")
+    private StoreCuisineCategory category;
+
+    @JsonInclude(JsonInclude.Include.NON_EMPTY)
+    @ApiModelProperty(value = "美食子项(menuType=1)")
+    private List<StoreCuisineWithPricesVO> cuisines;
+
+    @JsonInclude(JsonInclude.Include.NON_EMPTY)
+    @ApiModelProperty(value = "通用价目子项(menuType=2)")
+    private List<StorePriceWithPricesVO> prices;
+}

+ 2 - 6
alien-entity/src/main/java/shop/alien/entity/store/vo/CategoryWithCuisinesVO.java

@@ -10,12 +10,8 @@ import shop.alien.entity.store.StoreCuisineCategory;
 import java.util.List;
 
 /**
- * 菜品种类及其下属菜品 VO(通过门店ID查询时返回)
- * <p>
- * 与单独接口的返回结构一致:
- * - category 与 GET /store/info/categories 中每个元素的字段完全一致(StoreCuisineCategory)
- * - cuisines 在 StoreCuisine 字段基础上增加 originalPrice、currentPrice、hasActiveDiscount(与菜品优惠规则展示一致)
- * </p>
+ * 历史结构:仅美食分类 + 菜品列表。
+ * 新接口统一返回 {@link CategoryMenuGroupVO},通过 query {@code menuType} 区分美食/通用;此类仍保留供兼容与文档参照。
  *
  * @author system
  */

+ 6 - 0
alien-entity/src/main/java/shop/alien/entity/store/vo/OrderInfoVO.java

@@ -46,9 +46,15 @@ public class OrderInfoVO {
     @ApiModelProperty(value = "营业状态(-1:注销中, 0:正常营业, 1:暂停营业, 2:筹建中, 99:永久关门)")
     private Integer businessStatus;
 
+    @ApiModelProperty(value = "桌台主键 store_table.id(可与桌号展示文案对照)")
+    private Integer tableId;
+
     @ApiModelProperty(value = "桌号")
     private String tableNumber;
 
+    @ApiModelProperty(value = "菜单类型:1=美食(store_cuisine) 2=通用价目(store_price),与点餐下单时一致")
+    private Integer menuType;
+
     @ApiModelProperty(value = "关联用户预约ID(user_reservation.id)")
     private Integer userReservationId;
 

+ 27 - 0
alien-entity/src/main/java/shop/alien/entity/store/vo/StorePriceWithPricesVO.java

@@ -0,0 +1,27 @@
+package shop.alien.entity.store.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import shop.alien.entity.store.StorePrice;
+
+import java.math.BigDecimal;
+
+/**
+ * 通用价目表行 + 展示用原价/现价(与 {@link shop.alien.entity.store.StoreProductDiscountRule} 且 {@code rule_product_type=2} 命中逻辑对齐)
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ApiModel(value = "StorePriceWithPricesVO", description = "通用价目项(含原价、现价);其它字段与 StorePrice 一致")
+public class StorePriceWithPricesVO extends StorePrice {
+
+    @ApiModelProperty(value = "原价(门店标价,与 totalPrice 一致)")
+    private BigDecimal originalPrice;
+
+    @ApiModelProperty(value = "现价(按通用价目优惠规则当前时刻命中后的价格;无优惠时等于原价)")
+    private BigDecimal currentPrice;
+
+    @ApiModelProperty(value = "当前是否存在生效中的价目优惠")
+    private Boolean hasActiveDiscount;
+}

+ 28 - 0
alien-entity/src/main/java/shop/alien/entity/store/vo/TurnoverBreakdownItemVO.java

@@ -0,0 +1,28 @@
+package shop.alien.entity.store.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+@Data
+@ApiModel(value = "TurnoverBreakdownItemVO", description = "营业额明细-分项占比")
+public class TurnoverBreakdownItemVO {
+
+	@ApiModelProperty("分类编码:payType 或 paymentMethod")
+	private Integer code;
+
+	@ApiModelProperty("分类名称")
+	private String name;
+
+	@ApiModelProperty("成交金额")
+	private BigDecimal amount;
+
+	@ApiModelProperty("交易笔数")
+	private Integer count;
+
+	@ApiModelProperty("占比(0-100,保留两位小数)")
+	private BigDecimal percent;
+}
+

+ 19 - 0
alien-entity/src/main/java/shop/alien/entity/store/vo/TurnoverDetailByDateVO.java

@@ -0,0 +1,19 @@
+package shop.alien.entity.store.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+@ApiModel(value = "TurnoverDetailByDateVO", description = "营业额对账-按日期分组明细")
+public class TurnoverDetailByDateVO {
+
+	@ApiModelProperty("日期(yyyy-MM-dd)")
+	private String date;
+
+	@ApiModelProperty("该日期下的明细列表")
+	private List<TurnoverDetailItemVO> items;
+}
+

+ 37 - 0
alien-entity/src/main/java/shop/alien/entity/store/vo/TurnoverDetailItemVO.java

@@ -0,0 +1,37 @@
+package shop.alien.entity.store.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@ApiModel(value = "TurnoverDetailItemVO", description = "营业额对账-明细记录")
+public class TurnoverDetailItemVO {
+
+	@ApiModelProperty("订单号")
+	private String orderNo;
+
+	@ApiModelProperty("实付金额")
+	private BigDecimal payAmount;
+
+	@ApiModelProperty("支付时间")
+	@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+	private Date payTime;
+
+	@ApiModelProperty("支付方式(1:微信, 2:支付宝, 3:现金 ,4:银行卡)")
+	private Integer payType;
+
+	@ApiModelProperty("支付方式名称")
+	private String payTypeName;
+
+	@ApiModelProperty("收款方式 1.手机支付 2.收银台")
+	private Integer paymentMethod;
+
+	@ApiModelProperty("收款方式名称")
+	private String paymentMethodName;
+}
+

+ 29 - 0
alien-entity/src/main/java/shop/alien/entity/store/vo/TurnoverSummaryVO.java

@@ -0,0 +1,29 @@
+package shop.alien.entity.store.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+@Data
+@ApiModel(value = "TurnoverSummaryVO", description = "营业额对账-汇总数据")
+public class TurnoverSummaryVO {
+
+    @ApiModelProperty("总销售额")
+    private BigDecimal totalSalesAmount;
+
+    @ApiModelProperty("交易笔数")
+    private Integer totalOrderCount;
+
+    @ApiModelProperty("平均客单价(总销售额/交易笔数)")
+    private BigDecimal averageOrderAmount;
+
+    @ApiModelProperty("按支付方式分布:微信/支付宝/银行卡/现金")
+    private List<TurnoverBreakdownItemVO> payTypeBreakdown;
+
+    @ApiModelProperty("按收款方式分布:手机支付/收银台")
+    private List<TurnoverBreakdownItemVO> receiveMethodBreakdown;
+}
+

+ 7 - 0
alien-entity/src/main/java/shop/alien/mapper/StoreReceiptTemplateConfigMapper.java

@@ -0,0 +1,7 @@
+package shop.alien.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import shop.alien.entity.store.StoreReceiptTemplateConfig;
+
+public interface StoreReceiptTemplateConfigMapper extends BaseMapper<StoreReceiptTemplateConfig> {
+}

+ 5 - 7
alien-entity/src/main/java/shop/alien/mapper/UserReservationMapper.java

@@ -53,11 +53,10 @@ public interface UserReservationMapper extends BaseMapper<UserReservation> {
     List<Integer> listReservationIdsForTimeoutMark();
 
     /**
-     * 查询分类下是否有符合条件的预订信息
-     * 条件:订单状态为"待使用"(1)或"已完成"(2)
-     * 对于"已完成"状态的订单,结束时间需要在当前时间3小时内
+     * 查询分类下是否有符合条件的预订信息(仅 user_reservation,不查订单表;规则同桌位 hasReservation)
+     * 待确认(0)、已确认(1) 视为有预订;已到店(2) 仅当当前时间不超过「预约结束时间 + 3 小时」时视为有预订(无结束时间则视为有预订)
      *
-     * @param categoryId 分类ID
+     * @param categoryId 分类ID(ur.category_id)
      * @param storeId    门店ID
      * @return 符合条件的预订数量
      */
@@ -67,9 +66,8 @@ public interface UserReservationMapper extends BaseMapper<UserReservation> {
     );
 
     /**
-     * 查询桌号下是否有符合条件的预订信息
-     * 条件:订单状态为"待使用"(1)或"已完成"(2)
-     * 对于"已完成"状态的订单,结束时间需要在当前时间3小时内
+     * 查询桌号下是否有符合条件的预订信息(主表仅 user_reservation;桌位用 EXISTS 匹配 user_reservation_table,不用 JOIN;不查订单表)
+     * 待确认(0)、已确认(1) 视为有预订;已到店(2) 仅当当前时间不超过「预约结束时间 + 3 小时」时视为有预订(无结束时间则视为有预订)
      *
      * @param tableId 桌号ID
      * @param storeId 门店ID

+ 3 - 0
alien-entity/src/main/resources/db/migration/store_cuisine_category_menu_type.sql

@@ -0,0 +1,3 @@
+-- 菜品/价目分类表:区分美食分类与通用价目分类(与订单 menu_type 语义一致)
+ALTER TABLE `store_cuisine_category`
+    ADD COLUMN `menu_type` TINYINT NOT NULL DEFAULT 1 COMMENT '分类归属:1=美食(store_cuisine) 2=通用价目(store_price)' AFTER `store_id`;

+ 1 - 1
alien-entity/src/main/resources/db/migration/store_table_app_qrcode_url.sql

@@ -1,3 +1,3 @@
 -- APP 桌码二维码 URL(与微信小程序码并列存储)
 ALTER TABLE `store_table`
-    ADD COLUMN `app_qrcode_url` VARCHAR(512) DEFAULT NULL COMMENT 'APP桌码二维码URL(JSON: storeId + tableNumber)' AFTER `qrcode_url`;
+    ADD COLUMN `app_qrcode_url` VARCHAR(512) DEFAULT NULL COMMENT 'APP桌码二维码URL(JSON: storeId + tableId + menuType)' AFTER `qrcode_url`;

+ 2 - 1
alien-entity/src/main/resources/mapper/StoreBookingBusinessHoursMapper.xml

@@ -13,6 +13,7 @@
         <result column="booking_time_type" property="bookingTimeType" />
         <result column="start_time" property="startTime" />
         <result column="end_time" property="endTime" />
+        <result column="business_hours_str" property="businessHoursStr" />
         <result column="sort" property="sort" />
         <result column="delete_flag" property="deleteFlag" />
         <result column="created_time" property="createdTime" />
@@ -27,7 +28,7 @@
     <!-- 通用查询结果列 -->
     <sql id="Base_Column_List">
         id, store_id, settings_id, business_type, holiday_type, holiday_date, 
-        booking_time_type, start_time, end_time, sort, 
+        booking_time_type, start_time, end_time, business_hours_str, sort, 
         delete_flag, created_time, created_user_id, updated_time, updated_user_id,
         essential_id, reservation, reservation_money
     </sql>

+ 1 - 0
alien-entity/src/main/resources/mapper/StoreReservationMapper.xml

@@ -101,6 +101,7 @@
         LEFT JOIN user_reservation_order uro ON ur.id = uro.reservation_id AND uro.delete_flag = 0
         WHERE
             ur.delete_flag = 0
+            AND ur.type = 0
             AND ur.store_id = #{storeId}
         <if test="status != null">
             AND ur.status = #{status}

+ 70 - 63
alien-entity/src/main/resources/mapper/UserReservationMapper.xml

@@ -244,86 +244,93 @@
           )
     </select>
 
-    <!-- 查询分类下是否有符合条件的预订信息
-         条件:订单状态为"待使用"(1)或"已完成"(2)
-         对于"已完成"状态的订单,要求 当前时间 < 结束时间 + 3小时 -->
+    <!-- 分类 hasReservation:仅 user_reservation,不联查 user_reservation_order;0/1 有预订;2 在结束+3h 内 -->
     <select id="countReservationsByCategoryAndOrderStatus" resultType="java.lang.Long">
-        SELECT COUNT(DISTINCT ur.id)
+        SELECT COUNT(ur.id)
         FROM user_reservation ur
-        INNER JOIN user_reservation_order uro ON ur.id = uro.reservation_id AND uro.delete_flag = 0
         WHERE ur.delete_flag = 0
           AND ur.category_id = #{categoryId}
           AND ur.store_id = #{storeId}
-          AND uro.order_status IN (1, 2)
           AND (
-            -- 订单状态为"待使用"(1),直接符合条件
-            uro.order_status = 1
-            OR
-            -- 订单状态为"已完成"(2),需要检查 当前时间 小于 订单结束时间+ 3小时
-            (
-              uro.order_status = 2
-              AND ur.end_time IS NOT NULL
-              AND TRIM(ur.end_time) != ''
-              AND (
-                -- 如果 end_time 是完整日期时间格式 (yyyy-MM-dd HH:mm)
-                (
-                  LENGTH(TRIM(ur.end_time)) &gt; 5
-                  AND STR_TO_DATE(TRIM(ur.end_time), '%Y-%m-%d %H:%i') IS NOT NULL
-                  AND DATE_ADD(STR_TO_DATE(TRIM(ur.end_time), '%Y-%m-%d %H:%i'), INTERVAL 3 HOUR) &gt; NOW()
-                )
-                OR
-                -- 如果 end_time 是时间格式 (HH:mm),需要结合 reservation_date
-                (
-                  LENGTH(TRIM(ur.end_time)) &lt;= 5
-                  AND ur.reservation_date IS NOT NULL
-                  AND STR_TO_DATE(CONCAT(DATE_FORMAT(ur.reservation_date, '%Y-%m-%d'), ' ', TRIM(ur.end_time)), '%Y-%m-%d %H:%i') IS NOT NULL
-                  AND DATE_ADD(STR_TO_DATE(CONCAT(DATE_FORMAT(ur.reservation_date, '%Y-%m-%d'), ' ', TRIM(ur.end_time)), '%Y-%m-%d %H:%i'), INTERVAL 3 HOUR) &gt; NOW()
-                )
+              ur.status IN (0, 1)
+              OR (
+                  ur.status = 2
+                  AND (
+                      ur.end_time IS NULL
+                      OR TRIM(ur.end_time) = ''
+                      OR
+                      (
+                          (
+                              LENGTH(TRIM(ur.end_time)) &gt; 5
+                              AND STR_TO_DATE(TRIM(ur.end_time), '%Y-%m-%d %H:%i') IS NOT NULL
+                              AND NOW() &lt;= DATE_ADD(STR_TO_DATE(TRIM(ur.end_time), '%Y-%m-%d %H:%i'), INTERVAL 3 HOUR)
+                          )
+                          OR
+                          (
+                              LENGTH(TRIM(ur.end_time)) &lt;= 5
+                              AND ur.reservation_date IS NOT NULL
+                              AND STR_TO_DATE(
+                                  CONCAT(DATE_FORMAT(ur.reservation_date, '%Y-%m-%d'), ' ', TRIM(ur.end_time)),
+                                  '%Y-%m-%d %H:%i'
+                              ) IS NOT NULL
+                              AND NOW() &lt;= DATE_ADD(
+                                  STR_TO_DATE(
+                                      CONCAT(DATE_FORMAT(ur.reservation_date, '%Y-%m-%d'), ' ', TRIM(ur.end_time)),
+                                      '%Y-%m-%d %H:%i'
+                                  ),
+                                  INTERVAL 3 HOUR
+                              )
+                          )
+                      )
+                  )
               )
-            )
           )
     </select>
 
-    <!-- 查询桌号下是否有符合条件的预订信息
-         条件:订单状态为"待使用"(1)或"已完成"(2)
-         对于"已完成"状态的订单,结束时间需要在当前时间3小时内 -->
+    <!-- 桌位 hasReservation:联查 user_reservation_table;不查 user_reservation_order
+         0/1 有预订;2 有预订当 NOW() &lt;= 解析后的 end_time + 3 小时(无 end 则算有预订) -->
     <select id="countReservationsByTableAndOrderStatus" resultType="java.lang.Long">
         SELECT COUNT(DISTINCT ur.id)
         FROM user_reservation ur
-        INNER JOIN user_reservation_table urt ON ur.id = urt.reservation_id AND urt.delete_flag = 0
-        INNER JOIN user_reservation_order uro ON ur.id = uro.reservation_id AND uro.delete_flag = 0
+        INNER JOIN user_reservation_table urt
+            ON urt.reservation_id = ur.id
+           AND urt.delete_flag = 0
+           AND urt.table_id = #{tableId}
         WHERE ur.delete_flag = 0
-          AND urt.table_id = #{tableId}
           AND ur.store_id = #{storeId}
-          AND uro.order_status IN (1, 2)
           AND (
-            -- 订单状态为"待使用"(1),直接符合条件
-            uro.order_status = 1
-            OR
-            -- 订单状态为"已完成"(2),需要检查结束时间在3小时内
-            (
-              uro.order_status = 2
-              AND ur.end_time IS NOT NULL
-              AND TRIM(ur.end_time) != ''
-              AND (
-                -- 如果 end_time 是完整日期时间格式 (yyyy-MM-dd HH:mm)
-                (
-                  LENGTH(TRIM(ur.end_time)) &gt; 5
-                  AND STR_TO_DATE(TRIM(ur.end_time), '%Y-%m-%d %H:%i') IS NOT NULL
-                  AND STR_TO_DATE(TRIM(ur.end_time), '%Y-%m-%d %H:%i') &lt; NOW()
-                  AND STR_TO_DATE(TRIM(ur.end_time), '%Y-%m-%d %H:%i') &gt; DATE_SUB(NOW(), INTERVAL 3 HOUR)
-                )
-                OR
-                -- 如果 end_time 是时间格式 (HH:mm),需要结合 reservation_date
-                (
-                  LENGTH(TRIM(ur.end_time)) &lt;= 5
-                  AND ur.reservation_date IS NOT NULL
-                  AND STR_TO_DATE(CONCAT(DATE_FORMAT(ur.reservation_date, '%Y-%m-%d'), ' ', TRIM(ur.end_time)), '%Y-%m-%d %H:%i') IS NOT NULL
-                  AND STR_TO_DATE(CONCAT(DATE_FORMAT(ur.reservation_date, '%Y-%m-%d'), ' ', TRIM(ur.end_time)), '%Y-%m-%d %H:%i') &lt; NOW()
-                  AND STR_TO_DATE(CONCAT(DATE_FORMAT(ur.reservation_date, '%Y-%m-%d'), ' ', TRIM(ur.end_time)), '%Y-%m-%d %H:%i') &gt; DATE_SUB(NOW(), INTERVAL 3 HOUR)
-                )
+              ur.status IN (0, 1)
+              OR (
+                  ur.status = 2
+                  AND (
+                      ur.end_time IS NULL
+                      OR TRIM(ur.end_time) = ''
+                      OR
+                      (
+                          (
+                              LENGTH(TRIM(ur.end_time)) &gt; 5
+                              AND STR_TO_DATE(TRIM(ur.end_time), '%Y-%m-%d %H:%i') IS NOT NULL
+                              AND NOW() &lt;= DATE_ADD(STR_TO_DATE(TRIM(ur.end_time), '%Y-%m-%d %H:%i'), INTERVAL 3 HOUR)
+                          )
+                          OR
+                          (
+                              LENGTH(TRIM(ur.end_time)) &lt;= 5
+                              AND ur.reservation_date IS NOT NULL
+                              AND STR_TO_DATE(
+                                  CONCAT(DATE_FORMAT(ur.reservation_date, '%Y-%m-%d'), ' ', TRIM(ur.end_time)),
+                                  '%Y-%m-%d %H:%i'
+                              ) IS NOT NULL
+                              AND NOW() &lt;= DATE_ADD(
+                                  STR_TO_DATE(
+                                      CONCAT(DATE_FORMAT(ur.reservation_date, '%Y-%m-%d'), ' ', TRIM(ur.end_time)),
+                                      '%Y-%m-%d %H:%i'
+                                  ),
+                                  INTERVAL 3 HOUR
+                              )
+                          )
+                      )
+                  )
               )
-            )
           )
     </select>
 

+ 26 - 0
alien-store/pom.xml

@@ -42,6 +42,32 @@
             <artifactId>spring-boot-starter-jdbc</artifactId>
         </dependency>
 
+        <!-- 唯一需要的打印依赖 -->
+        <dependency>
+            <groupId>com.github.anastaciocintra</groupId>
+            <artifactId>escpos-coffee</artifactId>
+            <version>4.1.0</version>
+        </dependency>
+
+
+            <!-- ESC/POS 小票打印 -->
+            <dependency>
+                <groupId>com.github.anastaciocintra</groupId>
+                <artifactId>escpos-coffee</artifactId>
+                <version>4.1.0</version>
+            </dependency>
+
+            <!-- USB/串口通信 -->
+            <dependency>
+                <groupId>com.fazecast</groupId>
+                <artifactId>jSerialComm</artifactId>
+                <version>2.9.2</version>
+            </dependency>
+
+
+
+
+
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-data-redis</artifactId>

+ 8 - 7
alien-store/src/main/java/shop/alien/store/controller/DiningServiceController.java

@@ -326,18 +326,19 @@ public class DiningServiceController {
         }
     }
 
-    @ApiOperation(value = "根据门店ID查询菜品种类及各类别下菜品", notes = "一次返回所有菜品种类及每个分类下的菜品列表;可选 keyword 按菜品名称模糊查询")
+    @ApiOperation(value = "门店分类树(美食或通用价目)", notes = "与 alien-dining 同名接口;menuType=1(默认)cuisines;menuType=2 prices;可选 keyword")
     @ApiOperationSupport(order = 11)
     @GetMapping("/store/info/categories-with-cuisines")
-    public R<List<CategoryWithCuisinesVO>> getCategoriesWithCuisinesByStoreId(
+    public R<List<CategoryMenuGroupVO>> getCategoriesWithMenuGroupsByStoreId(
             @ApiParam(value = "门店ID", required = true) @RequestParam Integer storeId,
-            @ApiParam(value = "菜品名称模糊查询关键词(可选)") @RequestParam(required = false) String keyword) {
+            @ApiParam(value = "名称模糊查询关键词(可选)") @RequestParam(required = false) String keyword,
+            @ApiParam(value = "1=美食 2=通用价目,默认 1") @RequestParam(required = false) Integer menuType) {
         try {
-            log.info("根据门店ID查询菜品种类及菜品: storeId={}, keyword={}", storeId, keyword);
-            return diningServiceFeign.getCategoriesWithCuisinesByStoreId(storeId, keyword);
+            log.info("门店分类树: storeId={}, keyword={}, menuType={}", storeId, keyword, menuType);
+            return diningServiceFeign.getCategoriesWithMenuGroupsByStoreId(storeId, keyword, menuType);
         } catch (Exception e) {
-            log.error("查询菜品种类及菜品失败: {}", e.getMessage(), e);
-            return R.fail("查询菜品种类及菜品失败: " + e.getMessage());
+            log.error("查询门店分类树失败: {}", e.getMessage(), e);
+            return R.fail("查询门店分类树失败: " + e.getMessage());
         }
     }
 

+ 2 - 0
alien-store/src/main/java/shop/alien/store/controller/StoreBookingBusinessHoursController.java

@@ -129,6 +129,7 @@ public class StoreBookingBusinessHoursController {
             businessHours.setBookingTimeType(dto.getBookingTimeType());
             businessHours.setStartTime(dto.getStartTime());
             businessHours.setEndTime(dto.getEndTime());
+            businessHours.setBusinessHoursStr(dto.getBusinessHoursStr());
             businessHours.setSort(dto.getSort());
             
             boolean result = storeBookingBusinessHoursService.saveOrUpdateBusinessHours(businessHours);
@@ -177,6 +178,7 @@ public class StoreBookingBusinessHoursController {
                         businessHours.setBookingTimeType(dto.getBookingTimeType());
                         businessHours.setStartTime(dto.getStartTime());
                         businessHours.setEndTime(dto.getEndTime());
+                        businessHours.setBusinessHoursStr(dto.getBusinessHoursStr());
                         businessHours.setSort(dto.getSort());
                         return businessHours;
                     })

+ 1 - 1
alien-store/src/main/java/shop/alien/store/controller/StoreBookingCategoryController.java

@@ -238,7 +238,7 @@ public class StoreBookingCategoryController {
     }
 
     @ApiOperationSupport(order = 8)
-    @ApiOperation("查询分类下是否有预订信息")
+    @ApiOperation("查询分类下是否有预订信息(0/1 有预订;已到店(2) 在结束时间+3h 内;不依赖订单表)")
     @ApiImplicitParams({
             @ApiImplicitParam(name = "categoryId", value = "分类ID", dataType = "Integer", paramType = "query", required = true),
             @ApiImplicitParam(name = "storeId", value = "门店ID", dataType = "Integer", paramType = "query", required = true)

+ 1 - 43
alien-store/src/main/java/shop/alien/store/controller/StoreBookingSettingsController.java

@@ -89,49 +89,7 @@ public class StoreBookingSettingsController {
         if (dto.getBookingNotAvailableTime() == null || dto.getBookingNotAvailableTime() < 0) {
             return R.fail("营业时间结束前不可预订时间必须大于等于0");
         }
-        
-        // 如果选择非全天,必须填写开始时间和结束时间
-//        if (dto.getBookingTimeType() != null && dto.getBookingTimeType() == 0) {
-//            if (!StringUtils.hasText(dto.getBookingStartTime())) {
-//                return R.fail("非全天时必须填写开始时间");
-//            }
-//            if (!StringUtils.hasText(dto.getBookingEndTime())) {
-//                return R.fail("非全天时必须填写结束时间");
-//            }
-//        }
-        
-        // 验证正常营业时间
-//        if (dto.getNormalBusinessHours() != null) {
-//            StoreBookingBusinessHoursDTO normalHours = dto.getNormalBusinessHours();
-//            if (normalHours.getBookingTimeType() == null) {
-//                return R.fail("正常营业时间的预订时间类型不能为空");
-//            }
-//            if (normalHours.getBookingTimeType() == 0) {
-//                if (!StringUtils.hasText(normalHours.getStartTime())) {
-//                    return R.fail("正常营业时间非全天时必须填写开始时间");
-//                }
-//                if (!StringUtils.hasText(normalHours.getEndTime())) {
-//                    return R.fail("正常营业时间非全天时必须填写结束时间");
-//                }
-//            }
-//        }
-        
-        // 验证特殊营业时间列表
-//        if (dto.getSpecialBusinessHoursList() != null && !dto.getSpecialBusinessHoursList().isEmpty()) {
-//            for (StoreBookingBusinessHoursDTO specialHours : dto.getSpecialBusinessHoursList()) {
-//                if (specialHours.getBookingTimeType() == null) {
-//                    return R.fail("特殊营业时间的预订时间类型不能为空");
-//                }
-//                if (specialHours.getBookingTimeType() == 0) {
-//                    if (!StringUtils.hasText(specialHours.getStartTime())) {
-//                        return R.fail("特殊营业时间非全天时必须填写开始时间");
-//                    }
-//                    if (!StringUtils.hasText(specialHours.getEndTime())) {
-//                        return R.fail("特殊营业时间非全天时必须填写结束时间");
-//                    }
-//                }
-//            }
-//        }
+
         
         // 编辑校验:如果是编辑操作,检查正常营业时间的id是否有值
         // 注意:特殊营业时间列表中的项可以部分有id(编辑)部分没有id(新增)

+ 1 - 1
alien-store/src/main/java/shop/alien/store/controller/StoreBookingTableController.java

@@ -229,7 +229,7 @@ public class StoreBookingTableController {
     }
 
     @ApiOperationSupport(order = 6)
-    @ApiOperation("查询桌号下是否有预订信息")
+    @ApiOperation("查询桌号下是否有预订信息(0/1 有预订;已到店(2) 在结束时间+3h 内算有预订;主查 user_reservation)")
     @ApiImplicitParams({
             @ApiImplicitParam(name = "tableId", value = "桌号ID", dataType = "Integer", paramType = "query", required = true),
             @ApiImplicitParam(name = "storeId", value = "门店ID", dataType = "Integer", paramType = "query", required = true)

+ 10 - 6
alien-store/src/main/java/shop/alien/store/controller/StoreCuisineCategoryController.java

@@ -33,14 +33,17 @@ public class StoreCuisineCategoryController {
     private final StoreCuisineCategoryService storeCuisineCategoryService;
 
     @ApiOperationSupport(order = 1)
-    @ApiOperation("查询菜品分类列表")
+    @ApiOperation("查询菜品分类列表(可按 menuType 区分美食 / 通用价目分类)")
     @ApiImplicitParams({
-            @ApiImplicitParam(name = "storeId", value = "门店ID", dataType = "Integer", paramType = "query", required = true)
+            @ApiImplicitParam(name = "storeId", value = "门店ID", dataType = "Integer", paramType = "query", required = true),
+            @ApiImplicitParam(name = "menuType", value = "可选,1=美食 2=通用价目,默认 1", dataType = "Integer", paramType = "query", required = false)
     })
     @GetMapping("/getCategoryList")
-    public R<List<StoreCuisineCategory>> getCategoryList(@RequestParam Integer storeId) {
-        log.info("StoreCuisineCategoryController.getCategoryList?storeId={}", storeId);
-        return R.data(storeCuisineCategoryService.getCategoryList(storeId));
+    public R<List<StoreCuisineCategory>> getCategoryList(
+            @RequestParam Integer storeId,
+            @RequestParam(required = false) Integer menuType) {
+        log.info("StoreCuisineCategoryController.getCategoryList?storeId={}&menuType={}", storeId, menuType);
+        return R.data(storeCuisineCategoryService.getCategoryList(storeId, menuType));
     }
 
     @ApiOperationSupport(order = 3)
@@ -65,7 +68,8 @@ public class StoreCuisineCategoryController {
         }
 
         try {
-            boolean result = storeCuisineCategoryService.batchCreateCategories(dto.getStoreId(), categoryNameList);
+            boolean result = storeCuisineCategoryService.batchCreateCategories(
+                    dto.getStoreId(), categoryNameList, dto.getMenuType());
             if (result) {
                 return R.success("批量创建菜品分类成功");
             } else {

+ 126 - 0
alien-store/src/main/java/shop/alien/store/controller/StoreReceiptTemplateConfigController.java

@@ -0,0 +1,126 @@
+package shop.alien.store.controller;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiImplicitParam;
+import io.swagger.annotations.ApiImplicitParams;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiOperationSupport;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.StoreReceiptTemplateConfig;
+import shop.alien.entity.store.dto.StoreReceiptTemplateCreateDTO;
+import shop.alien.entity.store.dto.StoreReceiptTemplateEditDTO;
+import shop.alien.entity.store.dto.StoreReceiptTemplateResetDTO;
+import shop.alien.entity.store.dto.StoreReceiptTemplateDeleteDTO;
+import shop.alien.entity.store.dto.StoreReceiptTemplateSaveDTO;
+import shop.alien.store.service.StoreReceiptTemplateConfigService;
+
+import java.util.List;
+
+@Slf4j
+@Api(tags = {"票据样式模板"})
+@CrossOrigin
+@RestController
+@RequestMapping("/store/receipt/template")
+@RequiredArgsConstructor
+public class StoreReceiptTemplateConfigController {
+
+    private final StoreReceiptTemplateConfigService storeReceiptTemplateConfigService;
+
+    @ApiOperation("查询模板列表(按门店+票据类型)")
+    @ApiOperationSupport(order = 1)
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "storeId", value = "门店ID", dataType = "Integer", paramType = "query", required = true),
+            @ApiImplicitParam(name = "receiptType", value = "票据类型(1:客单,2:结账单)", dataType = "Integer", paramType = "query", required = true)
+    })
+    @GetMapping("/list")
+    public R<List<StoreReceiptTemplateConfig>> list(@RequestParam Integer storeId, @RequestParam Integer receiptType) {
+        log.info("StoreReceiptTemplateConfigController.list?storeId={},receiptType={}", storeId, receiptType);
+        try {
+            return R.data(storeReceiptTemplateConfigService.listByStoreAndReceiptType(storeId, receiptType));
+        } catch (Exception e) {
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation("查询模板详情")
+    @ApiOperationSupport(order = 2)
+    @ApiImplicitParams({
+            @ApiImplicitParam(name = "storeId", value = "门店ID", dataType = "Integer", paramType = "query", required = true),
+            @ApiImplicitParam(name = "receiptType", value = "票据类型(1:客单,2:结账单)", dataType = "Integer", paramType = "query", required = true),
+            @ApiImplicitParam(name = "templateType", value = "模板类型(1:默认模板,2:自定义模板)", dataType = "Integer", paramType = "query", required = true)
+    })
+    @GetMapping("/detail")
+    public R<StoreReceiptTemplateConfig> detail(@RequestParam Integer storeId,
+                                                @RequestParam Integer receiptType,
+                                                @RequestParam Integer templateType) {
+        log.info("StoreReceiptTemplateConfigController.detail?storeId={},receiptType={},templateType={}", storeId, receiptType, templateType);
+        try {
+            return R.data(storeReceiptTemplateConfigService.getDetail(storeId, receiptType, templateType));
+        } catch (Exception e) {
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation("保存模板配置")
+    @ApiOperationSupport(order = 3)
+    @PostMapping("/save")
+    public R<StoreReceiptTemplateConfig> save(@RequestBody StoreReceiptTemplateSaveDTO dto) {
+        log.info("StoreReceiptTemplateConfigController.save?dto={}", dto);
+        try {
+            return R.data(storeReceiptTemplateConfigService.saveTemplate(dto));
+        } catch (Exception e) {
+            return R.fail(e.getMessage());
+        }
+    }
+
+//    @ApiOperation("创建自定义模板")
+//    @ApiOperationSupport(order = 4)
+//    @PostMapping("/create")
+//    public R<StoreReceiptTemplateConfig> create(@RequestBody StoreReceiptTemplateCreateDTO dto) {
+//        log.info("StoreReceiptTemplateConfigController.create?dto={}", dto);
+//        try {
+//            return R.data(storeReceiptTemplateConfigService.createTemplate(dto));
+//        } catch (Exception e) {
+//            return R.fail(e.getMessage());
+//        }
+//    }
+
+    @ApiOperation("恢复默认模板")
+    @ApiOperationSupport(order = 5)
+    @PostMapping("/resetDefault")
+    public R<StoreReceiptTemplateConfig> resetDefault(@RequestBody StoreReceiptTemplateResetDTO dto) {
+        log.info("StoreReceiptTemplateConfigController.resetDefault?dto={}", dto);
+        try {
+            return R.data(storeReceiptTemplateConfigService.resetToDefault(dto.getStoreId(), dto.getReceiptType(), dto.getTemplateType()));
+        } catch (Exception e) {
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation("按ID编辑模板")
+    @ApiOperationSupport(order = 6)
+    @PostMapping("/update")
+    public R<StoreReceiptTemplateConfig> update(@RequestBody StoreReceiptTemplateEditDTO dto) {
+        log.info("StoreReceiptTemplateConfigController.update?dto={}", dto);
+        try {
+            return R.data(storeReceiptTemplateConfigService.updateTemplateById(dto));
+        } catch (Exception e) {
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation("按ID删除模板")
+    @ApiOperationSupport(order = 7)
+    @PostMapping("/delete")
+    public R<Boolean> delete(@RequestBody StoreReceiptTemplateDeleteDTO dto) {
+        log.info("StoreReceiptTemplateConfigController.delete?dto={}", dto);
+        try {
+            return R.data(storeReceiptTemplateConfigService.deleteTemplateById(dto));
+        } catch (Exception e) {
+            return R.fail(e.getMessage());
+        }
+    }
+}

+ 63 - 0
alien-store/src/main/java/shop/alien/store/controller/StoreTurnoverController.java

@@ -0,0 +1,63 @@
+package shop.alien.store.controller;
+
+import io.swagger.annotations.*;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.web.bind.annotation.*;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.vo.TurnoverDetailByDateVO;
+import shop.alien.entity.store.vo.TurnoverSummaryVO;
+import shop.alien.store.service.StoreTurnoverService;
+import java.util.Date;
+import java.util.List;
+
+@Slf4j
+@Api(tags = {"二期-营业额对账"})
+@ApiSort(20)
+@CrossOrigin
+@RestController
+@RequestMapping("/store/turnover")
+@RequiredArgsConstructor
+public class StoreTurnoverController {
+
+	private final StoreTurnoverService storeTurnoverService;
+
+	@ApiOperation("营业额汇总")
+	@ApiImplicitParams({
+		@ApiImplicitParam(name = "storeId", value = "门店ID", required = true, dataType = "Integer", paramType = "query"),
+		@ApiImplicitParam(name = "startTime", value = "开始时间 yyyy-MM-dd HH:mm:ss", dataType = "String", paramType = "query"),
+		@ApiImplicitParam(name = "endTime", value = "结束时间 yyyy-MM-dd HH:mm:ss", dataType = "String", paramType = "query")
+	})
+	@GetMapping("/summary")
+	public R<TurnoverSummaryVO> summary(@RequestParam Integer storeId,
+	                                    @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date startTime,
+	                                    @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date endTime) {
+		log.info("StoreTurnoverController.summary storeId={}, start={}, end={}", storeId, startTime, endTime);
+		return storeTurnoverService.summary(storeId, startTime, endTime);
+	}
+
+	@ApiOperation("支付方式/收款方式明细列表")
+	@ApiImplicitParams({
+		@ApiImplicitParam(name = "storeId", value = "门店ID", required = true, dataType = "Integer", paramType = "query"),
+		@ApiImplicitParam(name = "payType", value = "支付方式(1微信 2支付宝 4银行卡 3现金)", dataType = "Integer", paramType = "query"),
+		@ApiImplicitParam(name = "paymentMethod", value = "收款方式(1手机支付 2收银台)", dataType = "Integer", paramType = "query"),
+		@ApiImplicitParam(name = "startTime", value = "开始时间 yyyy-MM-dd HH:mm:ss", dataType = "String", paramType = "query"),
+		@ApiImplicitParam(name = "endTime", value = "结束时间 yyyy-MM-dd HH:mm:ss", dataType = "String", paramType = "query"),
+		@ApiImplicitParam(name = "pageNum", value = "页码", dataType = "Integer", paramType = "query"),
+		@ApiImplicitParam(name = "pageSize", value = "页大小", dataType = "Integer", paramType = "query")
+	})
+	@GetMapping("/details")
+	public R<List<TurnoverDetailByDateVO>> details(@RequestParam Integer storeId,
+												   @RequestParam(required = false) Integer payType,
+												   @RequestParam(required = false) Integer paymentMethod,
+												   @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date startTime,
+												   @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date endTime,
+												   @RequestParam(defaultValue = "1") Integer pageNum,
+												   @RequestParam(defaultValue = "10") Integer pageSize) {
+		log.info("StoreTurnoverController.details(storeId={}, payType={}, paymentMethod={}, start={}, end={}, pageNum={}, pageSize={})",
+			storeId, payType, paymentMethod, startTime, endTime, pageNum, pageSize);
+		return storeTurnoverService.detailsByDate(storeId, payType, paymentMethod, startTime, endTime, pageNum, pageSize);
+	}
+}
+

+ 198 - 0
alien-store/src/main/java/shop/alien/store/controller/dining/StoreGenericDiningPathProxyController.java

@@ -0,0 +1,198 @@
+package shop.alien.store.controller.dining;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiParam;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.vo.*;
+import shop.alien.store.feign.DiningServiceFeign;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.List;
+
+/**
+ * 与 alien-dining 一致的路径前缀 {@code /store/generic-dining},透传通用价目点餐(store_price、type=2 桌)。
+ */
+@Slf4j
+@Api(tags = {"APP/统一网关-通用价目点餐(与小程序路径一致)"})
+@CrossOrigin
+@RestController
+@RequestMapping("/store/generic-dining")
+@RequiredArgsConstructor
+public class StoreGenericDiningPathProxyController {
+
+    private final DiningServiceFeign diningServiceFeign;
+
+    private String auth(HttpServletRequest request) {
+        String h = request.getHeader("Authorization");
+        if (h == null || h.isEmpty()) {
+            h = request.getHeader("authorization");
+        }
+        return h;
+    }
+
+    @ApiOperation(value = "获取点餐页面信息", notes = "透传 alien-dining /store/generic-dining/page-info;仅通用桌 type=2。")
+    @GetMapping("/page-info")
+    public R<DiningPageInfoVO> getDiningPageInfo(
+            HttpServletRequest request,
+            @ApiParam(value = "桌号ID", required = true) @RequestParam Integer tableId,
+            @ApiParam(value = "就餐人数") @RequestParam(required = false) Integer dinerCount) {
+        try {
+            return diningServiceFeign.getGenericDiningPageInfo(auth(request), tableId, dinerCount);
+        } catch (Exception e) {
+            log.error("generic getDiningPageInfo: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation("搜索价目(store_price)")
+    @GetMapping("/search")
+    public R<List<CuisineListVO>> search(
+            HttpServletRequest request,
+            @RequestParam Integer storeId,
+            @RequestParam(required = false) String keyword,
+            @RequestParam Integer tableId) {
+        try {
+            return diningServiceFeign.genericSearchCuisines(auth(request), storeId, keyword, tableId);
+        } catch (Exception e) {
+            log.error("generic search: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation("价目分页列表")
+    @GetMapping("/cuisines")
+    public R<List<CuisineListVO>> cuisines(
+            HttpServletRequest request,
+            @RequestParam Integer storeId,
+            @RequestParam(required = false) Integer categoryId,
+            @RequestParam Integer tableId,
+            @RequestParam(defaultValue = "1") Integer page,
+            @RequestParam(defaultValue = "12") Integer size) {
+        try {
+            return diningServiceFeign.getGenericCuisinesByCategory(auth(request), storeId, categoryId, tableId, page, size);
+        } catch (Exception e) {
+            log.error("generic cuisines: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation("价目详情(priceItemId 为 store_price.id)")
+    @GetMapping("/cuisine/{priceItemId}")
+    public R<CuisineDetailVO> detail(
+            HttpServletRequest request,
+            @PathVariable Integer priceItemId,
+            @RequestParam Integer tableId) {
+        try {
+            return diningServiceFeign.getGenericPriceItemDetail(auth(request), priceItemId, tableId);
+        } catch (Exception e) {
+            log.error("generic cuisine detail: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation("订单确认页")
+    @GetMapping("/order/confirm")
+    public R<OrderConfirmVO> orderConfirm(
+            HttpServletRequest request,
+            @RequestParam Integer tableId,
+            @RequestParam Integer dinerCount) {
+        try {
+            return diningServiceFeign.getGenericOrderConfirmInfo(auth(request), tableId, dinerCount);
+        } catch (Exception e) {
+            log.error("generic orderConfirm: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation("可领取优惠券")
+    @GetMapping("/coupons/available")
+    public R<List<AvailableCouponVO>> availableCoupons(HttpServletRequest request, @RequestParam Integer storeId) {
+        try {
+            return diningServiceFeign.getGenericAvailableCoupons(auth(request), storeId);
+        } catch (Exception e) {
+            log.error("generic availableCoupons: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation("领取优惠券")
+    @PostMapping("/coupon/receive")
+    public R<Boolean> receiveCoupon(HttpServletRequest request, @RequestParam Integer couponId) {
+        try {
+            return diningServiceFeign.receiveGenericCoupon(auth(request), couponId);
+        } catch (Exception e) {
+            log.error("generic receiveCoupon: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation("锁定订单")
+    @PostMapping("/order/lock")
+    public R<Boolean> lockOrder(HttpServletRequest request, @RequestParam Integer tableId) {
+        try {
+            return diningServiceFeign.lockGenericOrder(auth(request), tableId);
+        } catch (Exception e) {
+            log.error("generic lockOrder: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation("解锁订单")
+    @PostMapping("/order/unlock")
+    public R<Boolean> unlockOrder(HttpServletRequest request, @RequestParam Integer tableId) {
+        try {
+            return diningServiceFeign.unlockGenericOrder(auth(request), tableId);
+        } catch (Exception e) {
+            log.error("generic unlockOrder: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation("查询订单锁")
+    @GetMapping("/order/check-lock")
+    public R<Integer> checkLock(HttpServletRequest request, @RequestParam Integer tableId) {
+        try {
+            return diningServiceFeign.checkGenericOrderLock(auth(request), tableId);
+        } catch (Exception e) {
+            log.error("generic checkLock: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation("结算页信息")
+    @GetMapping("/order/settlement")
+    public R<OrderSettlementVO> settlement(HttpServletRequest request, @RequestParam Integer orderId) {
+        try {
+            return diningServiceFeign.getGenericOrderSettlementInfo(auth(request), orderId);
+        } catch (Exception e) {
+            log.error("generic settlement: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation("锁定结算")
+    @PostMapping("/order/settlement/lock")
+    public R<Boolean> lockSettlement(HttpServletRequest request, @RequestParam Integer orderId) {
+        try {
+            return diningServiceFeign.lockGenericSettlement(auth(request), orderId);
+        } catch (Exception e) {
+            log.error("generic lockSettlement: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation("解锁结算")
+    @PostMapping("/order/settlement/unlock")
+    public R<Boolean> unlockSettlement(HttpServletRequest request, @RequestParam Integer orderId) {
+        try {
+            return diningServiceFeign.unlockGenericSettlement(auth(request), orderId);
+        } catch (Exception e) {
+            log.error("generic unlockSettlement: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+}

+ 150 - 0
alien-store/src/main/java/shop/alien/store/controller/dining/StoreGenericOrderPathProxyController.java

@@ -0,0 +1,150 @@
+package shop.alien.store.controller.dining;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.dto.AddCartItemDTO;
+import shop.alien.entity.store.dto.CartDTO;
+import shop.alien.entity.store.dto.CreateOrderDTO;
+import shop.alien.entity.store.vo.OrderSuccessVO;
+import shop.alien.store.feign.DiningServiceFeign;
+import shop.alien.store.service.dining.DiningSseProxyService;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.validation.Valid;
+
+/**
+ * 与 alien-dining 一致的路径前缀 {@code /store/generic-order},透传通用价目购物车与创建订单。
+ * 支付/详情等仍走 {@link StoreOrderPathProxyController} 的 {@code /store/order}。
+ */
+@Slf4j
+@Api(tags = {"APP/统一网关-通用价目购物车与下单(与小程序路径一致)"})
+@CrossOrigin
+@RestController
+@RequestMapping("/store/generic-order")
+@RequiredArgsConstructor
+public class StoreGenericOrderPathProxyController {
+
+    private final DiningServiceFeign diningServiceFeign;
+    private final DiningSseProxyService diningSseProxyService;
+
+    private String auth(HttpServletRequest request) {
+        String h = request.getHeader("Authorization");
+        if (h == null || h.isEmpty()) {
+            h = request.getHeader("authorization");
+        }
+        return h;
+    }
+
+    @ApiOperation("通用价目购物车")
+    @GetMapping("/cart/{tableId}")
+    public R<CartDTO> getCart(HttpServletRequest request, @PathVariable Integer tableId) {
+        try {
+            return diningServiceFeign.getGenericCart(auth(request), tableId);
+        } catch (Exception e) {
+            log.error("generic getCart: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation("加购(cuisineId 传 store_price.id)")
+    @PostMapping("/cart/add")
+    public R<CartDTO> addCartItem(HttpServletRequest request, @Valid @RequestBody AddCartItemDTO dto) {
+        try {
+            return diningServiceFeign.addGenericCartItem(auth(request), dto);
+        } catch (Exception e) {
+            log.error("generic addCartItem: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation("更新购物车数量")
+    @PutMapping("/cart/update")
+    public R<CartDTO> updateCartItem(
+            HttpServletRequest request,
+            @RequestParam Integer tableId,
+            @RequestParam Integer cuisineId,
+            @RequestParam Integer quantity) {
+        try {
+            return diningServiceFeign.updateGenericCartItem(auth(request), tableId, cuisineId, quantity);
+        } catch (Exception e) {
+            log.error("generic updateCartItem: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation("删除购物车行")
+    @DeleteMapping("/cart/remove")
+    public R<CartDTO> removeCartItem(
+            HttpServletRequest request,
+            @RequestParam Integer tableId,
+            @RequestParam Integer cuisineId) {
+        try {
+            return diningServiceFeign.removeGenericCartItem(auth(request), tableId, cuisineId);
+        } catch (Exception e) {
+            log.error("generic removeCartItem: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation("清空购物车")
+    @DeleteMapping("/cart/clear")
+    public R<CartDTO> clearCart(HttpServletRequest request, @RequestParam Integer tableId) {
+        try {
+            return diningServiceFeign.clearGenericCart(auth(request), tableId);
+        } catch (Exception e) {
+            log.error("generic clearCart: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation("设置就餐人数")
+    @PostMapping("/cart/set-diner-count")
+    public R<CartDTO> setDinerCount(
+            HttpServletRequest request,
+            @RequestParam Integer tableId,
+            @RequestParam Integer dinerCount) {
+        try {
+            return diningServiceFeign.setGenericDinerCount(auth(request), tableId, dinerCount);
+        } catch (Exception e) {
+            log.error("generic setDinerCount: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation("更新餐具数量")
+    @PutMapping("/cart/update-tableware")
+    public R<CartDTO> updateTablewareQuantity(
+            HttpServletRequest request,
+            @RequestParam Integer tableId,
+            @RequestParam(required = false) Integer quantity) {
+        try {
+            return diningServiceFeign.updateGenericTablewareQuantity(auth(request), tableId, quantity);
+        } catch (Exception e) {
+            log.error("generic updateTablewareQuantity: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation("创建订单(通用价目)")
+    @PostMapping("/create")
+    public R<OrderSuccessVO> createOrder(HttpServletRequest request, @Valid @RequestBody CreateOrderDTO dto) {
+        try {
+            return diningServiceFeign.createGenericOrder(auth(request), dto);
+        } catch (Exception e) {
+            log.error("generic createOrder: {}", e.getMessage(), e);
+            return R.fail(e.getMessage());
+        }
+    }
+
+    @ApiOperation(value = "SSE", notes = "透传 alien-dining /store/generic-order/sse/{tableId}")
+    @GetMapping(value = "/sse/{tableId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+    public SseEmitter sse(HttpServletRequest request, @PathVariable Integer tableId) {
+        return diningSseProxyService.proxyGenericOrderSse(tableId, auth(request));
+    }
+}

+ 11 - 8
alien-store/src/main/java/shop/alien/store/controller/dining/StoreInfoDiningPathProxyController.java

@@ -10,7 +10,7 @@ import shop.alien.entity.store.StoreCuisine;
 import shop.alien.entity.store.StoreCuisineCategory;
 import shop.alien.entity.store.StoreTable;
 import shop.alien.entity.store.dto.StoreInfoWithHomepageCuisinesDTO;
-import shop.alien.entity.store.vo.CategoryWithCuisinesVO;
+import shop.alien.entity.store.vo.CategoryMenuGroupVO;
 import shop.alien.store.feign.DiningServiceFeign;
 
 import java.util.List;
@@ -39,11 +39,13 @@ public class StoreInfoDiningPathProxyController {
         }
     }
 
-    @ApiOperation("菜品种类列表(与小程序 GetStoreCategories 一致)")
+    @ApiOperation("分类列表(与小程序一致;可选 menuType=2 拉通用价目分类)")
     @GetMapping("/categories")
-    public R<List<StoreCuisineCategory>> getCategories(@RequestParam Integer storeId) {
+    public R<List<StoreCuisineCategory>> getCategories(
+            @RequestParam Integer storeId,
+            @RequestParam(required = false) Integer menuType) {
         try {
-            return diningServiceFeign.getStoreInfoCategories(storeId);
+            return diningServiceFeign.getStoreInfoCategories(storeId, menuType);
         } catch (Exception e) {
             log.error("store/info/categories: {}", e.getMessage(), e);
             return R.fail(e.getMessage());
@@ -72,13 +74,14 @@ public class StoreInfoDiningPathProxyController {
         }
     }
 
-    @ApiOperation("门店分类及分类下菜品(点餐页左侧+右侧)")
+    @ApiOperation("门店分类树(menuType=1 美食+cuisines;menuType=2 通用+prices;默认 1)")
     @GetMapping("/categories-with-cuisines")
-    public R<List<CategoryWithCuisinesVO>> getCategoriesWithCuisines(
+    public R<List<CategoryMenuGroupVO>> getCategoriesWithMenuGroups(
             @RequestParam Integer storeId,
-            @RequestParam(required = false) String keyword) {
+            @RequestParam(required = false) String keyword,
+            @RequestParam(required = false) Integer menuType) {
         try {
-            return diningServiceFeign.getCategoriesWithCuisinesByStoreId(storeId, keyword);
+            return diningServiceFeign.getCategoriesWithMenuGroupsByStoreId(storeId, keyword, menuType);
         } catch (Exception e) {
             log.error("categories-with-cuisines: {}", e.getMessage(), e);
             return R.fail(e.getMessage());

+ 128 - 8
alien-store/src/main/java/shop/alien/store/feign/DiningServiceFeign.java

@@ -197,6 +197,127 @@ public interface DiningServiceFeign {
             @RequestHeader(value = "Authorization", required = false) String authorization,
             @RequestParam("orderId") Integer orderId);
 
+    // ==================== 通用价目点餐(/store/generic-dining、/store/generic-order) ====================
+
+    @GetMapping("/store/generic-dining/page-info")
+    R<DiningPageInfoVO> getGenericDiningPageInfo(
+            @RequestHeader(value = "Authorization", required = false) String authorization,
+            @RequestParam("tableId") Integer tableId,
+            @RequestParam(value = "dinerCount", required = false) Integer dinerCount);
+
+    @GetMapping("/store/generic-dining/search")
+    R<List<CuisineListVO>> genericSearchCuisines(
+            @RequestHeader(value = "Authorization", required = false) String authorization,
+            @RequestParam("storeId") Integer storeId,
+            @RequestParam(value = "keyword", required = false) String keyword,
+            @RequestParam("tableId") Integer tableId);
+
+    @GetMapping("/store/generic-dining/cuisines")
+    R<List<CuisineListVO>> getGenericCuisinesByCategory(
+            @RequestHeader(value = "Authorization", required = false) String authorization,
+            @RequestParam("storeId") Integer storeId,
+            @RequestParam(value = "categoryId", required = false) Integer categoryId,
+            @RequestParam("tableId") Integer tableId,
+            @RequestParam(value = "page", defaultValue = "1") Integer page,
+            @RequestParam(value = "size", defaultValue = "12") Integer size);
+
+    @GetMapping("/store/generic-dining/cuisine/{priceItemId}")
+    R<CuisineDetailVO> getGenericPriceItemDetail(
+            @RequestHeader(value = "Authorization", required = false) String authorization,
+            @PathVariable("priceItemId") Integer priceItemId,
+            @RequestParam("tableId") Integer tableId);
+
+    @GetMapping("/store/generic-dining/order/confirm")
+    R<OrderConfirmVO> getGenericOrderConfirmInfo(
+            @RequestHeader(value = "Authorization", required = false) String authorization,
+            @RequestParam("tableId") Integer tableId,
+            @RequestParam("dinerCount") Integer dinerCount);
+
+    @GetMapping("/store/generic-dining/coupons/available")
+    R<List<AvailableCouponVO>> getGenericAvailableCoupons(
+            @RequestHeader(value = "Authorization", required = false) String authorization,
+            @RequestParam("storeId") Integer storeId);
+
+    @PostMapping("/store/generic-dining/coupon/receive")
+    R<Boolean> receiveGenericCoupon(
+            @RequestHeader(value = "Authorization", required = false) String authorization,
+            @RequestParam("couponId") Integer couponId);
+
+    @PostMapping("/store/generic-dining/order/lock")
+    R<Boolean> lockGenericOrder(
+            @RequestHeader(value = "Authorization", required = false) String authorization,
+            @RequestParam("tableId") Integer tableId);
+
+    @PostMapping("/store/generic-dining/order/unlock")
+    R<Boolean> unlockGenericOrder(
+            @RequestHeader(value = "Authorization", required = false) String authorization,
+            @RequestParam("tableId") Integer tableId);
+
+    @GetMapping("/store/generic-dining/order/check-lock")
+    R<Integer> checkGenericOrderLock(
+            @RequestHeader(value = "Authorization", required = false) String authorization,
+            @RequestParam("tableId") Integer tableId);
+
+    @GetMapping("/store/generic-dining/order/settlement")
+    R<OrderSettlementVO> getGenericOrderSettlementInfo(
+            @RequestHeader(value = "Authorization", required = false) String authorization,
+            @RequestParam("orderId") Integer orderId);
+
+    @PostMapping("/store/generic-dining/order/settlement/lock")
+    R<Boolean> lockGenericSettlement(
+            @RequestHeader(value = "Authorization", required = false) String authorization,
+            @RequestParam("orderId") Integer orderId);
+
+    @PostMapping("/store/generic-dining/order/settlement/unlock")
+    R<Boolean> unlockGenericSettlement(
+            @RequestHeader(value = "Authorization", required = false) String authorization,
+            @RequestParam("orderId") Integer orderId);
+
+    @GetMapping("/store/generic-order/cart/{tableId}")
+    R<CartDTO> getGenericCart(
+            @RequestHeader(value = "Authorization", required = false) String authorization,
+            @PathVariable("tableId") Integer tableId);
+
+    @PostMapping("/store/generic-order/cart/add")
+    R<CartDTO> addGenericCartItem(
+            @RequestHeader(value = "Authorization", required = false) String authorization,
+            @RequestBody AddCartItemDTO dto);
+
+    @PutMapping("/store/generic-order/cart/update")
+    R<CartDTO> updateGenericCartItem(
+            @RequestHeader(value = "Authorization", required = false) String authorization,
+            @RequestParam("tableId") Integer tableId,
+            @RequestParam("cuisineId") Integer cuisineId,
+            @RequestParam("quantity") Integer quantity);
+
+    @DeleteMapping("/store/generic-order/cart/remove")
+    R<CartDTO> removeGenericCartItem(
+            @RequestHeader(value = "Authorization", required = false) String authorization,
+            @RequestParam("tableId") Integer tableId,
+            @RequestParam("cuisineId") Integer cuisineId);
+
+    @DeleteMapping("/store/generic-order/cart/clear")
+    R<CartDTO> clearGenericCart(
+            @RequestHeader(value = "Authorization", required = false) String authorization,
+            @RequestParam("tableId") Integer tableId);
+
+    @PostMapping("/store/generic-order/cart/set-diner-count")
+    R<CartDTO> setGenericDinerCount(
+            @RequestHeader(value = "Authorization", required = false) String authorization,
+            @RequestParam("tableId") Integer tableId,
+            @RequestParam("dinerCount") Integer dinerCount);
+
+    @PutMapping("/store/generic-order/cart/update-tableware")
+    R<CartDTO> updateGenericTablewareQuantity(
+            @RequestHeader(value = "Authorization", required = false) String authorization,
+            @RequestParam("tableId") Integer tableId,
+            @RequestParam(value = "quantity", required = false) Integer quantity);
+
+    @PostMapping("/store/generic-order/create")
+    R<OrderSuccessVO> createGenericOrder(
+            @RequestHeader(value = "Authorization", required = false) String authorization,
+            @RequestBody CreateOrderDTO dto);
+
     // ==================== 订单相关接口 ====================
 
     /**
@@ -499,7 +620,9 @@ public interface DiningServiceFeign {
     R<StoreInfoWithHomepageCuisinesDTO> getStoreInfoWithHomepageCuisines(@PathVariable("storeId") Integer storeId);
 
     @GetMapping("/store/info/categories")
-    R<List<StoreCuisineCategory>> getStoreInfoCategories(@RequestParam("storeId") Integer storeId);
+    R<List<StoreCuisineCategory>> getStoreInfoCategories(
+            @RequestParam("storeId") Integer storeId,
+            @RequestParam(value = "menuType", required = false) Integer menuType);
 
     @GetMapping("/store/info/cuisines")
     R<List<StoreCuisine>> getStoreInfoCuisinesByCategoryId(@RequestParam("categoryId") Integer categoryId);
@@ -515,16 +638,13 @@ public interface DiningServiceFeign {
             @RequestParam("storeId") Integer storeId);
 
     /**
-     * 根据门店ID查询菜品种类及各类别下菜品(可选按菜品名称模糊查询)
-     *
-     * @param storeId 门店ID
-     * @param keyword 菜品名称模糊查询关键词(可选)
-     * @return R.data 为 List&lt;CategoryWithCuisinesVO&gt;,cuisines 每项含 originalPrice、currentPrice、hasActiveDiscount
+     * 分类树:menuType=1(默认)美食+cuisines;menuType=2 通用价目+prices
      */
     @GetMapping("/store/info/categories-with-cuisines")
-    R<List<CategoryWithCuisinesVO>> getCategoriesWithCuisinesByStoreId(
+    R<List<CategoryMenuGroupVO>> getCategoriesWithMenuGroupsByStoreId(
             @RequestParam("storeId") Integer storeId,
-            @RequestParam(value = "keyword", required = false) String keyword);
+            @RequestParam(value = "keyword", required = false) String keyword,
+            @RequestParam(value = "menuType", required = false) Integer menuType);
 
     /**
      * 删除菜品种类(仅解除绑定+逻辑删分类,价目表菜品不变)

+ 11 - 0
alien-store/src/main/java/shop/alien/store/service/ReceiptPrinter.java

@@ -0,0 +1,11 @@
+package shop.alien.store.service;
+
+import shop.alien.entity.store.Receipt;
+
+public interface ReceiptPrinter {
+
+    /**
+     * 打印收银小票
+     */
+    void print(Receipt receipt) throws Exception;
+}

+ 1 - 1
alien-store/src/main/java/shop/alien/store/service/StoreBookingCategoryService.java

@@ -75,7 +75,7 @@ public interface StoreBookingCategoryService extends IService<StoreBookingCatego
     boolean updateDisplayStatus(Integer id, Integer isDisplay);
 
     /**
-     * 查询分类下是否有预订信息
+     * 查询分类下是否有有效预订(仅 user_reservation.category_id,不查订单表;规则同桌位 hasReservation)
      *
      * @param categoryId 分类ID
      * @param storeId    门店ID

+ 1 - 1
alien-store/src/main/java/shop/alien/store/service/StoreBookingTableService.java

@@ -93,7 +93,7 @@ public interface StoreBookingTableService extends IService<StoreTable> {
     boolean deleteTable(Integer id);
 
     /**
-     * 查询桌号下是否有预订信息
+     * 查询桌号下是否有有效预订(以 user_reservation 为主,桌位 EXISTS;0/1 有预订,2 需未超过结束时间+3h)
      *
      * @param tableId 桌号ID
      * @param storeId 门店ID

+ 20 - 10
alien-store/src/main/java/shop/alien/store/service/StoreCuisineCategoryService.java

@@ -2,6 +2,7 @@ package shop.alien.store.service;
 
 import com.baomidou.mybatisplus.extension.service.IService;
 import shop.alien.entity.store.StoreCuisineCategory;
+import shop.alien.entity.store.constants.StoreMenuType;
 
 import java.util.List;
 
@@ -14,21 +15,30 @@ import java.util.List;
 public interface StoreCuisineCategoryService extends IService<StoreCuisineCategory> {
 
     /**
-     * 查询菜品分类列表(按排序字段排序)
-     *
-     * @param storeId 门店ID
-     * @return List<StoreCuisineCategory>
+     * 查询分类列表(默认仅美食分类,与历史行为一致)
+     */
+    default List<StoreCuisineCategory> getCategoryList(Integer storeId) {
+        return getCategoryList(storeId, StoreMenuType.CUISINE);
+    }
+
+    /**
+     * 按门店与菜单维度查询分类({@link StoreMenuType})
      */
-    List<StoreCuisineCategory> getCategoryList(Integer storeId);
+    List<StoreCuisineCategory> getCategoryList(Integer storeId, Integer menuType);
 
     /**
-     * 批量创建菜品分类
+     * 批量创建分类(默认美食)
+     */
+    default boolean batchCreateCategories(Integer storeId, List<String> categoryNames) {
+        return batchCreateCategories(storeId, categoryNames, StoreMenuType.CUISINE);
+    }
+
+    /**
+     * 批量创建分类
      *
-     * @param storeId        门店ID
-     * @param categoryNames  分类名称列表
-     * @return boolean
+     * @param menuType {@link StoreMenuType#CUISINE} 或 {@link StoreMenuType#GENERIC_PRICE}
      */
-    boolean batchCreateCategories(Integer storeId, List<String> categoryNames);
+    boolean batchCreateCategories(Integer storeId, List<String> categoryNames, Integer menuType);
 
     /**
      * 更新菜品分类

+ 26 - 0
alien-store/src/main/java/shop/alien/store/service/StoreReceiptTemplateConfigService.java

@@ -0,0 +1,26 @@
+package shop.alien.store.service;
+
+import shop.alien.entity.store.StoreReceiptTemplateConfig;
+import shop.alien.entity.store.dto.StoreReceiptTemplateCreateDTO;
+import shop.alien.entity.store.dto.StoreReceiptTemplateEditDTO;
+import shop.alien.entity.store.dto.StoreReceiptTemplateDeleteDTO;
+import shop.alien.entity.store.dto.StoreReceiptTemplateSaveDTO;
+
+import java.util.List;
+
+public interface StoreReceiptTemplateConfigService {
+
+    List<StoreReceiptTemplateConfig> listByStoreAndReceiptType(Integer storeId, Integer receiptType);
+
+    StoreReceiptTemplateConfig getDetail(Integer storeId, Integer receiptType, Integer templateType);
+
+//    StoreReceiptTemplateConfig createTemplate(StoreReceiptTemplateCreateDTO dto);
+
+    StoreReceiptTemplateConfig saveTemplate(StoreReceiptTemplateSaveDTO dto);
+
+    StoreReceiptTemplateConfig resetToDefault(Integer storeId, Integer receiptType, Integer templateType);
+
+    StoreReceiptTemplateConfig updateTemplateById(StoreReceiptTemplateEditDTO dto);
+
+    Boolean deleteTemplateById(StoreReceiptTemplateDeleteDTO dto);
+}

+ 28 - 0
alien-store/src/main/java/shop/alien/store/service/StoreTurnoverService.java

@@ -0,0 +1,28 @@
+package shop.alien.store.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.vo.TurnoverDetailItemVO;
+import shop.alien.entity.store.vo.TurnoverSummaryVO;
+import shop.alien.entity.store.vo.TurnoverDetailByDateVO;
+
+import java.util.Date;
+import java.util.List;
+
+public interface StoreTurnoverService {
+
+	R<TurnoverSummaryVO> summary(Integer storeId, Date startTime, Date endTime);
+
+	/**
+	 * 原分页明细(保留以便需要纯列表时使用)
+	 */
+	R<IPage<TurnoverDetailItemVO>> details(Integer storeId, Integer payType, Integer paymentMethod,
+	                                       Date startTime, Date endTime, Integer pageNum, Integer pageSize);
+
+	/**
+	 * 按日期分组的明细(用于前端分组展示)
+	 */
+	R<List<TurnoverDetailByDateVO>> detailsByDate(Integer storeId, Integer payType, Integer paymentMethod,
+	                                              Date startTime, Date endTime, Integer pageNum, Integer pageSize);
+}
+

+ 13 - 5
alien-store/src/main/java/shop/alien/store/service/TableAppQrCodeService.java

@@ -1,16 +1,24 @@
 package shop.alien.store.service;
 
 /**
- * APP 桌码二维码(普通 QR,内容为门店ID+桌 JSON,供 APP 扫码识别)
+ * APP 桌码二维码(普通 QR,内容为门店ID+桌台主键 JSON,供 APP 扫码识别)
  */
 public interface TableAppQrCodeService {
 
     /**
-     * 生成二维码图片并上传 OSS,内容为 {@code {"storeId":..,"tableNumber":".."}}
+     * 生成 APP 桌码(默认美食 {@code menuType=1})
+     */
+    default String generateTableAppQrCodeAndUpload(Integer storeId, Integer tableId) {
+        return generateTableAppQrCodeAndUpload(storeId, tableId, 1);
+    }
+
+    /**
+     * 生成二维码图片并上传 OSS,内容为 {@code {"storeId":..,"tableId":..,"menuType":1|2}}
      *
-     * @param storeId      商户/门店 ID
-     * @param tableNumber  桌号
+     * @param storeId   商户/门店 ID
+     * @param tableId   桌台主键 {@code store_table.id}(非桌号展示文案)
+     * @param menuType  1=美食 2=通用价目,与 store_table.type 一致;非法或 null 按 1
      * @return OSS 访问 URL
      */
-    String generateTableAppQrCodeAndUpload(Integer storeId, String tableNumber);
+    String generateTableAppQrCodeAndUpload(Integer storeId, Integer tableId, Integer menuType);
 }

+ 13 - 1
alien-store/src/main/java/shop/alien/store/service/WeChatMiniProgramQrCodeService.java

@@ -37,11 +37,23 @@ public interface WeChatMiniProgramQrCodeService {
     String generateQrCodeAndUpload(String scene, String page, Integer width, String ossPath);
 
     /**
+     * 为桌号生成小程序二维码(默认按美食桌 {@code menuType=1})
+     *
+     * @param tableId   桌号ID
+     * @param storeId   门店ID
+     * @return 二维码URL
+     */
+    default String generateTableQrCode(Integer tableId, Integer storeId) {
+        return generateTableQrCode(tableId, storeId, 1);
+    }
+
+    /**
      * 为桌号生成小程序二维码
      *
      * @param tableId   桌号ID
      * @param storeId   门店ID
+     * @param menuType  桌台点餐类型:1=美食(store_cuisine)2=通用价目(store_price),与 store_table.type 一致;非法或 null 按 1
      * @return 二维码URL
      */
-    String generateTableQrCode(Integer tableId, Integer storeId);
+    String generateTableQrCode(Integer tableId, Integer storeId, Integer menuType);
 }

+ 12 - 1
alien-store/src/main/java/shop/alien/store/service/dining/DiningSseProxyService.java

@@ -30,6 +30,17 @@ public class DiningSseProxyService {
     private String diningBaseUrl;
 
     public SseEmitter proxyOrderSse(Integer tableId, String authorization) {
+        return proxySse(tableId, authorization, "/store/order/sse/");
+    }
+
+    /**
+     * 通用价目购物车 SSE,对应 alien-dining {@code /store/generic-order/sse/{tableId}}。
+     */
+    public SseEmitter proxyGenericOrderSse(Integer tableId, String authorization) {
+        return proxySse(tableId, authorization, "/store/generic-order/sse/");
+    }
+
+    private SseEmitter proxySse(Integer tableId, String authorization, String pathPrefix) {
         SseEmitter emitter = new SseEmitter(0L);
         String base = diningBaseUrl == null ? "" : diningBaseUrl.trim();
         if (base.isEmpty()) {
@@ -39,7 +50,7 @@ public class DiningSseProxyService {
         if (base.endsWith("/")) {
             base = base.substring(0, base.length() - 1);
         }
-        final String urlStr = base + "/store/order/sse/" + tableId;
+        final String urlStr = base + pathPrefix + tableId;
         EXECUTOR.execute(() -> {
             HttpURLConnection conn = null;
             try {

+ 66 - 0
alien-store/src/main/java/shop/alien/store/service/impl/NetworkReceiptPrinter.java

@@ -0,0 +1,66 @@
+package shop.alien.store.service.impl;
+
+import com.github.anastaciocintra.escpos.EscPos;
+import com.github.anastaciocintra.escpos.Style;
+import shop.alien.entity.store.Receipt;
+import shop.alien.store.service.ReceiptPrinter;
+import java.net.Socket;
+import java.io.OutputStream;
+import java.text.SimpleDateFormat;
+
+
+public class NetworkReceiptPrinter implements ReceiptPrinter {
+    private final String ip;
+    private final int port;
+
+    public NetworkReceiptPrinter(String ip, int port) {
+        this.ip = ip;
+        this.port = port;
+    }
+
+
+
+
+    @Override
+    public void print(Receipt receipt) throws Exception {
+        try (Socket socket = new Socket(ip, port);
+             OutputStream out = socket.getOutputStream()) {
+
+            EscPos escpos = new EscPos(out);
+            doPrint(escpos, receipt);
+
+            escpos.cut(EscPos.CutMode.FULL);
+            escpos.flush();
+        }
+             
+    }
+
+    private void doPrint(EscPos escpos, Receipt receipt) throws Exception {
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+
+//        escpos.setAlign(Style.Align.CENTER)
+//                .setFontSize(Style.FontSize._2, Style.FontSize._2)
+//                .writeLn("XX便利店")
+//                .feed(1);
+//
+//        escpos.setFontSize(Style.FontSize._1, Style.FontSize._1)
+//                .setAlign(Style.Align.LEFT)
+//                .writeLn("单号:" + receipt.getOrderNo())
+//                .writeLn("时间:" + sdf.format(receipt.getCreateTime()))
+//                .writeLn("------------------------------");
+//
+//        for (ReceiptItem item : receipt.getItems()) {
+//            escpos.writeLn(item.getName() + " ×" + item.getQty());
+//            escpos.writeLn(String.format("          %s × %s = %s",
+//                    item.getPrice(), item.getQty(), item.getAmount()));
+//        }
+//
+//        escpos.writeLn("------------------------------")
+//                .setAlign(Style.Align.RIGHT)
+//                .writeLn("合计:" + receipt.getTotalAmount())
+//                .writeLn("支付:" + receipt.getPayType())
+//                .feed(3);
+    }
+
+
+}

+ 1 - 0
alien-store/src/main/java/shop/alien/store/service/impl/StoreBookingBusinessHoursServiceImpl.java

@@ -146,6 +146,7 @@ public class StoreBookingBusinessHoursServiceImpl extends ServiceImpl<StoreBooki
             updateWrapper.set(StoreBookingBusinessHours::getBookingTimeType, businessHours.getBookingTimeType());
             updateWrapper.set(StoreBookingBusinessHours::getStartTime, businessHours.getStartTime());
             updateWrapper.set(StoreBookingBusinessHours::getEndTime, businessHours.getEndTime());
+            updateWrapper.set(StoreBookingBusinessHours::getBusinessHoursStr, businessHours.getBusinessHoursStr());
             updateWrapper.set(StoreBookingBusinessHours::getSort, businessHours.getSort());
             
             if (userId != null) {

+ 1 - 3
alien-store/src/main/java/shop/alien/store/service/impl/StoreBookingCategoryServiceImpl.java

@@ -366,9 +366,7 @@ public class StoreBookingCategoryServiceImpl extends ServiceImpl<StoreBookingCat
             return false;
         }
         
-        // 查询该门店该分类下是否有符合条件的预订信息
-        // 条件:订单状态为"待使用"(1)或"已完成"(2)
-        // 对于"已完成"状态的订单,结束时间需要在当前时间3小时内
+        // 仅 user_reservation(ur.category_id),不查订单表;0/1 有预订;2 在「结束时间+3h」内算有预订
         long count = userReservationMapper.countReservationsByCategoryAndOrderStatus(categoryId, storeId);
         boolean hasReservation = count > 0;
         

+ 2 - 0
alien-store/src/main/java/shop/alien/store/service/impl/StoreBookingSettingsServiceImpl.java

@@ -378,6 +378,7 @@ public class StoreBookingSettingsServiceImpl extends ServiceImpl<StoreBookingSet
         businessHours.setBookingTimeType(dto.getBookingTimeType());
         businessHours.setStartTime(dto.getStartTime());
         businessHours.setEndTime(dto.getEndTime());
+        businessHours.setBusinessHoursStr(dto.getBusinessHoursStr());
         businessHours.setSort(dto.getSort() != null ? dto.getSort() : 0);
         businessHours.setEssentialId(dto.getEssentialId());
         businessHours.setReservation(dto.getReservation());
@@ -401,6 +402,7 @@ public class StoreBookingSettingsServiceImpl extends ServiceImpl<StoreBookingSet
         dto.setBookingTimeType(businessHours.getBookingTimeType());
         dto.setStartTime(businessHours.getStartTime());
         dto.setEndTime(businessHours.getEndTime());
+        dto.setBusinessHoursStr(businessHours.getBusinessHoursStr());
         dto.setSort(businessHours.getSort());
         dto.setEssentialId(businessHours.getEssentialId());
         dto.setReservation(businessHours.getReservation());

+ 1 - 3
alien-store/src/main/java/shop/alien/store/service/impl/StoreBookingTableServiceImpl.java

@@ -769,9 +769,7 @@ public class StoreBookingTableServiceImpl extends ServiceImpl<StoreBookingTableM
             return false;
         }
         
-        // 查询该桌号下是否有符合条件的预订信息
-        // 条件:订单状态为"待使用"(1)或"已完成"(2)
-        // 对于"已完成"状态的订单,结束时间需要在当前时间3小时内
+        // 联查 user_reservation_table;0/1 有预订;2 在「结束时间+3h」内算有预订(不查订单表)
         long count = userReservationMapper.countReservationsByTableAndOrderStatus(tableId, storeId);
         boolean hasReservation = count > 0;
         

+ 31 - 13
alien-store/src/main/java/shop/alien/store/service/impl/StoreCuisineCategoryServiceImpl.java

@@ -9,6 +9,7 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import shop.alien.entity.store.StoreCuisineCategory;
+import shop.alien.entity.store.constants.StoreMenuType;
 import shop.alien.mapper.StoreCuisineCategoryMapper;
 import shop.alien.store.service.StoreCuisineCategoryService;
 import shop.alien.util.common.JwtUtil;
@@ -30,21 +31,25 @@ import java.util.stream.Collectors;
 public class StoreCuisineCategoryServiceImpl extends ServiceImpl<StoreCuisineCategoryMapper, StoreCuisineCategory> implements StoreCuisineCategoryService {
 
     @Override
-    public List<StoreCuisineCategory> getCategoryList(Integer storeId) {
-        log.info("StoreCuisineCategoryServiceImpl.getCategoryList?storeId={}", storeId);
-        
+    public List<StoreCuisineCategory> getCategoryList(Integer storeId, Integer menuType) {
+        int mt = StoreMenuType.normalizeOrCuisine(menuType);
+        log.info("StoreCuisineCategoryServiceImpl.getCategoryList?storeId={}&menuType={}", storeId, mt);
+
         LambdaQueryWrapper<StoreCuisineCategory> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(StoreCuisineCategory::getStoreId, storeId)
-                .eq(StoreCuisineCategory::getStatus, 1) // 只查询启用的分类
-                .orderByAsc(StoreCuisineCategory::getSort) // 按排序字段升序
-                .orderByDesc(StoreCuisineCategory::getCreatedTime); // 如果排序字段相同,按创建时间倒序
-        
+                .eq(StoreCuisineCategory::getMenuType, mt)
+                .eq(StoreCuisineCategory::getStatus, 1)
+                .orderByAsc(StoreCuisineCategory::getSort)
+                .orderByDesc(StoreCuisineCategory::getCreatedTime);
+
         return this.list(wrapper);
     }
 
     @Override
-    public boolean batchCreateCategories(Integer storeId, List<String> categoryNames) {
-        log.info("StoreCuisineCategoryServiceImpl.batchCreateCategories?storeId={}&categoryNames={}", storeId, categoryNames);
+    public boolean batchCreateCategories(Integer storeId, List<String> categoryNames, Integer menuType) {
+        int mt = StoreMenuType.normalizeOrCuisine(menuType);
+        log.info("StoreCuisineCategoryServiceImpl.batchCreateCategories?storeId={}&categoryNames={}&menuType={}",
+                storeId, categoryNames, mt);
         
         // 从JWT获取当前登录用户ID
         Integer userId = getCurrentUserId();
@@ -62,9 +67,10 @@ public class StoreCuisineCategoryServiceImpl extends ServiceImpl<StoreCuisineCat
             }
         }
         
-        // 检查分类名称是否已存在
+        // 检查分类名称是否已存在(同店同 menu_type 下名称唯一)
         LambdaQueryWrapper<StoreCuisineCategory> checkWrapper = new LambdaQueryWrapper<>();
         checkWrapper.eq(StoreCuisineCategory::getStoreId, storeId)
+                .eq(StoreCuisineCategory::getMenuType, mt)
                 .in(StoreCuisineCategory::getCategoryName, categoryNames);
         List<StoreCuisineCategory> existingCategories = this.list(checkWrapper);
         
@@ -76,9 +82,10 @@ public class StoreCuisineCategoryServiceImpl extends ServiceImpl<StoreCuisineCat
             throw new RuntimeException("此分类名称已存在:" + String.join(",", existingNames));
         }
 
-        // 查询当前最大的排序值
+        // 查询当前 menu_type 下最大的排序值
         LambdaQueryWrapper<StoreCuisineCategory> maxSortWrapper = new LambdaQueryWrapper<>();
         maxSortWrapper.eq(StoreCuisineCategory::getStoreId, storeId)
+                .eq(StoreCuisineCategory::getMenuType, mt)
                 .orderByDesc(StoreCuisineCategory::getSort)
                 .last("LIMIT 1");
         StoreCuisineCategory maxSortCategory = this.getOne(maxSortWrapper);
@@ -92,6 +99,7 @@ public class StoreCuisineCategoryServiceImpl extends ServiceImpl<StoreCuisineCat
                 .map(categoryName -> {
                     StoreCuisineCategory category = new StoreCuisineCategory();
                     category.setStoreId(storeId);
+                    category.setMenuType(mt);
                     category.setCategoryName(categoryName);
                     category.setStatus(1); // 默认启用
                     category.setSort(sortCounter.incrementAndGet()); // 设置排序值
@@ -123,6 +131,8 @@ public class StoreCuisineCategoryServiceImpl extends ServiceImpl<StoreCuisineCat
         if (!categoryName.equals(category.getCategoryName())) {
             LambdaQueryWrapper<StoreCuisineCategory> wrapper = new LambdaQueryWrapper<>();
             wrapper.eq(StoreCuisineCategory::getStoreId, category.getStoreId())
+                    .eq(StoreCuisineCategory::getMenuType, category.getMenuType() != null
+                            ? category.getMenuType() : StoreMenuType.CUISINE)
                     .eq(StoreCuisineCategory::getCategoryName, categoryName)
                     .ne(StoreCuisineCategory::getId, id);
             StoreCuisineCategory existingCategory = this.getOne(wrapper);
@@ -172,11 +182,19 @@ public class StoreCuisineCategoryServiceImpl extends ServiceImpl<StoreCuisineCat
         LambdaQueryWrapper<StoreCuisineCategory> checkWrapper = new LambdaQueryWrapper<>();
         checkWrapper.eq(StoreCuisineCategory::getStoreId, storeId)
                 .in(StoreCuisineCategory::getId, categoryIds);
-        long count = this.count(checkWrapper);
-        if (count != categoryIds.size()) {
+        List<StoreCuisineCategory> owned = this.list(checkWrapper);
+        if (owned.size() != categoryIds.size()) {
             log.warn("更新菜品分类排序失败:部分分类ID不属于该门店");
             throw new RuntimeException("部分分类ID不属于该门店");
         }
+        long distinctTypes = owned.stream()
+                .map(c -> c.getMenuType() != null ? c.getMenuType() : StoreMenuType.CUISINE)
+                .distinct()
+                .count();
+        if (distinctTypes > 1) {
+            log.warn("更新菜品分类排序失败:一次仅允许同一 menu_type 的分类参与排序");
+            throw new RuntimeException("排序列表需全部为美食分类或全部为通用价目分类");
+        }
 
         // 从JWT获取当前登录用户ID
         Integer userId = getCurrentUserId();

+ 23 - 2
alien-store/src/main/java/shop/alien/store/service/impl/StoreProductDiscountServiceImpl.java

@@ -103,7 +103,9 @@ public class StoreProductDiscountServiceImpl implements StoreProductDiscountServ
 				discount = BigDecimal.ZERO;
 			} else if (TYPE_DISCOUNT.equalsIgnoreCase(head.getDiscountType())) {
 				BigDecimal rate = head.getDiscountRate() == null ? BigDecimal.ZERO : head.getDiscountRate();
-				discount = origin.multiply(rate).divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
+				// 折扣价 = 原价 - 原价 * 折扣比例
+				BigDecimal discountAmount = origin.multiply(rate).divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
+				discount = origin.subtract(discountAmount).max(BigDecimal.ZERO);
 			} else {
 				discount = BigDecimal.ZERO;
 			}
@@ -463,7 +465,13 @@ public class StoreProductDiscountServiceImpl implements StoreProductDiscountServ
 			if (total == null) {
 				return null;
 			}
-			return total.multiply(head.getDiscountRate()).divide(java.math.BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
+			// 折扣价 = 原价 - 原价 * 折扣比例
+			java.math.BigDecimal discountAmount = total.multiply(head.getDiscountRate())
+					.divide(java.math.BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
+			java.math.BigDecimal discounted = total.subtract(discountAmount);
+			return discounted.compareTo(java.math.BigDecimal.ZERO) < 0
+					? java.math.BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)
+					: discounted.setScale(2, RoundingMode.HALF_UP);
 		}
 		return null;
 	}
@@ -520,6 +528,7 @@ public class StoreProductDiscountServiceImpl implements StoreProductDiscountServ
 		StoreProductDiscountRule e = new StoreProductDiscountRule();
 		e.setStoreId(dto.getStoreId());
 		e.setProductId(dto.getProductId());
+		e.setRuleProductType(dto.getRuleProductType() == null ? 1 : dto.getRuleProductType());
 		e.setRuleName(dto.getRuleName().trim());
 		e.setDiscountType(dto.getDiscountType());
 		e.setDiscountRate(dto.getDiscountRate());
@@ -537,6 +546,18 @@ public class StoreProductDiscountServiceImpl implements StoreProductDiscountServ
 		if (dto == null) return "参数不能为空";
 		if (dto.getStoreId() == null) return "门店ID不能为空";
 		if (dto.getProductId() == null) return "菜品ID不能为空";
+		// 规则商品类型校验与默认值
+		Integer rpt = dto.getRuleProductType() == null ? 1 : dto.getRuleProductType();
+		if (rpt != 1 && rpt != 2) return "规则商品类型不合法,仅支持1或2";
+		dto.setRuleProductType(rpt);
+		// 基于类型校验 productId 是否存在
+		if (rpt == 1) {
+			StoreCuisine cuisine = storeCuisineService.getById(dto.getProductId());
+			if (cuisine == null || !dto.getStoreId().equals(cuisine.getStoreId())) return "美食不存在或不属于该门店";
+		} else {
+			StorePrice price = storePriceService.getById(dto.getProductId());
+			if (price == null || !dto.getStoreId().equals(price.getStoreId())) return "通用价目不存在或不属于该门店";
+		}
 		if (!StringUtils.hasText(dto.getRuleName())) return "规则名称不能为空";
 		if (!StringUtils.hasText(dto.getDiscountType())) return "优惠类型不能为空";
 		if (!TYPE_FREE.equals(dto.getDiscountType()) && !TYPE_DISCOUNT.equals(dto.getDiscountType())) return "优惠类型不合法";

+ 452 - 0
alien-store/src/main/java/shop/alien/store/service/impl/StoreReceiptTemplateConfigServiceImpl.java

@@ -0,0 +1,452 @@
+package shop.alien.store.service.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import shop.alien.entity.store.StoreReceiptTemplateConfig;
+import shop.alien.entity.store.dto.StoreReceiptTemplateCreateDTO;
+import shop.alien.entity.store.dto.StoreReceiptTemplateEditDTO;
+import shop.alien.entity.store.dto.StoreReceiptTemplateDeleteDTO;
+import shop.alien.entity.store.dto.StoreReceiptTemplateSaveDTO;
+import shop.alien.mapper.StoreReceiptTemplateConfigMapper;
+import shop.alien.store.service.StoreReceiptTemplateConfigService;
+import shop.alien.util.common.JwtUtil;
+
+import java.util.Arrays;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class StoreReceiptTemplateConfigServiceImpl implements StoreReceiptTemplateConfigService {
+
+    private final StoreReceiptTemplateConfigMapper storeReceiptTemplateConfigMapper;
+
+    private static final Set<String> SUPPORTED_FIELD_KEYS = new HashSet<>(Arrays.asList(
+            "ticketName", "storeName", "orderNo", "tableNo", "peopleCount", "diningTime", "cashierTime", "name", "phone",
+            "customData", "beginRemark",
+            "item", "wholeRemark", "countSubtotal", "orderPrice",
+            "paymentDiscount", "paymentInfo",
+            "orderBy", "orderAt", "printBy", "printAt", "customImage", "customText", "storeAddress", "storeTel"
+    ));
+
+    private static final Set<String> SUPPORTED_SUB_KEYS = new HashSet<>(Arrays.asList(
+            "unitPrice", "quantity", "subtotal",
+            "dishPriceTotal", "serviceFeeTotal", "otherServiceFeeTotal", "chargeReason", "orderTotal",
+            "coupon", "manualReduction", "reductionReason",
+            "settlementMethod", "paymentMethod", "paymentTotal", "divider"
+    ));
+
+    @Override
+    public List<StoreReceiptTemplateConfig> listByStoreAndReceiptType(Integer storeId, Integer receiptType) {
+        validateStoreAndReceiptType(storeId, receiptType);
+        ensureDefaultTemplate(storeId, receiptType);
+        LambdaQueryWrapper<StoreReceiptTemplateConfig> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(StoreReceiptTemplateConfig::getStoreId, storeId)
+                .eq(StoreReceiptTemplateConfig::getReceiptType, receiptType)
+                .eq(StoreReceiptTemplateConfig::getDeleteFlag, 0)
+                .orderByAsc(StoreReceiptTemplateConfig::getTemplateType, StoreReceiptTemplateConfig::getId);
+        return storeReceiptTemplateConfigMapper.selectList(wrapper);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Boolean deleteTemplateById(StoreReceiptTemplateDeleteDTO dto) {
+        if (dto == null || dto.getId() == null || dto.getId() <= 0) {
+            throw new RuntimeException("模板ID不能为空");
+        }
+        StoreReceiptTemplateConfig existing = storeReceiptTemplateConfigMapper.selectById(dto.getId());
+        if (existing == null || (existing.getDeleteFlag() != null && existing.getDeleteFlag() == 1)) {
+            return true; // 视为已删除
+        }
+        if (dto.getStoreId() != null && !dto.getStoreId().equals(existing.getStoreId())) {
+            throw new RuntimeException("模板归属不匹配,禁止删除");
+        }
+        if (existing.getTemplateType() != null && existing.getTemplateType() == 1) {
+            throw new RuntimeException("默认模板不可删除");
+        }
+
+
+        LambdaUpdateWrapper<StoreReceiptTemplateConfig> wrapper = new LambdaUpdateWrapper<>();
+        wrapper.eq(StoreReceiptTemplateConfig::getId, dto.getId());
+        wrapper.set(StoreReceiptTemplateConfig::getDeleteFlag, 1);
+        wrapper.set(StoreReceiptTemplateConfig::getUpdatedTime, new Date());
+        return storeReceiptTemplateConfigMapper.update(null, wrapper) > 0;
+    }
+
+    @Override
+    public StoreReceiptTemplateConfig getDetail(Integer storeId, Integer receiptType, Integer templateType) {
+        validateStoreAndReceiptType(storeId, receiptType);
+        validateTemplateType(templateType);
+        ensureDefaultTemplate(storeId, receiptType);
+        return getOne(storeId, receiptType, templateType);
+    }
+
+//    @Override
+//    @Transactional(rollbackFor = Exception.class)
+//    public StoreReceiptTemplateConfig createTemplate(StoreReceiptTemplateCreateDTO dto) {
+//        if (dto == null) {
+//            throw new RuntimeException("参数不能为空");
+//        }
+//        validateStoreAndReceiptType(dto.getStoreId(), dto.getReceiptType());
+//        if (StringUtils.isBlank(dto.getTemplateName())) {
+//            throw new RuntimeException("模板名称不能为空");
+//        }
+//        ensureDefaultTemplate(dto.getStoreId(), dto.getReceiptType());
+//
+//        // 创建与编辑保存走同一套保存逻辑;新建时用默认模板配置初始化自定义模板
+//        StoreReceiptTemplateConfig defaultTemplate = getOne(dto.getStoreId(), dto.getReceiptType(), 1);
+//        String initConfig = defaultTemplate != null ? defaultTemplate.getTemplateConfigJson() : buildDefaultTemplateJson();
+//
+//        StoreReceiptTemplateSaveDTO saveDTO = new StoreReceiptTemplateSaveDTO();
+//        saveDTO.setStoreId(dto.getStoreId());
+//        saveDTO.setReceiptType(dto.getReceiptType());
+//        saveDTO.setTemplateType(2);
+//        saveDTO.setTemplateName(dto.getTemplateName().trim());
+//        saveDTO.setEnabled(0);
+//        saveDTO.setTemplateConfigJson(initConfig);
+//        return saveTemplate(saveDTO);
+//    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public StoreReceiptTemplateConfig saveTemplate(StoreReceiptTemplateSaveDTO dto) {
+        validateSaveDto(dto);
+        validateTemplateConfigJson(dto.getTemplateConfigJson());
+        ensureDefaultTemplate(dto.getStoreId(), dto.getReceiptType());
+
+        StoreReceiptTemplateConfig existing = getOne(dto.getStoreId(), dto.getReceiptType(), dto.getTemplateType());
+        Integer userId = getCurrentUserId();
+        if (existing == null) {
+            existing = new StoreReceiptTemplateConfig();
+            existing.setStoreId(dto.getStoreId());
+            existing.setReceiptType(dto.getReceiptType());
+            existing.setTemplateType(dto.getTemplateType());
+            existing.setTemplateName(defaultTemplateName(dto.getTemplateType()));
+            existing.setEnabled(dto.getTemplateType() == 1 ? 1 : 0);
+            existing.setTemplateConfigJson(buildDefaultTemplateJson());
+            existing.setDeleteFlag(0);
+            existing.setCreatedTime(new Date());
+            existing.setCreatedUserId(userId);
+            storeReceiptTemplateConfigMapper.insert(existing);
+        }
+
+        if (StringUtils.isNotBlank(dto.getTemplateName())) {
+            existing.setTemplateName(dto.getTemplateName().trim());
+        }
+        if (dto.getEnabled() != null) {
+            existing.setEnabled(dto.getEnabled() == 1 ? 1 : 0);
+        }
+        existing.setTemplateConfigJson(dto.getTemplateConfigJson());
+        existing.setUpdatedTime(new Date());
+        existing.setUpdatedUserId(userId);
+        storeReceiptTemplateConfigMapper.updateById(existing);
+
+        if (existing.getEnabled() != null && existing.getEnabled() == 1) {
+            // 一个类型只允许一个启用模板
+            LambdaUpdateWrapper<StoreReceiptTemplateConfig> updateWrapper = new LambdaUpdateWrapper<>();
+            updateWrapper.eq(StoreReceiptTemplateConfig::getStoreId, existing.getStoreId())
+                    .eq(StoreReceiptTemplateConfig::getReceiptType, existing.getReceiptType())
+                    .ne(StoreReceiptTemplateConfig::getId, existing.getId())
+                    .set(StoreReceiptTemplateConfig::getEnabled, 0);
+            storeReceiptTemplateConfigMapper.update(null, updateWrapper);
+        }
+        return getOne(existing.getStoreId(), existing.getReceiptType(), existing.getTemplateType());
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public StoreReceiptTemplateConfig resetToDefault(Integer storeId, Integer receiptType, Integer templateType) {
+        validateStoreAndReceiptType(storeId, receiptType);
+        validateTemplateType(templateType);
+        ensureDefaultTemplate(storeId, receiptType);
+        StoreReceiptTemplateConfig existing = getOne(storeId, receiptType, templateType);
+        if (existing == null) {
+            throw new RuntimeException("模板不存在");
+        }
+        existing.setTemplateConfigJson(buildDefaultTemplateJson());
+        existing.setUpdatedTime(new Date());
+        existing.setUpdatedUserId(getCurrentUserId());
+        storeReceiptTemplateConfigMapper.updateById(existing);
+        return existing;
+    }
+
+    private void ensureDefaultTemplate(Integer storeId, Integer receiptType) {
+        StoreReceiptTemplateConfig defaultTemplate = getOne(storeId, receiptType, 1);
+        if (defaultTemplate == null) {
+            createDefault(storeId, receiptType, 1, "默认模板", 1);
+        }
+    }
+
+    private void createDefault(Integer storeId, Integer receiptType, Integer templateType, String templateName, Integer enabled) {
+        StoreReceiptTemplateConfig config = new StoreReceiptTemplateConfig();
+        config.setStoreId(storeId);
+        config.setReceiptType(receiptType);
+        config.setTemplateType(templateType);
+        config.setTemplateName(templateName);
+        config.setEnabled(enabled);
+        config.setTemplateConfigJson(buildDefaultTemplateJson());
+        config.setDeleteFlag(0);
+        config.setCreatedTime(new Date());
+        config.setCreatedUserId(getCurrentUserId());
+        storeReceiptTemplateConfigMapper.insert(config);
+    }
+
+    private StoreReceiptTemplateConfig getOne(Integer storeId, Integer receiptType, Integer templateType) {
+        LambdaQueryWrapper<StoreReceiptTemplateConfig> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(StoreReceiptTemplateConfig::getStoreId, storeId)
+                .eq(StoreReceiptTemplateConfig::getReceiptType, receiptType)
+                .eq(StoreReceiptTemplateConfig::getTemplateType, templateType)
+                .eq(StoreReceiptTemplateConfig::getDeleteFlag, 0)
+                .last("limit 1");
+        return storeReceiptTemplateConfigMapper.selectOne(wrapper);
+    }
+
+    private void validateSaveDto(StoreReceiptTemplateSaveDTO dto) {
+        if (dto == null) {
+            throw new RuntimeException("参数不能为空");
+        }
+        validateStoreAndReceiptType(dto.getStoreId(), dto.getReceiptType());
+        validateTemplateType(dto.getTemplateType());
+        if (StringUtils.isBlank(dto.getTemplateConfigJson())) {
+            throw new RuntimeException("模板配置不能为空");
+        }
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public StoreReceiptTemplateConfig updateTemplateById(StoreReceiptTemplateEditDTO dto) {
+        if (dto == null || dto.getId() == null || dto.getId() <= 0) {
+            throw new RuntimeException("模板ID不能为空");
+        }
+        validateStoreAndReceiptType(dto.getStoreId(), dto.getReceiptType());
+        validateTemplateType(dto.getTemplateType());
+        validateTemplateConfigJson(dto.getTemplateConfigJson());
+
+        StoreReceiptTemplateConfig existing = storeReceiptTemplateConfigMapper.selectById(dto.getId());
+        if (existing == null || existing.getDeleteFlag() != null && existing.getDeleteFlag() == 1) {
+            throw new RuntimeException("模板不存在或已删除");
+        }
+        if (!existing.getStoreId().equals(dto.getStoreId())
+                || !existing.getReceiptType().equals(dto.getReceiptType())
+                || !existing.getTemplateType().equals(dto.getTemplateType())) {
+            throw new RuntimeException("模板归属不匹配,禁止跨店铺或跨类型修改");
+        }
+
+        if (StringUtils.isNotBlank(dto.getTemplateName())) {
+            existing.setTemplateName(dto.getTemplateName().trim());
+        }
+        if (dto.getEnabled() != null) {
+            existing.setEnabled(dto.getEnabled() == 1 ? 1 : 0);
+        }
+        existing.setTemplateConfigJson(dto.getTemplateConfigJson());
+        existing.setUpdatedTime(new Date());
+        existing.setUpdatedUserId(getCurrentUserId());
+        storeReceiptTemplateConfigMapper.updateById(existing);
+
+        if (existing.getEnabled() != null && existing.getEnabled() == 1) {
+            LambdaUpdateWrapper<StoreReceiptTemplateConfig> updateWrapper = new LambdaUpdateWrapper<>();
+            updateWrapper.eq(StoreReceiptTemplateConfig::getStoreId, existing.getStoreId())
+                    .eq(StoreReceiptTemplateConfig::getReceiptType, existing.getReceiptType())
+                    .ne(StoreReceiptTemplateConfig::getId, existing.getId())
+                    .set(StoreReceiptTemplateConfig::getEnabled, 0);
+            storeReceiptTemplateConfigMapper.update(null, updateWrapper);
+        }
+        return existing;
+    }
+
+    private void validateStoreAndReceiptType(Integer storeId, Integer receiptType) {
+        if (storeId == null || storeId <= 0) {
+            throw new RuntimeException("门店ID不能为空且必须大于0");
+        }
+        if (receiptType == null || (receiptType != 1 && receiptType != 2)) {
+            throw new RuntimeException("票据类型仅支持 1-客单, 2-结账单");
+        }
+    }
+
+    private void validateTemplateType(Integer templateType) {
+        if (templateType == null || (templateType != 1 && templateType != 2)) {
+            throw new RuntimeException("模板类型仅支持 1-默认模板, 2-自定义模板");
+        }
+    }
+
+    private String defaultTemplateName(Integer templateType) {
+        return templateType != null && templateType == 2 ? "自定义模板" : "默认模板";
+    }
+
+    private void validateTemplateConfigJson(String json) {
+        JSONObject root;
+        try {
+            root = JSON.parseObject(json);
+        } catch (Exception e) {
+            throw new RuntimeException("模板配置JSON格式不正确");
+        }
+        if (root == null) {
+            throw new RuntimeException("模板配置不能为空");
+        }
+        JSONArray sections = root.getJSONArray("sections");
+        if (sections == null) {
+            throw new RuntimeException("模板配置缺少sections");
+        }
+        for (int i = 0; i < sections.size(); i++) {
+            JSONObject section = sections.getJSONObject(i);
+            if (section == null) {
+                continue;
+            }
+            JSONArray fields = section.getJSONArray("fields");
+            if (fields == null) {
+                continue;
+            }
+            for (int j = 0; j < fields.size(); j++) {
+                JSONObject field = fields.getJSONObject(j);
+                if (field == null) {
+                    continue;
+                }
+                String key = field.getString("key");
+                if (StringUtils.isBlank(key) || !SUPPORTED_FIELD_KEYS.contains(key)) {
+                    throw new RuntimeException("包含不支持的字段: " + key);
+                }
+                JSONArray children = field.getJSONArray("children");
+                if (children == null) {
+                    continue;
+                }
+                for (int k = 0; k < children.size(); k++) {
+                    JSONObject child = children.getJSONObject(k);
+                    if (child == null) {
+                        continue;
+                    }
+                    String subKey = child.getString("key");
+                    if (StringUtils.isBlank(subKey) || !SUPPORTED_SUB_KEYS.contains(subKey)) {
+                        throw new RuntimeException("包含不支持的子字段: " + subKey);
+                    }
+                }
+            }
+        }
+    }
+
+    private Integer getCurrentUserId() {
+        try {
+            JSONObject userInfo = JwtUtil.getCurrentUserInfo();
+            if (userInfo != null) {
+                return userInfo.getInteger("userId");
+            }
+        } catch (Exception e) {
+            log.warn("获取当前登录用户ID失败: {}", e.getMessage());
+        }
+        return null;
+    }
+
+    private String buildDefaultTemplateJson() {
+        JSONObject root = new JSONObject();
+        JSONArray sections = new JSONArray();
+        root.put("sections", sections);
+
+        sections.add(createSection("basic", "基础信息", createBasicFields()));
+        sections.add(createSection("itemInfo", "品相信息", createItemFields()));
+        sections.add(createSection("settlement", "结算信息", createSettlementFields()));
+        sections.add(createSection("footer", "底栏", createFooterFields()));
+        return root.toJSONString();
+    }
+
+    private JSONObject createSection(String key, String name, JSONArray fields) {
+        JSONObject section = new JSONObject();
+        section.put("key", key);
+        section.put("name", name);
+        section.put("fields", fields);
+        return section;
+    }
+
+    private JSONArray createBasicFields() {
+        JSONArray fields = new JSONArray();
+        fields.add(createTextField("ticketName", "票据名称", true));
+        fields.add(createTextField("storeName", "店铺名称", true));
+        fields.add(createTextField("orderNo", "单号", true));
+        fields.add(createTextField("tableNo", "桌号", true));
+        fields.add(createTextField("peopleCount", "人数", true));
+        fields.add(createTextField("diningTime", "就餐时间", true));
+        fields.add(createTextField("cashierTime", "结账时间", true));
+        fields.add(createTextField("name", "姓名", true));
+        fields.add(createTextField("phone", "电话", true));
+        fields.add(createTextField("customData", "自定义数据", false));
+        fields.add(createTextField("beginRemark", "开台备注", false));
+        return fields;
+    }
+
+    private JSONArray createItemFields() {
+        JSONArray fields = new JSONArray();
+        fields.add(createSwitchField("item", "品项", true,
+                new String[]{"unitPrice", "quantity", "subtotal"},
+                new String[]{"单价", "数量", "小计"},
+                new boolean[]{true, true, true}));
+        fields.add(createTextField("wholeRemark", "整单备注", false));
+        fields.add(createTextField("countSubtotal", "数量合计", false));
+        fields.add(createSwitchField("orderPrice", "订单价格", true,
+                new String[]{"dishPriceTotal", "serviceFeeTotal", "otherServiceFeeTotal", "chargeReason", "orderTotal"},
+                new String[]{"菜品价格合计", "服务费合计", "其他费用合计", "收费原因", "订单合计"},
+                new boolean[]{true, true, false, false, true}));
+        return fields;
+    }
+
+    private JSONArray createSettlementFields() {
+        JSONArray fields = new JSONArray();
+        fields.add(createSwitchField("paymentDiscount", "支付优惠", true,
+                new String[]{"coupon", "manualReduction", "reductionReason"},
+                new String[]{"优惠券", "手动减免", "减免原因"},
+                new boolean[]{true, false, false}));
+        fields.add(createSwitchField("paymentInfo", "支付信息", true,
+                new String[]{"settlementMethod", "paymentMethod", "paymentTotal", "divider"},
+                new String[]{"结算方式", "支付方式", "支付合计", "分割线"},
+                new boolean[]{true, true, true, true}));
+        return fields;
+    }
+
+    private JSONArray createFooterFields() {
+        JSONArray fields = new JSONArray();
+        fields.add(createTextField("orderBy", "下单人", true));
+        fields.add(createTextField("orderAt", "下单时间", true));
+        fields.add(createTextField("printBy", "打印人", true));
+        fields.add(createTextField("printAt", "打印时间", true));
+        fields.add(createTextField("customImage", "自定义图片", false));
+        fields.add(createTextField("customText", "自定义文字", false));
+        fields.add(createTextField("storeAddress", "门店地址", false));
+        fields.add(createTextField("storeTel", "门店电话", false));
+        return fields;
+    }
+
+    private JSONObject createTextField(String key, String titleName, boolean visible) {
+        JSONObject field = new JSONObject();
+        field.put("key", key);
+        field.put("titleName", titleName);
+        field.put("visible", visible);
+        field.put("fontSize", "normal");
+        field.put("align", "left");
+        field.put("bold", false);
+        field.put("divider", false);
+        field.put("blankLines", 0);
+        return field;
+    }
+
+    private JSONObject createSwitchField(String key, String titleName, boolean visible, String[] subKeys, String[] subTitles, boolean[] subVisible) {
+        JSONObject field = createTextField(key, titleName, visible);
+        JSONArray children = new JSONArray();
+        for (int i = 0; i < subKeys.length; i++) {
+            JSONObject child = new JSONObject();
+            child.put("key", subKeys[i]);
+            child.put("titleName", subTitles[i]);
+            child.put("visible", subVisible[i]);
+            children.add(child);
+        }
+        field.put("children", children);
+        return field;
+    }
+}

+ 9 - 89
alien-store/src/main/java/shop/alien/store/service/impl/StoreReservationServiceImpl.java

@@ -14,7 +14,6 @@ import shop.alien.entity.store.UserReservationTable;
 import shop.alien.entity.store.UserReservationOrder;
 import shop.alien.entity.store.vo.StoreReservationListVo;
 import shop.alien.entity.store.StoreBookingBusinessHours;
-import shop.alien.entity.store.StoreBookingSettings;
 import shop.alien.entity.store.EssentialHolidayComparison;
 import shop.alien.entity.store.vo.StoreBusinessInfoVo;
 import shop.alien.mapper.StoreReservationMapper;
@@ -27,8 +26,6 @@ import shop.alien.store.service.StoreInfoService;
 import shop.alien.store.service.MerchantPaymentOrderService;
 import shop.alien.store.service.StoreReservationService;
 import shop.alien.store.service.UserReservationOrderService;
-import shop.alien.store.service.StoreBookingBusinessHoursService;
-import shop.alien.store.service.StoreBookingSettingsService;
 import shop.alien.store.strategy.merchantPayment.MerchantPaymentStrategy;
 import shop.alien.store.strategy.merchantPayment.MerchantPaymentStrategyFactory;
 import shop.alien.store.util.ali.AliSms;
@@ -74,8 +71,6 @@ public class StoreReservationServiceImpl extends ServiceImpl<StoreReservationMap
     private final LifeUserMapper lifeUserMapper;
     private final StoreUserMapper storeUserMapper;
     private final MerchantPaymentStrategyFactory merchantPaymentStrategyFactory;
-    private final StoreBookingBusinessHoursService storeBookingBusinessHoursService;
-    private final StoreBookingSettingsService storeBookingSettingsService;
     private final EssentialHolidayComparisonMapper essentialHolidayComparisonMapper;
     private final MerchantPaymentOrderService merchantPaymentOrderService;
 
@@ -712,7 +707,7 @@ public class StoreReservationServiceImpl extends ServiceImpl<StoreReservationMap
         // 校验:不能超过下一个已确认预约的开始时间
         validateAddTimeNotExceedNextReservation(reservation, normalizedAddTimeStart, newEndTime);
 
-        // 校验:不能超过营业时间结束时间
+        // 校验:不能超过门店营业时间结束时间(store_business_info)
         validateAddTimeNotExceedBusinessHours(reservation, normalizedAddTimeStart, newEndTime);
 
         // 更新预约结束时间
@@ -1077,10 +1072,11 @@ public class StoreReservationServiceImpl extends ServiceImpl<StoreReservationMap
     }
 
     /**
-     * 校验加时不能超过营业时间的结束时间
-     * 
+     * 校验加时不能超过门店营业结束时间(数据来源:{@code store_business_info},不再使用预订营业时间 store_booking_business_hours)
+     *
      * @param reservation 预约信息
-     * @param newEndTime 新的结束时间(yyyy-MM-dd HH:mm:ss格式)
+     * @param addTimeStart  加时开始时间(yyyy-MM-dd HH:mm:ss)
+     * @param newEndTime    新的结束时间(yyyy-MM-dd HH:mm 或 HH:mm:ss 经 normalize 后)
      */
     private void validateAddTimeNotExceedBusinessHours(UserReservation reservation, String addTimeStart, String newEndTime) {
         try {
@@ -1089,95 +1085,19 @@ public class StoreReservationServiceImpl extends ServiceImpl<StoreReservationMap
                 return;
             }
 
-            // 查询门店的预订设置
-            StoreBookingSettings settings = storeBookingSettingsService.getByStoreId(reservation.getStoreId());
-            if (settings == null || settings.getId() == null) {
-                log.warn("门店未配置预订设置,跳过营业时间校验,storeId={}", reservation.getStoreId());
-                return;
-            }
-
-            // 查询营业时间列表
-            List<StoreBookingBusinessHours> businessHoursList = storeBookingBusinessHoursService.getListBySettingsId(settings.getId());
-            if (businessHoursList == null || businessHoursList.isEmpty()) {
-                log.warn("门店未配置营业时间,跳过营业时间校验,storeId={}", reservation.getStoreId());
-                return;
-            }
-
-            // 解析预约日期
             Date reservationDate = reservation.getReservationDate();
             SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
             String reservationDateStr = dateFormat.format(reservationDate);
 
-            // 查找匹配的营业时间
-            StoreBookingBusinessHours matchedBusinessHours = null;
-
-            // 先查找特殊营业时间(business_type=2,兼容旧值1)
-            // 特殊营业时间的特征:有 holidayDate 或 essentialId 或 holidayType 不为空
-            for (StoreBookingBusinessHours businessHours : businessHoursList) {
-                Integer businessType = businessHours.getBusinessType();
-                // 判断是否为特殊营业时间:新值2,或旧值1且有关联的节假日信息
-                boolean isSpecialBusinessHours = false;
-                if (businessType != null) {
-                    if (businessType == 2) {
-                        // 新值:2 表示特殊营业时间
-                        isSpecialBusinessHours = true;
-                    } else if (businessType == 1) {
-                        // 旧值:1 可能是特殊营业时间,需要检查是否有节假日信息
-                        if (businessHours.getEssentialId() != null || 
-                            businessHours.getHolidayDate() != null || 
-                            (businessHours.getHolidayType() != null && !businessHours.getHolidayType().trim().isEmpty())) {
-                            isSpecialBusinessHours = true;
-                        }
-                    }
-                }
-                
-                if (isSpecialBusinessHours) {
-                    // 特殊营业时间:需要匹配 essential_holiday_comparison 表中的日期范围
-                    if (businessHours.getEssentialId() != null) {
-                        EssentialHolidayComparison holiday = essentialHolidayComparisonMapper.selectById(businessHours.getEssentialId());
-                        if (holiday != null && holiday.getStartTime() != null && holiday.getEndTime() != null) {
-                            // 判断预约日期是否在节假日的日期范围内
-                            Date startTime = holiday.getStartTime();
-                            Date endTime = holiday.getEndTime();
-                            if (!reservationDate.before(startTime) && !reservationDate.after(endTime)) {
-                                matchedBusinessHours = businessHours;
-                                break;
-                            }
-                        }
-                    } else if (businessHours.getHolidayDate() != null) {
-                        // 如果没有 essentialId,使用 holidayDate 匹配
-                        if (reservationDateStr.equals(businessHours.getHolidayDate())) {
-                            matchedBusinessHours = businessHours;
-                            break;
-                        }
-                    }
-                }
-            }
-
-            // 如果没有匹配到特殊营业时间,使用正常营业时间(business_type=1,兼容旧值0)
-            if (matchedBusinessHours == null) {
-                matchedBusinessHours = businessHoursList.stream()
-                        .filter(h -> {
-                            Integer businessType = h.getBusinessType();
-                            // 兼容旧值:0 和新值:1 都表示正常营业时间
-                            return businessType != null && (businessType == 1 || businessType == 0);
-                        })
-                        .findFirst()
-                        .orElse(null);
-            }
-
-            // 如果 store_booking_business_hours 中没有找到匹配的营业时间,则从 store_business_info 查询
-            if (matchedBusinessHours == null) {
-                log.info("store_booking_business_hours 中未找到匹配的营业时间,尝试从 store_business_info 查询,storeId={}", reservation.getStoreId());
-                matchedBusinessHours = findBusinessHoursFromStoreBusinessInfo(reservation, reservationDate, reservationDateStr);
-            }
+            StoreBookingBusinessHours matchedBusinessHours =
+                    findBusinessHoursFromStoreBusinessInfo(reservation, reservationDate, reservationDateStr);
 
             if (matchedBusinessHours == null) {
-                log.warn("未找到匹配的营业时间,跳过营业时间校验");
+                log.warn("未找到门店营业时间(store_business_info),跳过加时营业结束时间校验,storeId={}", reservation.getStoreId());
                 return;
             }
 
-            // 如果是全天营业(booking_time_type=1),不限制结束时间
+            // store_business_info 无预订侧「全天」字段;包装对象上 bookingTimeType 恒为 null,此处保留分支以备扩展
             if (matchedBusinessHours.getBookingTimeType() != null && matchedBusinessHours.getBookingTimeType() == 1) {
                 log.info("全天营业,不限制结束时间");
                 return;

+ 98 - 32
alien-store/src/main/java/shop/alien/store/service/impl/StoreServiceFeeRuleServiceImpl.java

@@ -229,36 +229,43 @@ public class StoreServiceFeeRuleServiceImpl implements StoreServiceFeeRuleServic
         if (existing == null) {
             return R.fail("服务费规则不存在");
         }
-        String checkMsg = validateAndCheckConflict(dto, dto.getId());
+        // 仅编辑当前这一条:限定校验到“当前桌台 + 首个时段”
+        StoreServiceFeeRuleSlotDto slotForEdit;
+        if (dto.getSlots() != null && !dto.getSlots().isEmpty()) {
+            slotForEdit = dto.getSlots().get(0);
+        } else {
+            StoreServiceFeeRuleSlotDto s = new StoreServiceFeeRuleSlotDto();
+            s.setWeekdayMask(existing.getWeekdayMask());
+            s.setStartTime(existing.getStartTime() != null ? existing.getStartTime().format(TIME_FORMATTER) : null);
+            s.setEndTime(existing.getEndTime() != null ? existing.getEndTime().format(TIME_FORMATTER) : null);
+            slotForEdit = s;
+        }
+        StoreServiceFeeRuleSaveDto dtoForCheck = new StoreServiceFeeRuleSaveDto();
+        dtoForCheck.setId(dto.getId());
+        dtoForCheck.setStoreId(dto.getStoreId());
+        dtoForCheck.setFeeName(dto.getFeeName());
+        dtoForCheck.setFeeType(dto.getFeeType());
+        dtoForCheck.setFeeValue(dto.getFeeValue());
+        dtoForCheck.setStatus(dto.getStatus());
+        dtoForCheck.setEffectiveMode(dto.getEffectiveMode());
+        dtoForCheck.setStartDate(dto.getStartDate());
+        dtoForCheck.setEndDate(dto.getEndDate());
+        dtoForCheck.setTableIds(Collections.singletonList(existing.getTableId()));
+        dtoForCheck.setSlots(Collections.singletonList(slotForEdit));
+        String checkMsg = validateAndCheckConflict(dtoForCheck, dto.getId());
         if (checkMsg != null) {
             return R.fail(checkMsg);
         }
 
-        // 删除同组旧记录并重建
-        List<StoreServiceFeeRule> sameGroup = ruleMapper.selectList(new LambdaQueryWrapper<StoreServiceFeeRule>()
-                .eq(StoreServiceFeeRule::getStoreId, existing.getStoreId())
-                .eq(StoreServiceFeeRule::getFeeName, existing.getFeeName())
-                .eq(StoreServiceFeeRule::getFeeType, existing.getFeeType())
-                .eq(StoreServiceFeeRule::getFeeValue, existing.getFeeValue())
-                .eq(StoreServiceFeeRule::getStatus, existing.getStatus())
-                .eq(StoreServiceFeeRule::getEffectiveMode, existing.getEffectiveMode())
-                .eq(StoreServiceFeeRule::getStartDate, existing.getStartDate())
-                .eq(StoreServiceFeeRule::getEndDate, existing.getEndDate()));
-        for (StoreServiceFeeRule r : sameGroup) {
-            ruleMapper.deleteById(r.getId());
-        }
         Integer userId = getCurrentUserId();
-        for (Integer tableId : dto.getTableIds()) {
-            for (StoreServiceFeeRuleSlotDto slotDto : dto.getSlots()) {
-                StoreServiceFeeRule rule = new StoreServiceFeeRule();
-                fillRuleFromDto(rule, dto, userId, true);
-                rule.setTableId(tableId);
-                rule.setWeekdayMask(slotDto.getWeekdayMask());
-                rule.setStartTime(LocalTime.parse(slotDto.getStartTime(), TIME_FORMATTER));
-                rule.setEndTime(LocalTime.parse(slotDto.getEndTime(), TIME_FORMATTER));
-                ruleMapper.insert(rule);
-            }
-        }
+        StoreServiceFeeRule update = new StoreServiceFeeRule();
+        update.setId(existing.getId());
+        fillRuleFromDto(update, dto, userId, true);
+        update.setTableId(existing.getTableId());
+        update.setWeekdayMask(slotForEdit.getWeekdayMask());
+        update.setStartTime(slotForEdit.getStartTime() != null ? LocalTime.parse(slotForEdit.getStartTime(), TIME_FORMATTER) : existing.getStartTime());
+        update.setEndTime(slotForEdit.getEndTime() != null ? LocalTime.parse(slotForEdit.getEndTime(), TIME_FORMATTER) : existing.getEndTime());
+        ruleMapper.updateById(update);
         return R.data(true);
     }
 
@@ -423,10 +430,10 @@ public class StoreServiceFeeRuleServiceImpl implements StoreServiceFeeRuleServic
                 return "开始日期不能大于结束日期";
             }
         }
-        for (StoreServiceFeeRuleSlotDto slot : dto.getSlots()) {
-            if (slot.getWeekdayMask() == null || slot.getWeekdayMask() <= 0) {
-                return "生效星期不能为空";
-            }
+
+        // 校验每个时段的时间合法性(星期可选填)
+        List<StoreServiceFeeRuleSlotDto> slots = dto.getSlots();
+        for (StoreServiceFeeRuleSlotDto slot : slots) {
             if (!StringUtils.hasText(slot.getStartTime()) || !StringUtils.hasText(slot.getEndTime())) {
                 return "生效时间不能为空";
             }
@@ -436,6 +443,25 @@ public class StoreServiceFeeRuleServiceImpl implements StoreServiceFeeRuleServic
                 return "生效开始时间必须早于结束时间";
             }
         }
+        // 校验同一次提交内的时段互不重叠(星期为空时视为“全周”)
+        for (int i = 0; i < slots.size(); i++) {
+            StoreServiceFeeRuleSlotDto a = slots.get(i);
+            LocalTime aStart = LocalTime.parse(a.getStartTime(), TIME_FORMATTER);
+            LocalTime aEnd = LocalTime.parse(a.getEndTime(), TIME_FORMATTER);
+            for (int j = i + 1; j < slots.size(); j++) {
+                StoreServiceFeeRuleSlotDto b = slots.get(j);
+                int aMask = (a.getWeekdayMask() == null || a.getWeekdayMask() <= 0) ? 127 : a.getWeekdayMask();
+                int bMask = (b.getWeekdayMask() == null || b.getWeekdayMask() <= 0) ? 127 : b.getWeekdayMask();
+                if ( (aMask & bMask) <= 0 ) {
+                    continue;
+                }
+                LocalTime bStart = LocalTime.parse(b.getStartTime(), TIME_FORMATTER);
+                LocalTime bEnd = LocalTime.parse(b.getEndTime(), TIME_FORMATTER);
+                if (isTimeOverlap(aStart, aEnd, bStart, bEnd)) {
+                    return "同一次提交的生效时段之间存在重叠,请调整后再提交";
+                }
+            }
+        }
         return null;
     }
 
@@ -453,6 +479,30 @@ public class StoreServiceFeeRuleServiceImpl implements StoreServiceFeeRuleServic
             return null;
         }
 
+        // 永久生效规则(无起止日期)与同桌台下任意其他开启规则互斥
+        boolean newIsPermanent = MODE_PERMANENT.equals(dto.getEffectiveMode());
+        if (newIsPermanent) {
+            Integer conflictTableId = existRules.stream()
+                    .map(StoreServiceFeeRule::getTableId)
+                    .filter(tableIds::contains)
+                    .findFirst()
+                    .orElse(null);
+            if (conflictTableId != null) {
+                String tableName = getTableName(conflictTableId);
+                return "桌台[" + tableName + "]已存在开启的服务费规则,永久生效规则不允许与其他规则共存";
+            }
+        }
+        Integer conflictPermanentTableId = existRules.stream()
+                .filter(rule -> MODE_PERMANENT.equals(rule.getEffectiveMode()))
+                .map(StoreServiceFeeRule::getTableId)
+                .filter(tableIds::contains)
+                .findFirst()
+                .orElse(null);
+        if (conflictPermanentTableId != null) {
+            String tableName = getTableName(conflictPermanentTableId);
+            return "桌台[" + tableName + "]已存在永久生效服务费规则,不允许再创建或编辑其他服务费规则";
+        }
+
         Date newStartDate = MODE_CUSTOM.equals(dto.getEffectiveMode()) ? parseDate(dto.getStartDate()) : toDate(LocalDate.of(1970, 1, 1));
         Date newEndDate = MODE_CUSTOM.equals(dto.getEffectiveMode()) ? parseDate(dto.getEndDate()) : toDate(LocalDate.of(2099, 12, 31));
         List<StoreServiceFeeRuleSlotDto> newSlots = dto.getSlots();
@@ -466,16 +516,21 @@ public class StoreServiceFeeRuleServiceImpl implements StoreServiceFeeRuleServic
             for (StoreServiceFeeRuleSlotDto ns : newSlots) {
                 LocalTime nsStart = LocalTime.parse(ns.getStartTime(), TIME_FORMATTER);
                 LocalTime nsEnd = LocalTime.parse(ns.getEndTime(), TIME_FORMATTER);
-                if ((ns.getWeekdayMask() & (existRule.getWeekdayMask() == null ? 0 : existRule.getWeekdayMask())) <= 0) {
+                int newMask = (ns.getWeekdayMask() == null || ns.getWeekdayMask() <= 0) ? 127 : ns.getWeekdayMask();
+                int oldMask = (existRule.getWeekdayMask() == null || existRule.getWeekdayMask() <= 0) ? 127 : existRule.getWeekdayMask();
+                if ( (newMask & oldMask) <= 0 ) {
                     continue;
                 }
                 if (isTimeOverlap(nsStart, nsEnd, existRule.getStartTime(), existRule.getEndTime())) {
                     Integer tableId = existRule.getTableId();
+                    String tableName = getTableName(tableId);
                     if (tableId != null && tableIds.contains(tableId)) {
                         if (!dto.getFeeType().equals(existRule.getFeeType())) {
-                            return "桌台[" + tableId + "]在该时间段已配置其他类型服务费";
+                            return "桌台[" + tableName + "]在该时间段已配置其他类型服务费";
+//                            return "桌台[" + tableId + "]在该时间段已配置其他类型服务费";
                         }
-                        return "桌台[" + tableId + "]服务费时间重叠,请调整生效日期/星期/时间";
+                        return "桌台[" + tableName + "]服务费时间重叠,请调整生效日期/星期/时间";
+//                        return "桌台[" + tableId + "]服务费时间重叠,请调整生效日期/星期/时间";
                     }
                 }
             }
@@ -483,6 +538,17 @@ public class StoreServiceFeeRuleServiceImpl implements StoreServiceFeeRuleServic
         return null;
     }
 
+    private String getTableName(Integer tableId) {
+        if (tableId == null) {
+            return "";
+        }
+        shop.alien.entity.store.StoreTable table = storeBookingTableService.getById(tableId);
+        if (table == null || !StringUtils.hasText(table.getTableNumber())) {
+            return String.valueOf(tableId);
+        }
+        return table.getTableNumber();
+    }
+
     private boolean isDateOverlap(Date s1, Date e1, Date s2, Date e2) {
         return !s1.after(e2) && !s2.after(e1);
     }

+ 37 - 4
alien-store/src/main/java/shop/alien/store/service/impl/StoreTableServiceImpl.java

@@ -12,11 +12,13 @@ import org.apache.commons.lang3.StringUtils;
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
+import shop.alien.entity.store.StoreInfo;
 import shop.alien.entity.store.StoreTable;
 import shop.alien.entity.store.StoreTableLog;
 import shop.alien.entity.store.dto.StoreTableChangeDTO;
 import shop.alien.entity.store.dto.StoreTableDTO;
 import shop.alien.entity.store.vo.StoreTableStatusVO;
+import shop.alien.mapper.StoreInfoMapper;
 import shop.alien.mapper.StoreTableLogMapper;
 import shop.alien.mapper.StoreTableMapper;
 import shop.alien.store.config.BaseRedisService;
@@ -42,6 +44,7 @@ import java.util.stream.Collectors;
 public class StoreTableServiceImpl extends ServiceImpl<StoreTableMapper, StoreTable> implements StoreTableService {
 
     private final StoreTableLogMapper storeTableLogMapper;
+    private final StoreInfoMapper storeInfoMapper;
     private final WeChatMiniProgramQrCodeService weChatMiniProgramQrCodeService;
     private final TableAppQrCodeService tableAppQrCodeService;
     private final BaseRedisService baseRedisService;
@@ -132,7 +135,8 @@ public class StoreTableServiceImpl extends ServiceImpl<StoreTableMapper, StoreTa
                     boolean any = false;
 
                     if (StringUtils.isBlank(table.getQrcodeUrl())) {
-                        String qrCodeUrl = weChatMiniProgramQrCodeService.generateTableQrCode(table.getId(), storeId);
+                        String qrCodeUrl = weChatMiniProgramQrCodeService.generateTableQrCode(table.getId(), storeId,
+                                resolveQrMenuTypeForTable(table));
                         if (StringUtils.isNotBlank(qrCodeUrl)) {
                             updateWrapper.set(StoreTable::getQrcodeUrl, qrCodeUrl);
                             any = true;
@@ -141,7 +145,8 @@ public class StoreTableServiceImpl extends ServiceImpl<StoreTableMapper, StoreTa
                     }
 
                     if (StringUtils.isBlank(table.getAppQrcodeUrl())) {
-                        String appUrl = tableAppQrCodeService.generateTableAppQrCodeAndUpload(storeId, table.getTableNumber());
+                        String appUrl = tableAppQrCodeService.generateTableAppQrCodeAndUpload(storeId, table.getId(),
+                                resolveQrMenuTypeForTable(table));
                         if (StringUtils.isNotBlank(appUrl)) {
                             updateWrapper.set(StoreTable::getAppQrcodeUrl, appUrl);
                             any = true;
@@ -218,8 +223,10 @@ public class StoreTableServiceImpl extends ServiceImpl<StoreTableMapper, StoreTa
                 return;
             }
 
-            String qrCodeUrl = weChatMiniProgramQrCodeService.generateTableQrCode(tableId, storeId);
-            String appUrl = tableAppQrCodeService.generateTableAppQrCodeAndUpload(storeId, table.getTableNumber());
+            String qrCodeUrl = weChatMiniProgramQrCodeService.generateTableQrCode(tableId, storeId,
+                    resolveQrMenuTypeForTable(table));
+            String appUrl = tableAppQrCodeService.generateTableAppQrCodeAndUpload(storeId, table.getId(),
+                    resolveQrMenuTypeForTable(table));
 
             LambdaUpdateWrapper<StoreTable> updateWrapper = new LambdaUpdateWrapper<>();
             updateWrapper.eq(StoreTable::getId, tableId);
@@ -393,6 +400,32 @@ public class StoreTableServiceImpl extends ServiceImpl<StoreTableMapper, StoreTa
     }
 
     /**
+     * 桌码中的点餐类型 m(1=美食/store_cuisine,2=通用价目/store_price):
+     * 优先按门店 {@code store_info.business_section}——1 特色美食→1;2 休闲娱乐、3 生活服务→2;
+     * 未配置时再按 {@code store_table.type}(2→2,否则→1)。
+     */
+    private int resolveQrMenuTypeForTable(StoreTable table) {
+        if (table == null) {
+            return 1;
+        }
+        Integer storeId = table.getStoreId();
+        if (storeId != null) {
+            StoreInfo info = storeInfoMapper.selectById(storeId);
+            if (info != null && info.getBusinessSection() != null) {
+                int sec = info.getBusinessSection();
+                if (sec == 1) {
+                    return 1;
+                }
+                if (sec == 2 || sec == 3) {
+                    return 2;
+                }
+            }
+        }
+        Integer t = table.getType();
+        return Integer.valueOf(2).equals(t) ? 2 : 1;
+    }
+
+    /**
      * 从JWT获取当前登录用户ID
      *
      * @return 用户ID,如果未登录返回null

+ 216 - 0
alien-store/src/main/java/shop/alien/store/service/impl/StoreTurnoverServiceImpl.java

@@ -0,0 +1,216 @@
+package shop.alien.store.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import lombok.RequiredArgsConstructor;
+import org.apache.commons.collections4.CollectionUtils;
+import org.springframework.stereotype.Service;
+import shop.alien.entity.result.R;
+import shop.alien.entity.store.StoreOrder;
+import shop.alien.entity.store.vo.TurnoverBreakdownItemVO;
+import shop.alien.entity.store.vo.TurnoverDetailByDateVO;
+import shop.alien.entity.store.vo.TurnoverDetailItemVO;
+import shop.alien.entity.store.vo.TurnoverSummaryVO;
+import shop.alien.mapper.StoreOrderMapper;
+import shop.alien.store.service.StoreTurnoverService;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class StoreTurnoverServiceImpl implements StoreTurnoverService {
+
+	private final StoreOrderMapper storeOrderMapper;
+
+	@Override
+	public R<TurnoverSummaryVO> summary(Integer storeId, Date startTime, Date endTime) {
+		LambdaQueryWrapper<StoreOrder> qw = basePaidQuery(storeId, startTime, endTime);
+		List<StoreOrder> orders = storeOrderMapper.selectList(qw);
+
+		BigDecimal totalSales = orders.stream()
+			.map(StoreOrder::getPayAmount)
+			.filter(Objects::nonNull)
+			.reduce(BigDecimal.ZERO, BigDecimal::add);
+		int orderCount = orders.size();
+		BigDecimal avg = orderCount == 0 ? BigDecimal.ZERO :
+			totalSales.divide(BigDecimal.valueOf(orderCount), 2, RoundingMode.HALF_UP);
+
+		// 支付方式分布
+		Map<Integer, String> payTypeName = new HashMap<>();
+		payTypeName.put(1, "微信");
+		payTypeName.put(2, "支付宝");
+		payTypeName.put(4, "银行卡");
+		payTypeName.put(3, "现金");
+
+		List<TurnoverBreakdownItemVO> payTypeItems = buildBreakdown(
+			groupByAndSum(orders, StoreOrder::getPayType),
+			payTypeName,
+			totalSales
+		);
+
+		// 收款方式分布
+		Map<Integer, String> receiveName = new HashMap<>();
+		receiveName.put(1, "手机支付");
+		receiveName.put(2, "收银台");
+		List<TurnoverBreakdownItemVO> receiveItems = buildBreakdown(
+			groupByAndSum(orders, StoreOrder::getPaymentMethod),
+			receiveName,
+			totalSales
+		);
+
+		TurnoverSummaryVO vo = new TurnoverSummaryVO();
+		vo.setTotalSalesAmount(scale2(totalSales));
+		vo.setTotalOrderCount(orderCount);
+		vo.setAverageOrderAmount(scale2(avg));
+		vo.setPayTypeBreakdown(payTypeItems);
+		vo.setReceiveMethodBreakdown(receiveItems);
+		return R.data(vo);
+	}
+
+	@Override
+	public R<IPage<TurnoverDetailItemVO>> details(Integer storeId, Integer payType, Integer paymentMethod,
+	                                              Date startTime, Date endTime, Integer pageNum, Integer pageSize) {
+		LambdaQueryWrapper<StoreOrder> qw = basePaidQuery(storeId, startTime, endTime);
+		if (payType != null) {
+			qw.eq(StoreOrder::getPayType, payType);
+		}
+		if (paymentMethod != null) {
+			qw.eq(StoreOrder::getPaymentMethod, paymentMethod);
+		}
+		qw.orderByDesc(StoreOrder::getPayTime).orderByDesc(StoreOrder::getId);
+		Page<StoreOrder> page = new Page<>(pageNum == null ? 1 : pageNum, pageSize == null ? 10 : pageSize);
+		IPage<StoreOrder> orderPage = storeOrderMapper.selectPage(page, qw);
+
+		Map<Integer, String> payTypeName = new HashMap<>();
+		payTypeName.put(1, "微信");
+		payTypeName.put(2, "支付宝");
+		payTypeName.put(4, "银行卡");
+		payTypeName.put(3, "现金");
+		Map<Integer, String> receiveName = new HashMap<>();
+		receiveName.put(1, "手机支付");
+		receiveName.put(2, "收银台");
+
+		List<TurnoverDetailItemVO> details = orderPage.getRecords().stream().map(o -> {
+			TurnoverDetailItemVO item = new TurnoverDetailItemVO();
+			item.setOrderNo(o.getOrderNo());
+			item.setPayAmount(scale2(o.getPayAmount() == null ? BigDecimal.ZERO : o.getPayAmount()));
+			item.setPayTime(o.getPayTime());
+			item.setPayType(o.getPayType());
+			item.setPayTypeName(payTypeName.getOrDefault(o.getPayType(), "-"));
+			item.setPaymentMethod(o.getPaymentMethod());
+			item.setPaymentMethodName(receiveName.getOrDefault(o.getPaymentMethod(), "-"));
+			return item;
+		}).collect(Collectors.toList());
+
+		Page<TurnoverDetailItemVO> result = new Page<>(orderPage.getCurrent(), orderPage.getSize(), orderPage.getTotal());
+		result.setRecords(details);
+		return R.data(result);
+	}
+
+	@Override
+	public R<List<TurnoverDetailByDateVO>> detailsByDate(Integer storeId, Integer payType, Integer paymentMethod,
+	                                                     Date startTime, Date endTime, Integer pageNum, Integer pageSize) {
+		R<IPage<TurnoverDetailItemVO>> plain = details(storeId, payType, paymentMethod, startTime, endTime, pageNum, pageSize);
+		IPage<TurnoverDetailItemVO> page = plain.getData();
+
+		// 按 yyyy-MM-dd 分组并按日期倒序
+		Map<String, List<TurnoverDetailItemVO>> grouped = page.getRecords().stream()
+			.collect(Collectors.groupingBy(item -> {
+				Date t = item.getPayTime();
+				if (t == null) return "-";
+				java.time.LocalDate d = t.toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDate();
+				return d.toString();
+			}));
+
+		List<TurnoverDetailByDateVO> groups = grouped.entrySet().stream()
+			.sorted((a, b) -> b.getKey().compareTo(a.getKey()))
+			.map(e -> {
+				TurnoverDetailByDateVO g = new TurnoverDetailByDateVO();
+				g.setDate(e.getKey());
+				// 同一天内按时间倒序
+				List<TurnoverDetailItemVO> items = new ArrayList<>(e.getValue());
+				items.sort((x, y) -> {
+					Date dx = x.getPayTime();
+					Date dy = y.getPayTime();
+					if (dx == null && dy == null) return 0;
+					if (dx == null) return 1;
+					if (dy == null) return -1;
+					return dy.compareTo(dx);
+				});
+				g.setItems(items);
+				return g;
+			}).collect(Collectors.toList());
+
+		return R.data(groups);
+	}
+
+	private LambdaQueryWrapper<StoreOrder> basePaidQuery(Integer storeId, Date startTime, Date endTime) {
+		LambdaQueryWrapper<StoreOrder> qw = new LambdaQueryWrapper<>();
+		qw.eq(StoreOrder::getStoreId, storeId)
+			.eq(StoreOrder::getPayStatus, 1)
+			.eq(StoreOrder::getOrderStatus, 1)
+			.eq(StoreOrder::getDeleteFlag, 0);
+		if (startTime != null) {
+			qw.ge(StoreOrder::getPayTime, startTime);
+		}
+		if (endTime != null) {
+			qw.le(StoreOrder::getPayTime, endTime);
+		}
+		return qw;
+	}
+
+	private Map<Integer, List<StoreOrder>> groupBy(List<StoreOrder> orders, java.util.function.Function<StoreOrder, Integer> classifier) {
+		if (CollectionUtils.isEmpty(orders)) {
+			return Collections.emptyMap();
+		}
+		return orders.stream().collect(Collectors.groupingBy(classifier));
+	}
+
+	private List<TurnoverBreakdownItemVO> buildBreakdown(Map<Integer, SumCount> map, Map<Integer, String> nameMap, BigDecimal total) {
+		List<TurnoverBreakdownItemVO> list = new ArrayList<>();
+		for (Map.Entry<Integer, SumCount> e : map.entrySet()) {
+			Integer code = e.getKey();
+			SumCount sc = e.getValue();
+			TurnoverBreakdownItemVO item = new TurnoverBreakdownItemVO();
+			item.setCode(code);
+			item.setName(nameMap.getOrDefault(code, "-"));
+			item.setAmount(scale2(sc.amount));
+			item.setCount(sc.count);
+			item.setPercent(total.signum() == 0 ? BigDecimal.ZERO :
+				sc.amount.multiply(BigDecimal.valueOf(100)).divide(total, 2, RoundingMode.HALF_UP));
+			list.add(item);
+		}
+		// 保持稳定顺序:根据 code 升序
+		list.sort(Comparator.comparing(TurnoverBreakdownItemVO::getCode, Comparator.nullsLast(Integer::compareTo)));
+		return list;
+	}
+
+	private Map<Integer, SumCount> groupByAndSum(List<StoreOrder> orders, java.util.function.Function<StoreOrder, Integer> classifier) {
+		Map<Integer, SumCount> result = new HashMap<>();
+		for (StoreOrder o : orders) {
+			Integer key = classifier.apply(o);
+			if (key == null) {
+				continue;
+			}
+			SumCount sc = result.computeIfAbsent(key, k -> new SumCount());
+			BigDecimal amt = o.getPayAmount() == null ? BigDecimal.ZERO : o.getPayAmount();
+			sc.amount = sc.amount.add(amt);
+			sc.count++;
+		}
+		return result;
+	}
+
+	private BigDecimal scale2(BigDecimal v) {
+		return v == null ? BigDecimal.ZERO : v.setScale(2, RoundingMode.HALF_UP);
+	}
+
+	private static class SumCount {
+		private BigDecimal amount = BigDecimal.ZERO;
+		private int count = 0;
+	}
+}
+

+ 11 - 11
alien-store/src/main/java/shop/alien/store/service/impl/TableAppQrCodeServiceImpl.java

@@ -7,7 +7,6 @@ import com.google.zxing.common.BitMatrix;
 import com.google.zxing.qrcode.QRCodeWriter;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
-import org.apache.commons.lang3.StringUtils;
 import org.springframework.stereotype.Service;
 import shop.alien.store.service.TableAppQrCodeService;
 import shop.alien.util.ali.AliOSSUtil;
@@ -16,7 +15,7 @@ import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 
 /**
- * APP 桌码:仅编码 storeId + tableNumber,不依赖微信接口
+ * APP 桌码:编码 storeId + tableId(store_table 主键),不依赖微信接口
  */
 @Slf4j
 @Service
@@ -28,31 +27,32 @@ public class TableAppQrCodeServiceImpl implements TableAppQrCodeService {
     private final AliOSSUtil aliOSSUtil;
 
     @Override
-    public String generateTableAppQrCodeAndUpload(Integer storeId, String tableNumber) {
-        if (storeId == null || StringUtils.isBlank(tableNumber)) {
-            throw new IllegalArgumentException("storeId与tableNumber不能为空");
+    public String generateTableAppQrCodeAndUpload(Integer storeId, Integer tableId, Integer menuType) {
+        if (storeId == null || tableId == null) {
+            throw new IllegalArgumentException("storeId与tableId不能为空");
         }
+        int m = (menuType != null && menuType == 2) ? 2 : 1;
 
         JSONObject payload = new JSONObject();
         payload.put("storeId", storeId);
-        payload.put("tableNumber", tableNumber.trim());
+        payload.put("tableId", tableId);
+        payload.put("menuType", m);
         String content = payload.toJSONString();
 
         byte[] png = encodeQrPng(content, QR_SIZE);
-        String safeNum = tableNumber.trim().replaceAll("[^a-zA-Z0-9_-]", "_");
         String ossPath = String.format(
-                "qrcode/table/app/%d_%s_%d.png",
+                "qrcode/table/app/%d_%d_%d.png",
                 storeId,
-                safeNum,
+                tableId,
                 System.currentTimeMillis());
 
         ByteArrayInputStream in = new ByteArrayInputStream(png);
         String url = aliOSSUtil.uploadFile(in, ossPath);
-        if (StringUtils.isBlank(url)) {
+        if (url == null || url.isEmpty()) {
             log.error("上传APP桌码到OSS失败, ossPath={}", ossPath);
             throw new RuntimeException("上传APP桌码到OSS失败");
         }
-        log.info("APP桌码生成并上传成功, storeId={}, tableNumber={}, url={}", storeId, tableNumber, url);
+        log.info("APP桌码生成并上传成功, storeId={}, tableId={}, menuType={}, url={}", storeId, tableId, m, url);
         return url;
     }
 

+ 7 - 10
alien-store/src/main/java/shop/alien/store/service/impl/WeChatMiniProgramQrCodeServiceImpl.java

@@ -275,26 +275,23 @@ public class WeChatMiniProgramQrCodeServiceImpl implements WeChatMiniProgramQrCo
     }
 
     @Override
-    public String generateTableQrCode(Integer tableId, Integer storeId) {
-        log.info("为桌号生成小程序二维码, tableId={}, storeId={}", tableId, storeId);
+    public String generateTableQrCode(Integer tableId, Integer storeId, Integer menuType) {
+        int m = (menuType != null && menuType == 2) ? 2 : 1;
+        log.info("为桌号生成小程序二维码, tableId={}, storeId={}, menuType={}", tableId, storeId, m);
 
         if (tableId == null || storeId == null) {
             throw new IllegalArgumentException("tableId和storeId不能为空");
         }
 
-        // 构建scene参数(格式:t=tableId&s=storeId,使用简写确保不超过32字符)
-        String scene = String.format("t=%d&s=%d", tableId, storeId);
-        
-        // 如果scene超过32字符,使用更简洁的格式
+        // scene≤32:微信 getwxacode/unlimit 限制;含点餐类型 m:1 美食 2 通用价目
+        String scene = String.format("t=%d&s=%d&m=%d", tableId, storeId, m);
         if (scene.length() > 32) {
-            scene = String.format("%d-%d", tableId, storeId);
+            scene = String.format("%d,%d,%d", tableId, storeId, m);
         }
 
-        // 构建OSS存储路径
-        String ossPath = String.format("qrcode/table/%d_%d_%d.png", 
+        String ossPath = String.format("qrcode/table/%d_%d_%d.png",
                 storeId, tableId, System.currentTimeMillis());
 
-        // 生成二维码并上传,扫码后打开指定页面
         return generateQrCodeAndUpload(scene, "pages/launch/index", 430, ossPath);
     }
 }

+ 26 - 0
alien-store/src/main/java/shop/alien/store/util/PrinterFactory.java

@@ -0,0 +1,26 @@
+package shop.alien.store.util;
+
+import shop.alien.entity.store.PrinterConfig;
+import shop.alien.store.service.ReceiptPrinter;
+import shop.alien.store.service.impl.NetworkReceiptPrinter;
+
+
+public class PrinterFactory {
+
+
+
+    public static ReceiptPrinter getPrinter(PrinterConfig config) {
+        String type = config.getConnectType();
+
+        if ("NETWORK".equalsIgnoreCase(type)) {
+            return new NetworkReceiptPrinter(config.getIp(), config.getPort());
+        }
+
+//        if ("USB".equalsIgnoreCase(type) || "SERIAL".equalsIgnoreCase(type)) {
+//            return new SerialReceiptPrinter(config.getComPort());
+//        }
+
+        throw new RuntimeException("不支持的打印机类型:" + type);
+    }
+
+}