Quellcode durchsuchen

跨租户数据处理

徐滕 vor 4 Stunden
Ursprung
Commit
d20d76dd51

+ 323 - 0
code-copilot/knowledge/tech-cross-tenant-operation-pattern.md

@@ -0,0 +1,323 @@
+# 跨租户操作模式(Cross-Tenant Operation Pattern)
+
+> 来源:酒店-餐厅双租户拆分
+> 时间:2026-09-30
+
+## 业务背景
+
+酒店点餐模块拆分为两个租户:
+- **酒店租户**(如 tenant_id=8):管理客房、订单、支付、二维码、对账
+- **餐厅租户**(如 tenant_id=9):管理菜品、分类、规格、营业时段、暂停接单
+
+顾客通过二维码扫码下单,订单归属酒店租户(tenant_id=8)。但后续订单操作(接单、备餐、出餐、配送、完成、退单等)可能由餐厅员工在餐厅租户下执行。
+
+## 核心问题
+
+### TenantLineInnerInterceptor 的行为
+
+MyBatis-Plus 的 `TenantLineInnerInterceptor` 会根据**当前登录上下文**自动追加 `tenant_id = 当前租户ID`:
+
+- **SELECT**:`WHERE ... AND tenant_id = 当前上下文`
+- **UPDATE**:`WHERE ... AND tenant_id = 当前上下文`
+- **DELETE**:`WHERE ... AND tenant_id = 当前上下文`
+
+### 关键陷阱
+
+**传参数不能覆盖拦截器行为!**
+
+```java
+// ❌ 错误:以为传了 tenantId=8 就能查 tenant_id=8 的数据
+orderMapper.selectKitchenOrderList(8, queryDate);
+
+// 实际生成的 SQL:
+SELECT ... FROM hotel_order
+WHERE tenant_id = 8          -- XML 显式条件(参数传入)
+  AND tenant_id = 9          -- 拦截器自动追加(当前登录上下文)
+-- 两个条件矛盾 → 查不到数据!
+```
+
+## 解决方案
+
+### 方案一:executeWithTenant — 临时切换租户上下文
+
+用于**查询/操作另一个租户的数据**。
+
+```java
+// ✅ 正确:用 executeWithTenant 切换上下文
+final Long hotelTenantId = 8L; // 从映射表解析
+TenantContextHolder.executeWithTenant(hotelTenantId, new Runnable() {
+    @Override
+    public void run() {
+        // 拦截器现在追加 tenant_id = 8
+        orderMapper.selectKitchenOrderList(hotelTenantId, queryDate);
+        orderMapper.updateById(order); // WHERE tenant_id = 8
+    }
+});
+```
+
+**关键点**:
+- `executeWithTenant` 切换的是**整个上下文**,拦截器和 XML 都会使用新值
+- 适用于有返回值的场景用 `Supplier<T>` 重载
+- 适用于无返回值的场景用 `Runnable` 重载
+
+### 方案二:executeIgnore — 绕过租户过滤
+
+用于**系统级配置表**(如映射表),所有租户都需要访问。
+
+```java
+// ✅ 正确:映射表是系统级配置,用 executeIgnore 绕过
+default HotelTenantMapping selectByRestaurantTenantId(Long restaurantTenantId) {
+    return TenantContextHolder.executeIgnore(() -> _selectByRestaurantTenantId(restaurantTenantId));
+}
+```
+
+**关键点**:
+- 映射表的 `tenant_id` 应设为 `1`(系统级)
+- XML 中不要写 `tenant_id = ?` 条件
+- 用 `default` 方法 + `executeIgnore` 包裹实际查询方法
+
+## 双租户映射表设计
+
+### 表结构
+
+```sql
+CREATE TABLE hotel_tenant_mapping (
+    id              BIGINT NOT NULL,
+    tenant_id       BIGINT NOT NULL DEFAULT 1 COMMENT '系统级配置,固定为1',
+    hotel_tenant_id BIGINT NOT NULL COMMENT '酒店租户ID',
+    restaurant_tenant_id BIGINT NOT NULL COMMENT '餐厅租户ID',
+    status          VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
+    ...
+);
+```
+
+### 查询模式
+
+```java
+// Mapper 接口:用 default 方法 + executeIgnore
+default Long selectRestaurantTenantId(Long hotelTenantId) {
+    return TenantContextHolder.executeIgnore(() -> _selectRestaurantTenantId(hotelTenantId));
+}
+
+// XML:不写 tenant_id 条件
+<select id="_selectRestaurantTenantId" resultType="java.lang.Long">
+    SELECT restaurant_tenant_id
+    FROM hotel_tenant_mapping
+    WHERE hotel_tenant_id = #{hotelTenantId}
+      AND status = 'ACTIVE'
+      AND del_flag = 0
+    LIMIT 1
+</select>
+```
+
+## Service 层租户解析模式
+
+### 解析酒店租户(管理端查询/操作订单)
+
+```java
+private Long resolveHotelTenantForAdmin() {
+    Long currentTenantId = resolveTenantId();
+    // 尝试从映射表反查:restaurant_tenant_id -> hotel_tenant_id
+    HotelTenantMapping mapping = tenantMappingMapper.selectByRestaurantTenantId(currentTenantId);
+    if (mapping != null && mapping.getHotelTenantId() != null) {
+        return mapping.getHotelTenantId();
+    }
+    // 未找到映射,说明当前就是酒店租户或未配置双租户
+    return currentTenantId;
+}
+```
+
+### 解析餐厅租户(顾客端查菜品)
+
+```java
+private Long resolveRestaurantTenantId(Long hotelTenantId) {
+    Long restaurantTenantId = tenantMappingMapper.selectRestaurantTenantId(hotelTenantId);
+    return restaurantTenantId != null ? restaurantTenantId : hotelTenantId;
+}
+```
+
+## 完整方法包裹模式
+
+### 查询方法
+
+```java
+@Override
+public List<HotelOrderVO> kitchenOrderList(final Date queryDate) {
+    final Long hotelTenantId = resolveHotelTenantForAdmin();
+    return TenantContextHolder.executeWithTenant(hotelTenantId, new java.util.function.Supplier<List<HotelOrderVO>>() {
+        @Override
+        public List<HotelOrderVO> get() {
+            List<HotelOrderVO> list = orderMapper.selectKitchenOrderList(hotelTenantId, queryDate);
+            for (HotelOrderVO vo : list) {
+                fillOrderItems(vo);
+            }
+            return list;
+        }
+    });
+}
+```
+
+### 操作方法
+
+```java
+@Override
+@Transactional(rollbackFor = Exception.class)
+public void orderAccept(final Long id) {
+    final Long hotelTenantId = resolveHotelTenantForAdmin();
+    TenantContextHolder.executeWithTenant(hotelTenantId, new Runnable() {
+        @Override
+        public void run() {
+            HotelOrder order = requireOrder(id);
+            if (order.getStatus() != HotelOrderConstants.STATUS_PLACED) {
+                throw new BusinessException("只有待接单的订单才能接单");
+            }
+            order.setStatus(HotelOrderConstants.STATUS_ACCEPTED);
+            order.setAcceptTime(new Date());
+            orderMapper.updateById(order); // WHERE tenant_id = hotelTenantId
+        }
+    });
+}
+```
+
+### 混合租户上下文(订单 + 营业时段)
+
+```java
+@Override
+public DashboardVO dashboard() {
+    final Long hotelTenantId = resolveHotelTenantForAdmin();
+    final DashboardVO vo = new DashboardVO();
+
+    // 订单查询:切换到酒店租户
+    TenantContextHolder.executeWithTenant(hotelTenantId, new Runnable() {
+        @Override
+        public void run() {
+            // 订单相关查询...
+        }
+    });
+
+    // 营业时段:用当前上下文(餐厅租户)
+    List<HotelBusinessHours> hoursList = businessHoursMapper.selectEnabled(resolveTenantId());
+
+    // 餐段订单统计:切回酒店租户
+    TenantContextHolder.executeWithTenant(hotelTenantId, new Runnable() {
+        @Override
+        public void run() {
+            // 订单统计查询...
+        }
+    });
+
+    return vo;
+}
+```
+
+## 跨租户方法调用注意事项
+
+### 问题场景
+
+```java
+// orderComplete 在 executeWithTenant(8, ...) 内调用 incrementDishSales
+public void orderComplete(Long id) {
+    executeWithTenant(8, () -> {
+        // ... 订单操作 ...
+        incrementDishSales(id); // ❌ 内部 resolveTenantId() 返回 8(酒店租户)
+    });
+}
+
+private void incrementDishSales(Long orderId) {
+    // resolveTenantId() 返回 8(因为外层已切换到酒店租户)
+    Long restaurantTenantId = resolveRestaurantTenantId(resolveTenantId());
+    // resolveRestaurantTenantId(8) → 查映射表 → 返回 9 ✅ 碰巧正确
+    // 但如果映射表查询失败,回退返回 8 → executeWithTenant(8) → 查不到菜品数据 ❌
+}
+```
+
+### 正确做法:显式传递租户 ID
+
+```java
+// 在切换租户前保存原始餐厅租户ID
+public void orderComplete(final Long id) {
+    final Long hotelTenantId = resolveHotelTenantForAdmin();
+    final Long originalRestaurantTenantId = resolveRestaurantTenantId(resolveTenantId());
+    TenantContextHolder.executeWithTenant(hotelTenantId, new Runnable() {
+        @Override
+        public void run() {
+            // ... 订单操作 ...
+            incrementDishSales(id, originalRestaurantTenantId); // ✅ 显式传递
+        }
+    });
+}
+
+private void incrementDishSales(Long orderId, Long restaurantTenantId) {
+    List<HotelOrderItem> items = orderItemMapper.selectByOrderId(orderId);
+    TenantContextHolder.executeWithTenant(restaurantTenantId, new Runnable() {
+        @Override
+        public void run() {
+            for (HotelOrderItem item : items) {
+                dishMapper.incrementSales(item.getDishId(), item.getQuantity());
+            }
+        }
+    });
+}
+```
+
+## tenant_id 是否会被意外修改?
+
+### updateById(entity)
+
+```sql
+UPDATE hotel_order
+SET status = 2, ..., tenant_id = 8  -- 实体内存中的值(加载时是 8,未改动)
+WHERE id = ? AND tenant_id = 8      -- 拦截器追加
+```
+
+**结论**:`tenant_id` 保持原值,不会变。
+
+### UpdateWrapper
+
+```sql
+UPDATE hotel_order
+SET status = 7, reject_reason = ?   -- 只 SET 显式指定的字段
+WHERE id = ? AND tenant_id = 8      -- 拦截器追加
+```
+
+**结论**:没有 `.set("tenant_id", ...)` → `tenant_id` 不会被修改。
+
+### deleteById
+
+```sql
+UPDATE hotel_order SET del_flag = id WHERE id = ? AND tenant_id = 8
+```
+
+**结论**:逻辑删除只改 `del_flag`,`tenant_id` 不变。
+
+## 影响范围分析
+
+### 对酒店租户(8)登录无影响
+
+- `resolveHotelTenantForAdmin()` 查映射表 → 未找到(8 是 hotel_tenant_id,不是 restaurant_tenant_id)→ 回退返回 8
+- `executeWithTenant(8, ...)` → 当前上下文已经是 8 → **no-op,无变化**
+
+### 对餐厅租户(9)登录的影响
+
+- `resolveHotelTenantForAdmin()` 查映射表 → 找到 `hotel_tenant_id = 8` → 返回 8
+- `executeWithTenant(8, ...)` → 从 9 切换到 8 → **订单相关操作正确执行**
+
+### 不受影响的模块
+
+菜品管理、菜品分类、规格管理、营业时段、房间管理、入住管理、二维码管理、支付回调、对账管理等模块完全不涉及跨租户,不受影响。
+
+## 最佳实践总结
+
+1. **传参数不能覆盖拦截器**:必须用 `executeWithTenant` 切换上下文
+2. **系统级配置表用 `executeIgnore`**:映射表等跨租户共享数据,`tenant_id = 1`
+3. **整个操作包裹**:`requireOrder` + `updateById` 必须在同一个 `executeWithTenant` 内
+4. **显式传递租户 ID**:跨租户方法调用时,在切换前保存原始租户 ID,显式传递
+5. **对当前租户无影响**:`executeWithTenant(当前租户, ...)` 是 no-op,不会改变现有行为
+6. **tenant_id 不会被意外修改**:`updateById` 的 SET 来自实体内存值,`UpdateWrapper` 只 SET 显式字段
+
+## 相关文件
+
+- 映射表:`hotel_tenant_mapping`(Flyway V1.0.144)
+- Mapper:`HotelTenantMappingMapper.java` / `HotelTenantMappingMapper.xml`
+- Service:`HotelOrderServiceImpl.java`(所有订单操作方法)
+- 租户上下文:`TenantContextHolder.executeWithTenant()` / `executeIgnore()`

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

@@ -521,3 +521,14 @@ export function getPartnerReconciliationDetail(id) {
 export function confirmReconciliation(id) {
   return request.post('/hotel/reconciliation/partner/confirm', null, { params: { id } })
 }
+
+/** 餐厅端导出单条对账单 */
+export function exportPartnerReconciliationDetail(id) {
+  return request({
+    method: 'get',
+    url: `/hotel/reconciliation/partner/export/${id}`,
+    responseType: 'blob',
+    rawResponse: true,
+    encrypt: false,
+  })
+}

+ 23 - 2
forge-admin-ui/src/views/hotel/reconciliation.vue

@@ -396,7 +396,7 @@ const tableColumns = computed(() => [
           onClick: () => handleSubmit(row.id),
         }, '提交'))
       }
-      if (row.status === 1) {
+      if (row.status === 2) {
         actions.push(h('a', {
           class: 'text-success cursor-pointer hover:opacity-80',
           onClick: () => handleSettle(row.id),
@@ -429,9 +429,30 @@ async function handleGenerate() {
     message.warning('请选择对账月份')
     return false
   }
+  // NDatePicker value-format 可能不生效,实际返回时间戳字符串,需统一转换为 yyyy-MM
+  let yearMonth = generateMonth.value
+  if (yearMonth instanceof Date) {
+    const y = yearMonth.getFullYear()
+    const m = String(yearMonth.getMonth() + 1).padStart(2, '0')
+    yearMonth = `${y}-${m}`
+  }
+  else if (typeof yearMonth === 'string' && /^\d{13}$/.test(yearMonth)) {
+    // 时间戳字符串(如 "1788192000000"),转为 yyyy-MM
+    const d = new Date(Number(yearMonth))
+    const y = d.getFullYear()
+    const m = String(d.getMonth() + 1).padStart(2, '0')
+    yearMonth = `${y}-${m}`
+  }
+  else if (typeof yearMonth === 'number') {
+    // 时间戳数字
+    const d = new Date(yearMonth)
+    const y = d.getFullYear()
+    const m = String(d.getMonth() + 1).padStart(2, '0')
+    yearMonth = `${y}-${m}`
+  }
   generateLoading.value = true
   try {
-    await generateReconciliation({ yearMonth: generateMonth.value })
+    await generateReconciliation({ yearMonth })
     message.success('对账单生成成功')
     generateVisible.value = false
     crudRef.value?.refresh()

+ 46 - 1
forge-admin-ui/src/views/hotel/reconciliationPartner.vue

@@ -162,6 +162,7 @@ import {
 import { computed, h, ref } from 'vue'
 import {
   confirmReconciliation,
+  exportPartnerReconciliationDetail,
   getOrderDetail,
   getPartnerReconciliationDetail,
 } from '@/api/hotel'
@@ -259,10 +260,15 @@ const tableColumns = computed(() => [
   { prop: 'createTime', label: '创建时间', width: 170 },
   {
     label: '操作',
-    width: 140,
+    width: 160,
     fixed: 'right',
     render: (row) => {
       const actions = []
+      // 导出按钮(所有状态都可导出)
+      actions.push(h('a', {
+        class: 'text-info cursor-pointer hover:opacity-80',
+        onClick: () => handleExportSingle(row),
+      }, '导出'))
       if (row.status === 1) {
         actions.push(h('a', {
           class: 'text-success cursor-pointer hover:opacity-80',
@@ -366,6 +372,45 @@ async function openOrderDetail(orderId) {
   }
 }
 
+// ==================== 导出单条对账单 ====================
+const exportLoadingId = ref(null)
+
+async function handleExportSingle(row) {
+  if (exportLoadingId.value) {
+    message.warning('正在导出中,请稍候')
+    return
+  }
+  exportLoadingId.value = row.id
+  try {
+    const response = await exportPartnerReconciliationDetail(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)
+}
+
 // ==================== 确认对账单 ====================
 async function handleConfirm(id) {
   const dialog = window.$dialog

Datei-Diff unterdrückt, da er zu groß ist
+ 133 - 107
forge-server/db/forge_admin.sql


+ 24 - 0
forge-server/db/migration/V1.0.146__fix_reconciliation_status_dict.sql

@@ -0,0 +1,24 @@
+-- 修正对账单状态字典标签,并新增"已结算"状态
+-- 0: 待确认 → 待提交(酒店生成后尚未提交)
+-- 1: 已确认 → 待确认(酒店已提交,等待餐厅确认)
+-- 2: 已结算 → 已确认(餐厅确认后)
+-- 3: 新增,已结算(酒店完成结算)
+
+-- 更新 status=0 标签:待确认 → 待提交
+UPDATE sys_dict_data SET dict_label = '待提交', remark = '酒店生成后尚未提交'
+WHERE dict_type = 'hotel_reconciliation_status' AND dict_value = '0';
+
+-- 更新 status=1 标签:已确认 → 待确认
+UPDATE sys_dict_data SET dict_label = '待确认', remark = '酒店已提交,等待餐厅确认'
+WHERE dict_type = 'hotel_reconciliation_status' AND dict_value = '1';
+
+-- 更新 status=2 标签:已结算 → 已确认
+UPDATE sys_dict_data SET dict_label = '已确认', remark = '餐厅已确认', list_class = 'primary'
+WHERE dict_type = 'hotel_reconciliation_status' AND dict_value = '2';
+
+-- 新增 status=3:已结算
+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, 4, '已结算', '3', '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 = '3'
+);

+ 40 - 0
forge-server/db/migration/V1.0.147__fix_h5_menu_perms_and_client_code.sql

@@ -0,0 +1,40 @@
+-- V1.0.147: 修复 H5 移动端菜单资源缺少 perms 和 client_code 的问题
+-- 问题:通过后台界面手动创建的 H5 资源 perms 字段为空,导致后端权限加载时被 isNotNull 过滤,
+--       前端拿不到权限字符串,H5 端菜单入口不显示。
+-- 注意:资源可能在 tenant_id=1(系统级)或具体租户下,此处不限定 tenant_id。
+
+-- 1. 酒店移动端管理(目录)
+UPDATE sys_resource
+SET client_code = 'h5',
+    perms = 'hotel:h5:mobile',
+    update_time = NOW()
+WHERE resource_name = '酒店移动端管理'
+  AND resource_type = 1
+  AND (perms IS NULL OR perms = '');
+
+-- 2. H5餐厅订单(按钮)
+UPDATE sys_resource
+SET client_code = 'h5',
+    perms = 'hotel:h5:staff-order',
+    update_time = NOW()
+WHERE resource_name = 'H5餐厅订单'
+  AND resource_type = 3
+  AND (perms IS NULL OR perms = '');
+
+-- 3. H5酒店订单(按钮)
+UPDATE sys_resource
+SET client_code = 'h5',
+    perms = 'hotel:h5:hotel-order',
+    update_time = NOW()
+WHERE resource_name = 'H5酒店订单'
+  AND resource_type = 3
+  AND (perms IS NULL OR perms = '');
+
+-- 4. H5扫码绑定(按钮)
+UPDATE sys_resource
+SET client_code = 'h5',
+    perms = 'hotel:qrcode:scan',
+    update_time = NOW()
+WHERE resource_name = 'H5扫码绑定'
+  AND resource_type = 3
+  AND (perms IS NULL OR perms = '');

+ 1 - 1
forge-server/forge-admin-server/src/main/resources/application-dev.yml

@@ -121,6 +121,6 @@ hotel:
     # 沙箱网关地址
     gateway-url: https://openapi-sandbox.dl.alipaydev.com/gateway.do
     # 异步回调地址(内网穿透映射到本地8580端口)
-    notify-url: http://d3af9b6b.natappfree.cc/hotel/open/pay/notify
+    notify-url: http://h9fa694c.natappfree.cc/hotel/open/pay/notify
     # 是否沙箱环境
     sandbox: true

+ 1 - 1
forge-server/forge-app-server/src/main/resources/application-dev.yml

@@ -92,7 +92,7 @@ hotel:
     # 沙箱网关地址
     gateway-url: https://openapi-sandbox.dl.alipaydev.com/gateway.do
     # 异步回调地址(内网穿透映射到本地8583端口)
-    notify-url: http://d3af9b6b.natappfree.cc/hotel/open/pay/notify
+    notify-url: http://h9fa694c.natappfree.cc/hotel/open/pay/notify
     # 支付完成后跳转回 H5 页面(支付页会自动检测已支付状态并跳转订单详情页)
     return-url: http://192.168.10.7:3001/#/pages/hotel/customer/pay
     # 是否沙箱环境

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

@@ -9,8 +9,10 @@ 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.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
@@ -57,4 +59,13 @@ public class HotelReconciliationPartnerController {
         reconciliationService.confirm(id);
         return RespInfo.success();
     }
+
+    /**
+     * 餐厅端导出单条对账单(跨租户访问酒店数据)。
+     */
+    @GetMapping("/export/{id}")
+    @OperationLog(module = "对账管理", type = OperationType.EXPORT, desc = "餐厅端导出对账单")
+    public void exportSingle(@PathVariable Long id, HttpServletResponse response) {
+        reconciliationService.partnerExportSingle(id, response);
+    }
 }

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

@@ -51,6 +51,14 @@ public interface HotelReconciliationService {
     ReconciliationDetailVO partnerDetail(Long id);
 
     /**
+     * 餐厅端导出单条对账单(跨租户访问酒店数据)。
+     *
+     * @param id       对账单ID
+     * @param response HTTP响应
+     */
+    void partnerExportSingle(Long id, HttpServletResponse response);
+
+    /**
      * 生成对账单:按月份拉取已完成的顾客点餐订单,生成对账单及明细。
      *
      * @param dto 生成请求(yearMonth 格式 yyyy-MM)
@@ -67,7 +75,7 @@ public interface HotelReconciliationService {
     void excludeItem(Long itemId, String reason);
 
     /**
-     * 提交对账单给餐厅:将状态从「待确认」改为「已确认」的前置步骤。
+     * 提交对账单给餐厅:将状态从「待提交(0)」改为「待确认(1)」。
      * 酒店确认金额无误后提交,餐厅即可看到。
      *
      * @param id 对账单ID
@@ -75,9 +83,8 @@ public interface HotelReconciliationService {
     void submit(Long id);
 
     /**
-     * 餐厅确认对账单:将状态从「已确认」改为... 实际上按需求,
-     * 提交后状态变为「已确认」,餐厅确认后状态不变但记录确认人和时间。
-     * 最终由酒店标记「已结算」。
+     * 餐厅确认对账单:将状态从「待确认(1)」改为「已确认(2)」。
+     * 最终由酒店标记「已结算(3)」。
      *
      * @param id 对账单ID
      */

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

@@ -7,6 +7,9 @@ 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.alibaba.excel.write.style.column.LongestMatchColumnWidthStyleStrategy;
+import com.mdframe.forge.business.core.hotel.domain.HotelTenantMapping;
+import com.mdframe.forge.business.core.hotel.mapper.HotelTenantMappingMapper;
 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;
@@ -29,8 +32,12 @@ import java.math.BigDecimal;
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
 import java.text.SimpleDateFormat;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
 import java.util.ArrayList;
-import java.util.Calendar;
 import java.util.Collections;
 import java.util.Date;
 import java.util.List;
@@ -53,9 +60,12 @@ public class HotelReconciliationServiceImpl implements HotelReconciliationServic
     @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");
+    @Autowired
+    private HotelTenantMappingMapper tenantMappingMapper;
+
+    /** 线程安全的日期格式化器(SimpleDateFormat 非线程安全,并发下会解析失败) */
+    private static final DateTimeFormatter MONTH_FMT = DateTimeFormatter.ofPattern("yyyy-MM");
+    private static final DateTimeFormatter MONTH_START_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
 
     // ==================== 查询类 ====================
 
@@ -67,8 +77,8 @@ public class HotelReconciliationServiceImpl implements HotelReconciliationServic
 
     @Override
     public IPage<HotelReconciliationVO> partnerPage(PageQuery pageQuery, HotelReconciliation query) {
-        // 餐厅端跨租户访问:切到酒店租户(tenant_id=1)
-        final Long hotelTenantId = 1L;
+        // 餐厅端跨租户访问:通过映射表动态解析酒店租户ID
+        final Long hotelTenantId = resolveHotelTenantForAdmin();
         final Page<HotelReconciliationVO> page = new Page<>(pageQuery.getPageNum(), pageQuery.getPageSize());
         return TenantContextHolder.executeWithTenant(hotelTenantId, new java.util.function.Supplier<IPage<HotelReconciliationVO>>() {
             @Override
@@ -86,7 +96,7 @@ public class HotelReconciliationServiceImpl implements HotelReconciliationServic
 
     @Override
     public ReconciliationDetailVO partnerDetail(Long id) {
-        final Long hotelTenantId = 1L;
+        final Long hotelTenantId = resolveHotelTenantForAdmin();
         return TenantContextHolder.executeWithTenant(hotelTenantId, new java.util.function.Supplier<ReconciliationDetailVO>() {
             @Override
             public ReconciliationDetailVO get() {
@@ -134,6 +144,7 @@ public class HotelReconciliationServiceImpl implements HotelReconciliationServic
     @Transactional(rollbackFor = Exception.class)
     public void generate(ReconciliationGenerateDTO dto) {
         String yearMonth = dto.getYearMonth();
+        log.info("[对账单生成] 收到 yearMonth={}, class={}", yearMonth, yearMonth != null ? yearMonth.getClass().getName() : "null");
         if (yearMonth == null || yearMonth.trim().isEmpty()) {
             throw new BusinessException("请选择对账月份");
         }
@@ -144,18 +155,16 @@ public class HotelReconciliationServiceImpl implements HotelReconciliationServic
         String prefix = "DZ-" + yearMonth.replace("-", "") + "-";
         String existMaxNo = reconciliationMapper.selectMaxNo(tenantId, prefix);
 
-        // 计算月份起止时间
+        // 计算月份起止时间(LocalDate 无时间字段,需转 LocalDateTime 再格式化)
         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());
+            LocalDate firstDay = LocalDate.parse(yearMonth + "-01", DateTimeFormatter.ISO_LOCAL_DATE);
+            startTime = firstDay.atStartOfDay().format(MONTH_START_FMT);
             // 下个月1号 00:00:00 作为结束边界(不含)
-            cal.add(Calendar.MONTH, 1);
-            endTime = MONTH_START_FMT.format(cal.getTime());
-        } catch (Exception e) {
+            LocalDate nextMonthFirst = firstDay.plusMonths(1);
+            endTime = nextMonthFirst.atStartOfDay().format(MONTH_START_FMT);
+        } catch (DateTimeParseException e) {
             throw new BusinessException("月份格式错误,请使用 yyyy-MM 格式");
         }
 
@@ -187,13 +196,11 @@ public class HotelReconciliationServiceImpl implements HotelReconciliationServic
         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) {
+            LocalDate firstDay = LocalDate.parse(yearMonth + "-01", DateTimeFormatter.ISO_LOCAL_DATE);
+            periodStart = Date.from(firstDay.atStartOfDay(ZoneId.systemDefault()).toInstant());
+            LocalDate lastDay = firstDay.plusMonths(1).minusDays(1);
+            periodEnd = Date.from(lastDay.atStartOfDay(ZoneId.systemDefault()).toInstant());
+        } catch (DateTimeParseException e) {
             // 不会走到这里,前面已经校验过格式
             throw new BusinessException("月份格式错误");
         }
@@ -292,12 +299,12 @@ public class HotelReconciliationServiceImpl implements HotelReconciliationServic
     public void submit(Long id) {
         HotelReconciliation reconciliation = requireReconciliation(id);
         if (reconciliation.getStatus() != 0) {
-            throw new BusinessException("只有待确认状态的对账单才能提交");
+            throw new BusinessException("只有待提交状态的对账单才能提交");
         }
 
         UpdateWrapper<HotelReconciliation> updateWrapper = new UpdateWrapper<>();
         updateWrapper.eq("id", id);
-        updateWrapper.set("status", 1); // 已确认(提交后餐厅可见)
+        updateWrapper.set("status", 1); // 待确认(提交后餐厅可见)
         reconciliationMapper.update(null, updateWrapper);
 
         log.info("对账单已提交: id={}, no={}", id, reconciliation.getReconciliationNo());
@@ -305,7 +312,7 @@ public class HotelReconciliationServiceImpl implements HotelReconciliationServic
 
     @Override
     public void confirm(Long id) {
-        final Long hotelTenantId = 1L;
+        final Long hotelTenantId = resolveHotelTenantForAdmin();
         TenantContextHolder.executeWithTenant(hotelTenantId, new Runnable() {
             @Override
             public void run() {
@@ -314,13 +321,14 @@ public class HotelReconciliationServiceImpl implements HotelReconciliationServic
                     throw new BusinessException("对账单不存在");
                 }
                 if (reconciliation.getStatus() != 1) {
-                    throw new BusinessException("只有已确认状态的对账单才能确认");
+                    throw new BusinessException("只有待确认状态的对账单才能确认");
                 }
 
                 Long currentUserId = SessionHelper.getUserId();
 
                 UpdateWrapper<HotelReconciliation> updateWrapper = new UpdateWrapper<>();
                 updateWrapper.eq("id", id);
+                updateWrapper.set("status", 2); // 已确认(餐厅确认后)
                 updateWrapper.set("confirmed_by", currentUserId);
                 updateWrapper.set("confirmed_time", new Date());
                 reconciliationMapper.update(null, updateWrapper);
@@ -333,7 +341,7 @@ public class HotelReconciliationServiceImpl implements HotelReconciliationServic
     @Override
     public void settle(Long id, String remark) {
         HotelReconciliation reconciliation = requireReconciliation(id);
-        if (reconciliation.getStatus() != 1) {
+        if (reconciliation.getStatus() != 2) {
             throw new BusinessException("只有已确认状态的对账单才能标记结算");
         }
 
@@ -341,7 +349,7 @@ public class HotelReconciliationServiceImpl implements HotelReconciliationServic
 
         UpdateWrapper<HotelReconciliation> updateWrapper = new UpdateWrapper<>();
         updateWrapper.eq("id", id);
-        updateWrapper.set("status", 2); // 已结算
+        updateWrapper.set("status", 3); // 已结算
         updateWrapper.set("settled_by", currentUserId);
         updateWrapper.set("settled_time", new Date());
         updateWrapper.set("settled_remark", remark);
@@ -388,15 +396,32 @@ public class HotelReconciliationServiceImpl implements HotelReconciliationServic
         // 状态文本映射
         String statusText;
         if (detail.getStatus() == 0) {
-            statusText = "待确认";
+            statusText = "待提交";
         } else if (detail.getStatus() == 1) {
-            statusText = "已确认";
+            statusText = "待确认";
         } else if (detail.getStatus() == 2) {
+            statusText = "已确认";
+        } else if (detail.getStatus() == 3) {
             statusText = "已结算";
         } else {
             statusText = String.valueOf(detail.getStatus());
         }
 
+        // 提取年月用于标题(从 reconciliationNo 如 DZ-202609-005 提取 202609)
+        String yearMonthLabel = "";
+        String reconNo = detail.getReconciliationNo();
+        if (reconNo != null && reconNo.startsWith("DZ-")) {
+            int dashIdx = reconNo.indexOf('-', 3);
+            if (dashIdx > 0) {
+                String ym = reconNo.substring(3, dashIdx); // 202609
+                if (ym.length() == 6) {
+                    yearMonthLabel = ym.substring(0, 4) + "年" + ym.substring(4, 6) + "月";
+                }
+            }
+        }
+        String summaryTitleText = yearMonthLabel + "对账单汇总";
+        String itemTitleText = yearMonthLabel + "订单明细清单";
+
         // ===== Sheet1: 对账汇总 =====
         List<List<String>> summaryHeaders = new ArrayList<List<String>>();
         summaryHeaders.add(Collections.singletonList("对账单编号"));
@@ -413,6 +438,7 @@ public class HotelReconciliationServiceImpl implements HotelReconciliationServic
         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()));
@@ -440,6 +466,7 @@ public class HotelReconciliationServiceImpl implements HotelReconciliationServic
         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++) {
@@ -450,7 +477,6 @@ public class HotelReconciliationServiceImpl implements HotelReconciliationServic
                 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) {
@@ -464,24 +490,53 @@ public class HotelReconciliationServiceImpl implements HotelReconciliationServic
             }
         }
 
-        // ===== 写入 Excel =====
+        // ===== 写入 Excel(先写内存,再用 POI 插入标题行)=====
         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();
+            // 1. 先用 EasyExcel 写到 ByteArrayOutputStream
+            java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
+            ExcelWriter excelWriter = EasyExcel.write(baos).build();
             try {
-                WriteSheet summarySheet = EasyExcel.writerSheet(0, "对账汇总").head(summaryHeaders).build();
+                WriteSheet summarySheet = EasyExcel.writerSheet(0, "对账汇总")
+                        .head(summaryHeaders)
+                        .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
+                        .build();
                 excelWriter.write(summaryRows, summarySheet);
 
-                WriteSheet itemSheet = EasyExcel.writerSheet(1, "订单明细").head(itemHeaders).build();
+                WriteSheet itemSheet = EasyExcel.writerSheet(1, "订单明细")
+                        .head(itemHeaders)
+                        .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
+                        .build();
                 excelWriter.write(itemRows, itemSheet);
             } finally {
                 excelWriter.finish();
             }
+
+            // 2. 用 POI 打开内存中的工作簿,在每个 Sheet 第0行插入标题
+            byte[] excelBytes = baos.toByteArray();
+            baos.close();
+
+            org.apache.poi.xssf.usermodel.XSSFWorkbook workbook =
+                    new org.apache.poi.xssf.usermodel.XSSFWorkbook(new java.io.ByteArrayInputStream(excelBytes));
+            try {
+                // Sheet1: 对账汇总标题
+                org.apache.poi.ss.usermodel.Sheet sheet1 = workbook.getSheetAt(0);
+                insertTitleRow(sheet1, summaryTitleText, 11);
+
+                // Sheet2: 订单明细标题
+                org.apache.poi.ss.usermodel.Sheet sheet2 = workbook.getSheetAt(1);
+                insertTitleRow(sheet2, itemTitleText, 7);
+
+                // 3. 写出到响应
+                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);
+
+                workbook.write(response.getOutputStream());
+            } finally {
+                workbook.close();
+            }
         } catch (java.io.IOException e) {
             throw new BusinessException("导出对账单失败: " + e.getMessage());
         }
@@ -490,6 +545,133 @@ public class HotelReconciliationServiceImpl implements HotelReconciliationServic
     }
 
     /**
+     * 在 Sheet 第0行插入标题并合并单元格,同时美化表头和数据行样式(参考 EasyPoi 风格)。
+     */
+    private void insertTitleRow(org.apache.poi.ss.usermodel.Sheet sheet, String title, int lastColIndex) {
+        org.apache.poi.xssf.usermodel.XSSFWorkbook workbook =
+                (org.apache.poi.xssf.usermodel.XSSFWorkbook) sheet.getWorkbook();
+
+        // ===== 样式定义 =====
+        // 标题样式:16pt 加粗、居中
+        org.apache.poi.xssf.usermodel.XSSFCellStyle titleStyle = workbook.createCellStyle();
+        org.apache.poi.xssf.usermodel.XSSFFont titleFont = workbook.createFont();
+        titleFont.setBold(true);
+        titleFont.setFontHeightInPoints((short) 16);
+        titleStyle.setFont(titleFont);
+        titleStyle.setAlignment(org.apache.poi.ss.usermodel.HorizontalAlignment.CENTER);
+        titleStyle.setVerticalAlignment(org.apache.poi.ss.usermodel.VerticalAlignment.CENTER);
+
+        // 表头样式:11pt 加粗、居中、浅蓝背景、细边框
+        org.apache.poi.xssf.usermodel.XSSFCellStyle headerStyle = workbook.createCellStyle();
+        org.apache.poi.xssf.usermodel.XSSFFont headerFont = workbook.createFont();
+        headerFont.setBold(true);
+        headerFont.setFontHeightInPoints((short) 11);
+        headerStyle.setFont(headerFont);
+        headerStyle.setAlignment(org.apache.poi.ss.usermodel.HorizontalAlignment.CENTER);
+        headerStyle.setVerticalAlignment(org.apache.poi.ss.usermodel.VerticalAlignment.CENTER);
+        headerStyle.setFillForegroundColor(new org.apache.poi.xssf.usermodel.XSSFColor(
+                new byte[]{(byte) 0xD9, (byte) 0xE1, (byte) 0xF2}, null));
+        headerStyle.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
+        setThinBorder(headerStyle);
+
+        // 数据行样式:10pt、细边框
+        org.apache.poi.xssf.usermodel.XSSFCellStyle dataStyle = workbook.createCellStyle();
+        org.apache.poi.xssf.usermodel.XSSFFont dataFont = workbook.createFont();
+        dataFont.setFontHeightInPoints((short) 10);
+        dataStyle.setFont(dataFont);
+        dataStyle.setVerticalAlignment(org.apache.poi.ss.usermodel.VerticalAlignment.CENTER);
+        setThinBorder(dataStyle);
+
+        // ===== 将现有所有行下移一行 =====
+        for (int i = sheet.getLastRowNum(); i >= 0; i--) {
+            org.apache.poi.ss.usermodel.Row srcRow = sheet.getRow(i);
+            if (srcRow != null) {
+                org.apache.poi.ss.usermodel.Row newRow = sheet.createRow(i + 1);
+                for (int j = 0; j < srcRow.getLastCellNum(); j++) {
+                    org.apache.poi.ss.usermodel.Cell srcCell = srcRow.getCell(j);
+                    if (srcCell != null) {
+                        org.apache.poi.ss.usermodel.Cell newCell = newRow.createCell(j);
+                        switch (srcCell.getCellType()) {
+                            case STRING:
+                                newCell.setCellValue(srcCell.getStringCellValue());
+                                break;
+                            case NUMERIC:
+                                if (org.apache.poi.ss.usermodel.DateUtil.isCellDateFormatted(srcCell)) {
+                                    // 日期单元格:格式化为字符串,避免显示为 Excel 序列号
+                                    newCell.setCellValue(formatDateStr(srcCell.getDateCellValue()));
+                                } else {
+                                    newCell.setCellValue(srcCell.getNumericCellValue());
+                                }
+                                break;
+                            case BOOLEAN:
+                                newCell.setCellValue(srcCell.getBooleanCellValue());
+                                break;
+                            default:
+                                newCell.setCellValue(srcCell.toString());
+                        }
+                    }
+                }
+                sheet.removeRow(srcRow);
+            }
+        }
+
+        // ===== 第0行:标题行 =====
+        org.apache.poi.ss.usermodel.Row titleRow = sheet.createRow(0);
+        titleRow.setHeightInPoints(30);
+        org.apache.poi.ss.usermodel.Cell titleCell = titleRow.createCell(0);
+        titleCell.setCellValue(title);
+        titleCell.setCellStyle(titleStyle);
+        sheet.addMergedRegion(new org.apache.poi.ss.util.CellRangeAddress(0, 0, 0, lastColIndex));
+
+        // ===== 第1行:表头行样式 =====
+        org.apache.poi.ss.usermodel.Row headerRow = sheet.getRow(1);
+        if (headerRow != null) {
+            headerRow.setHeightInPoints(22);
+            for (int j = 0; j < headerRow.getLastCellNum(); j++) {
+                org.apache.poi.ss.usermodel.Cell cell = headerRow.getCell(j);
+                if (cell != null) {
+                    cell.setCellStyle(headerStyle);
+                }
+            }
+        }
+
+        // ===== 第2行起:数据行样式 =====
+        for (int i = 2; i <= sheet.getLastRowNum(); i++) {
+            org.apache.poi.ss.usermodel.Row dataRow = sheet.getRow(i);
+            if (dataRow != null) {
+                dataRow.setHeightInPoints(20);
+                for (int j = 0; j < dataRow.getLastCellNum(); j++) {
+                    org.apache.poi.ss.usermodel.Cell cell = dataRow.getCell(j);
+                    if (cell != null) {
+                        cell.setCellStyle(dataStyle);
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * 设置单元格细边框。
+     */
+    private void setThinBorder(org.apache.poi.xssf.usermodel.XSSFCellStyle style) {
+        style.setBorderTop(org.apache.poi.ss.usermodel.BorderStyle.THIN);
+        style.setBorderBottom(org.apache.poi.ss.usermodel.BorderStyle.THIN);
+        style.setBorderLeft(org.apache.poi.ss.usermodel.BorderStyle.THIN);
+        style.setBorderRight(org.apache.poi.ss.usermodel.BorderStyle.THIN);
+    }
+
+    @Override
+    public void partnerExportSingle(Long id, HttpServletResponse response) {
+        final Long hotelTenantId = resolveHotelTenantForAdmin();
+        TenantContextHolder.executeWithTenant(hotelTenantId, new Runnable() {
+            @Override
+            public void run() {
+                exportSingle(id, response);
+            }
+        });
+    }
+
+    /**
      * 日期格式化为 yyyy-MM-dd。
      */
     private String formatDateStr(Date date) {
@@ -518,6 +700,19 @@ public class HotelReconciliationServiceImpl implements HotelReconciliationServic
     }
 
     /**
+     * 管理端反查酒店租户ID。
+     * 餐厅租户登录时,通过映射表找到对应的酒店租户;酒店租户登录时,直接返回当前租户。
+     */
+    private Long resolveHotelTenantForAdmin() {
+        Long currentTenantId = resolveTenantId();
+        HotelTenantMapping mapping = tenantMappingMapper.selectByRestaurantTenantId(currentTenantId);
+        if (mapping != null && mapping.getHotelTenantId() != null) {
+            return mapping.getHotelTenantId();
+        }
+        return currentTenantId;
+    }
+
+    /**
      * 查找对账单,不存在则抛异常。
      */
     private HotelReconciliation requireReconciliation(Long id) {

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

@@ -31,13 +31,13 @@
         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)
+          AND r.status IN (1, 2, 3)
         <if test="query.status != null">
             AND r.status = #{query.status}
         </if>