Prechádzať zdrojové kódy

酒店餐厅对账功能

徐滕 1 týždeň pred
rodič
commit
92f057fad2
20 zmenil súbory, kde vykonal 3471 pridanie a 0 odobranie
  1. 269 0
      code-copilot/changes/hotel-reconciliation/spec.md
  2. 168 0
      code-copilot/changes/hotel-reconciliation/tasks.md
  3. 63 0
      forge-admin-ui/src/api/hotel.js
  4. 749 0
      forge-admin-ui/src/views/hotel/reconciliation.vue
  5. 413 0
      forge-admin-ui/src/views/hotel/reconciliationPartner.vue
  6. 184 0
      forge-server/db/migration/V1.0.143__add_hotel_reconciliation_module.sql
  7. 113 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/reconciliation/controller/HotelReconciliationController.java
  8. 60 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/reconciliation/controller/HotelReconciliationPartnerController.java
  9. 86 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/reconciliation/domain/HotelReconciliation.java
  10. 66 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/reconciliation/domain/HotelReconciliationItem.java
  11. 16 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/reconciliation/dto/ReconciliationGenerateDTO.java
  12. 50 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/reconciliation/mapper/HotelReconciliationItemMapper.java
  13. 62 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/reconciliation/mapper/HotelReconciliationMapper.java
  14. 120 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/reconciliation/service/HotelReconciliationService.java
  15. 562 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/reconciliation/service/impl/HotelReconciliationServiceImpl.java
  16. 62 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/reconciliation/vo/HotelReconciliationVO.java
  17. 97 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/reconciliation/vo/ReconciliationDetailVO.java
  18. 47 0
      forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/reconciliation/HotelReconciliationItemMapper.xml
  19. 77 0
      forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/reconciliation/HotelReconciliationMapper.xml
  20. 207 0
      output/Forge-Admin-打印方案选型指南.md

+ 269 - 0
code-copilot/changes/hotel-reconciliation/spec.md

@@ -0,0 +1,269 @@
+# 酒店-餐厅对账功能
+
+> status: in-progress
+> created: 2026-09-30
+> complexity: ⭐⭐⭐中等
+> 需求依据:用户口述 + 需求汇总讨论(2026-09-30)
+
+---
+
+## 1. 背景与目标
+
+### 1.1 业务背景
+
+酒店与餐厅为独立法人实体(同一老板、不同公司),属于合作关系。顾客在酒店房间扫码点餐,钱付给酒店(通过支付宝等渠道)。酒店需要**按月**与餐厅对账,确认应付金额后付款给餐厅。
+
+### 1.2 目标
+
+- 酒店端按月自动生成对账单,汇总该月顾客点餐订单
+- 退款订单单独标注,系统提供「排除」能力(排除后自动重算应付金额)
+- 餐厅端可查看、确认对账单
+- 双方均可导出 Excel
+- 对账单状态流转:待确认 → 已确认 → 已结算
+
+### 1.3 不做的事
+
+- ❌ 不实现平台抽成/服务费计算(暂不扣除)
+- ❌ 不实现线上签字/电子签章
+- ❌ 不按餐厅维度拆分(目前只有一个餐厅)
+- ❌ 不改动现有订单模块逻辑
+
+---
+
+## 2. 多租户架构(方案一:受控跨租户)
+
+对账单数据归属**酒店租户**,餐厅通过后端受控跨租户访问。
+
+```
+酒店端(tenant_id = A):
+  生成对账单 / 排除订单 / 标记结算 / 导出
+  → 正常操作,TenantLine 自动追加 tenant_id = A
+
+餐厅端(tenant_id = B):
+  查看对账单 / 确认 / 导出
+  → 后端 TenantContextHolder.executeWithTenant(A, ...) 切到酒店租户
+  → 仅暴露对账相关接口,不暴露订单/支付等其他数据
+```
+
+安全边界:
+- 餐厅角色仅配 `hotel:reconciliation:partner:*` 权限
+- 餐厅仅能访问 `/hotel/reconciliation/partner/**` 专用接口
+- 跨租户接口内部写死只查对账相关表
+
+---
+
+## 3. 功能点
+
+### 3.1 酒店端
+
+| # | 功能 | 说明 |
+|---|------|------|
+| F1 | 生成对账单 | 选月份 → 自动拉取该月已完成订单(status=6),生成对账单 |
+| F2 | 对账单列表 | 按月查看,状态筛选(待确认/已确认/已结算) |
+| F3 | 对账单详情 | 查看每笔订单明细:订单号、房间号、联系人、金额、订单状态、退款状态 |
+| F4 | 排除订单 | 对争议订单点击「排除」,排除后自动重算应付金额 |
+| F5 | 导出 Excel | 导出对账单明细(含排除标记) |
+| F6 | 标记已结算 | 餐厅确认后,酒店财务标记结算完成,填写结算备注 |
+
+### 3.2 餐厅端
+
+| # | 功能 | 说明 |
+|---|------|------|
+| F7 | 查看对账单列表 | 只看到「已确认」和「已结算」的对账单(待确认的还没提交) |
+| F8 | 查看对账单详情 | 查看明细(含被排除的订单,排除订单标注) |
+| F9 | 确认对账单 | 核对无误后点击确认 |
+| F10 | 导出 Excel | 同酒店端导出内容 |
+
+---
+
+## 4. 业务规则
+
+| 规则 | 内容 |
+|------|------|
+| R1 | 对账范围:仅顾客点餐订单(`hotel_order` 中 `status = 6` 已完成的订单) |
+| R2 | 对账单编号格式:`DZ-YYYYMM-NNN`(如 `DZ-202609-001`) |
+| R3 | 同一月份只能生成一张对账单(重复生成提示已存在) |
+| R4 | 退款订单默认包含在对账单中,酒店可手动排除 |
+| R5 | 排除订单需填写排除原因 |
+| R6 | 应付金额 = 订单总额 - 排除金额(自动重算) |
+| R7 | 状态流转:待确认(0) → 已确认(1) → 已结算(2),单向不可逆 |
+| R8 | 餐厅端只能看到「已确认」和「已结算」状态的对账单 |
+| R9 | 只有「已确认」状态的对账单才能被酒店标记为「已结算」 |
+| R10 | 对账单生成后,对应的订单数据快照到明细表,后续订单状态变更不影响已生成的对账单 |
+
+---
+
+## 5. 数据库设计
+
+### 5.1 hotel_reconciliation(对账单主表)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | bigint NOT NULL | 主键(雪花算法) |
+| tenant_id | bigint NOT NULL DEFAULT 1 | 租户ID(酒店租户) |
+| reconciliation_no | varchar(20) NOT NULL | 对账单编号(DZ-YYYYMM-NNN) |
+| period_start | date NOT NULL | 对账周期开始 |
+| period_end | date NOT NULL | 对账周期结束 |
+| order_count | int NOT NULL DEFAULT 0 | 订单总笔数(含排除) |
+| total_amount | decimal(10,2) NOT NULL DEFAULT 0 | 订单总额 |
+| excluded_count | int NOT NULL DEFAULT 0 | 排除笔数 |
+| excluded_amount | decimal(10,2) NOT NULL DEFAULT 0 | 排除金额 |
+| settlement_amount | decimal(10,2) NOT NULL DEFAULT 0 | 应付金额 = total - excluded |
+| status | tinyint NOT NULL DEFAULT 0 | 0=待确认 / 1=已确认 / 2=已结算 |
+| confirmed_by | bigint DEFAULT NULL | 确认人 |
+| confirmed_time | datetime DEFAULT NULL | 确认时间 |
+| settled_by | bigint DEFAULT NULL | 结算操作人 |
+| settled_time | datetime DEFAULT NULL | 结算时间 |
+| settled_remark | varchar(500) DEFAULT NULL | 结算备注 |
+| del_flag | bigint NOT NULL DEFAULT 0 | 逻辑删除 |
+| create_by / create_time / create_dept / update_by / update_time | — | 审计字段 |
+
+### 5.2 hotel_reconciliation_item(对账单明细)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | bigint NOT NULL | 主键 |
+| tenant_id | bigint NOT NULL DEFAULT 1 | 租户ID |
+| reconciliation_id | bigint NOT NULL | 对账单ID |
+| order_id | bigint NOT NULL | 关联 hotel_order.id |
+| order_no | varchar(30) | 冗余订单号 |
+| room_no | varchar(10) | 冗余房间号 |
+| contact_name | varchar(50) | 联系人 |
+| pay_amount | decimal(10,2) | 实付金额 |
+| order_status | tinyint | 订单状态(冗余快照) |
+| refund_status | tinyint | 退款状态(冗余快照,0/1/2) |
+| excluded | tinyint NOT NULL DEFAULT 0 | 是否排除(0=否 / 1=是) |
+| exclude_reason | varchar(200) DEFAULT NULL | 排除原因 |
+| del_flag | bigint NOT NULL DEFAULT 0 | 逻辑删除 |
+| create_by / create_time / create_dept / update_by / update_time | — | 审计字段 |
+
+### 5.3 字典
+
+| 字典类型 | dict_value | dict_label | list_class |
+|---------|-----------|-----------|-----------|
+| hotel_reconciliation_status | 0 | 待确认 | warning |
+| hotel_reconciliation_status | 1 | 已确认 | primary |
+| hotel_reconciliation_status | 2 | 已结算 | success |
+
+---
+
+## 6. 后端接口设计
+
+### 6.1 酒店端(/hotel/reconciliation)
+
+| 方法 | 路径 | 说明 | 权限 |
+|------|------|------|------|
+| GET | /page | 对账单分页列表 | hotel:reconciliation:query |
+| GET | /detail | 对账单详情(含明细列表) | hotel:reconciliation:query |
+| POST | /generate | 生成对账单 | hotel:reconciliation:generate |
+| POST | /excludeItem | 排除/恢复明细 | hotel:reconciliation:exclude |
+| POST | /settle | 标记已结算 | hotel:reconciliation:settle |
+| GET | /export | 导出 Excel | hotel:reconciliation:export |
+
+### 6.2 餐厅端(/hotel/reconciliation/partner)
+
+| 方法 | 路径 | 说明 | 权限 |
+|------|------|------|------|
+| GET | /page | 对账单分页列表(仅已确认+已结算) | hotel:reconciliation:partner:query |
+| GET | /detail | 对账单详情 | hotel:reconciliation:partner:query |
+| POST | /confirm | 确认对账单 | hotel:reconciliation:partner:confirm |
+| GET | /export | 导出 Excel | hotel:reconciliation:partner:export |
+
+---
+
+## 7. 前端页面设计
+
+### 7.1 酒店端 reconciliation.vue
+
+- AiCrudPage 列表(隐藏新增/批量删除/选择列)
+- 工具栏:生成对账单按钮
+- 搜索筛选:状态(DictSelect)、月份范围
+- 表格列:对账单编号、周期、订单笔数、总额、排除笔数/金额、应付金额、状态(DictTag)、操作
+- 操作列:详情(text-info)、排除管理(text-warning,仅待确认)、导出(text-success)、确认提交(仅待确认→提交给餐厅看)、标记结算(text-primary,仅已确认)
+- 详情弹窗:汇总信息 + 明细表格(排除订单灰显+标注原因)
+- 排除弹窗:选择要排除的订单 + 填写原因
+
+### 7.2 餐厅端 reconciliationPartner.vue
+
+- AiCrudPage 列表(只读,隐藏新增/批量删除/选择列)
+- 搜索筛选:状态(仅已确认+已结算)
+- 表格列:同酒店端(去掉排除管理操作)
+- 操作列:详情(text-info)、确认(text-success,仅已确认)、导出(text-success)
+- 详情弹窗:同酒店端(排除订单灰显标注)
+
+---
+
+## 8. 菜单与权限
+
+```
+对账管理(一级菜单,sort=12,与餐厅管理同级)
+├── 对账单(酒店端)  path=/hotel/reconciliation  component=hotel/reconciliation
+│   └── 按钮权限:hotel:reconciliation:query / generate / exclude / settle / export / submit
+└── 对账单(餐厅端)  path=/hotel/reconciliation/partner  component=hotel/reconciliationPartner
+    └── 按钮权限:hotel:reconciliation:partner:query / confirm / export
+```
+
+---
+
+## 9. Excel 导出配置
+
+config_key: `hotel_reconciliation_export`
+data_source_bean: `hotelReconciliationServiceImpl`
+query_method: `queryExportList`
+
+导出列:对账单编号、周期、订单笔数、总额、排除笔数、排除金额、应付金额、状态、确认时间、结算时间
+
+---
+
+## 10. 技术决策
+
+| 决策 | 说明 |
+|------|------|
+| 多租户方案 | 方案一:受控跨租户。对账单归酒店租户,餐厅通过 `TenantContextHolder.executeWithTenant()` 访问 |
+| 对账单编号生成 | 按月份查当前最大序号 +1,格式 `DZ-YYYYMM-NNN` |
+| 订单数据快照 | 生成对账单时将订单关键信息复制到 `hotel_reconciliation_item`,后续订单变更不影响已生成对账单 |
+| 退款订单处理 | 默认包含,酒店可排除;排除后自动重算应付金额 |
+| 餐厅可见范围 | 餐厅只能看到「已确认」和「已结算」的对账单,酒店需先「提交」才推送给餐厅 |
+| 导出方式 | 复用 `sys_excel_export_config` + `sys_excel_column_config` 配置化导出 |
+
+---
+
+## 11. 对现有模块的影响
+
+| 模块 | 影响 |
+|------|------|
+| hotel_order | **只读**,生成对账单时 SELECT 该月已完成订单 |
+| hotel_order_item | **只读**,取明细金额 |
+| 租户/角色/菜单 | 新增对账菜单 + 按钮权限(Flyway 脚本) |
+| 现有代码 | **零改动**,纯增量开发 |
+
+---
+
+## 12. 代码结构
+
+```
+reconciliation/
+├── controller/
+│   ├── HotelReconciliationController.java        # 酒店端 /hotel/reconciliation/**
+│   └── HotelReconciliationPartnerController.java # 餐厅端 /hotel/reconciliation/partner/**
+├── domain/
+│   ├── HotelReconciliation.java
+│   └── HotelReconciliationItem.java
+├── dto/
+│   └── ReconciliationGenerateDTO.java            # 生成请求(yearMonth)
+├── vo/
+│   ├── HotelReconciliationVO.java
+│   └── ReconciliationDetailVO.java               # 含明细列表
+├── mapper/
+│   ├── HotelReconciliationMapper.java
+│   └── HotelReconciliationItemMapper.java
+└── service/
+    ├── HotelReconciliationService.java
+    └── impl/
+        └── HotelReconciliationServiceImpl.java
+```
+
+前端:
+- `forge-admin-ui/src/views/hotel/reconciliation.vue` — 酒店端
+- `forge-admin-ui/src/views/hotel/reconciliationPartner.vue` — 餐厅端
+- `forge-admin-ui/src/api/hotel.js` — 追加对账相关 API 函数

+ 168 - 0
code-copilot/changes/hotel-reconciliation/tasks.md

@@ -0,0 +1,168 @@
+# 任务拆分 — 酒店-餐厅对账功能
+
+> 拆分顺序:数据库 → 实体/VO/DTO → Mapper/XML → Service → Controller → 前端 API → 前端页面
+> 每个任务 = 可独立提交的原子变更
+
+---
+
+## 前置条件
+
+- [x] 确认多租户方案:方案一(受控跨租户),对账单归酒店租户
+- [x] 确认对账周期:按月
+- [x] 确认退款订单处理:系统提供排除能力
+- [x] 确认餐厅端载体:方案 A(同后台不同角色)
+- [x] 确认对账单编号规则:DZ-YYYYMM-NNN
+- [x] 确认不需要线上签字
+- [x] 确认双方均需导出 Excel
+- [x] 确认不需要按餐厅维度拆分
+
+---
+
+## Task 1: Flyway 迁移脚本 — 建表 + 字典 + 菜单 + 导出配置
+
+- **目标**: 创建对账单主表、明细表、字典数据、菜单权限、Excel 导出配置
+- **涉及文件**:
+    - `forge-server/db/migration/V1.0.143__add_hotel_reconciliation_module.sql` — **新建**
+- **内容**:
+    1. `CREATE TABLE IF NOT EXISTS hotel_reconciliation`(主表,含逻辑删除 del_flag)
+    2. `CREATE TABLE IF NOT EXISTS hotel_reconciliation_item`(明细表)
+    3. 字典类型 `hotel_reconciliation_status`(0=待确认/1=已确认/2=已结算)
+    4. 一级菜单「对账管理」(与「餐厅管理」同级,sort=12)
+    5. 子菜单「对账单」(酒店端)+ 按钮权限(query/generate/exclude/settle/export/submit)
+    6. 子菜单「对账单(餐厅)」+ 按钮权限(partner:query/confirm/export)
+    7. Excel 导出配置 `hotel_reconciliation_export` + 列配置
+- **约束**: 全部 `NOT EXISTS` 防重复,`tenant_id = 1`
+
+---
+
+## Task 2: Entity + DTO + VO
+
+- **目标**: 创建对账单实体、明细实体、生成 DTO、VO
+- **涉及文件**(均在 `reconciliation/` 子包下):
+    - `domain/HotelReconciliation.java` — **新建**,extends TenantEntity,含 @TableLogic
+    - `domain/HotelReconciliationItem.java` — **新建**,extends TenantEntity
+    - `dto/ReconciliationGenerateDTO.java` — **新建**(yearMonth 字段)
+    - `vo/HotelReconciliationVO.java` — **新建**,纯 @Data POJO
+    - `vo/ReconciliationDetailVO.java` — **新建**,含主表信息 + List<HotelReconciliationItemVO> 明细
+- **约束**: 遵循编码规范(@Data + @EqualsAndHashCode(callSuper=true) + @TableName + @Serial + Javadoc 注释)
+
+---
+
+## Task 3: Mapper 接口 + XML
+
+- **目标**: 创建 Mapper 接口和 XML,实现核心查询 SQL
+- **涉及文件**:
+    - `mapper/HotelReconciliationMapper.java` — **新建**
+    - `mapper/HotelReconciliationItemMapper.java` — **新建**
+    - `resources/mapper/business/hotel/reconciliation/HotelReconciliationMapper.xml` — **新建**
+    - `resources/mapper/business/hotel/reconciliation/HotelReconciliationItemMapper.xml` — **新建**
+- **核心 SQL**:
+    - `selectPage`:对账单分页查询(支持 status 筛选)
+    - `selectPartnerPage`:餐厅端分页(仅 status IN (1,2))
+    - `selectMaxNo`:查当月最大编号(生成编号用)
+    - `selectOrdersByMonth`:查指定月份已完成订单(生成对账单数据源)
+    - `selectDetailByReconciliationId`:按对账单 ID 查明细列表
+    - `selectExportList`:导出查询(供 Excel 导出配置调用)
+
+---
+
+## Task 4: Service 接口 + 实现
+
+- **目标**: 实现核心业务逻辑
+- **涉及文件**:
+    - `service/HotelReconciliationService.java` — **新建**
+    - `service/impl/HotelReconciliationServiceImpl.java` — **新建**
+- **核心方法**:
+    - `IPage<HotelReconciliationVO> reconciliationPage(PageQuery, HotelReconciliation query)` — 酒店端分页
+    - `IPage<HotelReconciliationVO> partnerPage(PageQuery, HotelReconciliation query)` — 餐厅端分页(跨租户)
+    - `ReconciliationDetailVO detail(Long id)` — 详情
+    - `ReconciliationDetailVO partnerDetail(Long id)` — 餐厅端详情(跨租户)
+    - `void generate(ReconciliationGenerateDTO dto)` — 生成对账单
+    - `void excludeItem(Long itemId, String reason)` — 排除/恢复明细
+    - `void submit(Long id)` — 提交给餐厅(待确认→已确认的前置步骤,或直接改为可确认)
+    - `void confirm(Long id)` — 餐厅确认(跨租户)
+    - `void settle(Long id, String remark)` — 标记已结算
+    - `List<HotelReconciliationVO> queryExportList(Map<String, Object> params)` — 导出查询
+
+---
+
+## Task 5: Controller(酒店端 + 餐厅端)
+
+- **目标**: 创建 REST 接口
+- **涉及文件**:
+    - `controller/HotelReconciliationController.java` — **新建**,基路径 `/hotel/reconciliation`
+    - `controller/HotelReconciliationPartnerController.java` — **新建**,基路径 `/hotel/reconciliation/partner`
+- **酒店端接口**:
+    - `GET /page` — 分页列表
+    - `GET /detail` — 详情
+    - `POST /generate` — 生成对账单
+    - `POST /excludeItem` — 排除/恢复
+    - `POST /submit` — 提交给餐厅
+    - `POST /settle` — 标记结算
+    - `GET /export` — 导出
+- **餐厅端接口**:
+    - `GET /page` — 分页列表(跨租户,仅已确认+已结算)
+    - `GET /detail` — 详情(跨租户)
+    - `POST /confirm` — 确认(跨租户)
+    - `GET /export` — 导出(跨租户)
+- **约束**: @Autowired 字段注入、@OperationLog、@SaCheckPermission、实体类接收查询参数、方法名路径风格
+
+---
+
+## Task 6: 前端 API 接口定义
+
+- **目标**: 在 hotel.js 中追加对账相关 API 函数
+- **涉及文件**:
+    - `forge-admin-ui/src/api/hotel.js` — 修改
+- **新增函数**:
+    - `getReconciliationPage(params)` — 酒店端分页
+    - `getReconciliationDetail(id)` — 酒店端详情
+    - `generateReconciliation(data)` — 生成对账单
+    - `excludeReconciliationItem(itemId, reason)` — 排除明细
+    - `submitReconciliation(id)` — 提交
+    - `settleReconciliation(id, remark)` — 标记结算
+    - `exportReconciliation(params)` — 导出
+    - `getPartnerReconciliationPage(params)` — 餐厅端分页
+    - `getPartnerReconciliationDetail(id)` — 餐厅端详情
+    - `confirmReconciliation(id)` — 餐厅确认
+    - `exportPartnerReconciliation(params)` — 餐厅端导出
+
+---
+
+## Task 7: 前端酒店端页面 reconciliation.vue
+
+- **目标**: 酒店端对账单管理页面
+- **涉及文件**:
+    - `forge-admin-ui/src/views/hotel/reconciliation.vue` — **新建**
+- **功能**:
+    - AiCrudPage 列表(hide-add / hide-batch-delete / hide-selection)
+    - 工具栏:生成对账单按钮(弹窗选月份)
+    - 搜索:状态筛选(DictSelect hotel_reconciliation_status)
+    - 表格列:编号、周期、订单数、总额、排除数/额、应付、状态(DictTag)、操作
+    - 操作列:详情 / 排除管理(待确认)/ 提交(待确认)/ 标记结算(已确认)/ 导出
+    - 详情弹窗:汇总信息 + 明细 NDataTable(排除订单灰显)
+    - 排除弹窗:排除原因输入
+    - 结算弹窗:结算备注输入
+
+---
+
+## Task 8: 前端餐厅端页面 reconciliationPartner.vue
+
+- **目标**: 餐厅端对账单查看页面
+- **涉及文件**:
+    - `forge-admin-ui/src/views/hotel/reconciliationPartner.vue` — **新建**
+- **功能**:
+    - AiCrudPage 列表(只读,hide-add / hide-batch-delete / hide-selection)
+    - 搜索:状态筛选(仅已确认+已结算)
+    - 表格列:同酒店端(去掉排除管理)
+    - 操作列:详情 / 确认(已确认状态)/ 导出
+    - 详情弹窗:同酒店端(排除订单灰显标注)
+
+---
+
+## Task 9: 构建验证
+
+- **目标**: 确保后端编译通过 + 前端 lint 通过
+- **步骤**:
+    1. `mvn compile -pl forge-business/forge-hotel -am -DskipTests`
+    2. `pnpm.cmd lint:fix`(forge-admin-ui)

+ 63 - 0
forge-admin-ui/src/api/hotel.js

@@ -458,3 +458,66 @@ export function kitchenReady(id) {
 export function getDashboard() {
   return request.get('/hotel/dashboard')
 }
+
+// ==================== 对账管理 ====================
+
+/** 分页查询对账单(酒店端) */
+export function getReconciliationPage(params) {
+  return request.get('/hotel/reconciliation/page', { params })
+}
+
+/** 查询对账单详情(含明细) */
+export function getReconciliationDetail(id) {
+  return request.get('/hotel/reconciliation/detail', { params: { id } })
+}
+
+/** 生成对账单 */
+export function generateReconciliation(data) {
+  return request.post('/hotel/reconciliation/generate', data)
+}
+
+/** 排除/恢复明细 */
+export function excludeReconciliationItem(itemId, reason) {
+  return request.post('/hotel/reconciliation/excludeItem', null, { params: { itemId, reason } })
+}
+
+/** 提交对账单 */
+export function submitReconciliation(id) {
+  return request.post('/hotel/reconciliation/submit', null, { params: { id } })
+}
+
+/** 标记已结算 */
+export function settleReconciliation(id, remark) {
+  return request.post('/hotel/reconciliation/settle', null, { params: { id, remark } })
+}
+
+/** 删除对账单 */
+export function deleteReconciliation(id) {
+  return request.delete(`/hotel/reconciliation/${id}`)
+}
+
+/** 导出单条对账单(双Sheet:汇总 + 明细) */
+export function exportReconciliationDetail(id) {
+  return request({
+    method: 'get',
+    url: `/hotel/reconciliation/export/${id}`,
+    responseType: 'blob',
+    rawResponse: true,
+    encrypt: false,
+  })
+}
+
+/** 餐厅端分页查询对账单 */
+export function getPartnerReconciliationPage(params) {
+  return request.get('/hotel/reconciliation/partner/page', { params })
+}
+
+/** 餐厅端查询对账单详情 */
+export function getPartnerReconciliationDetail(id) {
+  return request.get('/hotel/reconciliation/partner/detail', { params: { id } })
+}
+
+/** 餐厅确认对账单 */
+export function confirmReconciliation(id) {
+  return request.post('/hotel/reconciliation/partner/confirm', null, { params: { id } })
+}

+ 749 - 0
forge-admin-ui/src/views/hotel/reconciliation.vue

@@ -0,0 +1,749 @@
+<template>
+  <div class="hotel-reconciliation-page" style="height: 100%">
+    <AiCrudPage
+      ref="crudRef"
+      :api-config="{
+        list: 'get@/hotel/reconciliation/page',
+        export: 'post@/api/excel/export/hotel_reconciliation_export',
+      }"
+      :search-schema="searchSchema"
+      :columns="tableColumns"
+      row-key="id"
+      :hide-add="true"
+      :hide-batch-delete="true"
+      :hide-selection="true"
+      :show-import="false"
+      :show-export="true"
+      export-button-text="导出对账单"
+      export-file-name="对账单列表.xlsx"
+      :before-load-list="transformSearchParams"
+    >
+      <template #toolbar-start>
+        <NButton size="small" type="primary" @click="showGenerateDialog">
+          <template #icon>
+            <i class="i-material-symbols:add-rounded" />
+          </template>
+          生成对账单
+        </NButton>
+      </template>
+    </AiCrudPage>
+
+    <!-- 生成对账单弹窗 -->
+    <NModal
+      v-model:show="generateVisible"
+      title="生成对账单"
+      preset="dialog"
+      style="width: 420px"
+      :show-icon="false"
+      :mask-closable="false"
+      positive-text="生成"
+      negative-text="取消"
+      :positive-button-props="{ loading: generateLoading }"
+      @positive-click="handleGenerate"
+    >
+      <div style="padding: 12px 0">
+        <div style="margin-bottom: 8px; font-size: 14px; color: #333">
+          选择对账月份
+        </div>
+        <NDatePicker
+          v-model:value="generateMonth"
+          type="month"
+          value-format="yyyy-MM"
+          style="width: 100%"
+          placeholder="请选择月份"
+        />
+        <div style="margin-top: 8px; font-size: 12px; color: #999">
+          系统将自动拉取该月已完成的订单生成对账单
+        </div>
+      </div>
+    </NModal>
+
+    <!-- 详情抽屉 -->
+    <NDrawer v-model:show="detailVisible" :width="860" placement="right">
+      <NDrawerContent :title="`对账单详情 - ${detail.reconciliationNo || ''}`" closable>
+        <!-- 汇总信息 -->
+        <NDescriptions label-placement="left" bordered :column="2" size="small">
+          <NDescriptionsItem label="对账单编号">
+            {{ detail.reconciliationNo }}
+          </NDescriptionsItem>
+          <NDescriptionsItem label="状态">
+            <DictTag dict-type="hotel_reconciliation_status" :value="String(detail.status)" size="small" />
+          </NDescriptionsItem>
+          <NDescriptionsItem label="对账周期">
+            {{ formatDate(detail.periodStart) }} ~ {{ formatDate(detail.periodEnd) }}
+          </NDescriptionsItem>
+          <NDescriptionsItem label="订单笔数">
+            {{ detail.orderCount }} 笔
+          </NDescriptionsItem>
+          <NDescriptionsItem label="订单总额">
+            <span style="font-weight: 600">¥{{ detail.totalAmount }}</span>
+          </NDescriptionsItem>
+          <NDescriptionsItem label="排除笔数/金额">
+            <span style="color: #e03e3e">{{ detail.excludedCount }} 笔 / ¥{{ detail.excludedAmount }}</span>
+          </NDescriptionsItem>
+          <NDescriptionsItem label="应付金额">
+            <span style="font-weight: 700; color: #18a058; font-size: 16px">¥{{ detail.settlementAmount }}</span>
+          </NDescriptionsItem>
+          <NDescriptionsItem label="创建时间">
+            {{ detail.createTime }}
+          </NDescriptionsItem>
+          <NDescriptionsItem v-if="detail.confirmedTime" label="确认时间">
+            {{ formatDate(detail.confirmedTime) }}
+          </NDescriptionsItem>
+          <NDescriptionsItem v-if="detail.settledTime" label="结算时间">
+            {{ formatDate(detail.settledTime) }}
+          </NDescriptionsItem>
+          <NDescriptionsItem v-if="detail.settledRemark" label="结算备注" :span="2">
+            {{ detail.settledRemark }}
+          </NDescriptionsItem>
+        </NDescriptions>
+
+        <!-- 操作按钮 -->
+        <div v-if="detail.id" style="margin: 16px 0; display: flex; gap: 8px">
+          <NButton
+            v-if="detail.status === 0"
+            size="small"
+            type="primary"
+            @click="handleSubmit(detail.id)"
+          >
+            提交给餐厅
+          </NButton>
+          <NButton
+            v-if="detail.status === 1"
+            size="small"
+            type="success"
+            @click="handleSettle(detail.id)"
+          >
+            标记已结算
+          </NButton>
+          <NButton
+            v-if="detail.status === 0 || userStore.isAdmin"
+            size="small"
+            type="error"
+            @click="handleDelete(detail)"
+          >
+            删除对账单
+          </NButton>
+        </div>
+
+        <!-- 明细列表 -->
+        <div style="margin-top: 8px; margin-bottom: 8px; font-weight: 600; font-size: 14px">
+          订单明细
+        </div>
+        <NDataTable
+          :columns="detailColumns"
+          :data="detail.items"
+          :row-key="row => row.id"
+          size="small"
+          striped
+          :bordered="false"
+          max-height="500"
+        />
+
+        <template #footer>
+          <NSpace justify="end">
+            <NButton @click="detailVisible = false">
+              关闭
+            </NButton>
+          </NSpace>
+        </template>
+      </NDrawerContent>
+    </NDrawer>
+
+    <!-- 订单详情弹窗 -->
+    <NModal
+      v-model:show="orderDetailVisible"
+      title="订单详情"
+      preset="card"
+      style="width: 640px; max-width: 95vw"
+      :bordered="false"
+      :segmented="{ content: true }"
+    >
+      <div v-if="orderDetail">
+        <NDescriptions label-placement="left" bordered :column="2" size="small">
+          <NDescriptionsItem label="订单号">
+            {{ orderDetail.orderNo }}
+          </NDescriptionsItem>
+          <NDescriptionsItem label="房间号">
+            {{ orderDetail.roomNo }}
+          </NDescriptionsItem>
+          <NDescriptionsItem label="房型">
+            {{ orderDetail.roomType || '-' }}
+          </NDescriptionsItem>
+          <NDescriptionsItem label="联系人">
+            {{ orderDetail.contactName || '-' }}
+          </NDescriptionsItem>
+          <NDescriptionsItem label="订单状态">
+            <DictTag dict-type="hotel_order_status" :value="String(orderDetail.status)" size="small" />
+          </NDescriptionsItem>
+          <NDescriptionsItem label="支付状态">
+            <span v-if="orderDetail.payStatus === 1" style="color:#18a058">已支付</span>
+            <span v-else style="color:#999">未支付</span>
+          </NDescriptionsItem>
+          <NDescriptionsItem label="退款状态">
+            <span v-if="orderDetail.refundStatus === 1" style="color:#18a058">已退款</span>
+            <span v-else-if="orderDetail.refundStatus === 2" style="color:#e03e3e">退款失败</span>
+            <span v-else>未退款</span>
+          </NDescriptionsItem>
+          <NDescriptionsItem label="下单时间">
+            {{ orderDetail.createTime }}
+          </NDescriptionsItem>
+          <NDescriptionsItem label="订单总额" :span="2">
+            <span style="font-weight:600">¥{{ orderDetail.totalAmount }}</span>
+            <span v-if="orderDetail.paidAmount" style="margin-left:12px;color:#18a058">实付 ¥{{ orderDetail.paidAmount }}</span>
+          </NDescriptionsItem>
+          <NDescriptionsItem v-if="orderDetail.note" label="备注" :span="2">
+            {{ orderDetail.note }}
+          </NDescriptionsItem>
+        </NDescriptions>
+
+        <!-- 菜品明细 -->
+        <div v-if="orderDetail.items && orderDetail.items.length" style="margin-top:16px">
+          <div style="font-weight:600;font-size:14px;margin-bottom:8px">
+            菜品明细
+          </div>
+          <NDataTable
+            :columns="orderItemColumns"
+            :data="orderDetail.items"
+            :row-key="row => row.id"
+            size="small"
+            striped
+            :bordered="false"
+            :pagination="false"
+          />
+        </div>
+      </div>
+    </NModal>
+
+    <!-- 排除原因输入弹窗 -->
+    <NModal
+      v-model:show="excludeVisible"
+      title="排除订单"
+      preset="dialog"
+      style="width: 420px"
+      :show-icon="false"
+      :mask-closable="false"
+      positive-text="确认排除"
+      negative-text="取消"
+      @positive-click="handleExcludeConfirm"
+    >
+      <div style="margin-bottom: 8px; font-size: 13px; color: #666">
+        排除后该笔订单将不计入应付金额
+      </div>
+      <NInput
+        v-model:value="excludeReason"
+        type="textarea"
+        placeholder="请输入排除原因(必填)"
+        :rows="3"
+      />
+    </NModal>
+
+    <!-- 结算备注输入弹窗 -->
+    <NModal
+      v-model:show="settleVisible"
+      title="标记已结算"
+      preset="dialog"
+      style="width: 420px"
+      :show-icon="false"
+      :mask-closable="false"
+      positive-text="确认结算"
+      negative-text="取消"
+      @positive-click="handleSettleConfirm"
+    >
+      <div style="margin-bottom: 8px; font-size: 13px; color: #666">
+        确认该对账单已完成付款结算?
+      </div>
+      <NInput
+        v-model:value="settleRemark"
+        type="textarea"
+        placeholder="结算备注(选填,如付款方式、流水号等)"
+        :rows="3"
+      />
+    </NModal>
+  </div>
+</template>
+
+<script setup>
+import {
+  NButton,
+  NDataTable,
+  NDatePicker,
+  NDescriptions,
+  NDescriptionsItem,
+  NDrawer,
+  NDrawerContent,
+  NInput,
+  NModal,
+  NSpace,
+  useMessage,
+} from 'naive-ui'
+import { computed, h, ref } from 'vue'
+import {
+  deleteReconciliation,
+  excludeReconciliationItem,
+  exportReconciliationDetail,
+  generateReconciliation,
+  getOrderDetail,
+  getReconciliationDetail,
+  settleReconciliation,
+  submitReconciliation,
+} from '@/api/hotel'
+import { AiCrudPage } from '@/components/ai-form'
+import DictTag from '@/components/DictTag.vue'
+import { useDict } from '@/composables/useDict'
+import { useUserStore } from '@/store'
+
+defineOptions({ name: 'HotelReconciliation' })
+
+const message = useMessage()
+const userStore = useUserStore()
+const { dict } = useDict('hotel_reconciliation_status', 'hotel_order_status')
+
+const crudRef = ref(null)
+
+// ==================== 搜索配置 ====================
+const statusOptions = computed(() => {
+  const list = dict.value.hotel_reconciliation_status || []
+  return list.map(item => ({ label: item.label, value: Number(item.value) }))
+})
+
+const searchSchema = computed(() => [
+  {
+    field: 'reconciliationNo',
+    label: '对账单编号',
+    type: 'input',
+    props: { placeholder: '请输入编号' },
+  },
+  {
+    field: 'yearMonth',
+    label: '对账月份',
+    type: 'month',
+    props: { valueFormat: 'yyyy-MM', placeholder: '请选择月份' },
+  },
+  {
+    field: 'status',
+    label: '状态',
+    type: 'select',
+    props: { placeholder: '请选择状态', options: statusOptions.value },
+  },
+])
+
+function transformSearchParams(params) {
+  // yearMonth 字段直接由 month picker 以 "yyyy-MM" 格式返回,无需转换
+  return params
+}
+
+// ==================== 表格列配置 ====================
+const tableColumns = computed(() => [
+  {
+    prop: 'reconciliationNo',
+    label: '对账单编号',
+    width: 170,
+    render: row => h('a', {
+      class: 'text-primary cursor-pointer hover:opacity-80',
+      onClick: () => openDetail(row.id),
+    }, row.reconciliationNo),
+  },
+  {
+    prop: 'periodStart',
+    label: '对账周期',
+    width: 200,
+    render: row => `${formatDate(row.periodStart)} ~ ${formatDate(row.periodEnd)}`,
+  },
+  { prop: 'orderCount', label: '订单数', width: 80, align: 'right' },
+  {
+    prop: 'totalAmount',
+    label: '总额(元)',
+    width: 110,
+    align: 'right',
+    render: row => `¥${row.totalAmount}`,
+  },
+  {
+    prop: 'excludedCount',
+    label: '排除',
+    width: 100,
+    align: 'right',
+    render: row => row.excludedCount > 0 ? `${row.excludedCount}笔/¥${row.excludedAmount}` : '0',
+  },
+  {
+    prop: 'settlementAmount',
+    label: '应付(元)',
+    width: 120,
+    align: 'right',
+    render: row => h('span', { style: 'font-weight:600;color:#18a058' }, `¥${row.settlementAmount}`),
+  },
+  {
+    prop: 'status',
+    label: '状态',
+    width: 90,
+    render: row => h(DictTag, { dictType: 'hotel_reconciliation_status', value: String(row.status), size: 'small' }),
+  },
+  { prop: 'createTime', label: '创建时间', width: 170 },
+  {
+    label: '操作',
+    width: 330,
+    fixed: 'right',
+    render: (row) => {
+      const actions = []
+      // 导出按钮(所有状态都可导出)
+      actions.push(h('a', {
+        class: 'text-info cursor-pointer hover:opacity-80',
+        onClick: () => handleExportSingle(row),
+      }, '导出'))
+      if (row.status === 0) {
+        actions.push(h('a', {
+          class: 'text-primary cursor-pointer hover:opacity-80',
+          onClick: () => handleSubmit(row.id),
+        }, '提交'))
+      }
+      if (row.status === 1) {
+        actions.push(h('a', {
+          class: 'text-success cursor-pointer hover:opacity-80',
+          onClick: () => handleSettle(row.id),
+        }, '结算'))
+      }
+      // 删除按钮:待确认状态 或 管理员
+      if (row.status === 0 || userStore.isAdmin) {
+        actions.push(h('a', {
+          class: 'text-error cursor-pointer hover:opacity-80',
+          onClick: () => handleDelete(row),
+        }, '删除'))
+      }
+      return h('div', { class: 'flex gap-2' }, actions)
+    },
+  },
+])
+
+// ==================== 生成对账单 ====================
+const generateVisible = ref(false)
+const generateLoading = ref(false)
+const generateMonth = ref(null)
+
+function showGenerateDialog() {
+  generateMonth.value = getCurrentMonth()
+  generateVisible.value = true
+}
+
+async function handleGenerate() {
+  if (!generateMonth.value) {
+    message.warning('请选择对账月份')
+    return false
+  }
+  generateLoading.value = true
+  try {
+    await generateReconciliation({ yearMonth: generateMonth.value })
+    message.success('对账单生成成功')
+    generateVisible.value = false
+    crudRef.value?.refresh()
+  }
+  catch (e) {
+    message.error(e.message || '生成失败')
+  }
+  finally {
+    generateLoading.value = false
+  }
+  return false
+}
+
+// ==================== 详情抽屉 ====================
+const detailVisible = ref(false)
+const detail = ref({})
+
+async function openDetail(id) {
+  try {
+    const res = await getReconciliationDetail(id)
+    if (res.code === 200 && res.data) {
+      detail.value = res.data
+      detailVisible.value = true
+    }
+  }
+  catch (e) {
+    message.error(e.message || '加载详情失败')
+  }
+}
+
+// 明细表格列
+const detailColumns = [
+  {
+    title: '序号',
+    key: 'index',
+    width: 55,
+    render: (row, rowIndex) => rowIndex + 1,
+  },
+  {
+    title: '订单号',
+    key: 'orderNo',
+    width: 160,
+    render: row => h('a', {
+      class: 'text-primary cursor-pointer hover:opacity-80',
+      onClick: () => openOrderDetail(row.orderId),
+    }, row.orderNo),
+  },
+  { title: '房间号', key: 'roomNo', width: 80 },
+  { title: '联系人', key: 'contactName', width: 90 },
+  {
+    title: '实付金额',
+    key: 'payAmount',
+    width: 100,
+    align: 'right',
+    render: row => `¥${row.payAmount}`,
+  },
+  {
+    title: '退款状态',
+    key: 'refundStatus',
+    width: 90,
+    render: (row) => {
+      if (row.refundStatus === 0 || row.refundStatus == null)
+        return '未退款'
+      if (row.refundStatus === 1)
+        return h('span', { style: 'color:#18a058' }, '已退款')
+      if (row.refundStatus === 2)
+        return h('span', { style: 'color:#e03e3e' }, '退款失败')
+      return '-'
+    },
+  },
+  {
+    title: '排除',
+    key: 'excluded',
+    width: 80,
+    render: row => row.excluded === 1
+      ? h('span', { style: 'color:#e03e3e;font-weight:600' }, '已排除')
+      : '否',
+  },
+  {
+    title: '操作',
+    key: 'action',
+    width: 100,
+    render: (row) => {
+      if (detail.value.status !== 0)
+        return null
+      if (row.excluded === 1) {
+        return h('a', {
+          class: 'text-warning cursor-pointer hover:opacity-80',
+          onClick: () => handleExcludeToggle(row),
+        }, '恢复')
+      }
+      return h('a', {
+        class: 'text-error cursor-pointer hover:opacity-80',
+        onClick: () => handleExcludeClick(row),
+      }, '排除')
+    },
+  },
+]
+
+// ==================== 排除/恢复 ====================
+const excludeVisible = ref(false)
+const excludeReason = ref('')
+const excludeItemId = ref(null)
+
+function handleExcludeClick(row) {
+  excludeItemId.value = row.id
+  excludeReason.value = ''
+  excludeVisible.value = true
+}
+
+async function handleExcludeToggle(row) {
+  // 恢复排除的订单
+  try {
+    await excludeReconciliationItem(row.id, null)
+    message.success('已恢复')
+    await refreshDetail()
+    crudRef.value?.refresh()
+  }
+  catch (e) {
+    message.error(e.message || '操作失败')
+  }
+}
+
+async function handleExcludeConfirm() {
+  if (!excludeReason.value || !excludeReason.value.trim()) {
+    message.warning('请填写排除原因')
+    return false
+  }
+  try {
+    await excludeReconciliationItem(excludeItemId.value, excludeReason.value)
+    message.success('已排除')
+    excludeVisible.value = false
+    await refreshDetail()
+    crudRef.value?.refresh()
+  }
+  catch (e) {
+    message.error(e.message || '排除失败')
+  }
+  return false
+}
+
+async function refreshDetail() {
+  if (detail.value.id) {
+    const res = await getReconciliationDetail(detail.value.id)
+    if (res.code === 200 && res.data) {
+      detail.value = res.data
+    }
+  }
+}
+
+// ==================== 提交 ====================
+async function handleSubmit(id) {
+  try {
+    await submitReconciliation(id)
+    message.success('已提交给餐厅')
+    crudRef.value?.refresh()
+    if (detailVisible.value)
+      await refreshDetail()
+  }
+  catch (e) {
+    message.error(e.message || '提交失败')
+  }
+}
+
+// ==================== 结算 ====================
+const settleVisible = ref(false)
+const settleRemark = ref('')
+const settleId = ref(null)
+
+function handleSettle(id) {
+  settleId.value = id
+  settleRemark.value = ''
+  settleVisible.value = true
+}
+
+async function handleSettleConfirm() {
+  try {
+    await settleReconciliation(settleId.value, settleRemark.value)
+    message.success('已标记结算')
+    settleVisible.value = false
+    crudRef.value?.refresh()
+    if (detailVisible.value)
+      await refreshDetail()
+  }
+  catch (e) {
+    message.error(e.message || '结算失败')
+  }
+  return false
+}
+
+// ==================== 删除对账单 ====================
+function handleDelete(row) {
+  const dialog = window.$dialog
+  if (!dialog)
+    return
+  const tip = row.status === 0
+    ? `确认删除对账单「${row.reconciliationNo}」?删除后不可恢复。`
+    : `管理员操作:确认删除对账单「${row.reconciliationNo}」?当前状态非待确认,删除后不可恢复。`
+  dialog.warning({
+    title: '删除对账单',
+    content: tip,
+    positiveText: '确认删除',
+    negativeText: '取消',
+    onPositiveClick: async () => {
+      try {
+        await deleteReconciliation(row.id)
+        message.success('已删除')
+        crudRef.value?.refresh()
+        if (detailVisible.value && detail.value.id === row.id)
+          detailVisible.value = false
+      }
+      catch (e) {
+        message.error(e.message || '删除失败')
+      }
+    },
+  })
+}
+
+// ==================== 导出单条对账单 ====================
+const exportLoadingId = ref(null)
+
+async function handleExportSingle(row) {
+  if (exportLoadingId.value) {
+    message.warning('正在导出中,请稍候')
+    return
+  }
+  exportLoadingId.value = row.id
+  try {
+    const response = await exportReconciliationDetail(row.id)
+    downloadBlobResponse(response, `对账单_${row.reconciliationNo}.xlsx`)
+    message.success('导出成功')
+  }
+  catch (e) {
+    message.error(e.message || '导出失败')
+  }
+  finally {
+    exportLoadingId.value = null
+  }
+}
+
+function downloadBlobResponse(response, fallbackName) {
+  const blob = response?.data instanceof Blob ? response.data : response
+  if (!(blob instanceof Blob))
+    throw new TypeError('下载响应不是文件流')
+  const disposition = response?.headers?.['content-disposition'] || ''
+  const utf8Match = disposition.match(/filename\*=utf-8''([^;]+)/i)
+  const fileName = utf8Match?.[1] ? decodeURIComponent(utf8Match[1]) : fallbackName
+  const url = URL.createObjectURL(blob)
+  const link = document.createElement('a')
+  link.href = url
+  link.download = fileName
+  document.body.appendChild(link)
+  link.click()
+  document.body.removeChild(link)
+  URL.revokeObjectURL(url)
+}
+
+// ==================== 订单详情弹窗 ====================
+const orderDetailVisible = ref(false)
+const orderDetail = ref(null)
+
+const orderItemColumns = [
+  { title: '菜品', key: 'dishName', width: 160 },
+  { title: '规格', key: 'specDesc', width: 120, render: row => row.specDesc || '-' },
+  { title: '数量', key: 'quantity', width: 60, align: 'right' },
+  {
+    title: '单价',
+    key: 'unitPrice',
+    width: 80,
+    align: 'right',
+    render: row => `¥${row.unitPrice || '0.00'}`,
+  },
+  {
+    title: '小计',
+    key: 'subtotal',
+    width: 80,
+    align: 'right',
+    render: row => `¥${row.subtotal || '0.00'}`,
+  },
+]
+
+async function openOrderDetail(orderId) {
+  try {
+    const res = await getOrderDetail(orderId)
+    if (res.code === 200 && res.data) {
+      orderDetail.value = res.data
+      orderDetailVisible.value = true
+    }
+  }
+  catch (e) {
+    message.error(e.message || '加载订单详情失败')
+  }
+}
+
+// ==================== 工具方法 ====================
+function formatDate(val) {
+  if (!val)
+    return ''
+  if (typeof val === 'string')
+    return val.substring(0, 10)
+  const d = new Date(val)
+  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
+}
+
+function getCurrentMonth() {
+  const d = new Date()
+  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
+}
+</script>
+
+<style scoped>
+.hotel-reconciliation-page {
+  padding: 16px;
+}
+</style>

+ 413 - 0
forge-admin-ui/src/views/hotel/reconciliationPartner.vue

@@ -0,0 +1,413 @@
+<template>
+  <div class="reconciliation-partner-page" style="height: 100%">
+    <AiCrudPage
+      ref="crudRef"
+      :api-config="{
+        list: 'get@/hotel/reconciliation/partner/page',
+        export: 'post@/api/excel/export/hotel_reconciliation_export',
+      }"
+      :search-schema="searchSchema"
+      :columns="tableColumns"
+      row-key="id"
+      :hide-add="true"
+      :hide-batch-delete="true"
+      :hide-selection="true"
+      :show-import="false"
+      :show-export="true"
+      export-button-text="导出对账单"
+      export-file-name="对账单列表(餐厅).xlsx"
+      :before-load-list="transformSearchParams"
+    />
+
+    <!-- 详情抽屉 -->
+    <NDrawer v-model:show="detailVisible" :width="800" placement="right">
+      <NDrawerContent :title="`对账单详情 - ${detail.reconciliationNo || ''}`" closable>
+        <!-- 汇总信息 -->
+        <NDescriptions label-placement="left" bordered :column="2" size="small">
+          <NDescriptionsItem label="对账单编号">
+            {{ detail.reconciliationNo }}
+          </NDescriptionsItem>
+          <NDescriptionsItem label="状态">
+            <DictTag dict-type="hotel_reconciliation_status" :value="String(detail.status)" size="small" />
+          </NDescriptionsItem>
+          <NDescriptionsItem label="对账周期">
+            {{ formatDate(detail.periodStart) }} ~ {{ formatDate(detail.periodEnd) }}
+          </NDescriptionsItem>
+          <NDescriptionsItem label="订单笔数">
+            {{ detail.orderCount }} 笔
+          </NDescriptionsItem>
+          <NDescriptionsItem label="订单总额">
+            <span style="font-weight: 600">¥{{ detail.totalAmount }}</span>
+          </NDescriptionsItem>
+          <NDescriptionsItem label="排除笔数/金额">
+            <span style="color: #e03e3e">{{ detail.excludedCount }} 笔 / ¥{{ detail.excludedAmount }}</span>
+          </NDescriptionsItem>
+          <NDescriptionsItem label="应付金额">
+            <span style="font-weight: 700; color: #18a058; font-size: 16px">¥{{ detail.settlementAmount }}</span>
+          </NDescriptionsItem>
+          <NDescriptionsItem label="创建时间">
+            {{ detail.createTime }}
+          </NDescriptionsItem>
+          <NDescriptionsItem v-if="detail.confirmedTime" label="确认时间">
+            {{ formatDate(detail.confirmedTime) }}
+          </NDescriptionsItem>
+          <NDescriptionsItem v-if="detail.settledTime" label="结算时间">
+            {{ formatDate(detail.settledTime) }}
+          </NDescriptionsItem>
+          <NDescriptionsItem v-if="detail.settledRemark" label="结算备注" :span="2">
+            {{ detail.settledRemark }}
+          </NDescriptionsItem>
+        </NDescriptions>
+
+        <!-- 操作按钮 -->
+        <div v-if="detail.id && detail.status === 1" style="margin: 16px 0">
+          <NButton type="primary" size="small" @click="handleConfirm(detail.id)">
+            确认对账单(金额无误)
+          </NButton>
+        </div>
+
+        <!-- 明细列表 -->
+        <div style="margin-top: 8px; margin-bottom: 8px; font-weight: 600; font-size: 14px">
+          订单明细
+        </div>
+        <NDataTable
+          :columns="detailColumns"
+          :data="detail.items"
+          :row-key="row => row.id"
+          size="small"
+          striped
+          :bordered="false"
+          max-height="500"
+        />
+
+        <template #footer>
+          <NSpace justify="end">
+            <NButton @click="detailVisible = false">
+              关闭
+            </NButton>
+          </NSpace>
+        </template>
+      </NDrawerContent>
+    </NDrawer>
+
+    <!-- 订单详情弹窗 -->
+    <NModal
+      v-model:show="orderDetailVisible"
+      title="订单详情"
+      preset="card"
+      style="width: 640px; max-width: 95vw"
+      :bordered="false"
+      :segmented="{ content: true }"
+    >
+      <div v-if="orderDetail">
+        <NDescriptions label-placement="left" bordered :column="2" size="small">
+          <NDescriptionsItem label="订单号">
+            {{ orderDetail.orderNo }}
+          </NDescriptionsItem>
+          <NDescriptionsItem label="房间号">
+            {{ orderDetail.roomNo }}
+          </NDescriptionsItem>
+          <NDescriptionsItem label="房型">
+            {{ orderDetail.roomType || '-' }}
+          </NDescriptionsItem>
+          <NDescriptionsItem label="联系人">
+            {{ orderDetail.contactName || '-' }}
+          </NDescriptionsItem>
+          <NDescriptionsItem label="订单状态">
+            <DictTag dict-type="hotel_order_status" :value="String(orderDetail.status)" size="small" />
+          </NDescriptionsItem>
+          <NDescriptionsItem label="支付状态">
+            <span v-if="orderDetail.payStatus === 1" style="color:#18a058">已支付</span>
+            <span v-else style="color:#999">未支付</span>
+          </NDescriptionsItem>
+          <NDescriptionsItem label="退款状态">
+            <span v-if="orderDetail.refundStatus === 1" style="color:#18a058">已退款</span>
+            <span v-else-if="orderDetail.refundStatus === 2" style="color:#e03e3e">退款失败</span>
+            <span v-else>未退款</span>
+          </NDescriptionsItem>
+          <NDescriptionsItem label="下单时间">
+            {{ orderDetail.createTime }}
+          </NDescriptionsItem>
+          <NDescriptionsItem label="订单总额" :span="2">
+            <span style="font-weight:600">¥{{ orderDetail.totalAmount }}</span>
+            <span v-if="orderDetail.paidAmount" style="margin-left:12px;color:#18a058">实付 ¥{{ orderDetail.paidAmount }}</span>
+          </NDescriptionsItem>
+          <NDescriptionsItem v-if="orderDetail.note" label="备注" :span="2">
+            {{ orderDetail.note }}
+          </NDescriptionsItem>
+        </NDescriptions>
+        <div v-if="orderDetail.items && orderDetail.items.length" style="margin-top:16px">
+          <div style="font-weight:600;font-size:14px;margin-bottom:8px">
+            菜品明细
+          </div>
+          <NDataTable :columns="orderItemColumns" :data="orderDetail.items" :row-key="row => row.id" size="small" striped :bordered="false" :pagination="false" />
+        </div>
+      </div>
+    </NModal>
+  </div>
+</template>
+
+<script setup>
+import {
+  NButton,
+  NDataTable,
+  NDescriptions,
+  NDescriptionsItem,
+  NDrawer,
+  NDrawerContent,
+  NModal,
+  NSpace,
+  useMessage,
+} from 'naive-ui'
+import { computed, h, ref } from 'vue'
+import {
+  confirmReconciliation,
+  getOrderDetail,
+  getPartnerReconciliationDetail,
+} from '@/api/hotel'
+import { AiCrudPage } from '@/components/ai-form'
+import DictTag from '@/components/DictTag.vue'
+import { useDict } from '@/composables/useDict'
+
+defineOptions({ name: 'HotelReconciliationPartner' })
+
+const message = useMessage()
+const { dict } = useDict('hotel_reconciliation_status', 'hotel_order_status')
+
+const crudRef = ref(null)
+
+// ==================== 搜索配置 ====================
+const statusOptions = computed(() => {
+  const list = dict.value.hotel_reconciliation_status || []
+  // 餐厅端只能看到已确认(1)和已结算(2)
+  return list
+    .filter(item => Number(item.value) >= 1)
+    .map(item => ({ label: item.label, value: Number(item.value) }))
+})
+
+const searchSchema = computed(() => [
+  {
+    field: 'reconciliationNo',
+    label: '对账单编号',
+    type: 'input',
+    props: { placeholder: '请输入编号' },
+  },
+  {
+    field: 'yearMonth',
+    label: '对账月份',
+    type: 'month',
+    props: { valueFormat: 'yyyy-MM', placeholder: '请选择月份' },
+  },
+  {
+    field: 'status',
+    label: '状态',
+    type: 'select',
+    props: { placeholder: '请选择状态', options: statusOptions.value },
+  },
+])
+
+function transformSearchParams(params) {
+  // yearMonth 字段直接由 month picker 以 "yyyy-MM" 格式返回,无需转换
+  return params
+}
+
+// ==================== 表格列配置 ====================
+const tableColumns = computed(() => [
+  {
+    prop: 'reconciliationNo',
+    label: '对账单编号',
+    width: 170,
+    render: row => h('a', {
+      class: 'text-primary cursor-pointer hover:opacity-80',
+      onClick: () => openDetail(row.id),
+    }, row.reconciliationNo),
+  },
+  {
+    prop: 'periodStart',
+    label: '对账周期',
+    width: 200,
+    render: row => `${formatDate(row.periodStart)} ~ ${formatDate(row.periodEnd)}`,
+  },
+  { prop: 'orderCount', label: '订单数', width: 80, align: 'right' },
+  {
+    prop: 'totalAmount',
+    label: '总额(元)',
+    width: 110,
+    align: 'right',
+    render: row => `¥${row.totalAmount}`,
+  },
+  {
+    prop: 'excludedCount',
+    label: '排除',
+    width: 100,
+    align: 'right',
+    render: row => row.excludedCount > 0 ? `${row.excludedCount}笔/¥${row.excludedAmount}` : '0',
+  },
+  {
+    prop: 'settlementAmount',
+    label: '应付(元)',
+    width: 120,
+    align: 'right',
+    render: row => h('span', { style: 'font-weight:600;color:#18a058' }, `¥${row.settlementAmount}`),
+  },
+  {
+    prop: 'status',
+    label: '状态',
+    width: 90,
+    render: row => h(DictTag, { dictType: 'hotel_reconciliation_status', value: String(row.status), size: 'small' }),
+  },
+  { prop: 'createTime', label: '创建时间', width: 170 },
+  {
+    label: '操作',
+    width: 140,
+    fixed: 'right',
+    render: (row) => {
+      const actions = []
+      if (row.status === 1) {
+        actions.push(h('a', {
+          class: 'text-success cursor-pointer hover:opacity-80',
+          onClick: () => handleConfirm(row.id),
+        }, '确认'))
+      }
+      return h('div', { class: 'flex gap-2' }, actions)
+    },
+  },
+])
+
+// ==================== 详情抽屉 ====================
+const detailVisible = ref(false)
+const detail = ref({})
+
+async function openDetail(id) {
+  try {
+    const res = await getPartnerReconciliationDetail(id)
+    if (res.code === 200 && res.data) {
+      detail.value = res.data
+      detailVisible.value = true
+    }
+  }
+  catch (e) {
+    message.error(e.message || '加载详情失败')
+  }
+}
+
+// 明细表格列(只读,无操作列)
+const detailColumns = [
+  {
+    title: '序号',
+    key: 'index',
+    width: 55,
+    render: (row, rowIndex) => rowIndex + 1,
+  },
+  {
+    title: '订单号',
+    key: 'orderNo',
+    width: 160,
+    render: row => h('a', {
+      class: 'text-primary cursor-pointer hover:opacity-80',
+      onClick: () => openOrderDetail(row.orderId),
+    }, row.orderNo),
+  },
+  { title: '房间号', key: 'roomNo', width: 80 },
+  { title: '联系人', key: 'contactName', width: 90 },
+  {
+    title: '实付金额',
+    key: 'payAmount',
+    width: 100,
+    align: 'right',
+    render: row => `¥${row.payAmount}`,
+  },
+  {
+    title: '退款状态',
+    key: 'refundStatus',
+    width: 90,
+    render: (row) => {
+      if (row.refundStatus === 0 || row.refundStatus == null)
+        return '未退款'
+      if (row.refundStatus === 1)
+        return h('span', { style: 'color:#18a058' }, '已退款')
+      if (row.refundStatus === 2)
+        return h('span', { style: 'color:#e03e3e' }, '退款失败')
+      return '-'
+    },
+  },
+  {
+    title: '排除',
+    key: 'excluded',
+    width: 80,
+    render: row => row.excluded === 1
+      ? h('span', { style: 'color:#e03e3e;font-weight:600' }, '已排除')
+      : '否',
+  },
+]
+
+// ==================== 订单详情弹窗 ====================
+const orderDetailVisible = ref(false)
+const orderDetail = ref(null)
+
+const orderItemColumns = [
+  { title: '菜品', key: 'dishName', width: 160 },
+  { title: '规格', key: 'specDesc', width: 120, render: row => row.specDesc || '-' },
+  { title: '数量', key: 'quantity', width: 60, align: 'right' },
+  { title: '单价', key: 'unitPrice', width: 80, align: 'right', render: row => `¥${row.unitPrice || '0.00'}` },
+  { title: '小计', key: 'subtotal', width: 80, align: 'right', render: row => `¥${row.subtotal || '0.00'}` },
+]
+
+async function openOrderDetail(orderId) {
+  try {
+    const res = await getOrderDetail(orderId)
+    if (res.code === 200 && res.data) {
+      orderDetail.value = res.data
+      orderDetailVisible.value = true
+    }
+  }
+  catch (e) {
+    message.error(e.message || '加载订单详情失败')
+  }
+}
+
+// ==================== 确认对账单 ====================
+async function handleConfirm(id) {
+  const dialog = window.$dialog
+  if (!dialog)
+    return
+  dialog.warning({
+    title: '确认对账单',
+    content: '确认该对账单金额无误?确认后不可修改。',
+    positiveText: '确认无误',
+    negativeText: '再想想',
+    onPositiveClick: async () => {
+      try {
+        await confirmReconciliation(id)
+        message.success('已确认对账单')
+        crudRef.value?.refresh()
+        if (detailVisible.value) {
+          const res = await getPartnerReconciliationDetail(id)
+          if (res.code === 200 && res.data) {
+            detail.value = res.data
+          }
+        }
+      }
+      catch (e) {
+        message.error(e.message || '确认失败')
+      }
+    },
+  })
+}
+
+// ==================== 工具方法 ====================
+function formatDate(val) {
+  if (!val)
+    return ''
+  if (typeof val === 'string')
+    return val.substring(0, 10)
+  const d = new Date(val)
+  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
+}
+</script>
+
+<style scoped>
+.reconciliation-partner-page {
+  padding: 16px;
+}
+</style>

+ 184 - 0
forge-server/db/migration/V1.0.143__add_hotel_reconciliation_module.sql

@@ -0,0 +1,184 @@
+-- ============================================================
+-- V1.0.143  酒店-餐厅对账模块
+-- 建表 + 字典 + 菜单权限 + Excel 导出配置
+-- ============================================================
+
+-- ==================== 1. 对账单主表 ====================
+CREATE TABLE IF NOT EXISTS `hotel_reconciliation` (
+    `id`                bigint        NOT NULL COMMENT '主键ID(雪花算法)',
+    `tenant_id`         bigint        NOT NULL DEFAULT 1 COMMENT '租户ID',
+    `reconciliation_no` varchar(20)   NOT NULL COMMENT '对账单编号(DZ-YYYYMM-NNN)',
+    `period_start`      date          NOT NULL COMMENT '对账周期开始',
+    `period_end`        date          NOT NULL COMMENT '对账周期结束',
+    `order_count`       int           NOT NULL DEFAULT 0 COMMENT '订单总笔数(含排除)',
+    `total_amount`      decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '订单总额',
+    `excluded_count`    int           NOT NULL DEFAULT 0 COMMENT '排除笔数',
+    `excluded_amount`   decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '排除金额',
+    `settlement_amount` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '应付金额 = total - excluded',
+    `status`            tinyint       NOT NULL DEFAULT 0 COMMENT '状态: 0=待确认 1=已确认 2=已结算',
+    `confirmed_by`      bigint        DEFAULT NULL COMMENT '确认人',
+    `confirmed_time`    datetime      DEFAULT NULL COMMENT '确认时间',
+    `settled_by`        bigint        DEFAULT NULL COMMENT '结算操作人',
+    `settled_time`      datetime      DEFAULT NULL COMMENT '结算时间',
+    `settled_remark`    varchar(500)  DEFAULT NULL COMMENT '结算备注',
+    `del_flag`          bigint        NOT NULL DEFAULT 0 COMMENT '删除标记(0=未删除,删除时写入主键ID)',
+    `create_by`         bigint        DEFAULT NULL COMMENT '创建者',
+    `create_time`       datetime      NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `create_dept`       bigint        DEFAULT NULL COMMENT '创建部门',
+    `update_by`         bigint        DEFAULT NULL COMMENT '更新者',
+    `update_time`       datetime      DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_reconciliation_no` (`tenant_id`, `reconciliation_no`),
+    KEY `idx_tenant_status` (`tenant_id`, `status`),
+    KEY `idx_tenant_period` (`tenant_id`, `period_start`, `period_end`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='酒店-餐厅对账单主表';
+
+-- ==================== 2. 对账单明细表 ====================
+CREATE TABLE IF NOT EXISTS `hotel_reconciliation_item` (
+    `id`                bigint        NOT NULL COMMENT '主键ID(雪花算法)',
+    `tenant_id`         bigint        NOT NULL DEFAULT 1 COMMENT '租户ID',
+    `reconciliation_id` bigint        NOT NULL COMMENT '对账单ID',
+    `order_id`          bigint        NOT NULL COMMENT '关联 hotel_order.id',
+    `order_no`          varchar(30)   DEFAULT NULL COMMENT '冗余订单号',
+    `room_no`           varchar(10)   DEFAULT NULL COMMENT '冗余房间号',
+    `contact_name`      varchar(50)   DEFAULT NULL COMMENT '联系人',
+    `pay_amount`        decimal(10,2) DEFAULT 0.00 COMMENT '实付金额',
+    `order_status`      tinyint       DEFAULT NULL COMMENT '订单状态(冗余快照)',
+    `refund_status`     tinyint       DEFAULT NULL COMMENT '退款状态(冗余快照: 0=未退款 1=成功 2=失败可重试)',
+    `excluded`          tinyint       NOT NULL DEFAULT 0 COMMENT '是否排除: 0=否 1=是',
+    `exclude_reason`    varchar(200)  DEFAULT NULL COMMENT '排除原因',
+    `del_flag`          bigint        NOT NULL DEFAULT 0 COMMENT '删除标记(0=未删除,删除时写入主键ID)',
+    `create_by`         bigint        DEFAULT NULL COMMENT '创建者',
+    `create_time`       datetime      NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `create_dept`       bigint        DEFAULT NULL COMMENT '创建部门',
+    `update_by`         bigint        DEFAULT NULL COMMENT '更新者',
+    `update_time`       datetime      DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_reconciliation_id` (`reconciliation_id`),
+    KEY `idx_order_id` (`order_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='酒店-餐厅对账单明细表';
+
+-- ==================== 3. 字典:对账单状态 ====================
+INSERT INTO sys_dict_type (tenant_id, dict_name, dict_type, dict_status, remark, create_time, update_time)
+SELECT 1, '对账单状态', 'hotel_reconciliation_status', 1, '酒店-餐厅对账单状态', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'hotel_reconciliation_status' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 1, '待确认', '0', 'hotel_reconciliation_status', NULL, 'warning', 'N', 1, '待确认', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'hotel_reconciliation_status' AND dict_value = '0' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 2, '已确认', '1', 'hotel_reconciliation_status', NULL, 'primary', 'N', 1, '已确认', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'hotel_reconciliation_status' AND dict_value = '1' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 3, '已结算', '2', 'hotel_reconciliation_status', NULL, 'success', 'N', 1, '已结算', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'hotel_reconciliation_status' AND dict_value = '2' AND tenant_id = 1);
+
+-- ==================== 4. 菜单:对账管理(挂在「酒店管理」下) ====================
+-- 查找酒店管理目录
+SET @hotel_parent_id = (SELECT id FROM sys_resource WHERE resource_name = '酒店管理' AND tenant_id = 1 AND parent_id = 0 LIMIT 1);
+
+-- 对账管理目录(resource_type=1,挂在酒店管理下)
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, path, component, is_external, is_public, menu_status, visible, icon, keep_alive, always_show, remark, create_time, update_time)
+SELECT 1, '对账管理', @hotel_parent_id, 1, 30, '/reconciliation', NULL, 0, 0, 1, 1, 'mdi:file-document-multiple-outline', 0, 0, '酒店-餐厅对账管理', NOW(), NOW()
+WHERE @hotel_parent_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE resource_name = '对账管理' AND tenant_id = 1 AND parent_id = @hotel_parent_id);
+
+-- 获取对账管理目录ID
+SET @reconciliation_parent_id = (SELECT id FROM sys_resource WHERE resource_name = '对账管理' AND parent_id = @hotel_parent_id LIMIT 1);
+
+-- 子菜单:酒店端
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, path, component, is_external, is_public, menu_status, visible, icon, keep_alive, always_show, remark, create_time, update_time)
+SELECT 1, '酒店端', @reconciliation_parent_id, 2, 1, '/hotel/reconciliation', 'hotel/reconciliation', 0, 0, 1, 1, 'mdi:file-table-outline', 0, 0, '对账单管理(酒店端)', NOW(), NOW()
+WHERE @reconciliation_parent_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE path = '/hotel/reconciliation' AND tenant_id = 1);
+
+-- 酒店端按钮权限
+SET @reconciliation_menu_id = (SELECT id FROM sys_resource WHERE resource_name = '酒店端' AND parent_id = @reconciliation_parent_id LIMIT 1);
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '对账单查询', @reconciliation_menu_id, 3, 1, 'hotel:reconciliation:query', 0, 0, 1, 1, '查询对账单', NOW(), NOW()
+WHERE @reconciliation_menu_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:reconciliation:query');
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '生成对账单', @reconciliation_menu_id, 3, 2, 'hotel:reconciliation:generate', 0, 0, 1, 1, '生成对账单', NOW(), NOW()
+WHERE @reconciliation_menu_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:reconciliation:generate');
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '排除订单', @reconciliation_menu_id, 3, 3, 'hotel:reconciliation:exclude', 0, 0, 1, 1, '排除争议订单', NOW(), NOW()
+WHERE @reconciliation_menu_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:reconciliation:exclude');
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '提交对账', @reconciliation_menu_id, 3, 4, 'hotel:reconciliation:submit', 0, 0, 1, 1, '提交对账单给餐厅', NOW(), NOW()
+WHERE @reconciliation_menu_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:reconciliation:submit');
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '标记结算', @reconciliation_menu_id, 3, 5, 'hotel:reconciliation:settle', 0, 0, 1, 1, '标记对账单已结算', NOW(), NOW()
+WHERE @reconciliation_menu_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:reconciliation:settle');
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '对账单导出', @reconciliation_menu_id, 3, 6, 'hotel:reconciliation:export', 0, 0, 1, 1, '导出对账单', NOW(), NOW()
+WHERE @reconciliation_menu_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:reconciliation:export');
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '删除对账单', @reconciliation_menu_id, 3, 7, 'hotel:reconciliation:delete', 0, 0, 1, 1, '删除对账单(待确认状态或管理员)', NOW(), NOW()
+WHERE @reconciliation_menu_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:reconciliation:delete');
+
+-- 子菜单:餐厅端
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, path, component, is_external, is_public, menu_status, visible, icon, keep_alive, always_show, remark, create_time, update_time)
+SELECT 1, '餐厅端', @reconciliation_parent_id, 2, 2, '/hotel/reconciliation/partner', 'hotel/reconciliationPartner', 0, 0, 1, 1, 'mdi:file-eye-outline', 0, 0, '对账单查看(餐厅端)', NOW(), NOW()
+WHERE @reconciliation_parent_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE path = '/hotel/reconciliation/partner' AND tenant_id = 1);
+
+-- 餐厅端按钮权限
+SET @partner_menu_id = (SELECT id FROM sys_resource WHERE resource_name = '餐厅端' AND parent_id = @reconciliation_parent_id LIMIT 1);
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '餐厅对账查询', @partner_menu_id, 3, 1, 'hotel:reconciliation:partner:query', 0, 0, 1, 1, '餐厅端查询对账单', NOW(), NOW()
+WHERE @partner_menu_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:reconciliation:partner:query');
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '餐厅对账确认', @partner_menu_id, 3, 2, 'hotel:reconciliation:partner:confirm', 0, 0, 1, 1, '餐厅端确认对账单', NOW(), NOW()
+WHERE @partner_menu_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:reconciliation:partner:confirm');
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '餐厅对账导出', @partner_menu_id, 3, 3, 'hotel:reconciliation:partner:export', 0, 0, 1, 1, '餐厅端导出对账单', NOW(), NOW()
+WHERE @partner_menu_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:reconciliation:partner:export');
+
+-- ==================== 5. Excel 导出配置 ====================
+INSERT INTO sys_excel_export_config
+(config_key, config_type, export_name, sheet_name, file_name_template, data_source_bean, query_method, auto_trans, pageable, max_rows, status, allow_import, remark)
+SELECT 'hotel_reconciliation_export', 'EXPORT', '对账单导出', '对账数据', '对账单_{date}_{time}.xlsx', 'hotelReconciliationServiceImpl', 'queryExportList', 1, 0, 100000, 1, 0, '酒店-餐厅对账单列表导出'
+WHERE NOT EXISTS (SELECT 1 FROM sys_excel_export_config WHERE config_key = 'hotel_reconciliation_export');
+
+INSERT INTO sys_excel_column_config
+(config_key, field_name, column_name, width, order_num, export, date_format, dict_type, importable)
+SELECT * FROM (
+    SELECT 'hotel_reconciliation_export' AS config_key, 'reconciliationNo' AS field_name, '对账单编号' AS column_name, 20 AS width, 1 AS order_num, 1 AS export, NULL AS date_format, NULL AS dict_type, 0 AS importable
+    UNION ALL SELECT 'hotel_reconciliation_export', 'periodStart', '周期开始', 14, 2, 1, 'yyyy-MM-dd', NULL, 0
+    UNION ALL SELECT 'hotel_reconciliation_export', 'periodEnd', '周期结束', 14, 3, 1, 'yyyy-MM-dd', NULL, 0
+    UNION ALL SELECT 'hotel_reconciliation_export', 'orderCount', '订单笔数', 10, 4, 1, NULL, NULL, 0
+    UNION ALL SELECT 'hotel_reconciliation_export', 'totalAmount', '订单总额(元)', 14, 5, 1, NULL, NULL, 0
+    UNION ALL SELECT 'hotel_reconciliation_export', 'excludedCount', '排除笔数', 10, 6, 1, NULL, NULL, 0
+    UNION ALL SELECT 'hotel_reconciliation_export', 'excludedAmount', '排除金额(元)', 14, 7, 1, NULL, NULL, 0
+    UNION ALL SELECT 'hotel_reconciliation_export', 'settlementAmount', '应付金额(元)', 14, 8, 1, NULL, NULL, 0
+    UNION ALL SELECT 'hotel_reconciliation_export', 'status', '状态', 10, 9, 1, NULL, 'hotel_reconciliation_status', 0
+    UNION ALL SELECT 'hotel_reconciliation_export', 'confirmedTime', '确认时间', 22, 10, 1, 'yyyy-MM-dd HH:mm:ss', NULL, 0
+    UNION ALL SELECT 'hotel_reconciliation_export', 'settledTime', '结算时间', 22, 11, 1, 'yyyy-MM-dd HH:mm:ss', NULL, 0
+    UNION ALL SELECT 'hotel_reconciliation_export', 'settledRemark', '结算备注', 30, 12, 1, NULL, NULL, 0
+) AS cols
+WHERE NOT EXISTS (
+    SELECT 1 FROM sys_excel_column_config c
+    WHERE c.config_key = cols.config_key AND c.field_name = cols.field_name
+);

+ 113 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/reconciliation/controller/HotelReconciliationController.java

@@ -0,0 +1,113 @@
+package com.mdframe.forge.business.core.hotel.reconciliation.controller;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.mdframe.forge.business.core.hotel.reconciliation.domain.HotelReconciliation;
+import com.mdframe.forge.business.core.hotel.reconciliation.dto.ReconciliationGenerateDTO;
+import com.mdframe.forge.business.core.hotel.reconciliation.service.HotelReconciliationService;
+import com.mdframe.forge.business.core.hotel.reconciliation.vo.HotelReconciliationVO;
+import com.mdframe.forge.business.core.hotel.reconciliation.vo.ReconciliationDetailVO;
+import com.mdframe.forge.starter.core.annotation.log.OperationLog;
+import com.mdframe.forge.starter.core.domain.OperationType;
+import com.mdframe.forge.starter.core.domain.PageQuery;
+import com.mdframe.forge.starter.core.domain.RespInfo;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 对账单管理接口(酒店端)。
+ * <p>
+ * 提供对账单的生成、查询、排除订单、提交、标记结算、导出等功能。
+ * </p>
+ */
+@RestController
+@RequestMapping("/hotel/reconciliation")
+public class HotelReconciliationController {
+
+    @Autowired
+    private HotelReconciliationService reconciliationService;
+
+    /**
+     * 分页查询对账单列表。
+     */
+    @GetMapping("/page")
+    @OperationLog(module = "对账管理", type = OperationType.QUERY, desc = "分页查询对账单")
+    public RespInfo<IPage<HotelReconciliationVO>> page(PageQuery pageQuery, HotelReconciliation query) {
+        return RespInfo.success(reconciliationService.reconciliationPage(pageQuery, query));
+    }
+
+    /**
+     * 查询对账单详情(含明细列表)。
+     */
+    @GetMapping("/detail")
+    @OperationLog(module = "对账管理", type = OperationType.QUERY, desc = "查询对账单详情")
+    public RespInfo<ReconciliationDetailVO> detail(@RequestParam Long id) {
+        return RespInfo.success(reconciliationService.detail(id));
+    }
+
+    /**
+     * 生成对账单:选择月份后自动拉取该月已完成的订单生成对账单。
+     */
+    @PostMapping("/generate")
+    @OperationLog(module = "对账管理", type = OperationType.ADD, desc = "生成对账单")
+    public RespInfo<Void> generate(@RequestBody ReconciliationGenerateDTO dto) {
+        reconciliationService.generate(dto);
+        return RespInfo.success();
+    }
+
+    /**
+     * 排除/恢复明细:切换某笔订单的排除状态,自动重算应付金额。
+     */
+    @PostMapping("/excludeItem")
+    @OperationLog(module = "对账管理", type = OperationType.UPDATE, desc = "排除/恢复明细")
+    public RespInfo<Void> excludeItem(@RequestParam Long itemId, @RequestParam(required = false) String reason) {
+        reconciliationService.excludeItem(itemId, reason);
+        return RespInfo.success();
+    }
+
+    /**
+     * 提交对账单给餐厅:酒店确认金额无误后提交,餐厅即可看到。
+     */
+    @PostMapping("/submit")
+    @OperationLog(module = "对账管理", type = OperationType.UPDATE, desc = "提交对账单")
+    public RespInfo<Void> submit(@RequestParam Long id) {
+        reconciliationService.submit(id);
+        return RespInfo.success();
+    }
+
+    /**
+     * 标记已结算:酒店财务付款完成后标记。
+     */
+    @PostMapping("/settle")
+    @OperationLog(module = "对账管理", type = OperationType.UPDATE, desc = "标记对账单已结算")
+    public RespInfo<Void> settle(@RequestParam Long id, @RequestParam(required = false) String remark) {
+        reconciliationService.settle(id, remark);
+        return RespInfo.success();
+    }
+
+    /**
+     * 删除对账单:待确认状态可删除,管理员任意阶段可删除。
+     */
+    @DeleteMapping("/{id}")
+    @OperationLog(module = "对账管理", type = OperationType.DELETE, desc = "删除对账单")
+    public RespInfo<Void> delete(@PathVariable Long id) {
+        reconciliationService.delete(id);
+        return RespInfo.success();
+    }
+
+    /**
+     * 导出单条对账单(双Sheet:对账汇总 + 订单明细)。
+     */
+    @GetMapping("/export/{id}")
+    @OperationLog(module = "对账管理", type = OperationType.EXPORT, desc = "导出对账单")
+    public void exportSingle(@PathVariable Long id, HttpServletResponse response) {
+        reconciliationService.exportSingle(id, response);
+    }
+}

+ 60 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/reconciliation/controller/HotelReconciliationPartnerController.java

@@ -0,0 +1,60 @@
+package com.mdframe.forge.business.core.hotel.reconciliation.controller;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.mdframe.forge.business.core.hotel.reconciliation.domain.HotelReconciliation;
+import com.mdframe.forge.business.core.hotel.reconciliation.service.HotelReconciliationService;
+import com.mdframe.forge.business.core.hotel.reconciliation.vo.HotelReconciliationVO;
+import com.mdframe.forge.business.core.hotel.reconciliation.vo.ReconciliationDetailVO;
+import com.mdframe.forge.starter.core.annotation.log.OperationLog;
+import com.mdframe.forge.starter.core.domain.OperationType;
+import com.mdframe.forge.starter.core.domain.PageQuery;
+import com.mdframe.forge.starter.core.domain.RespInfo;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 对账单查看接口(餐厅端)。
+ * <p>
+ * 餐厅通过受控跨租户访问酒店的对账单数据,仅能查看和确认,不能修改。
+ * 只能看到「已确认」和「已结算」状态的对账单。
+ * </p>
+ */
+@RestController
+@RequestMapping("/hotel/reconciliation/partner")
+public class HotelReconciliationPartnerController {
+
+    @Autowired
+    private HotelReconciliationService reconciliationService;
+
+    /**
+     * 餐厅端分页查询对账单列表(仅已确认+已结算)。
+     */
+    @GetMapping("/page")
+    @OperationLog(module = "对账管理", type = OperationType.QUERY, desc = "餐厅端查询对账单列表")
+    public RespInfo<IPage<HotelReconciliationVO>> page(PageQuery pageQuery, HotelReconciliation query) {
+        return RespInfo.success(reconciliationService.partnerPage(pageQuery, query));
+    }
+
+    /**
+     * 餐厅端查询对账单详情。
+     */
+    @GetMapping("/detail")
+    @OperationLog(module = "对账管理", type = OperationType.QUERY, desc = "餐厅端查询对账单详情")
+    public RespInfo<ReconciliationDetailVO> detail(@RequestParam Long id) {
+        return RespInfo.success(reconciliationService.partnerDetail(id));
+    }
+
+    /**
+     * 餐厅确认对账单:核对金额无误后确认。
+     */
+    @PostMapping("/confirm")
+    @OperationLog(module = "对账管理", type = OperationType.UPDATE, desc = "餐厅确认对账单")
+    public RespInfo<Void> confirm(@RequestParam Long id) {
+        reconciliationService.confirm(id);
+        return RespInfo.success();
+    }
+}

+ 86 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/reconciliation/domain/HotelReconciliation.java

@@ -0,0 +1,86 @@
+package com.mdframe.forge.business.core.hotel.reconciliation.domain;
+
+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.mdframe.forge.starter.tenant.core.TenantEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.io.Serial;
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 酒店-餐厅对账单主表实体。
+ * <p>
+ * 对账单按月生成,汇总顾客点餐订单,用于酒店与餐厅之间的财务对账。
+ * 状态流转:待确认(0) → 已确认(1) → 已结算(2)。
+ * </p>
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("hotel_reconciliation")
+public class HotelReconciliation extends TenantEntity {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /** 主键ID(雪花算法) */
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 对账单编号(DZ-YYYYMM-NNN,如 DZ-202609-001) */
+    private String reconciliationNo;
+
+    /** 对账周期开始日期 */
+    private Date periodStart;
+
+    /** 对账周期结束日期 */
+    private Date periodEnd;
+
+    /** 订单总笔数(含排除) */
+    private Integer orderCount;
+
+    /** 订单总额(元) */
+    private BigDecimal totalAmount;
+
+    /** 排除笔数 */
+    private Integer excludedCount;
+
+    /** 排除金额(元) */
+    private BigDecimal excludedAmount;
+
+    /** 应付金额 = totalAmount - excludedAmount(元) */
+    private BigDecimal settlementAmount;
+
+    /** 状态: 0=待确认 1=已确认 2=已结算 */
+    private Integer status;
+
+    /** 确认人(餐厅端操作人ID) */
+    private Long confirmedBy;
+
+    /** 确认时间 */
+    private Date confirmedTime;
+
+    /** 结算操作人(酒店端操作人ID) */
+    private Long settledBy;
+
+    /** 结算时间 */
+    private Date settledTime;
+
+    /** 结算备注(付款方式、流水号等) */
+    private String settledRemark;
+
+    /** 删除标记(0=未删除,删除时写入主键ID) */
+    @TableLogic(value = "0", delval = "id")
+    private Long delFlag;
+
+    // ========== 非DB字段(查询辅助) ==========
+
+    /** 对账月份(非DB字段,格式 yyyy-MM,用于按月份筛选) */
+    @TableField(exist = false)
+    private String yearMonth;
+}

+ 66 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/reconciliation/domain/HotelReconciliationItem.java

@@ -0,0 +1,66 @@
+package com.mdframe.forge.business.core.hotel.reconciliation.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.mdframe.forge.starter.tenant.core.TenantEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.io.Serial;
+import java.math.BigDecimal;
+
+/**
+ * 酒店-餐厅对账单明细实体。
+ * <p>
+ * 每笔明细对应一个已完成的顾客点餐订单,生成对账单时从 hotel_order 快照关键信息。
+ * 支持「排除」操作:对争议订单(如退款但餐厅已出餐)标记排除,排除后自动重算应付金额。
+ * </p>
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("hotel_reconciliation_item")
+public class HotelReconciliationItem extends TenantEntity {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /** 主键ID(雪花算法) */
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 对账单ID(关联 hotel_reconciliation.id) */
+    private Long reconciliationId;
+
+    /** 关联 hotel_order.id(订单快照来源) */
+    private Long orderId;
+
+    /** 冗余订单号 */
+    private String orderNo;
+
+    /** 冗余房间号 */
+    private String roomNo;
+
+    /** 联系人姓名 */
+    private String contactName;
+
+    /** 实付金额(元) */
+    private BigDecimal payAmount;
+
+    /** 订单状态(冗余快照,对应 hotel_order.status) */
+    private Integer orderStatus;
+
+    /** 退款状态(冗余快照: 0=未退款 1=退款成功 2=退款失败可重试) */
+    private Integer refundStatus;
+
+    /** 是否排除: 0=否 1=是 */
+    private Integer excluded;
+
+    /** 排除原因 */
+    private String excludeReason;
+
+    /** 删除标记(0=未删除,删除时写入主键ID) */
+    @TableLogic(value = "0", delval = "id")
+    private Long delFlag;
+}

+ 16 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/reconciliation/dto/ReconciliationGenerateDTO.java

@@ -0,0 +1,16 @@
+package com.mdframe.forge.business.core.hotel.reconciliation.dto;
+
+import lombok.Data;
+
+/**
+ * 生成对账单请求 DTO。
+ * <p>
+ * 前端选择月份后提交,后端按该月份拉取已完成的顾客点餐订单生成对账单。
+ * </p>
+ */
+@Data
+public class ReconciliationGenerateDTO {
+
+    /** 对账月份,格式 yyyy-MM(如 2026-09) */
+    private String yearMonth;
+}

+ 50 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/reconciliation/mapper/HotelReconciliationItemMapper.java

@@ -0,0 +1,50 @@
+package com.mdframe.forge.business.core.hotel.reconciliation.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.mdframe.forge.business.core.hotel.reconciliation.domain.HotelReconciliationItem;
+import com.mdframe.forge.business.core.hotel.reconciliation.vo.ReconciliationDetailVO;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 对账单明细Mapper。
+ */
+@Mapper
+public interface HotelReconciliationItemMapper extends BaseMapper<HotelReconciliationItem> {
+
+    /**
+     * 按对账单ID查询明细列表。
+     *
+     * @param reconciliationId 对账单ID
+     * @param tenantId         租户ID
+     * @return 明细列表
+     */
+    List<ReconciliationDetailVO.ReconciliationItemVO> selectDetailByReconciliationId(
+            @Param("reconciliationId") Long reconciliationId,
+            @Param("tenantId") Long tenantId);
+
+    /**
+     * 查询指定月份已完成的订单列表(生成对账单数据源)。
+     * <p>
+     * 仅查询 status=6(已完成)的订单,包含退款状态的订单也一并返回(由酒店决定是否排除)。
+     *
+     * @param tenantId  租户ID
+     * @param startTime 月份开始(含)
+     * @param endTime   月份结束(含)
+     * @return 订单信息列表(Map: orderId/orderNo/roomNo/contactName/payAmount/status/refundStatus)
+     */
+    List<Map<String, Object>> selectOrdersByMonth(@Param("tenantId") Long tenantId,
+                                                   @Param("startTime") String startTime,
+                                                   @Param("endTime") String endTime);
+
+    /**
+     * 重新计算对账单的排除统计(排除笔数 + 排除金额)。
+     *
+     * @param reconciliationId 对账单ID
+     * @return Map: excludedCount / excludedAmount
+     */
+    Map<String, Object> selectExcludedStats(@Param("reconciliationId") Long reconciliationId);
+}

+ 62 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/reconciliation/mapper/HotelReconciliationMapper.java

@@ -0,0 +1,62 @@
+package com.mdframe.forge.business.core.hotel.reconciliation.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.mdframe.forge.business.core.hotel.reconciliation.domain.HotelReconciliation;
+import com.mdframe.forge.business.core.hotel.reconciliation.vo.HotelReconciliationVO;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 对账单Mapper。
+ */
+@Mapper
+public interface HotelReconciliationMapper extends BaseMapper<HotelReconciliation> {
+
+    /**
+     * 酒店端分页查询对账单列表。
+     *
+     * @param page     分页对象
+     * @param tenantId 租户ID
+     * @param query    查询条件
+     * @return 对账单分页数据
+     */
+    IPage<HotelReconciliationVO> selectPage(Page<HotelReconciliationVO> page,
+                                            @Param("tenantId") Long tenantId,
+                                            @Param("query") HotelReconciliation query);
+
+    /**
+     * 餐厅端分页查询对账单列表(仅已确认+已结算)。
+     *
+     * @param page     分页对象
+     * @param tenantId 租户ID
+     * @param query    查询条件
+     * @return 对账单分页数据
+     */
+    IPage<HotelReconciliationVO> selectPartnerPage(Page<HotelReconciliationVO> page,
+                                                   @Param("tenantId") Long tenantId,
+                                                   @Param("query") HotelReconciliation query);
+
+    /**
+     * 查询当月最大对账单编号(生成编号用)。
+     *
+     * @param tenantId 租户ID
+     * @param prefix   编号前缀(如 DZ-202609-)
+     * @return 最大编号(不含前缀的数字部分),无则返回 null
+     */
+    String selectMaxNo(@Param("tenantId") Long tenantId, @Param("prefix") String prefix);
+
+    /**
+     * 导出查询(供 Excel 导出配置反射调用)。
+     *
+     * @param tenantId 租户ID
+     * @param params   查询参数
+     * @return 对账单列表
+     */
+    List<HotelReconciliationVO> selectExportList(@Param("tenantId") Long tenantId,
+                                                 @Param("params") Map<String, Object> params);
+}

+ 120 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/reconciliation/service/HotelReconciliationService.java

@@ -0,0 +1,120 @@
+package com.mdframe.forge.business.core.hotel.reconciliation.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.mdframe.forge.business.core.hotel.reconciliation.domain.HotelReconciliation;
+import com.mdframe.forge.business.core.hotel.reconciliation.dto.ReconciliationGenerateDTO;
+import com.mdframe.forge.business.core.hotel.reconciliation.vo.HotelReconciliationVO;
+import com.mdframe.forge.business.core.hotel.reconciliation.vo.ReconciliationDetailVO;
+import com.mdframe.forge.starter.core.domain.PageQuery;
+
+import jakarta.servlet.http.HttpServletResponse;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 对账单服务接口。
+ */
+public interface HotelReconciliationService {
+
+    /**
+     * 酒店端分页查询对账单列表。
+     *
+     * @param pageQuery 分页参数
+     * @param query     查询条件
+     * @return 对账单分页数据
+     */
+    IPage<HotelReconciliationVO> reconciliationPage(PageQuery pageQuery, HotelReconciliation query);
+
+    /**
+     * 餐厅端分页查询对账单列表(仅已确认+已结算,跨租户访问酒店数据)。
+     *
+     * @param pageQuery 分页参数
+     * @param query     查询条件
+     * @return 对账单分页数据
+     */
+    IPage<HotelReconciliationVO> partnerPage(PageQuery pageQuery, HotelReconciliation query);
+
+    /**
+     * 对账单详情(含明细列表)。
+     *
+     * @param id 对账单ID
+     * @return 对账单详情
+     */
+    ReconciliationDetailVO detail(Long id);
+
+    /**
+     * 餐厅端对账单详情(跨租户访问)。
+     *
+     * @param id 对账单ID
+     * @return 对账单详情
+     */
+    ReconciliationDetailVO partnerDetail(Long id);
+
+    /**
+     * 生成对账单:按月份拉取已完成的顾客点餐订单,生成对账单及明细。
+     *
+     * @param dto 生成请求(yearMonth 格式 yyyy-MM)
+     * @throws com.mdframe.forge.starter.core.exception.BusinessException 月份为空或已存在对账单时抛出
+     */
+    void generate(ReconciliationGenerateDTO dto);
+
+    /**
+     * 排除/恢复明细:切换明细的排除状态,并自动重算对账单应付金额。
+     *
+     * @param itemId 明细ID
+     * @param reason 排除原因(排除时必填,恢复时忽略)
+     */
+    void excludeItem(Long itemId, String reason);
+
+    /**
+     * 提交对账单给餐厅:将状态从「待确认」改为「已确认」的前置步骤。
+     * 酒店确认金额无误后提交,餐厅即可看到。
+     *
+     * @param id 对账单ID
+     */
+    void submit(Long id);
+
+    /**
+     * 餐厅确认对账单:将状态从「已确认」改为... 实际上按需求,
+     * 提交后状态变为「已确认」,餐厅确认后状态不变但记录确认人和时间。
+     * 最终由酒店标记「已结算」。
+     *
+     * @param id 对账单ID
+     */
+    void confirm(Long id);
+
+    /**
+     * 标记已结算:酒店财务付款完成后标记。
+     *
+     * @param id     对账单ID
+     * @param remark 结算备注(选填)
+     */
+    void settle(Long id, String remark);
+
+    /**
+     * 删除对账单(逻辑删除)。
+     * <p>
+     * 规则:待确认(0)状态可由有权限的用户删除;管理员任意阶段均可删除。
+     * 删除时级联逻辑删除关联的明细记录。
+     * </p>
+     *
+     * @param id 对账单ID
+     */
+    void delete(Long id);
+
+    /**
+     * 导出单条对账单(双Sheet:汇总 + 订单明细)。
+     *
+     * @param id       对账单ID
+     * @param response HTTP响应
+     */
+    void exportSingle(Long id, HttpServletResponse response);
+
+    /**
+     * 导出查询(供 Excel 导出配置反射调用)。
+     *
+     * @param params 查询参数
+     * @return 对账单列表
+     */
+    List<HotelReconciliationVO> queryExportList(Map<String, Object> params);
+}

+ 562 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/reconciliation/service/impl/HotelReconciliationServiceImpl.java

@@ -0,0 +1,562 @@
+package com.mdframe.forge.business.core.hotel.reconciliation.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.alibaba.excel.EasyExcel;
+import com.alibaba.excel.ExcelWriter;
+import com.alibaba.excel.write.metadata.WriteSheet;
+import com.mdframe.forge.business.core.hotel.reconciliation.domain.HotelReconciliation;
+import com.mdframe.forge.business.core.hotel.reconciliation.domain.HotelReconciliationItem;
+import com.mdframe.forge.business.core.hotel.reconciliation.dto.ReconciliationGenerateDTO;
+import com.mdframe.forge.business.core.hotel.reconciliation.mapper.HotelReconciliationItemMapper;
+import com.mdframe.forge.business.core.hotel.reconciliation.mapper.HotelReconciliationMapper;
+import com.mdframe.forge.business.core.hotel.reconciliation.service.HotelReconciliationService;
+import com.mdframe.forge.business.core.hotel.reconciliation.vo.HotelReconciliationVO;
+import com.mdframe.forge.business.core.hotel.reconciliation.vo.ReconciliationDetailVO;
+import com.mdframe.forge.starter.core.domain.PageQuery;
+import com.mdframe.forge.starter.core.exception.BusinessException;
+import com.mdframe.forge.starter.core.session.SessionHelper;
+import com.mdframe.forge.starter.tenant.context.TenantContextHolder;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 对账单服务实现。
+ * <p>
+ * 核心业务:按月生成对账单、排除争议订单、餐厅确认、酒店结算。
+ * 餐厅端通过 TenantContextHolder.executeWithTenant() 受控跨租户访问酒店数据。
+ * </p>
+ */
+@Slf4j
+@Service
+public class HotelReconciliationServiceImpl implements HotelReconciliationService {
+
+    @Autowired
+    private HotelReconciliationMapper reconciliationMapper;
+
+    @Autowired
+    private HotelReconciliationItemMapper reconciliationItemMapper;
+
+    private static final SimpleDateFormat MONTH_FMT = new SimpleDateFormat("yyyy-MM");
+    private static final SimpleDateFormat MONTH_START_FMT = new SimpleDateFormat("yyyy-MM-01 00:00:00");
+    private static final SimpleDateFormat NUMBER_FMT = new SimpleDateFormat("yyyyMM");
+
+    // ==================== 查询类 ====================
+
+    @Override
+    public IPage<HotelReconciliationVO> reconciliationPage(PageQuery pageQuery, HotelReconciliation query) {
+        Page<HotelReconciliationVO> page = new Page<>(pageQuery.getPageNum(), pageQuery.getPageSize());
+        return reconciliationMapper.selectPage(page, resolveTenantId(), query);
+    }
+
+    @Override
+    public IPage<HotelReconciliationVO> partnerPage(PageQuery pageQuery, HotelReconciliation query) {
+        // 餐厅端跨租户访问:切到酒店租户(tenant_id=1)
+        final Long hotelTenantId = 1L;
+        final Page<HotelReconciliationVO> page = new Page<>(pageQuery.getPageNum(), pageQuery.getPageSize());
+        return TenantContextHolder.executeWithTenant(hotelTenantId, new java.util.function.Supplier<IPage<HotelReconciliationVO>>() {
+            @Override
+            public IPage<HotelReconciliationVO> get() {
+                return reconciliationMapper.selectPartnerPage(page, hotelTenantId, query);
+            }
+        });
+    }
+
+    @Override
+    public ReconciliationDetailVO detail(Long id) {
+        Long tenantId = resolveTenantId();
+        return buildDetail(id, tenantId);
+    }
+
+    @Override
+    public ReconciliationDetailVO partnerDetail(Long id) {
+        final Long hotelTenantId = 1L;
+        return TenantContextHolder.executeWithTenant(hotelTenantId, new java.util.function.Supplier<ReconciliationDetailVO>() {
+            @Override
+            public ReconciliationDetailVO get() {
+                return buildDetail(id, hotelTenantId);
+            }
+        });
+    }
+
+    /**
+     * 构建对账单详情(主表信息 + 明细列表)。
+     */
+    private ReconciliationDetailVO buildDetail(Long id, Long tenantId) {
+        HotelReconciliation entity = reconciliationMapper.selectById(id);
+        if (entity == null) {
+            throw new BusinessException("对账单不存在");
+        }
+
+        ReconciliationDetailVO vo = new ReconciliationDetailVO();
+        vo.setId(entity.getId());
+        vo.setReconciliationNo(entity.getReconciliationNo());
+        vo.setPeriodStart(entity.getPeriodStart());
+        vo.setPeriodEnd(entity.getPeriodEnd());
+        vo.setOrderCount(entity.getOrderCount());
+        vo.setTotalAmount(entity.getTotalAmount());
+        vo.setExcludedCount(entity.getExcludedCount());
+        vo.setExcludedAmount(entity.getExcludedAmount());
+        vo.setSettlementAmount(entity.getSettlementAmount());
+        vo.setStatus(entity.getStatus());
+        vo.setConfirmedTime(entity.getConfirmedTime());
+        vo.setSettledTime(entity.getSettledTime());
+        vo.setSettledRemark(entity.getSettledRemark());
+        vo.setCreateTime(entity.getCreateTime());
+
+        // 查询明细列表
+        List<ReconciliationDetailVO.ReconciliationItemVO> items =
+                reconciliationItemMapper.selectDetailByReconciliationId(id, tenantId);
+        vo.setItems(items != null ? items : new ArrayList<ReconciliationDetailVO.ReconciliationItemVO>());
+
+        return vo;
+    }
+
+    // ==================== 生成对账单 ====================
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void generate(ReconciliationGenerateDTO dto) {
+        String yearMonth = dto.getYearMonth();
+        if (yearMonth == null || yearMonth.trim().isEmpty()) {
+            throw new BusinessException("请选择对账月份");
+        }
+
+        Long tenantId = resolveTenantId();
+
+        // 查询该月已有对账单最大编号(用于序号自增)
+        String prefix = "DZ-" + yearMonth.replace("-", "") + "-";
+        String existMaxNo = reconciliationMapper.selectMaxNo(tenantId, prefix);
+
+        // 计算月份起止时间
+        String startTime;
+        String endTime;
+        try {
+            Date monthDate = MONTH_FMT.parse(yearMonth);
+            Calendar cal = Calendar.getInstance();
+            cal.setTime(monthDate);
+            startTime = MONTH_START_FMT.format(cal.getTime());
+            // 下个月1号 00:00:00 作为结束边界(不含)
+            cal.add(Calendar.MONTH, 1);
+            endTime = MONTH_START_FMT.format(cal.getTime());
+        } catch (Exception e) {
+            throw new BusinessException("月份格式错误,请使用 yyyy-MM 格式");
+        }
+
+        // 查询该月已完成的订单
+        List<Map<String, Object>> orders = reconciliationItemMapper.selectOrdersByMonth(tenantId, startTime, endTime);
+        if (orders == null || orders.isEmpty()) {
+            throw new BusinessException("该月份无已完成的订单,无法生成对账单");
+        }
+
+        // 生成对账单编号(yearMonth 格式 yyyy-MM,直接去横杠即得 yyyyMM)
+        String numberPart = yearMonth.replace("-", "");
+        int seq = 1;
+        if (existMaxNo != null) {
+            // 取最后3位作为序号,递增
+            String lastSeq = existMaxNo.substring(existMaxNo.lastIndexOf('-') + 1);
+            seq = Integer.parseInt(lastSeq) + 1;
+        }
+        String reconciliationNo = "DZ-" + numberPart + "-" + String.format("%03d", seq);
+
+        // 计算总额
+        BigDecimal totalAmount = BigDecimal.ZERO;
+        for (Map<String, Object> order : orders) {
+            Object payAmountObj = order.get("payAmount");
+            BigDecimal payAmount = payAmountObj != null ? new BigDecimal(payAmountObj.toString()) : BigDecimal.ZERO;
+            totalAmount = totalAmount.add(payAmount);
+        }
+
+        // 计算周期起止日期
+        Date periodStart;
+        Date periodEnd;
+        try {
+            periodStart = MONTH_FMT.parse(yearMonth);
+            Calendar endCal = Calendar.getInstance();
+            endCal.setTime(periodStart);
+            endCal.add(Calendar.MONTH, 1);
+            endCal.add(Calendar.DAY_OF_MONTH, -1);
+            periodEnd = endCal.getTime();
+        } catch (Exception e) {
+            // 不会走到这里,前面已经校验过格式
+            throw new BusinessException("月份格式错误");
+        }
+
+        // 插入对账单主表
+        HotelReconciliation reconciliation = new HotelReconciliation();
+        reconciliation.setReconciliationNo(reconciliationNo);
+        reconciliation.setPeriodStart(periodStart);
+        reconciliation.setPeriodEnd(periodEnd);
+        reconciliation.setOrderCount(orders.size());
+        reconciliation.setTotalAmount(totalAmount);
+        reconciliation.setExcludedCount(0);
+        reconciliation.setExcludedAmount(BigDecimal.ZERO);
+        reconciliation.setSettlementAmount(totalAmount);
+        reconciliation.setStatus(0); // 待确认
+        reconciliation.setTenantId(tenantId);
+        reconciliationMapper.insert(reconciliation);
+
+        // 批量插入明细
+        for (Map<String, Object> order : orders) {
+            HotelReconciliationItem item = new HotelReconciliationItem();
+            item.setReconciliationId(reconciliation.getId());
+            item.setOrderId(toLong(order.get("orderId")));
+            item.setOrderNo(toStr(order.get("orderNo")));
+            item.setRoomNo(toStr(order.get("roomNo")));
+            item.setContactName(toStr(order.get("contactName")));
+            Object payAmountObj = order.get("payAmount");
+            item.setPayAmount(payAmountObj != null ? new BigDecimal(payAmountObj.toString()) : BigDecimal.ZERO);
+            item.setOrderStatus(toInt(order.get("orderStatus")));
+            item.setRefundStatus(toInt(order.get("refundStatus")));
+            item.setExcluded(0);
+            item.setTenantId(tenantId);
+            reconciliationItemMapper.insert(item);
+        }
+
+        log.info("对账单生成成功: no={}, 月份={}, 订单数={}, 总额={}", reconciliationNo, yearMonth, orders.size(), totalAmount);
+    }
+
+    // ==================== 排除/恢复明细 ====================
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void excludeItem(Long itemId, String reason) {
+        HotelReconciliationItem item = reconciliationItemMapper.selectById(itemId);
+        if (item == null) {
+            throw new BusinessException("明细不存在");
+        }
+
+        // 校验对账单状态:只有「待确认」才能排除
+        HotelReconciliation reconciliation = reconciliationMapper.selectById(item.getReconciliationId());
+        if (reconciliation == null) {
+            throw new BusinessException("对账单不存在");
+        }
+        if (reconciliation.getStatus() != 0) {
+            throw new BusinessException("只有待确认状态的对账单才能排除订单");
+        }
+
+        // 切换排除状态
+        int newExcluded = (item.getExcluded() != null && item.getExcluded() == 1) ? 0 : 1;
+        if (newExcluded == 1 && (reason == null || reason.trim().isEmpty())) {
+            throw new BusinessException("排除订单时必须填写排除原因");
+        }
+
+        UpdateWrapper<HotelReconciliationItem> updateWrapper = new UpdateWrapper<>();
+        updateWrapper.eq("id", itemId);
+        updateWrapper.set("excluded", newExcluded);
+        updateWrapper.set("exclude_reason", newExcluded == 1 ? reason : null);
+        reconciliationItemMapper.update(null, updateWrapper);
+
+        // 重新计算对账单的排除统计和应付金额
+        recalculateSettlement(reconciliation.getId(), reconciliation.getTotalAmount());
+    }
+
+    /**
+     * 重新计算对账单的排除统计和应付金额。
+     */
+    private void recalculateSettlement(Long reconciliationId, BigDecimal totalAmount) {
+        Map<String, Object> stats = reconciliationItemMapper.selectExcludedStats(reconciliationId);
+        int excludedCount = toInt(stats != null ? stats.get("excludedCount") : 0);
+        BigDecimal excludedAmount = stats != null && stats.get("excludedAmount") != null
+                ? new BigDecimal(stats.get("excludedAmount").toString())
+                : BigDecimal.ZERO;
+        BigDecimal settlementAmount = totalAmount.subtract(excludedAmount);
+
+        UpdateWrapper<HotelReconciliation> updateWrapper = new UpdateWrapper<>();
+        updateWrapper.eq("id", reconciliationId);
+        updateWrapper.set("excluded_count", excludedCount);
+        updateWrapper.set("excluded_amount", excludedAmount);
+        updateWrapper.set("settlement_amount", settlementAmount);
+        reconciliationMapper.update(null, updateWrapper);
+    }
+
+    // ==================== 提交 / 确认 / 结算 ====================
+
+    @Override
+    public void submit(Long id) {
+        HotelReconciliation reconciliation = requireReconciliation(id);
+        if (reconciliation.getStatus() != 0) {
+            throw new BusinessException("只有待确认状态的对账单才能提交");
+        }
+
+        UpdateWrapper<HotelReconciliation> updateWrapper = new UpdateWrapper<>();
+        updateWrapper.eq("id", id);
+        updateWrapper.set("status", 1); // 已确认(提交后餐厅可见)
+        reconciliationMapper.update(null, updateWrapper);
+
+        log.info("对账单已提交: id={}, no={}", id, reconciliation.getReconciliationNo());
+    }
+
+    @Override
+    public void confirm(Long id) {
+        final Long hotelTenantId = 1L;
+        TenantContextHolder.executeWithTenant(hotelTenantId, new Runnable() {
+            @Override
+            public void run() {
+                HotelReconciliation reconciliation = reconciliationMapper.selectById(id);
+                if (reconciliation == null) {
+                    throw new BusinessException("对账单不存在");
+                }
+                if (reconciliation.getStatus() != 1) {
+                    throw new BusinessException("只有已确认状态的对账单才能确认");
+                }
+
+                Long currentUserId = SessionHelper.getUserId();
+
+                UpdateWrapper<HotelReconciliation> updateWrapper = new UpdateWrapper<>();
+                updateWrapper.eq("id", id);
+                updateWrapper.set("confirmed_by", currentUserId);
+                updateWrapper.set("confirmed_time", new Date());
+                reconciliationMapper.update(null, updateWrapper);
+
+                log.info("餐厅已确认对账单: id={}, no={}", id, reconciliation.getReconciliationNo());
+            }
+        });
+    }
+
+    @Override
+    public void settle(Long id, String remark) {
+        HotelReconciliation reconciliation = requireReconciliation(id);
+        if (reconciliation.getStatus() != 1) {
+            throw new BusinessException("只有已确认状态的对账单才能标记结算");
+        }
+
+        Long currentUserId = SessionHelper.getUserId();
+
+        UpdateWrapper<HotelReconciliation> updateWrapper = new UpdateWrapper<>();
+        updateWrapper.eq("id", id);
+        updateWrapper.set("status", 2); // 已结算
+        updateWrapper.set("settled_by", currentUserId);
+        updateWrapper.set("settled_time", new Date());
+        updateWrapper.set("settled_remark", remark);
+        reconciliationMapper.update(null, updateWrapper);
+
+        log.info("对账单已结算: id={}, no={}", id, reconciliation.getReconciliationNo());
+    }
+
+    // ==================== 删除 ====================
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void delete(Long id) {
+        HotelReconciliation reconciliation = requireReconciliation(id);
+
+        // 权限校验:待确认(0)状态允许删除;管理员任意阶段均可删除
+        if (reconciliation.getStatus() != 0 && !SessionHelper.isAdmin()) {
+            throw new BusinessException("只有待确认状态的对账单或未提交前才能删除,管理员可在任意阶段删除");
+        }
+
+        // 级联逻辑删除明细(del_flag 写入各明细行自身 ID)
+        List<HotelReconciliationItem> items = reconciliationItemMapper.selectList(
+                new LambdaQueryWrapper<HotelReconciliationItem>()
+                        .eq(HotelReconciliationItem::getReconciliationId, id)
+        );
+        if (items != null) {
+            for (HotelReconciliationItem item : items) {
+                reconciliationItemMapper.deleteById(item.getId());
+            }
+        }
+
+        // 逻辑删除主表(del_flag 写入主表自身 ID)
+        reconciliationMapper.deleteById(id);
+
+        log.info("对账单已删除: id={}, no={}, 操作人={}", id, reconciliation.getReconciliationNo(), SessionHelper.getUserId());
+    }
+
+    // ==================== 单条对账单导出(双Sheet) ====================
+
+    @Override
+    public void exportSingle(Long id, HttpServletResponse response) {
+        ReconciliationDetailVO detail = detail(id);
+
+        // 状态文本映射
+        String statusText;
+        if (detail.getStatus() == 0) {
+            statusText = "待确认";
+        } else if (detail.getStatus() == 1) {
+            statusText = "已确认";
+        } else if (detail.getStatus() == 2) {
+            statusText = "已结算";
+        } else {
+            statusText = String.valueOf(detail.getStatus());
+        }
+
+        // ===== Sheet1: 对账汇总 =====
+        List<List<String>> summaryHeaders = new ArrayList<List<String>>();
+        summaryHeaders.add(Collections.singletonList("对账单编号"));
+        summaryHeaders.add(Collections.singletonList("对账周期"));
+        summaryHeaders.add(Collections.singletonList("订单总笔数"));
+        summaryHeaders.add(Collections.singletonList("订单总额(元)"));
+        summaryHeaders.add(Collections.singletonList("排除笔数"));
+        summaryHeaders.add(Collections.singletonList("排除金额(元)"));
+        summaryHeaders.add(Collections.singletonList("应付金额(元)"));
+        summaryHeaders.add(Collections.singletonList("状态"));
+        summaryHeaders.add(Collections.singletonList("创建时间"));
+        summaryHeaders.add(Collections.singletonList("确认时间"));
+        summaryHeaders.add(Collections.singletonList("结算时间"));
+        summaryHeaders.add(Collections.singletonList("结算备注"));
+
+        List<List<Object>> summaryRows = new ArrayList<List<Object>>();
+        List<Object> row = new ArrayList<Object>();
+        row.add(detail.getReconciliationNo());
+        row.add(formatDateStr(detail.getPeriodStart()) + " ~ " + formatDateStr(detail.getPeriodEnd()));
+        row.add(detail.getOrderCount());
+        row.add(detail.getTotalAmount());
+        row.add(detail.getExcludedCount());
+        row.add(detail.getExcludedAmount());
+        row.add(detail.getSettlementAmount());
+        row.add(statusText);
+        row.add(detail.getCreateTime());
+        row.add(detail.getConfirmedTime());
+        row.add(detail.getSettledTime());
+        row.add(detail.getSettledRemark());
+        summaryRows.add(row);
+
+        // ===== Sheet2: 订单明细 =====
+        List<List<String>> itemHeaders = new ArrayList<List<String>>();
+        itemHeaders.add(Collections.singletonList("序号"));
+        itemHeaders.add(Collections.singletonList("订单号"));
+        itemHeaders.add(Collections.singletonList("房间号"));
+        itemHeaders.add(Collections.singletonList("联系人"));
+        itemHeaders.add(Collections.singletonList("实付金额(元)"));
+        itemHeaders.add(Collections.singletonList("退款状态"));
+        itemHeaders.add(Collections.singletonList("是否排除"));
+        itemHeaders.add(Collections.singletonList("排除原因"));
+
+        List<List<Object>> itemRows = new ArrayList<List<Object>>();
+        List<ReconciliationDetailVO.ReconciliationItemVO> items = detail.getItems();
+        if (items != null) {
+            for (int i = 0; i < items.size(); i++) {
+                ReconciliationDetailVO.ReconciliationItemVO item = items.get(i);
+                List<Object> itemRow = new ArrayList<Object>();
+                itemRow.add(i + 1);
+                itemRow.add(item.getOrderNo());
+                itemRow.add(item.getRoomNo());
+                itemRow.add(item.getContactName());
+                itemRow.add(item.getPayAmount());
+                // 退款状态文本
+                if (item.getRefundStatus() != null && item.getRefundStatus() == 1) {
+                    itemRow.add("已退款");
+                } else if (item.getRefundStatus() != null && item.getRefundStatus() == 2) {
+                    itemRow.add("退款失败");
+                } else {
+                    itemRow.add("未退款");
+                }
+                itemRow.add(item.getExcluded() != null && item.getExcluded() == 1 ? "是" : "否");
+                itemRow.add(item.getExcludeReason() != null ? item.getExcludeReason() : "");
+                itemRows.add(itemRow);
+            }
+        }
+
+        // ===== 写入 Excel =====
+        String fileName = "对账单_" + detail.getReconciliationNo() + ".xlsx";
+        try {
+            response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+            response.setCharacterEncoding("utf-8");
+            String encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()).replaceAll("\\+", "%20");
+            response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + encodedFileName);
+
+            ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream()).build();
+            try {
+                WriteSheet summarySheet = EasyExcel.writerSheet(0, "对账汇总").head(summaryHeaders).build();
+                excelWriter.write(summaryRows, summarySheet);
+
+                WriteSheet itemSheet = EasyExcel.writerSheet(1, "订单明细").head(itemHeaders).build();
+                excelWriter.write(itemRows, itemSheet);
+            } finally {
+                excelWriter.finish();
+            }
+        } catch (java.io.IOException e) {
+            throw new BusinessException("导出对账单失败: " + e.getMessage());
+        }
+
+        log.info("对账单导出成功: id={}, no={}", id, detail.getReconciliationNo());
+    }
+
+    /**
+     * 日期格式化为 yyyy-MM-dd。
+     */
+    private String formatDateStr(Date date) {
+        if (date == null) {
+            return "";
+        }
+        return new SimpleDateFormat("yyyy-MM-dd").format(date);
+    }
+
+    // ==================== 导出 ====================
+
+    @Override
+    public List<HotelReconciliationVO> queryExportList(Map<String, Object> params) {
+        Long tenantId = resolveTenantId();
+        return reconciliationMapper.selectExportList(tenantId, params);
+    }
+
+    // ==================== 内部工具方法 ====================
+
+    /**
+     * 获取当前租户ID,兜底返回1。
+     */
+    private Long resolveTenantId() {
+        Long tenantId = TenantContextHolder.getTenantId();
+        return tenantId == null ? 1L : tenantId;
+    }
+
+    /**
+     * 查找对账单,不存在则抛异常。
+     */
+    private HotelReconciliation requireReconciliation(Long id) {
+        HotelReconciliation reconciliation = reconciliationMapper.selectById(id);
+        if (reconciliation == null) {
+            throw new BusinessException("对账单不存在");
+        }
+        return reconciliation;
+    }
+
+    private static int toInt(Object obj) {
+        if (obj == null) {
+            return 0;
+        }
+        if (obj instanceof Number) {
+            return ((Number) obj).intValue();
+        }
+        try {
+            return Integer.parseInt(obj.toString());
+        } catch (NumberFormatException e) {
+            return 0;
+        }
+    }
+
+    private static long toLong(Object obj) {
+        if (obj == null) {
+            return 0L;
+        }
+        if (obj instanceof Number) {
+            return ((Number) obj).longValue();
+        }
+        try {
+            return Long.parseLong(obj.toString());
+        } catch (NumberFormatException e) {
+            return 0L;
+        }
+    }
+
+    private static String toStr(Object obj) {
+        return obj != null ? obj.toString() : null;
+    }
+}

+ 62 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/reconciliation/vo/HotelReconciliationVO.java

@@ -0,0 +1,62 @@
+package com.mdframe.forge.business.core.hotel.reconciliation.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.Date;
+
+/**
+ * 对账单列表视图对象。
+ */
+@Data
+public class HotelReconciliationVO {
+
+    /** 对账单ID */
+    private Long id;
+
+    /** 对账单编号 */
+    private String reconciliationNo;
+
+    /** 对账周期开始 */
+    private Date periodStart;
+
+    /** 对账周期结束 */
+    private Date periodEnd;
+
+    /** 订单总笔数(含排除) */
+    private Integer orderCount;
+
+    /** 订单总额(元) */
+    private BigDecimal totalAmount;
+
+    /** 排除笔数 */
+    private Integer excludedCount;
+
+    /** 排除金额(元) */
+    private BigDecimal excludedAmount;
+
+    /** 应付金额(元) */
+    private BigDecimal settlementAmount;
+
+    /** 状态: 0=待确认 1=已确认 2=已结算 */
+    private Integer status;
+
+    /** 确认人 */
+    private Long confirmedBy;
+
+    /** 确认时间 */
+    private Date confirmedTime;
+
+    /** 结算操作人 */
+    private Long settledBy;
+
+    /** 结算时间 */
+    private Date settledTime;
+
+    /** 结算备注 */
+    private String settledRemark;
+
+    /** 创建时间 */
+    private LocalDateTime createTime;
+}

+ 97 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/reconciliation/vo/ReconciliationDetailVO.java

@@ -0,0 +1,97 @@
+package com.mdframe.forge.business.core.hotel.reconciliation.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 对账单详情视图对象(含明细列表)。
+ */
+@Data
+public class ReconciliationDetailVO {
+
+    /** 对账单ID */
+    private Long id;
+
+    /** 对账单编号 */
+    private String reconciliationNo;
+
+    /** 对账周期开始 */
+    private Date periodStart;
+
+    /** 对账周期结束 */
+    private Date periodEnd;
+
+    /** 订单总笔数(含排除) */
+    private Integer orderCount;
+
+    /** 订单总额(元) */
+    private BigDecimal totalAmount;
+
+    /** 排除笔数 */
+    private Integer excludedCount;
+
+    /** 排除金额(元) */
+    private BigDecimal excludedAmount;
+
+    /** 应付金额(元) */
+    private BigDecimal settlementAmount;
+
+    /** 状态: 0=待确认 1=已确认 2=已结算 */
+    private Integer status;
+
+    /** 确认时间 */
+    private Date confirmedTime;
+
+    /** 结算时间 */
+    private Date settledTime;
+
+    /** 结算备注 */
+    private String settledRemark;
+
+    /** 创建时间 */
+    private LocalDateTime createTime;
+
+    /** 明细列表 */
+    private List<ReconciliationItemVO> items;
+
+    /**
+     * 对账单明细行视图对象。
+     */
+    @Data
+    public static class ReconciliationItemVO {
+
+        /** 明细ID */
+        private Long id;
+
+        /** 关联订单ID */
+        private Long orderId;
+
+        /** 订单号 */
+        private String orderNo;
+
+        /** 房间号 */
+        private String roomNo;
+
+        /** 联系人 */
+        private String contactName;
+
+        /** 实付金额(元) */
+        private BigDecimal payAmount;
+
+        /** 订单状态 */
+        private Integer orderStatus;
+
+        /** 退款状态(0=未退款 1=成功 2=失败可重试) */
+        private Integer refundStatus;
+
+        /** 是否排除(0=否 1=是) */
+        private Integer excluded;
+
+        /** 排除原因 */
+        private String excludeReason;
+    }
+}

+ 47 - 0
forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/reconciliation/HotelReconciliationItemMapper.xml

@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.mdframe.forge.business.core.hotel.reconciliation.mapper.HotelReconciliationItemMapper">
+
+    <!-- 按对账单ID查询明细列表 -->
+    <select id="selectDetailByReconciliationId" resultType="com.mdframe.forge.business.core.hotel.reconciliation.vo.ReconciliationDetailVO$ReconciliationItemVO">
+        SELECT i.id, i.order_id AS orderId, i.order_no AS orderNo, i.room_no AS roomNo,
+               i.contact_name AS contactName, i.pay_amount AS payAmount,
+               i.order_status AS orderStatus, i.refund_status AS refundStatus,
+               i.excluded, i.exclude_reason AS excludeReason
+        FROM hotel_reconciliation_item i
+        WHERE i.reconciliation_id = #{reconciliationId}
+          AND i.tenant_id = #{tenantId}
+          AND i.del_flag = 0
+        ORDER BY i.create_time ASC
+    </select>
+
+    <!--
+        查询指定月份已完成的订单列表(生成对账单数据源)。
+        仅查询 status=6(已完成)的订单;含退款的也返回,由酒店决定是否排除。
+        使用 paid_amount 作为实付金额(实际支付金额),若无则取 total_amount。
+    -->
+    <select id="selectOrdersByMonth" resultType="java.util.Map">
+        SELECT o.id AS orderId, o.order_no AS orderNo, o.room_no AS roomNo,
+               o.contact_name AS contactName,
+               IFNULL(o.paid_amount, o.total_amount) AS payAmount,
+               o.status AS orderStatus, o.refund_status AS refundStatus
+        FROM hotel_order o
+        WHERE o.tenant_id = #{tenantId}
+          AND o.del_flag = 0
+          AND o.status = 6
+          AND o.create_time &gt;= #{startTime}
+          AND o.create_time &lt; #{endTime}
+        ORDER BY o.create_time ASC
+    </select>
+
+    <!-- 重新计算对账单的排除统计 -->
+    <select id="selectExcludedStats" resultType="java.util.Map">
+        SELECT COUNT(1) AS excludedCount,
+               IFNULL(SUM(pay_amount), 0) AS excludedAmount
+        FROM hotel_reconciliation_item
+        WHERE reconciliation_id = #{reconciliationId}
+          AND del_flag = 0
+          AND excluded = 1
+    </select>
+
+</mapper>

+ 77 - 0
forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/reconciliation/HotelReconciliationMapper.xml

@@ -0,0 +1,77 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.mdframe.forge.business.core.hotel.reconciliation.mapper.HotelReconciliationMapper">
+
+    <!-- 对账单列表 VO 列定义 -->
+    <sql id="VoColumns">
+        r.id, r.reconciliation_no AS reconciliationNo, r.period_start AS periodStart,
+        r.period_end AS periodEnd, r.order_count AS orderCount, r.total_amount AS totalAmount,
+        r.excluded_count AS excludedCount, r.excluded_amount AS excludedAmount,
+        r.settlement_amount AS settlementAmount, r.status,
+        r.confirmed_by AS confirmedBy, r.confirmed_time AS confirmedTime,
+        r.settled_by AS settledBy, r.settled_time AS settledTime,
+        r.settled_remark AS settledRemark, r.create_time AS createTime
+    </sql>
+
+    <!-- 酒店端分页查询 -->
+    <select id="selectPage" resultType="com.mdframe.forge.business.core.hotel.reconciliation.vo.HotelReconciliationVO">
+        SELECT <include refid="VoColumns"/>
+        FROM hotel_reconciliation r
+        WHERE r.tenant_id = #{tenantId}
+          AND r.del_flag = 0
+        <if test="query.status != null">
+            AND r.status = #{query.status}
+        </if>
+        <if test="query.reconciliationNo != null and query.reconciliationNo != ''">
+            AND r.reconciliation_no LIKE CONCAT('%', #{query.reconciliationNo}, '%')
+        </if>
+        <if test="query.yearMonth != null and query.yearMonth != ''">
+            AND DATE_FORMAT(r.period_start, '%Y-%m') = #{query.yearMonth}
+        </if>
+        ORDER BY r.create_time DESC
+    </select>
+
+    <!-- 餐厅端分页查询(仅已确认+已结算) -->
+    <select id="selectPartnerPage" resultType="com.mdframe.forge.business.core.hotel.reconciliation.vo.HotelReconciliationVO">
+        SELECT <include refid="VoColumns"/>
+        FROM hotel_reconciliation r
+        WHERE r.tenant_id = #{tenantId}
+          AND r.del_flag = 0
+          AND r.status IN (1, 2)
+        <if test="query.status != null">
+            AND r.status = #{query.status}
+        </if>
+        <if test="query.reconciliationNo != null and query.reconciliationNo != ''">
+            AND r.reconciliation_no LIKE CONCAT('%', #{query.reconciliationNo}, '%')
+        </if>
+        <if test="query.yearMonth != null and query.yearMonth != ''">
+            AND DATE_FORMAT(r.period_start, '%Y-%m') = #{query.yearMonth}
+        </if>
+        ORDER BY r.create_time DESC
+    </select>
+
+    <!-- 查询当月最大对账单编号 -->
+    <select id="selectMaxNo" resultType="java.lang.String">
+        SELECT MAX(r.reconciliation_no)
+        FROM hotel_reconciliation r
+        WHERE r.tenant_id = #{tenantId}
+          AND r.del_flag = 0
+          AND r.reconciliation_no LIKE CONCAT(#{prefix}, '%')
+    </select>
+
+    <!-- 导出查询(供 Excel 导出配置反射调用) -->
+    <select id="selectExportList" resultType="com.mdframe.forge.business.core.hotel.reconciliation.vo.HotelReconciliationVO">
+        SELECT <include refid="VoColumns"/>
+        FROM hotel_reconciliation r
+        WHERE r.tenant_id = #{tenantId}
+          AND r.del_flag = 0
+        <if test="params.status != null and params.status != ''">
+            AND r.status = #{params.status}
+        </if>
+        <if test="params.yearMonth != null and params.yearMonth != ''">
+            AND DATE_FORMAT(r.period_start, '%Y-%m') = #{params.yearMonth}
+        </if>
+        ORDER BY r.create_time DESC
+    </select>
+
+</mapper>

+ 207 - 0
output/Forge-Admin-打印方案选型指南.md

@@ -0,0 +1,207 @@
+# Forge Admin 商业打印方案选型指南
+
+> 适用场景:酒店前台账单打印、餐厅收银小票打印、厨房出菜单打印
+>
+> 更新日期:2026 年 9 月
+
+---
+
+## 一、方案概览
+
+Forge Admin 系统支持两种打印对接模式,可根据实际网络环境选择:
+
+| 模式 | 说明 | 适用情况 |
+|------|------|----------|
+| **云打印模式** | 打印机联网即可用,通过云平台远程发送,无需局域网 | 无固定网络、多门店、远程管理 |
+| **网口直连模式** | 打印机通过网线接入局域网,服务器直接发送指令 | 有稳定局域网、追求零延迟 |
+
+两种模式后端均只需一套代码,更换打印机品牌只需调整配置,无需修改业务逻辑。
+
+---
+
+## 二、云打印品牌推荐
+
+### 2.1 飞鹅智能打印
+
+| 项目 | 说明 |
+|------|------|
+| 品牌定位 | 国内云打印市场占有率第一 |
+| 推荐型号 | FE-58CS(58mm)、FE-80CS(80mm)、FE-G58(前台专用) |
+| 连接方式 | WiFi / 4G / 网口(RJ45) |
+| 单台价格 | 150 ~ 400 元 |
+| 平台费用 | 基础功能免费,高级功能按需付费 |
+| API 对接 | HTTP REST API,签名验证,提供 Java 示例代码 |
+| 核心优势 | 开机联网即用,零运维;支持多打印机分组管理;缺纸/离线自动报警;自动重打机制 |
+| 适合场景 | 厨房出菜单、前台收银小票、酒店账单,全场景通用 |
+| 客户价值 | 不需要 IT 人员维护,开机联网就能打印 |
+
+### 2.2 易联云
+
+| 项目 | 说明 |
+|------|------|
+| 品牌定位 | 云打印领域优质品牌,硬件做工突出 |
+| 推荐型号 | K3(58mm)、K6(80mm)、KM-151(标签打印) |
+| 连接方式 | WiFi / 4G / 蓝牙 |
+| 单台价格 | 200 ~ 500 元 |
+| 平台费用 | 完全免费,无年费 |
+| API 对接 | HTTP REST API,接口简洁,提供 Java / PHP 示例 |
+| 核心优势 | 打印速度快;云平台免费无年费;**支持新订单语音播报**(厨房场景非常实用);硬件做工精良 |
+| 适合场景 | 厨房(语音提醒新订单)、前台收银、酒店前台 |
+| 客户价值 | 新订单自动语音播报,厨房人员不用一直看屏幕 |
+
+### 2.3 商米(Sunmi)
+
+| 项目 | 说明 |
+|------|------|
+| 品牌定位 | 智能商业硬件高端品牌(小米生态链) |
+| 推荐型号 | NT311(前台触屏一体机)、NT210(厨房打印机)、NT411(标签打印机) |
+| 连接方式 | WiFi / 蓝牙 / USB / 网口 |
+| 单台价格 | 500 ~ 1,500 元 |
+| 平台费用 | 云平台免费 |
+| API 对接 | 商米云平台 HTTP API + Android SDK |
+| 核心优势 | 硬件品质行业最高;外观精致适合高端场所;NT311 为触屏+打印一体机,可替代传统收银机;Android 系统可扩展 |
+| 适合场景 | 高端酒店前台(触屏收银+打印)、品牌连锁餐饮、对硬件品质有要求的场景 |
+| 客户价值 | 一台设备 = 触屏收银机 + 打印机,前台不用买两套设备 |
+
+---
+
+## 三、网口直连品牌推荐
+
+### 3.1 佳博(Gainscha)
+
+| 项目 | 说明 |
+|------|------|
+| 品牌定位 | 国产商用打印性价比之王 |
+| 推荐型号 | GP-L80180I(80mm 网口)、GP-58MBIII(58mm 网口) |
+| 连接方式 | 网口(RJ45)+ USB |
+| 单台价格 | 150 ~ 350 元 |
+| 平台费用 | 无,纯本地直连 |
+| API 对接 | ESC/POS 标准指令集,Java Socket 直连,官方提供 SDK |
+| 核心优势 | 性价比最高;80mm 和 58mm 纸卷兼容;ESC/POS 指令集完整;SDK 文档齐全;自动切纸 |
+| 适合场景 | 厨房出菜单、前台小票、酒店账单,全场景通用 |
+| 客户价值 | 最实惠的稳定方案,一台机器厨房和前台都能用 |
+
+### 3.2 芯烨(Xprinter)
+
+| 项目 | 说明 |
+|------|------|
+| 品牌定位 | 商用打印全品类覆盖,厨房场景专家 |
+| 推荐型号 | XP-N160II(80mm 网口)、XP-80 系列(厨房防油烟专用) |
+| 连接方式 | 网口 + WiFi + USB |
+| 单台价格 | 100 ~ 300 元 |
+| 平台费用 | 无,纯本地直连 |
+| API 对接 | ESC/POS 标准指令集,Java Socket 直连 |
+| 核心优势 | 价格最低;**有专门的厨房防油烟防水机型**;型号丰富,覆盖 58mm/80mm/标签全品类 |
+| 适合场景 | 厨房(防油烟机型)、前台收银、酒店 |
+| 客户价值 | 厨房油烟大也不怕,专门有防油防水款,经久耐用 |
+
+### 3.3 爱普生(Epson)
+
+| 项目 | 说明 |
+|------|------|
+| 品牌定位 | 全球商用打印设备标杆 |
+| 推荐型号 | TM-T82III(80mm 标准型)、TM-T88VI(80mm 高端型) |
+| 连接方式 | 网口(RJ45)+ USB |
+| 单台价格 | 500 ~ 1,500 元 |
+| 平台费用 | 无,纯本地直连 |
+| API 对接 | ePOS-SDK(HTTP/XML 方式)+ ESC/POS 指令,Java 原生支持 |
+| 核心优势 | 全球打印设备标杆,稳定性最强;故障率极低,使用寿命长;国际品牌背书,适合连锁品牌统一采购 |
+| 适合场景 | 高端酒店、连锁品牌餐饮、对稳定性和品牌有极高要求的场景 |
+| 客户价值 | 国际大牌,5 年以上不用换,适合连锁品牌统一标准采购 |
+
+---
+
+## 四、综合对比总表
+
+| 品牌 | 打印模式 | 单台价格 | 平台年费 | 是否需要局域网 | 对接难度 | 推荐指数 |
+|------|---------|---------|---------|--------------|---------|---------|
+| 飞鹅 | 云打印 | 150 ~ 400 元 | 免费额度 | 不需要(有网即可) | 最低 | ★★★★★ |
+| 易联云 | 云打印 | 200 ~ 500 元 | 完全免费 | 不需要 | 最低 | ★★★★★ |
+| 商米 | 云打印 + 智能终端 | 500 ~ 1,500 元 | 免费 | 不需要 | 中等 | ★★★★☆ |
+| 佳博 | 网口直连 | 150 ~ 350 元 | 无 | 需要 | 低 | ★★★★☆ |
+| 芯烨 | 网口直连 | 100 ~ 300 元 | 无 | 需要 | 低 | ★★★★☆ |
+| 爱普生 | 网口直连 | 500 ~ 1,500 元 | 无 | 需要 | 低 | ★★★★☆ |
+
+---
+
+## 五、按场景推荐
+
+### 场景一:单店 / 小店,不想折腾网络
+
+**推荐:飞鹅**
+
+- 开机联网就能用,不需要配置局域网
+- 价格实惠,150 元起步
+- 手机 APP 可远程管理打印机状态
+
+### 场景二:厨房油烟环境
+
+**推荐:芯烨防油烟款 或 易联云**
+
+- 芯烨有专门的厨房防油烟防水机型,硬件耐用
+- 易联云支持新订单语音播报,厨房人员不用看屏幕就知道来单了
+
+### 场景三:多门店连锁,需要统一管理
+
+**推荐:飞鹅 或 商米**
+
+- 云平台可远程管理所有门店的所有打印机
+- 支持分组管理(按门店/区域)
+- 打印机离线自动报警通知
+
+### 场景四:高端酒店前台
+
+**推荐:商米 NT311**
+
+- 触屏收银 + 打印一体机,一台设备解决两个需求
+- 外观精致,符合高端酒店形象
+- Android 系统,可安装 Forge H5 应用
+
+### 场景五:预算有限,有稳定局域网
+
+**推荐:佳博 GP-L80180I**
+
+- 性价比最高的稳定方案
+- 80mm / 58mm 纸卷兼容,一台通吃
+- 无平台费,纯本地运行
+
+### 场景六:国际品牌 / 甲方指定品牌
+
+**推荐:爱普生**
+
+- 全球商用打印标杆,品质无可挑剔
+- 适合连锁品牌统一采购标准
+- 故障率极低,使用寿命长
+
+---
+
+## 六、技术对接说明
+
+无论客户选择哪个品牌,Forge Admin 系统均已完成适配准备:
+
+| 对接方式 | 技术实现 | 说明 |
+|----------|---------|------|
+| 云打印 | 后端调用 HTTP API 发送打印指令 | 更换品牌只需修改 API 密钥和打印机编号 |
+| 网口直连 | 后端通过 TCP Socket 发送 ESC/POS 指令 | 更换品牌只需修改打印机 IP 地址 |
+
+**核心要点:**
+
+- 所有品牌均使用 ESC/POS 标准打印指令,通用性强
+- 后端统一封装打印服务,业务代码与打印机品牌完全解耦
+- 更换打印机品牌不影响任何业务功能,只需调整系统配置
+- 支持按场景分配打印机(厨房打印机、前台打印机、酒店打印机独立配置)
+
+---
+
+## 七、采购建议
+
+| 建议 | 说明 |
+|------|------|
+| 先买一台试跑 | 建议先采购一台跑通打印流程,确认效果后再批量采购 |
+| 80mm 优先 | 80mm 打印机向下兼容 58mm 纸卷,一台即可覆盖所有场景 |
+| 云打印优先 | 如果没有特殊网络限制,优先选择云打印方案,运维成本最低 |
+| 统一品牌 | 同一场所建议使用同一品牌,便于统一管理和耗材采购 |
+
+---
+
+*如有任何技术问题,请联系 Forge Admin 技术支持团队。*