Pārlūkot izejas kodu

移动端添加点餐时间限制,pc端功能优化

徐滕 2 nedēļas atpakaļ
vecāks
revīzija
e6efe1cca4
76 mainītis faili ar 5517 papildinājumiem un 267 dzēšanām
  1. 270 0
      code-copilot/changes/hotel-customer-business-hours-check/spec.md
  2. 238 0
      code-copilot/changes/hotel-customer-business-hours-check/tasks.md
  3. 33 1
      forge-admin-ui/src/api/hotel.js
  4. 568 0
      forge-admin-ui/src/views/hotel/board.vue
  5. 36 3
      forge-admin-ui/src/views/hotel/businessHours.vue
  6. 50 2
      forge-admin-ui/src/views/hotel/order.vue
  7. 5 5
      forge-admin-ui/src/views/hotel/qrcode.vue
  8. 11 16
      forge-admin-ui/src/views/hotel/room.vue
  9. 119 0
      forge-h5-ui/serve.cjs
  10. 13 0
      forge-h5-ui/src/api/index.js
  11. 1 1
      forge-h5-ui/src/pages/hotel/customer/cart.vue
  12. 52 0
      forge-h5-ui/src/pages/hotel/customer/menu.vue
  13. 121 2
      forge-h5-ui/src/pages/hotel/customer/order-confirm.vue
  14. 3 1
      forge-h5-ui/src/pages/hotel/customer/order-status.vue
  15. 4 3
      forge-h5-ui/src/pages/hotel/customer/orders.vue
  16. 46 3
      forge-h5-ui/src/pages/hotel/customer/pay.vue
  17. 193 23
      forge-h5-ui/src/pages/hotel/customer/room-confirm.vue
  18. 39 3
      forge-h5-ui/src/store/modules/hotel-order.js
  19. 150 0
      forge-server/db/migration/V1.0.106__add_hotel_room_stay_and_refund.sql
  20. 12 0
      forge-server/db/migration/V1.0.107__add_hotel_room_board_menu.sql
  21. 36 0
      forge-server/db/migration/V1.0.108__hotel_business_hours_day_of_week_to_varchar.sql
  22. 1 1
      forge-server/forge-admin-server/src/main/resources/application-dev.yml
  23. 1 1
      forge-server/forge-app-server/src/main/resources/application-dev.yml
  24. 6 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/constant/HotelQrConstants.java
  25. 98 1
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/HotelQrCodeController.java
  26. 58 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/open/AlipayAuthController.java
  27. 61 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/open/HotelCustomerController.java
  28. 2 2
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/domain/HotelBusinessHours.java
  29. 2 2
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/domain/HotelQrBindLog.java
  30. 3 3
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/domain/HotelQrCode.java
  31. 62 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/domain/HotelRoomStay.java
  32. 8 1
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/mapper/HotelBusinessHoursMapper.java
  33. 53 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/mapper/HotelRoomStayMapper.java
  34. 15 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/constant/HotelOrderConstants.java
  35. 25 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/controller/HotelOrderController.java
  36. 22 10
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/domain/HotelOrder.java
  37. 2 3
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/dto/OrderCreateDTO.java
  38. 23 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/mapper/HotelOrderMapper.java
  39. 12 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/service/HotelOrderService.java
  40. 148 18
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/service/impl/HotelOrderServiceImpl.java
  41. 22 10
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/vo/HotelOrderVO.java
  42. 55 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/config/AlipayConfig.java
  43. 4 4
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/domain/HotelPayLog.java
  44. 16 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/dto/AlipayAuthDTO.java
  45. 3 3
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/mapper/HotelPayLogMapper.java
  46. 32 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/service/AlipayAuthService.java
  47. 16 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/service/HotelPayService.java
  48. 72 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/service/impl/AlipayAuthServiceImpl.java
  49. 138 7
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/service/impl/HotelPayServiceImpl.java
  50. 19 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/vo/AlipayAuthVO.java
  51. 2 2
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/vo/PayResultVO.java
  52. 12 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/HotelBusinessHoursService.java
  53. 105 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/HotelRoomStayService.java
  54. 157 7
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/impl/HotelBusinessHoursServiceImpl.java
  55. 39 14
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/impl/HotelQrCodeServiceImpl.java
  56. 288 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/impl/HotelRoomStayServiceImpl.java
  57. 75 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/vo/BusinessHoursStatusVO.java
  58. 3 3
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/vo/HotelDishCategoryVO.java
  59. 3 3
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/vo/HotelDishVO.java
  60. 2 2
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/vo/HotelQrBindLogVO.java
  61. 5 5
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/vo/HotelQrCodeVO.java
  62. 47 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/vo/HotelRoomStayVO.java
  63. 3 3
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/vo/HotelRoomTypeVO.java
  64. 16 4
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/vo/HotelRoomVO.java
  65. 8 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/vo/QrCodeScanVO.java
  66. 54 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/vo/RoomCheckOutVO.java
  67. 15 1
      forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/HotelBusinessHoursMapper.xml
  68. 14 1
      forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/HotelRoomMapper.xml
  69. 61 0
      forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/HotelRoomStayMapper.xml
  70. 4 7
      forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/order/HotelOrderItemMapper.xml
  71. 33 1
      forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/order/HotelOrderMapper.xml
  72. 99 8
      forge-server/forge-business/forge-hotel/编码习惯与规范.md
  73. 216 77
      forge-server/forge-business/forge-hotel/酒店模块迁移跟踪.md
  74. 296 0
      forge-server/forge-business/forge-hotel/酒店模块需求缺口清单.md
  75. BIN
      forge-server/forge-business/forge-hotel/需求说明书.docx
  76. 981 0
      forge-server/forge-business/forge-hotel/需求说明书.html

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 270 - 0
code-copilot/changes/hotel-customer-business-hours-check/spec.md


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 238 - 0
code-copilot/changes/hotel-customer-business-hours-check/tasks.md


+ 33 - 1
forge-admin-ui/src/api/hotel.js

@@ -34,7 +34,7 @@ export function unbindQrCode(id) {
 
 /** 更新二维码状态 */
 export function updateQrCodeStatus(id, status) {
-  return request.put('/hotel/qrcode/updateStatus', null, { params: { id, status } })
+  return request.post('/hotel/qrcode/updateStatus', null, { params: { id, status } })
 }
 
 /** 查询绑定日志 */
@@ -84,6 +84,33 @@ export function deleteRoom(id) {
   return request.post('/hotel/room/remove', null, { params: { id } })
 }
 
+// ==================== 房间在住会话(入住/退房/维护) ====================
+
+/** 办理入住(开新在住会话,房间变入住中) */
+export function roomCheckIn(id, remark, guestName, guestPhone) {
+  return request.post('/hotel/room/checkIn', null, { params: { id, remark, guestName, guestPhone } })
+}
+
+/** 办理退房(force=true 表示已确认进行中订单强制退房) */
+export function roomCheckOut(id, force, remark) {
+  return request.post('/hotel/room/checkOut', null, { params: { id, force, remark } })
+}
+
+/** 打扫完成(打扫中 -> 空闲) */
+export function roomCleaningDone(id) {
+  return request.post('/hotel/room/cleaningDone', null, { params: { id } })
+}
+
+/** 维护切换(空闲 <-> 维护中) */
+export function roomMaintenanceToggle(id) {
+  return request.post('/hotel/room/maintenanceToggle', null, { params: { id } })
+}
+
+/** 分页查询房间入住记录(含当前在住) */
+export function getRoomStayPage(params) {
+  return request.get('/hotel/room/stayPage', { params })
+}
+
 // ==================== 房型管理 ====================
 
 /** 查询房型列表 */
@@ -375,3 +402,8 @@ export function approveRefund(id, reason) {
 export function rejectRefund(id, reason) {
   return request.post('/hotel/order/refundReject', null, { params: { id, reason } })
 }
+
+/** 手动发起退款(退房竞态等异常场景,仅已支付未退款订单) */
+export function orderRefund(id) {
+  return request.post('/hotel/order/refund', null, { params: { id } })
+}

+ 568 - 0
forge-admin-ui/src/views/hotel/board.vue

@@ -0,0 +1,568 @@
+<template>
+  <div class="hotel-board-page">
+    <!-- 筛选工具栏 -->
+    <div class="board-toolbar">
+      <div class="toolbar-left flex items-center gap-3">
+        <h3 class="text-base font-bold m-0">房态看板</h3>
+        <NTag :type="loading ? 'default' : 'success'" size="small" round>
+          {{ filteredRooms.length }} / {{ allRooms.length }} 间
+        </NTag>
+      </div>
+      <div class="toolbar-right flex items-center gap-2">
+        <!-- 房间号搜索 -->
+        <NInput
+          v-model:value="searchRoomNo"
+          placeholder="搜索房间号"
+          size="small"
+          clearable
+          style="width: 160px"
+          @input="onSearchChange"
+        />
+        <!-- 房型筛选 -->
+        <NSelect
+          v-model:value="filterRoomType"
+          :options="roomTypeOptions"
+          placeholder="全部房型"
+          size="small"
+          clearable
+          style="width: 140px"
+        />
+        <!-- 楼层筛选 -->
+        <NSelect
+          v-model:value="filterFloor"
+          :options="floorOptions"
+          placeholder="全部楼层"
+          size="small"
+          clearable
+          style="width: 120px"
+        />
+        <NButton size="small" :loading="loading" @click="loadRooms">
+          <template #icon><NIcon><RefreshIcon /></NIcon></template>
+        </NButton>
+      </div>
+    </div>
+
+    <!-- 状态 Tab -->
+    <div class="status-tabs flex items-center gap-1 mb-3">
+      <NButton
+        v-for="tab in statusTabs"
+        :key="tab.key"
+        size="small"
+        :type="activeStatus === tab.key ? tab.btnType : 'default'"
+        :ghost="activeStatus !== tab.key"
+        @click="activeStatus = tab.key"
+      >
+        {{ tab.label }}
+        <NBadge :value="getGroupCount(tab.key)" :max="999" show-zero style="margin-left: 6px" />
+      </NButton>
+    </div>
+
+    <!-- 房间卡片网格 -->
+    <div class="board-grid">
+      <div v-if="filteredRooms.length === 0" class="board-empty">
+        <span class="text-gray-400 text-sm">没有匹配的房间</span>
+      </div>
+      <div
+        v-for="room in filteredRooms"
+        :key="room.id"
+        class="room-card"
+        :class="`card-${room.status}`"
+      >
+        <div class="card-top flex items-center justify-between">
+          <div class="flex items-center gap-1">
+            <span class="room-no">{{ room.roomNo }}</span>
+            <span class="floor-tag">{{ room.floorNo || '' }}F</span>
+          </div>
+          <NTag :type="statusTagMap[room.status] || 'default'" size="tiny" round>
+            {{ statusLabelMap[room.status] || room.status }}
+          </NTag>
+        </div>
+        <div class="card-type text-xs text-gray-500">{{ room.roomTypeName || '未设房型' }}</div>
+        <!-- 入住中显示客人信息 -->
+        <div v-if="room.status === 'OCCUPIED' && room.guestName" class="card-guest">
+          <div class="text-xs">
+            <span class="text-gray-400">住客:</span>
+            <span class="font-bold text-gray-700">{{ room.guestName }}</span>
+          </div>
+          <div v-if="room.guestPhone" class="text-xs text-gray-400">{{ room.guestPhone }}</div>
+          <div v-if="room.checkInTime" class="text-xs text-gray-400">入住 {{ room.checkInTime }}</div>
+        </div>
+        <!-- 操作按钮 -->
+        <div class="card-actions">
+          <template v-if="room.status === 'VACANT' || room.status === 'CLEANING'">
+            <NButton size="tiny" type="success" :loading="actionLoading" @click="handleCheckIn(room)">入住</NButton>
+          </template>
+          <template v-if="room.status === 'OCCUPIED'">
+            <NButton size="tiny" type="error" :loading="actionLoading" @click="handleCheckOut(room)">退房</NButton>
+          </template>
+          <template v-if="room.status === 'CLEANING'">
+            <NButton size="tiny" type="warning" :loading="actionLoading" @click="handleCleaningDone(room)">打扫完</NButton>
+          </template>
+          <template v-if="room.status === 'VACANT' || room.status === 'MAINTENANCE'">
+            <NButton size="tiny" :type="room.status === 'MAINTENANCE' ? 'info' : 'default'" :loading="actionLoading" @click="handleMaintenanceToggle(room)">
+              {{ room.status === 'MAINTENANCE' ? '解除维护' : '维护' }}
+            </NButton>
+          </template>
+          <NButton size="tiny" quaternary type="primary" @click="handleStayRecords(room)">记录</NButton>
+        </div>
+      </div>
+    </div>
+
+    <!-- 入住登记弹窗 -->
+    <NModal
+      v-model:show="checkInVisible"
+      preset="card"
+      :title="`办理入住 - ${checkInRoom?.roomNo || ''}`"
+      style="width: 460px"
+      :mask-closable="false"
+    >
+      <NForm label-placement="left" label-width="70px" size="medium">
+        <NFormItem label="房间号">
+          <NInput :value="checkInRoom?.roomNo || ''" disabled />
+        </NFormItem>
+        <NFormItem label="房型">
+          <NInput :value="checkInRoom?.roomTypeName || '未设房型'" disabled />
+        </NFormItem>
+        <NFormItem label="住客姓名">
+          <NInput v-model:value="checkInGuestName" placeholder="请输入住客姓名(可空)" clearable />
+        </NFormItem>
+        <NFormItem label="联系电话">
+          <NInput v-model:value="checkInGuestPhone" placeholder="请输入联系电话(可空)" clearable />
+        </NFormItem>
+        <NFormItem label="备注">
+          <NInput v-model:value="checkInRemark" type="textarea" placeholder="入住备注(可空)" :rows="2" />
+        </NFormItem>
+      </NForm>
+      <template #footer>
+        <div class="flex justify-end gap-2">
+          <NButton size="small" @click="checkInVisible = false">取消</NButton>
+          <NButton size="small" type="success" :loading="actionLoading" @click="confirmCheckIn">确认入住</NButton>
+        </div>
+      </template>
+    </NModal>
+
+    <!-- 退房确认弹窗 -->
+    <NModal
+      v-model:show="checkOutVisible"
+      preset="card"
+      title="退房确认"
+      style="width: 560px"
+      :mask-closable="false"
+    >
+      <NAlert type="warning" :show-icon="true" style="margin-bottom: 12px">
+        {{ checkOutResult?.message || '该房间存在进行中的订单,退房后这些订单将无法继续履约。' }}
+      </NAlert>
+      <NDataTable
+        :columns="checkOutOrderColumns"
+        :data="checkOutResult?.activeOrders || []"
+        :bordered="false"
+        size="small"
+        :max-height="280"
+      />
+      <template #footer>
+        <div class="flex justify-end gap-2">
+          <NButton size="small" @click="checkOutVisible = false">取消</NButton>
+          <NButton size="small" type="error" :loading="actionLoading" @click="forceCheckOut">确认退房</NButton>
+        </div>
+      </template>
+    </NModal>
+
+    <!-- 入住记录弹窗 -->
+    <NModal
+      v-model:show="stayVisible"
+      preset="card"
+      :title="`入住记录 - ${stayRoom?.roomNo || ''}`"
+      style="width: 860px"
+    >
+      <NDataTable
+        :columns="stayColumns"
+        :data="stayList"
+        :loading="stayLoading"
+        :bordered="false"
+        size="small"
+        :max-height="400"
+      />
+      <template #footer>
+        <div class="flex justify-between items-center">
+          <span class="text-xs text-gray-400">共 {{ stayTotal }} 条记录</span>
+          <NPagination
+            v-model:page="stayPageNum"
+            :page-size="stayPageSize"
+            :item-count="stayTotal"
+            size="small"
+            @update:page="loadStayPage"
+          />
+        </div>
+      </template>
+    </NModal>
+  </div>
+</template>
+
+<script setup>
+import { computed, h, ref, onMounted } from 'vue'
+import {
+  NAlert, NBadge, NButton, NDataTable, NIcon, NInput, NModal, NPagination, NSelect, NTag, NFormItem, NForm, useMessage,
+} from 'naive-ui'
+import { RefreshOutline as RefreshIcon } from '@vicons/ionicons5'
+import {
+  getRoomListAll,
+  getRoomStayPage,
+  getRoomTypeList,
+  roomCheckIn,
+  roomCheckOut,
+  roomCleaningDone,
+  roomMaintenanceToggle,
+} from '@/api/hotel'
+
+defineOptions({ name: 'HotelBoard' })
+
+const message = useMessage()
+const loading = ref(false)
+const allRooms = ref([])
+
+// ==================== 筛选条件 ====================
+
+const searchRoomNo = ref('')
+const filterRoomType = ref(null)
+const filterFloor = ref(null)
+const activeStatus = ref('ALL')
+
+const statusTabs = [
+  { key: 'ALL', label: '全部', btnType: 'primary' },
+  { key: 'VACANT', label: '空闲', btnType: 'success' },
+  { key: 'OCCUPIED', label: '入住中', btnType: 'error' },
+  { key: 'CLEANING', label: '打扫中', btnType: 'warning' },
+  { key: 'MAINTENANCE', label: '维护中', btnType: 'info' },
+]
+
+const statusLabelMap = { VACANT: '空闲', OCCUPIED: '入住', CLEANING: '打扫中', MAINTENANCE: '维护中' }
+const statusTagMap = { VACANT: 'success', OCCUPIED: 'error', CLEANING: 'warning', MAINTENANCE: 'info' }
+
+// 房型选项
+const roomTypeOptions = ref([])
+async function loadRoomTypes() {
+  try {
+    const res = await getRoomTypeList()
+    if (res.code === 200) {
+      roomTypeOptions.value = (res.data || []).map(t => ({ label: t.typeName, value: t.id }))
+    }
+  } catch {
+    // ignore
+  }
+}
+
+// 楼层选项(从已有房间数据中提取)
+const floorOptions = computed(() => {
+  const floors = [...new Set(allRooms.value.map(r => r.floorNo).filter(Boolean))]
+  return floors.sort((a, b) => String(a).localeCompare(String(b), undefined, { numeric: true })).map(f => ({ label: `${f} 层`, value: f }))
+})
+
+function onSearchChange() {
+  // 搜索时自动切到全部状态 Tab,避免漏看
+  if (searchRoomNo.value && activeStatus.value !== 'ALL') {
+    activeStatus.value = 'ALL'
+  }
+}
+
+// 筛选后的房间列表
+const filteredRooms = computed(() => {
+  let rooms = allRooms.value
+  if (activeStatus.value !== 'ALL') {
+    rooms = rooms.filter(r => r.status === activeStatus.value)
+  }
+  if (filterRoomType.value) {
+    rooms = rooms.filter(r => r.roomTypeId === filterRoomType.value)
+  }
+  if (filterFloor.value) {
+    rooms = rooms.filter(r => r.floorNo === filterFloor.value)
+  }
+  if (searchRoomNo.value) {
+    const kw = searchRoomNo.value.toLowerCase()
+    rooms = rooms.filter(r => String(r.roomNo || '').toLowerCase().includes(kw))
+  }
+  return rooms.sort(sortByRoomNo)
+})
+
+function getGroupCount(key) {
+  if (key === 'ALL') return allRooms.value.length
+  return allRooms.value.filter(r => r.status === key).length
+}
+
+function sortByRoomNo(a, b) {
+  return String(a.roomNo || '').localeCompare(String(b.roomNo || ''), undefined, { numeric: true })
+}
+
+async function loadRooms() {
+  loading.value = true
+  try {
+    const res = await getRoomListAll()
+    if (res.code === 200) {
+      allRooms.value = res.data || []
+    } else {
+      message.error(res.msg || '加载房间失败')
+    }
+  } finally {
+    loading.value = false
+  }
+}
+onMounted(() => {
+  loadRoomTypes()
+  loadRooms()
+})
+
+// ==================== 业务操作 ====================
+
+const actionLoading = ref(false)
+
+// 入住登记弹窗
+const checkInVisible = ref(false)
+const checkInRoom = ref(null)
+const checkInGuestName = ref('')
+const checkInGuestPhone = ref('')
+const checkInRemark = ref('')
+
+function handleCheckIn(room) {
+  checkInRoom.value = room
+  checkInGuestName.value = ''
+  checkInGuestPhone.value = ''
+  checkInRemark.value = ''
+  checkInVisible.value = true
+}
+
+async function confirmCheckIn() {
+  if (!checkInRoom.value) return
+  actionLoading.value = true
+  try {
+    const res = await roomCheckIn(
+      checkInRoom.value.id,
+      checkInRemark.value || null,
+      checkInGuestName.value || null,
+      checkInGuestPhone.value || null,
+    )
+    if (res.code === 200) {
+      const name = checkInGuestName.value ? `(${checkInGuestName.value})` : ''
+      message.success(`房间 ${checkInRoom.value.roomNo}${name} 已办理入住`)
+      checkInVisible.value = false
+      loadRooms()
+    } else {
+      message.error(res.msg || '入住失败')
+    }
+  } finally {
+    actionLoading.value = false
+  }
+}
+
+const checkOutVisible = ref(false)
+const checkOutResult = ref(null)
+const checkOutRoom = ref(null)
+
+async function handleCheckOut(room) {
+  actionLoading.value = true
+  try {
+    const res = await roomCheckOut(room.id, false)
+    if (res.code !== 200) {
+      message.error(res.msg || '退房失败')
+      return
+    }
+    if (res.data && res.data.needConfirm) {
+      checkOutRoom.value = room
+      checkOutResult.value = res.data
+      checkOutVisible.value = true
+    } else if (res.data && res.data.checkedOut) {
+      message.success(`房间 ${room.roomNo} 已退房,请安排打扫`)
+      loadRooms()
+    }
+  } finally {
+    actionLoading.value = false
+  }
+}
+
+async function forceCheckOut() {
+  if (!checkOutRoom.value) return
+  actionLoading.value = true
+  try {
+    const res = await roomCheckOut(checkOutRoom.value.id, true)
+    if (res.code === 200 && res.data && res.data.checkedOut) {
+      message.success(`房间 ${checkOutRoom.value.roomNo} 已退房,请安排打扫`)
+      checkOutVisible.value = false
+      loadRooms()
+    } else {
+      message.error(res.msg || '退房失败')
+    }
+  } finally {
+    actionLoading.value = false
+  }
+}
+
+async function handleCleaningDone(room) {
+  actionLoading.value = true
+  try {
+    const res = await roomCleaningDone(room.id)
+    if (res.code === 200) {
+      message.success(`房间 ${room.roomNo} 打扫完成,已恢复空闲`)
+      loadRooms()
+    } else {
+      message.error(res.msg || '操作失败')
+    }
+  } finally {
+    actionLoading.value = false
+  }
+}
+
+async function handleMaintenanceToggle(room) {
+  actionLoading.value = true
+  try {
+    const res = await roomMaintenanceToggle(room.id)
+    if (res.code === 200) {
+      message.success(room.status === 'MAINTENANCE' ? '已解除维护' : '已设为维护中')
+      loadRooms()
+    } else {
+      message.error(res.msg || '操作失败')
+    }
+  } finally {
+    actionLoading.value = false
+  }
+}
+
+// ==================== 入住记录弹窗 ====================
+
+const stayVisible = ref(false)
+const stayLoading = ref(false)
+const stayRoom = ref(null)
+const stayList = ref([])
+const stayTotal = ref(0)
+const stayPageNum = ref(1)
+const stayPageSize = 10
+
+async function loadStayPage() {
+  if (!stayRoom.value) return
+  stayLoading.value = true
+  try {
+    const res = await getRoomStayPage({ roomId: stayRoom.value.id, pageNum: stayPageNum.value, pageSize: stayPageSize })
+    if (res.code === 200) {
+      stayList.value = res.data?.records || []
+      stayTotal.value = res.data?.total || 0
+    } else {
+      message.error(res.msg || '查询入住记录失败')
+    }
+  } finally {
+    stayLoading.value = false
+  }
+}
+
+function handleStayRecords(room) {
+  stayRoom.value = room
+  stayPageNum.value = 1
+  stayVisible.value = true
+  loadStayPage()
+}
+
+const activeOrderStatusMap = { 2: '已接单', 3: '备餐中', 4: '已出餐', 5: '配送中', 8: '退单待审核' }
+const checkOutOrderColumns = [
+  { title: '订单号', key: 'orderNo', minWidth: 160 },
+  { title: '状态', key: 'status', width: 100, render: row => activeOrderStatusMap[row.status] || String(row.status) },
+  { title: '联系人', key: 'contactName', width: 90 },
+  { title: '金额', key: 'totalAmount', width: 90, render: row => `¥${Number(row.totalAmount || 0).toFixed(2)}` },
+]
+
+const stayColumns = [
+  { title: '住客姓名', key: 'guestName', width: 110, render: row => row.guestName || '-' },
+  { title: '电话', key: 'guestPhone', width: 130, render: row => row.guestPhone || '-' },
+  { title: '入住时间', key: 'checkInTime', width: 170 },
+  { title: '退房时间', key: 'checkOutTime', width: 170, render: row => row.checkOutTime || '-' },
+  {
+    title: '状态', key: 'status', width: 90,
+    render: row => h(NTag, { type: row.status === '0' ? 'success' : 'info', size: 'small' },
+      { default: () => (row.status === '0' ? '在住' : '已退房') }),
+  },
+  { title: '备注', key: 'remark', minWidth: 140, render: row => row.remark || '-' },
+]
+</script>
+
+<style scoped>
+.hotel-board-page {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+}
+
+.board-toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12px;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.board-grid {
+  flex: 1;
+  display: grid;
+  grid-template-columns: repeat(auto-fill, 220px);
+  gap: 10px;
+  overflow-y: auto;
+  align-content: start;
+  min-height: 0;
+}
+
+.board-empty {
+  grid-column: 1 / -1;
+  text-align: center;
+  padding: 60px 0;
+}
+
+.room-card {
+  background: #fff;
+  border-radius: 8px;
+  padding: 10px 12px;
+  border: 1px solid #e8e8ec;
+  border-top: 3px solid #ccc;
+  transition: box-shadow 0.2s, transform 0.15s;
+  display: flex;
+  flex-direction: column;
+  height: 170px;
+}
+
+.room-card:hover {
+  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
+  transform: translateY(-1px);
+}
+
+/* 按状态区分顶部色条 */
+.card-VACANT { border-top-color: #18a058; }
+.card-OCCUPIED { border-top-color: #d03050; }
+.card-CLEANING { border-top-color: #f0a020; }
+.card-MAINTENANCE { border-top-color: #2080f0; }
+
+.room-no {
+  font-size: 18px;
+  font-weight: 700;
+  color: #333;
+}
+
+.floor-tag {
+  background: #f0f0f0;
+  color: #999;
+  font-size: 11px;
+  padding: 1px 5px;
+  border-radius: 3px;
+}
+
+.card-type {
+  margin-top: 4px;
+}
+
+.card-guest {
+  margin-top: 6px;
+  padding-top: 6px;
+  border-top: 1px dashed #eee;
+  line-height: 1.6;
+}
+
+.card-actions {
+  margin-top: auto;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+}
+</style>

+ 36 - 3
forge-admin-ui/src/views/hotel/businessHours.vue

@@ -16,6 +16,9 @@
       :edit-schema="editSchema"
       row-key="id"
       add-button-text="新增时段"
+      :before-submit="beforeSubmit"
+      :before-render-form="beforeRenderForm"
+      :before-render-detail="beforeRenderDetail"
     />
   </div>
 </template>
@@ -33,6 +36,30 @@ const crudRef = ref(null)
 // 星期映射
 const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
 
+// 提交前:将 dayOfWeek 数组转为逗号分隔字符串
+function beforeSubmit(data) {
+  if (Array.isArray(data.dayOfWeek)) {
+    data.dayOfWeek = data.dayOfWeek.length > 0 ? data.dayOfWeek.join(',') : null
+  }
+  return data
+}
+
+// 编辑表单渲染前:将 dayOfWeek 逗号分隔字符串转为数组
+function beforeRenderForm(data) {
+  if (data && data.dayOfWeek && typeof data.dayOfWeek === 'string') {
+    data.dayOfWeek = data.dayOfWeek.split(',').map(v => Number(v.trim()))
+  }
+  return data
+}
+
+// 详情数据渲染前(loadDetailOnEdit=true 时,详情接口返回的数据会覆盖 beforeRenderForm 的转换)
+function beforeRenderDetail(data) {
+  if (data && data.dayOfWeek && typeof data.dayOfWeek === 'string') {
+    data.dayOfWeek = data.dayOfWeek.split(',').map(v => Number(v.trim()))
+  }
+  return data
+}
+
 // 餐段类型 → 默认名称映射
 const mealTypeNameMap = {
   breakfast: '早餐',
@@ -95,8 +122,12 @@ const tableColumns = computed(() => [
   {
     prop: 'dayOfWeek',
     label: '适用星期',
-    width: 100,
-    render: row => h('span', {}, row.dayOfWeek != null ? weekDays[row.dayOfWeek] : '每天'),
+    width: 140,
+    render: row => {
+      if (!row.dayOfWeek) return h('span', {}, '每天')
+      const days = String(row.dayOfWeek).split(',').map(v => weekDays[Number(v)] || v)
+      return h('span', {}, days.join('、'))
+    },
   },
   { prop: 'startTime', label: '开始时间', width: 100 },
   { prop: 'endTime', label: '结束时间', width: 100 },
@@ -147,6 +178,7 @@ const editSchema = computed(() => [
         { label: '早餐', value: 'breakfast' },
         { label: '午餐', value: 'lunch' },
         { label: '晚餐', value: 'dinner' },
+        { label: '夜宵', value: 'night' },
       ],
       onUpdateValue: (value) => {
         // 选择餐段类型时自动填充名称
@@ -171,8 +203,9 @@ const editSchema = computed(() => [
     label: '适用星期',
     type: 'select',
     props: {
-      placeholder: '每天(不限制星期)',
+      placeholder: '请选择适用星期(不选则每天)',
       clearable: true,
+      multiple: true,
       options: [
         { label: '周日', value: 0 },
         { label: '周一', value: 1 },

+ 50 - 2
forge-admin-ui/src/views/hotel/order.vue

@@ -196,6 +196,7 @@ import {
   getOrderDetail,
   getOrderPage,
   hotelRefund,
+  orderRefund,
   prepareOrder,
   readyOrder,
   rejectOrder,
@@ -210,7 +211,7 @@ defineOptions({ name: 'HotelOrder' })
 
 const message = useMessage()
 const dialog = useDialog()
-const { dict } = useDict('hotel_order_status', 'hotel_pay_method')
+const { dict } = useDict('hotel_order_status', 'hotel_pay_method', 'hotel_refund_status')
 
 const crudRef = ref(null)
 
@@ -368,10 +369,21 @@ const tableColumns = computed(() => [
     width: 110,
     render: row => renderStatus(row.status),
   },
+  {
+    prop: 'refundStatus',
+    label: '退款',
+    width: 90,
+    render: (row) => {
+      // refundStatus 为 null 表示未触发过退款,仅已支付订单才有退款语义
+      if (row.refundStatus === null || row.refundStatus === undefined)
+        return '-'
+      return h(DictTag, { dictType: 'hotel_refund_status', value: String(row.refundStatus), size: 'small' })
+    },
+  },
   { prop: 'createTime', label: '下单时间', width: 172 },
   {
     label: '操作',
-    width: 320,
+    width: 360,
     fixed: 'right',
     render: (row) => {
       const actions = []
@@ -447,6 +459,18 @@ const tableColumns = computed(() => [
         }, '退单'))
       }
 
+      // 手动退款:已支付未退款的异常场景(退房竞态取消/退款失败重试)
+      // 7=已拒单 9=已退单 10=超时取消 11=退房联动取消;退款失败(2)也可重试
+      const refundable = row.payStatus === 1
+        && row.refundStatus !== 1
+        && (row.refundStatus === 2 || s === 7 || s === 9 || s === 10 || s === 11)
+      if (refundable) {
+        actions.push(h('a', {
+          class: 'text-error cursor-pointer hover:opacity-80',
+          onClick: () => handleAction('refund', row),
+        }, '退款'))
+      }
+
       return h('div', { class: 'flex gap-2' }, actions)
     },
   },
@@ -568,6 +592,30 @@ function handleAction(action, row) {
         loadDashboard()
       })
       break
+    case 'refund':
+      dialog.warning({
+        title: '手动退款',
+        content: `确认对订单 ${row.orderNo} 发起全额退款(¥${row.totalAmount})?`,
+        positiveText: '确认退款',
+        negativeText: '取消',
+        onPositiveClick: async () => {
+          try {
+            const res = await orderRefund(row.id)
+            if (res.code === 200) {
+              message.success('退款成功')
+            }
+            else {
+              message.error(res.msg || '退款失败,可稍后重试')
+            }
+          }
+          catch {
+            message.error('退款失败,订单已标记为退款失败状态,可稍后重试')
+          }
+          crudRef.value?.refresh()
+          loadDashboard()
+        },
+      })
+      break
   }
 }
 

+ 5 - 5
forge-admin-ui/src/views/hotel/qrcode.vue

@@ -253,7 +253,7 @@ async function handleBatchGenerate() {
     const res = await batchGenerateQrCode({ count: batchCount.value })
     if (res.code === 200) {
       window.$message.success(`成功生成 ${res.data.length} 个二维码`)
-      crudRef.value?.handleRefresh()
+      crudRef.value?.refresh()
     }
   } catch {
     window.$message.error('生成失败')
@@ -306,7 +306,7 @@ async function handleBindConfirm() {
     const res = await fn(bindQrId.value, { roomId: bindRoomId.value, remark: bindRemark.value })
     if (res.code === 200) {
       window.$message.success('操作成功')
-      crudRef.value?.handleRefresh()
+      crudRef.value?.refresh()
     }
   } catch {
     window.$message.error('操作失败')
@@ -324,7 +324,7 @@ function handleUnbind(row) {
         const res = await unbindQrCode(row.id)
         if (res.code === 200) {
           window.$message.success('解绑成功')
-          crudRef.value?.handleRefresh()
+          crudRef.value?.refresh()
         }
       } catch {
         window.$message.error('解绑失败')
@@ -339,7 +339,7 @@ async function handleToggleStatus(row) {
     const res = await updateQrCodeStatus(row.id, newStatus)
     if (res.code === 200) {
       window.$message.success(newStatus === '0' ? '已启用' : '已禁用')
-      crudRef.value?.handleRefresh()
+      crudRef.value?.refresh()
     }
   } catch {
     window.$message.error('操作失败')
@@ -435,7 +435,7 @@ async function handleBatchRemove() {
         if (res.code === 200) {
           window.$message.success('批量删除成功')
           selectedIds.value = []
-          crudRef.value?.handleRefresh()
+          crudRef.value?.refresh()
         }
       } catch (e) {
         window.$message.error(e?.message || '批量删除失败')

+ 11 - 16
forge-admin-ui/src/views/hotel/room.vue

@@ -22,15 +22,16 @@
 
 <script setup>
 import { computed, h, ref } from 'vue'
-import { NTag } from 'naive-ui'
+import { NTag, useMessage } from 'naive-ui'
 import { AiCrudPage } from '@/components/ai-form'
 import { getRoomTypeList } from '@/api/hotel'
 
 defineOptions({ name: 'HotelRoom' })
 
 const crudRef = ref(null)
+const message = useMessage()
 
-// 房间状态选项
+// 房间状态选项(搜索用)
 const roomStatusOptions = [
   { label: '空闲', value: 'VACANT' },
   { label: '入住', value: 'OCCUPIED' },
@@ -92,22 +93,23 @@ const searchSchema = [
 ]
 
 // 表格列配置
+// 注:编辑/删除由 AiCrudPage 默认操作列自动提供
 const tableColumns = computed(() => [
-  { prop: 'roomNo', label: '房间号', width: 120 },
-  { prop: 'roomTypeName', label: '房型', width: 140 },
-  { prop: 'floorNo', label: '楼层', width: 80 },
+  { prop: 'roomNo', label: '房间号', width: 100 },
+  { prop: 'roomTypeName', label: '房型', width: 120 },
+  { prop: 'floorNo', label: '楼层', width: 70 },
   {
     prop: 'status',
     label: '状态',
-    width: 100,
+    width: 90,
     render: row => h(NTag, { type: statusColorMap[row.status] || 'default', size: 'small' }, { default: () => statusLabelMap[row.status] || row.status }),
   },
-  { prop: 'sortOrder', label: '排序', width: 80 },
-  { prop: 'remark', label: '备注', minWidth: 200 },
-  { prop: 'createTime', label: '创建时间', width: 172 },
+  { prop: 'sortOrder', label: '排序', width: 70 },
+  { prop: 'remark', label: '备注', minWidth: 160 },
 ])
 
 // 编辑表单配置
+// 注意:状态不在此处维护,业务操作(入住/退房/打扫/维护)请到「房态看板」页面
 const editSchema = computed(() => [
   {
     field: 'roomNo',
@@ -129,13 +131,6 @@ const editSchema = computed(() => [
     props: { placeholder: '请输入楼层' },
   },
   {
-    field: 'status',
-    label: '状态',
-    type: 'select',
-    defaultValue: 'VACANT',
-    props: { placeholder: '请选择状态', options: roomStatusOptions },
-  },
-  {
     field: 'sortOrder',
     label: '排序',
     type: 'input',

+ 119 - 0
forge-h5-ui/serve.cjs

@@ -0,0 +1,119 @@
+const http = require('http')
+const fs = require('fs')
+const path = require('path')
+
+const PORT = 3001
+const BACKEND = 'http://127.0.0.1:8583'
+const STATIC_DIR = path.join(__dirname, 'dist', 'build', 'h5')
+const API_PREFIX = '/forge-h5-api'
+const APP_BASE = '/forge-h5'
+
+const MIME_TYPES = {
+  '.html': 'text/html; charset=utf-8',
+  '.js': 'application/javascript; charset=utf-8',
+  '.css': 'text/css; charset=utf-8',
+  '.json': 'application/json; charset=utf-8',
+  '.png': 'image/png',
+  '.jpg': 'image/jpeg',
+  '.jpeg': 'image/jpeg',
+  '.gif': 'image/gif',
+  '.svg': 'image/svg+xml',
+  '.ico': 'image/x-icon',
+  '.woff': 'font/woff',
+  '.woff2': 'font/woff2',
+  '.ttf': 'font/ttf',
+  '.map': 'application/json',
+}
+
+function getMimeType(filePath) {
+  const ext = path.extname(filePath).toLowerCase()
+  return MIME_TYPES[ext] || 'application/octet-stream'
+}
+
+function proxyRequest(req, res) {
+  const url = req.url.replace(API_PREFIX, '')
+  const options = {
+    hostname: '127.0.0.1',
+    port: 8583,
+    path: url,
+    method: req.method,
+    headers: {
+      ...req.headers,
+      host: '127.0.0.1:8583',
+    },
+  }
+
+  const proxyReq = http.request(options, (proxyRes) => {
+    // 复制响应头
+    const headers = {}
+    for (const [key, value] of Object.entries(proxyRes.headers)) {
+      if (key !== 'transfer-encoding') {
+        headers[key] = value
+      }
+    }
+    res.writeHead(proxyRes.statusCode, headers)
+    proxyRes.pipe(res)
+  })
+
+  proxyReq.on('error', (err) => {
+    console.error(`[API Proxy Error] ${req.method} ${url}:`, err.message)
+    res.writeHead(502, { 'Content-Type': 'application/json' })
+    res.end(JSON.stringify({ error: 'Backend service unavailable' }))
+  })
+
+  req.pipe(proxyReq)
+}
+
+function serveStatic(req, res) {
+  let urlPath = req.url
+
+  // 去掉 /forge-h5 前缀,映射到静态文件目录
+  if (urlPath.startsWith(APP_BASE)) {
+    urlPath = urlPath.slice(APP_BASE.length) || '/'
+  }
+
+  let filePath = path.join(STATIC_DIR, urlPath === '/' ? '/index.html' : urlPath)
+
+  // 安全检查:防止路径遍历
+  if (!filePath.startsWith(STATIC_DIR)) {
+    res.writeHead(403)
+    res.end('Forbidden')
+    return
+  }
+
+  fs.readFile(filePath, (err, data) => {
+    if (err) {
+      // SPA 回退:如果文件不存在,返回 index.html
+      fs.readFile(path.join(STATIC_DIR, 'index.html'), (err2, data2) => {
+        if (err2) {
+          res.writeHead(500)
+          res.end('Internal Server Error')
+          return
+        }
+        res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
+        res.end(data2)
+      })
+      return
+    }
+    res.writeHead(200, { 'Content-Type': getMimeType(filePath) })
+    res.end(data)
+  })
+}
+
+const server = http.createServer((req, res) => {
+  // API 请求代理到后端
+  if (req.url.startsWith(API_PREFIX)) {
+    console.log(`[API] ${req.method} ${req.url} -> ${BACKEND}${req.url.replace(API_PREFIX, '')}`)
+    proxyRequest(req, res)
+  } else {
+    // 静态文件服务
+    console.log(`[Static] ${req.method} ${req.url}`)
+    serveStatic(req, res)
+  }
+})
+
+server.listen(PORT, '0.0.0.0', () => {
+  console.log(`Server running at http://0.0.0.0:${PORT}`)
+  console.log(`Static files: ${STATIC_DIR}`)
+  console.log(`API proxy: ${API_PREFIX} -> ${BACKEND}`)
+})

+ 13 - 0
forge-h5-ui/src/api/index.js

@@ -324,6 +324,19 @@ export default {
     method: 'post',
     params: { id, tenantId, reason },
   }),
+  /** 顾客端 - 放弃待支付订单(物理删除) */
+  hotelAbandonOrder: (id, tenantId) => request({
+    url: '/hotel/open/customer/abandonOrder',
+    method: 'post',
+    params: { id, tenantId },
+  }),
+  /** 顾客端 - 当前营业状态(免登录) */
+  hotelBusinessHours: tenantId => request({
+    url: '/hotel/open/customer/businessHours',
+    method: 'get',
+    params: { tenantId },
+    skipAuthRefresh: true,
+  }),
 
   // ==================== 酒店支付 ====================
   /** 发起支付 */

+ 1 - 1
forge-h5-ui/src/pages/hotel/customer/cart.vue

@@ -122,7 +122,7 @@ function goCheckout() {
     uni.showToast({ title: '购物车是空的', icon: 'none' })
     return
   }
-  uni.redirectTo({ url: '/pages/hotel/customer/order-confirm' })
+  uni.navigateTo({ url: '/pages/hotel/customer/order-confirm' })
 }
 </script>
 

+ 52 - 0
forge-h5-ui/src/pages/hotel/customer/menu.vue

@@ -49,6 +49,20 @@
         <view class="m-spinner" />
         <text>加载中...</text>
       </view>
+      <view v-else-if="hoursLoading" class="m-loading">
+        <view class="m-spinner" />
+        <text>正在确认营业状态...</text>
+      </view>
+      <view v-else-if="hoursError" class="m-empty">
+        <text class="m-empty-icon">⚠️</text>
+        <text class="m-empty-text">营业状态获取失败</text>
+        <view class="m-retry-btn" @click="loadBusinessHours">重试</view>
+      </view>
+      <view v-else-if="!isOpenNow" class="m-empty">
+        <text class="m-empty-icon">🕐</text>
+        <text class="m-empty-text">当前不在营业时间</text>
+        <text v-if="businessTip" class="m-empty-text" style="margin-top: 8rpx;">{{ businessTip }}</text>
+      </view>
       <view v-else-if="filteredDishes.length === 0" class="m-empty">
         <text class="m-empty-icon">🍽️</text>
         <text class="m-empty-text">暂无菜品</text>
@@ -136,6 +150,12 @@ const categoryList = ref([])
 const dishList = ref([])
 const dishLoading = ref(false)
 
+// 营业状态
+const hoursLoading = ref(false)
+const hoursError = ref(false)
+const isOpenNow = computed(() => orderStore.isOpenNow)
+const businessTip = computed(() => orderStore.businessHoursTip)
+
 // 购物车
 const cartCount = computed(() => orderStore.cartCount)
 const cartTotal = computed(() => orderStore.cartTotal)
@@ -152,6 +172,7 @@ const filteredDishes = computed(() => {
 onMounted(() => {
   loadCategories()
   loadDishes()
+  loadBusinessHours()
 })
 
 /** 加载分类 */
@@ -164,6 +185,23 @@ async function loadCategories() {
   }
 }
 
+/** 加载营业状态 */
+async function loadBusinessHours() {
+  if (!orderStore.tenantId) return
+  hoursLoading.value = true
+  try {
+    var res = await api.hotelBusinessHours(orderStore.tenantId)
+    var bh = res?.data || res
+    if (bh) orderStore.setBusinessHours(bh)
+    hoursError.value = false
+  } catch (e) {
+    hoursError.value = true
+    console.error('加载营业状态失败:', e)
+  } finally {
+    hoursLoading.value = false
+  }
+}
+
 /** 加载菜品 */
 async function loadDishes() {
   dishLoading.value = true
@@ -568,6 +606,20 @@ function goToCart() {
   opacity: 0.8;
 }
 
+.m-retry-btn {
+  margin-top: 24rpx;
+  padding: 16rpx 48rpx;
+  border-radius: 40rpx;
+  background: var(--h-pri, #2D5016);
+  color: #fff;
+  font-size: 26rpx;
+  font-weight: 600;
+}
+
+.m-retry-btn:active {
+  opacity: 0.8;
+}
+
 /* 购物车浮层 */
 .m-cart-float {
   position: fixed;

+ 121 - 2
forge-h5-ui/src/pages/hotel/customer/order-confirm.vue

@@ -1,5 +1,13 @@
 <template>
   <view class="hotel-customer-theme oc-page">
+    <!-- 顶部返回栏 -->
+    <view class="oc-nav">
+      <view class="oc-nav-back" @click="goBack">
+        <text class="oc-nav-arrow">‹</text>
+        <text class="oc-nav-title">确认订单</text>
+      </view>
+    </view>
+
     <!-- 配送信息 -->
     <view class="oc-section">
       <view class="oc-section-title">📋 配送信息</view>
@@ -26,9 +34,16 @@
           :key="opt.value"
           class="oc-time-opt"
           :class="{ 'oc-time-opt--active': deliveryTimeType === opt.value }"
-          @click="deliveryTimeType = opt.value"
+          @click="selectDeliveryTime(opt.value)"
         >{{ opt.label }}</view>
       </view>
+      <!-- 指定时间:弹出时间选择器 -->
+      <view v-if="deliveryTimeType === 'CUSTOM'" class="oc-custom-time">
+        <picker mode="time" :value="customDeliveryTime" :start="minCustomTime" @change="onCustomTimeChange">
+          <view class="oc-time-picker-btn">{{ customDeliveryTime || '请选择送达时间' }}</view>
+        </picker>
+        <text class="oc-custom-time-hint">最早可选 {{ minCustomTime }}</text>
+      </view>
       <view class="oc-time-hint">预计 {{ estimatedTime }} 左右送达</view>
     </view>
 
@@ -84,13 +99,28 @@ const timeOptions = [
   { label: '立即送达', value: 'ASAP' },
   { label: '30分钟后', value: 'DELAY_30' },
   { label: '1小时后', value: 'DELAY_60' },
+  { label: '指定时间', value: 'CUSTOM' },
 ]
 
+const customDeliveryTime = ref('')
+
+// 指定时间最早可选:当前时间 + 25 分钟(与「立即送达」一致)
+const minCustomTime = computed(() => {
+  var now = new Date()
+  now.setMinutes(now.getMinutes() + 25)
+  var h = String(now.getHours()).padStart(2, '0')
+  var m = String(now.getMinutes()).padStart(2, '0')
+  return h + ':' + m
+})
+
 const estimatedTime = computed(() => {
   const now = new Date()
   let minutes = 25
   if (deliveryTimeType.value === 'DELAY_30') minutes = 55
   else if (deliveryTimeType.value === 'DELAY_60') minutes = 85
+  else if (deliveryTimeType.value === 'CUSTOM' && customDeliveryTime.value) {
+    return customDeliveryTime.value
+  }
   now.setMinutes(now.getMinutes() + minutes)
   const h = String(now.getHours()).padStart(2, '0')
   const m = String(now.getMinutes()).padStart(2, '0')
@@ -104,6 +134,28 @@ function formatSpecs(item) {
   return parts.join(' / ')
 }
 
+function goBack() {
+  uni.navigateBack({ delta: 1 })
+}
+
+function selectDeliveryTime(value) {
+  deliveryTimeType.value = value
+  if (value !== 'CUSTOM') {
+    customDeliveryTime.value = ''
+  }
+}
+
+function onCustomTimeChange(e) {
+  var selected = e.detail.value
+  // 校验:不能早于最早可选时间
+  if (selected < minCustomTime.value) {
+    uni.showToast({ title: '送达时间不能早于 ' + minCustomTime.value, icon: 'none' })
+    customDeliveryTime.value = ''
+    return
+  }
+  customDeliveryTime.value = selected
+}
+
 async function submitOrder() {
   if (!contactName.value.trim()) {
     uni.showToast({ title: '请输入联系人姓名', icon: 'none' })
@@ -113,6 +165,25 @@ async function submitOrder() {
     uni.showToast({ title: '请输入正确的手机号', icon: 'none' })
     return
   }
+
+  // 提交前刷新营业状态(失败不阻断,由后端兜底)
+  var bh = null
+  try {
+    var res = await api.hotelBusinessHours(orderStore.tenantId)
+    bh = res?.data || res
+    if (bh) orderStore.setBusinessHours(bh)
+  } catch (e) {
+    console.log('营业状态刷新失败,交由后端校验:', e?.message || e)
+  }
+  if (bh && bh.configured !== false && bh.open === false) {
+    uni.showModal({
+      title: '暂不可下单',
+      content: bh.nextText ? '当前不在营业时间,' + bh.nextText : '当前不在营业时间,请稍后再试',
+      showCancel: false,
+    })
+    return
+  }
+
   if (submitting.value) return
   submitting.value = true
 
@@ -124,6 +195,7 @@ async function submitOrder() {
       contactPhone: contactPhone.value,
       deliveryFee: deliveryFee.value,
       deliveryTimeType: deliveryTimeType.value,
+      customDeliveryTime: deliveryTimeType.value === 'CUSTOM' ? customDeliveryTime.value : null,
       note: orderStore.orderNote,
       items: cartItems.value.map(item => ({
         dishId: item.dishId,
@@ -159,10 +231,35 @@ async function submitOrder() {
 .oc-page {
   min-height: 100vh;
   background: var(--h-bg, #F7F7F7);
-  padding: 24rpx 28rpx;
   padding-bottom: calc(140rpx + env(safe-area-inset-bottom));
 }
 
+.oc-nav {
+  display: flex;
+  align-items: center;
+  padding: 20rpx 28rpx 16rpx;
+  background: var(--h-bg, #F7F7F7);
+}
+
+.oc-nav-back {
+  display: flex;
+  align-items: center;
+  gap: 8rpx;
+}
+
+.oc-nav-arrow {
+  font-size: 48rpx;
+  font-weight: 300;
+  color: var(--h-txt, #1A1A1A);
+  line-height: 1;
+}
+
+.oc-nav-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: var(--h-txt, #1A1A1A);
+}
+
 .oc-section {
   background: #fff;
   border-radius: var(--h-rad, 20rpx);
@@ -237,6 +334,28 @@ async function submitOrder() {
   margin-top: 16rpx;
 }
 
+/* 指定时间选择器 */
+.oc-custom-time {
+  margin-top: 16rpx;
+}
+
+.oc-custom-time-hint {
+  font-size: 22rpx;
+  color: var(--h-txt-light, #999);
+  margin-left: 16rpx;
+}
+
+.oc-time-picker-btn {
+  display: inline-block;
+  padding: 16rpx 32rpx;
+  border: 1rpx solid var(--h-bdr, #eee);
+  border-radius: 40rpx;
+  font-size: 24rpx;
+  color: var(--h-pri, #2D5016);
+  background: var(--h-acc-lt, #E8F0E3);
+  font-weight: 600;
+}
+
 /* 订单明细 */
 .oc-dish-mini {
   display: flex;

+ 3 - 1
forge-h5-ui/src/pages/hotel/customer/order-status.vue

@@ -248,7 +248,7 @@ const progressSteps = computed(() => {
 })
 
 function statusIcon(status) {
-  const map = { 0: '💳', 1: '📝', 2: '✅', 3: '👨‍🍳', 4: '🍱', 5: '🚀', 6: '🎉', 7: '❌', 8: '↩️', 9: '↩️', 10: '⏰' }
+  const map = { 0: '💳', 1: '📝', 2: '✅', 3: '👨‍🍳', 4: '🍱', 5: '🚀', 6: '🎉', 7: '❌', 8: '↩️', 9: '↩️', 10: '⏰', 11: '🚪' }
   return map[status] || '📋'
 }
 
@@ -257,6 +257,7 @@ function statusText(status) {
     0: '待支付', 1: '等待接单', 2: '餐厅已接单', 3: '厨房备餐中',
     4: '已出餐', 5: '配送中', 6: '已送达',
     7: '餐厅已拒单', 8: '退单审核中', 9: '已退单', 10: '支付超时已取消',
+    11: '已取消',
   }
   return map[status] || '未知状态'
 }
@@ -274,6 +275,7 @@ function statusHint(status) {
     8: '您的退单申请正在等待审核',
     9: '退单成功,款项将原路返回',
     10: '订单因超时未支付已自动取消',
+    11: '房间已退房,该订单已自动取消',
   }
   return map[status] || ''
 }

+ 4 - 3
forge-h5-ui/src/pages/hotel/customer/orders.vue

@@ -103,8 +103,9 @@ async function loadOrders() {
       tenantId: orderStore.tenantId,
       pageNum: pageNum.value,
       pageSize: pageSize.value,
-      // 按房间过滤(如果后端支持)
-      roomNo: roomInfo.value?.roomNo,
+      // 按在住会话过滤:同一住客的订单共享可见;退房后新住客的会话不同,互相隔离;
+      // 无会话时(未扫码/异常)退回按房间号过滤兼容存量数据。
+      ...(roomInfo.value?.stayId ? { stayId: roomInfo.value.stayId } : { roomNo: roomInfo.value?.roomNo }),
     })
     const data = res?.data || res
     const list = data?.records || (Array.isArray(data) ? data : [])
@@ -147,7 +148,7 @@ function statusLabel(status) {
     0: '待支付', 1: '待接单', 2: '已接单', 3: '备餐中',
     4: '已出餐', 5: '配送中', 6: '已完成',
     7: '已拒单', 8: '退单审核中', 9: '已退单',
-    10: '超时取消',
+    10: '超时取消', 11: '已取消',
   }
   return map[status] || '未知'
 }

+ 46 - 3
forge-h5-ui/src/pages/hotel/customer/pay.vue

@@ -360,9 +360,52 @@ function queryAlipayTradeStatus() {
   })
 }
 
-function goBack() {
-  // 购物车已清空,返回菜单页重新点餐
-  uni.redirectTo({ url: '/pages/hotel/customer/menu' })
+async function goBack() {
+  uni.showModal({
+    title: '放弃订单',
+    content: '确定放弃本次订单?菜品将恢复至购物车,您可以重新修改后下单。',
+    confirmText: '确定放弃',
+    confirmColor: '#DC2626',
+    success: async function(res) {
+      if (!res.confirm) return
+      try {
+        uni.showLoading({ title: '处理中...' })
+        var tenantId = orderStore.tenantId
+
+        // 1. 先查询订单详情,恢复菜品到购物车(必须在删除前)
+        var detailRes = await api.hotelOrderDetail(orderId.value, tenantId)
+        var detail = detailRes?.data || detailRes
+        if (detail && detail.items && detail.items.length) {
+          detail.items.forEach(function(item) {
+            orderStore.addToCartItem({
+              dishId: item.dishId,
+              dishName: item.dishName,
+              image: item.image || '',
+              price: item.unitPrice,
+              specs: item.specDesc ? [{ groupName: '', optionName: item.specDesc, priceExtra: 0 }] : [],
+              additions: item.additionsDesc ? [{ name: item.additionsDesc, extraPrice: 0 }] : [],
+              note: item.note || '',
+              quantity: item.quantity,
+              unitPrice: item.unitPrice,
+              specKey: item.dishId + '_' + (item.specDesc || ''),
+            })
+          })
+        }
+
+        // 2. 放弃待支付订单(物理删除)
+        await api.hotelAbandonOrder(orderId.value, tenantId)
+
+        uni.hideLoading()
+        uni.showToast({ title: '已恢复至购物车', icon: 'success' })
+        setTimeout(function() {
+          uni.redirectTo({ url: '/pages/hotel/customer/menu' })
+        }, 800)
+      } catch (err) {
+        uni.hideLoading()
+        uni.showToast({ title: err?.message || '操作失败', icon: 'none' })
+      }
+    }
+  })
 }
 </script>
 

+ 193 - 23
forge-h5-ui/src/pages/hotel/customer/room-confirm.vue

@@ -7,11 +7,59 @@
     </view>
 
     <!-- 错误状态 -->
-    <view v-else-if="errorMsg" class="rc-error">
-      <view class="rc-error-icon">!</view>
-      <text class="rc-error-title">验证失败</text>
-      <text class="rc-error-msg">{{ errorMsg }}</text>
-      <view class="rc-error-btn" @click="retry">重新加载</view>
+    <view v-else-if="errorMsg" class="rc-error-page">
+      <!-- 品牌头部(与成功页一致) -->
+      <view class="rc-brand">
+        <view class="rc-brand-icon">
+          <text class="rc-brand-icon-text">🏨</text>
+        </view>
+        <text class="rc-brand-name">亚朵酒店</text>
+        <text class="rc-brand-slogan">ATOUR HOTEL · 客房送餐服务</text>
+      </view>
+
+      <!-- 错误提示卡片 -->
+      <view class="rc-error-card">
+        <view class="rc-error-icon-wrap">
+          <view class="rc-error-icon-inner">!</view>
+        </view>
+        <text class="rc-error-title">房间验证未通过</text>
+        <view v-if="roomInfo.roomNo" class="rc-error-room">
+          <text class="rc-error-room-label">扫码房间</text>
+          <text class="rc-error-room-no">{{ roomInfo.roomNo }}</text>
+          <text v-if="roomInfo.roomTypeName" class="rc-error-room-type">{{ roomInfo.roomTypeName }}</text>
+        </view>
+        <text class="rc-error-msg">{{ errorMsg }}</text>
+        <view class="rc-error-btn" @click="retry">重新扫码</view>
+      </view>
+
+      <view class="rc-error-footer" @click="handleWrongRoom">
+        这不是我的房间?联系前台
+      </view>
+    </view>
+
+    <!-- 非营业时段拦截 -->
+    <view v-else-if="closedInfo" class="rc-error-page">
+      <view class="rc-brand">
+        <view class="rc-brand-icon">
+          <text class="rc-brand-icon-text">🏨</text>
+        </view>
+        <text class="rc-brand-name">亚朵酒店</text>
+        <text class="rc-brand-slogan">ATOUR HOTEL · 客房送餐服务</text>
+      </view>
+
+      <view class="rc-error-card">
+        <view class="rc-error-icon-wrap">
+          <view class="rc-error-icon-inner">🕐</view>
+        </view>
+        <text class="rc-error-title">当前不在营业时间</text>
+        <view v-if="roomInfo.roomNo" class="rc-error-room">
+          <text class="rc-error-room-label">扫码房间</text>
+          <text class="rc-error-room-no">{{ roomInfo.roomNo }}</text>
+          <text v-if="roomInfo.roomTypeName" class="rc-error-room-type">{{ roomInfo.roomTypeName }}</text>
+        </view>
+        <text class="rc-error-msg">{{ closedInfo.nextText || '请稍后再试' }}</text>
+        <view class="rc-error-btn" @click="retry">重新检查</view>
+      </view>
     </view>
 
     <!-- 房间确认 -->
@@ -72,7 +120,7 @@
 </template>
 
 <script setup>
-import { onMounted, ref } from 'vue'
+import { computed, onMounted, ref } from 'vue'
 import { useHotelOrderStore } from '@/store'
 import api from '@/api'
 
@@ -81,7 +129,8 @@ const orderStore = useHotelOrderStore()
 const loading = ref(true)
 const errorMsg = ref('')
 const roomInfo = ref({})
-const businessHoursText = ref('10:00 - 22:00')
+const businessHoursText = computed(() => orderStore.businessHoursText)
+const closedInfo = ref(null)
 const deliveryTime = ref('25-35')
 const deliveryFee = ref(0)
 
@@ -123,16 +172,51 @@ async function loadRoomInfo() {
       return
     }
 
+    // 非入住中的房间拦截点餐:系统与PMS不互通,入住/退房由前台在本系统操作,
+    // 仅入住中(OCCUPIED)的房间存在有效在住会话,才能下单。
+    if (data.room && data.room.roomStatus && data.room.roomStatus !== 'OCCUPIED') {
+      roomInfo.value = {
+        roomNo: data.room.roomNo || '',
+        roomTypeName: data.room.roomTypeName || '',
+      }
+      errorMsg.value = '该房间暂未入住,如需点餐请联系前台'
+      loading.value = false
+      return
+    }
+
     roomInfo.value = {
       roomId: data.room?.roomId,
       roomNo: data.room?.roomNo || '',
       roomTypeName: data.room?.roomTypeName || '',
+      roomStatus: data.room?.roomStatus || '',
+      // 在住会话ID:订单按会话共享可见,购物车随会话变更自动隔离
+      stayId: data.room?.stayId || null,
     }
 
     // 存入 Store
     orderStore.setTenantId(data.tenantId)
     orderStore.setRoomInfo(roomInfo.value)
 
+    // 校验营业状态(非营业时段整页拦截,不给进菜单)
+    const hoursState = await loadBusinessHours(data.tenantId)
+    if (hoursState === 'CLOSED') {
+      loading.value = false
+      return
+    }
+    if (hoursState === 'ERROR') {
+      errorMsg.value = '营业状态获取失败,请点击重试'
+      loading.value = false
+      return
+    }
+
+    // 入住时前台已录入客人信息,自动回填到联系人(优先级高于支付宝授权)
+    if (data.room?.guestName || data.room?.guestPhone) {
+      orderStore.setContactInfo(
+        data.room.guestName || orderStore.contactName,
+        data.room.guestPhone || orderStore.contactPhone,
+      )
+    }
+
     // 加载酒店配置(暂时使用默认值,后续可扩展接口)
     orderStore.setHotelConfig({
       deliveryFee: 0,
@@ -151,7 +235,24 @@ async function loadRoomInfo() {
   }
 }
 
+/** 查询当前营业状态,返回 'OPEN' / 'CLOSED' / 'ERROR' */
+async function loadBusinessHours(tenantId) {
+  try {
+    var res = await api.hotelBusinessHours(tenantId)
+    var bh = res?.data || res
+    if (!bh || bh.configured === false || bh.open !== false) {
+      return 'OPEN'
+    }
+    closedInfo.value = { nextText: bh.nextText || '' }
+    return 'CLOSED'
+  } catch (err) {
+    console.log('营业状态查询失败:', err?.message || err)
+    return 'ERROR'
+  }
+}
+
 function retry() {
+  closedInfo.value = null
   loadRoomInfo()
 }
 
@@ -278,52 +379,120 @@ async function doGetPhone() {
   font-size: 28rpx;
 }
 
-/* Error */
-.rc-error {
+/* Error Page */
+.rc-error-page {
+  min-height: 100vh;
+  display: flex;
+  flex-direction: column;
+}
+
+/* Error Card */
+.rc-error-card {
+  width: calc(100% - 64rpx);
+  margin: -40rpx 32rpx 0;
+  background: #fff;
+  border-radius: var(--h-rad-lg, 32rpx);
+  padding: 56rpx 48rpx 48rpx;
+  box-shadow: var(--h-shd-lg, 0 16rpx 48rpx rgba(0, 0, 0, 0.1));
   display: flex;
   flex-direction: column;
   align-items: center;
+  overflow: hidden;
+  box-sizing: border-box;
+}
+
+.rc-error-icon-wrap {
+  width: 140rpx;
+  height: 140rpx;
+  border-radius: 50%;
+  background: linear-gradient(135deg, #FEE2E2 0%, #FECACA 100%);
+  display: flex;
+  align-items: center;
   justify-content: center;
-  min-height: 100vh;
-  padding: 48rpx;
-  gap: 16rpx;
+  margin: 0 auto 24rpx;
 }
 
-.rc-error-icon {
-  width: 120rpx;
-  height: 120rpx;
+.rc-error-icon-inner {
+  width: 80rpx;
+  height: 80rpx;
   border-radius: 50%;
-  background: var(--h-dang-lt, #FEE2E2);
+  background: #fff;
   color: var(--h-dang, #DC2626);
-  font-size: 56rpx;
+  font-size: 44rpx;
   font-weight: 700;
   display: flex;
   align-items: center;
   justify-content: center;
-  margin-bottom: 8rpx;
 }
 
 .rc-error-title {
   font-size: 36rpx;
   font-weight: 700;
   color: var(--h-txt, #1A1A1A);
+  text-align: center;
+  margin-bottom: 12rpx;
+}
+
+.rc-error-room {
+  display: flex;
+  align-items: baseline;
+  justify-content: center;
+  gap: 12rpx;
+  margin-bottom: 16rpx;
+}
+
+.rc-error-room-label {
+  font-size: 24rpx;
+  color: var(--h-txt-light, #999);
+}
+
+.rc-error-room-no {
+  font-size: 40rpx;
+  font-weight: 800;
+  color: var(--h-pri, #2D5016);
+  letter-spacing: 4rpx;
+}
+
+.rc-error-room-type {
+  font-size: 22rpx;
+  color: var(--h-txt-sub, #666);
+  background: #f0f0f0;
+  padding: 4rpx 12rpx;
+  border-radius: 8rpx;
 }
 
 .rc-error-msg {
   font-size: 26rpx;
   color: var(--h-txt-sub, #666);
   text-align: center;
-  line-height: 1.6;
+  line-height: 1.8;
+  margin-bottom: 36rpx;
 }
 
 .rc-error-btn {
-  margin-top: 16rpx;
-  padding: 20rpx 64rpx;
+  width: 100%;
+  margin-top: 32rpx;
+  padding: 28rpx;
   border-radius: 48rpx;
   background: var(--h-pri, #2D5016);
   color: #fff;
-  font-size: 28rpx;
-  font-weight: 600;
+  font-size: 30rpx;
+  font-weight: 700;
+  text-align: center;
+  letter-spacing: 4rpx;
+  box-shadow: 0 8rpx 24rpx rgba(45, 80, 22, 0.3);
+}
+
+.rc-error-btn:active {
+  opacity: 0.85;
+  transform: scale(0.98);
+}
+
+.rc-error-footer {
+  margin-top: 32rpx;
+  text-align: center;
+  font-size: 24rpx;
+  color: var(--h-txt-light, #999);
 }
 
 /* Content */
@@ -444,6 +613,7 @@ async function doGetPhone() {
   font-weight: 700;
   text-align: center;
   letter-spacing: 4rpx;
+  box-sizing: border-box;
   box-shadow: 0 8rpx 24rpx rgba(45, 80, 22, 0.3);
 }
 

+ 39 - 3
forge-h5-ui/src/store/modules/hotel-order.js

@@ -9,8 +9,8 @@ export const useHotelOrderStore = defineStore('hotel-order', {
     /** 租户ID(扫码验证后存入,供后续顾客端接口使用) */
     tenantId: null,
 
-    /** 房间信息(扫码验证后存入) */
-    roomInfo: null, // { roomId, roomNo, roomTypeName }
+    /** 房间信息(扫码验证后存入,含在住会话用于隔离不同住客) */
+    roomInfo: null, // { roomId, roomNo, roomTypeName, roomStatus, stayId }
 
     /** 酒店配置 */
     hotelConfig: {
@@ -37,6 +37,9 @@ export const useHotelOrderStore = defineStore('hotel-order', {
 
     /** 授权获取的联系人手机号(支付宝授权) */
     contactPhone: '',
+
+    /** 当前营业状态(来自后端 BusinessHoursStatusVO,不持久化) */
+    businessHours: null,
   }),
 
   getters: {
@@ -65,6 +68,23 @@ export const useHotelOrderStore = defineStore('hotel-order', {
       const item = state.cartItems.find(i => i.dishId === dishId)
       return item ? item.quantity : 0
     },
+
+    /** 当前是否营业(未拉取不拦,由各页自己的拉取结果决定) */
+    isOpenNow(state) {
+      if (!state.businessHours) return true
+      if (state.businessHours.configured === false) return true
+      return state.businessHours.open !== false
+    },
+
+    /** 当天营业时段文案(如「早餐 07:00-10:00 · 午餐 11:00-14:00」) */
+    businessHoursText(state) {
+      return state.businessHours?.todayText || '营业时间以门店公告为准'
+    },
+
+    /** 下次营业提示文案(如「今天 11:00 开始(午餐)」) */
+    businessHoursTip(state) {
+      return state.businessHours?.nextText || ''
+    },
   },
 
   actions: {
@@ -73,8 +93,19 @@ export const useHotelOrderStore = defineStore('hotel-order', {
       this.tenantId = id
     },
 
-    /** 设置房间信息 */
+    /**
+     * 设置房间信息。
+     * 在住会话(stayId)变更时自动清空购物车:
+     * 退房后新客人扫码进来,购物车必须是全新的;
+     * 同一会话内多人共享会话但购物车各自手机独立,不受影响。
+     */
     setRoomInfo(info) {
+      const prevStayId = this.roomInfo ? this.roomInfo.stayId : null
+      const newStayId = info ? info.stayId : null
+      if (prevStayId !== null && newStayId !== null && prevStayId !== newStayId && this.cartItems.length > 0) {
+        this.cartItems = []
+        this.orderNote = ''
+      }
       this.roomInfo = info
     },
 
@@ -128,6 +159,11 @@ export const useHotelOrderStore = defineStore('hotel-order', {
       if (name) this.contactName = name
       if (phone) this.contactPhone = phone
     },
+
+    /** 设置营业状态(存后端 VO 原样) */
+    setBusinessHours(v) {
+      this.businessHours = v
+    },
   },
 
   persist: {

+ 150 - 0
forge-server/db/migration/V1.0.106__add_hotel_room_stay_and_refund.sql

@@ -0,0 +1,150 @@
+-- ============================================================
+-- 酒店在住会话机制 + 订单退款字段:建表 + 字段扩展 + 字典 + 权限
+-- 背景:系统与亚朵PMS不互通,客房入住/退房由前台在本系统手动操作。
+-- 引入“在住会话(hotel_room_stay)”概念:入住=开新会话,退房=关闭会话;
+-- 订单挂载到会话(stay_id),退房联动取消待支付订单;
+-- 已支付订单在拒单/退单时自动发起支付宝退款。
+-- ============================================================
+
+-- ------------------------------------------------------------
+-- 1. 在住会话表
+-- ------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `hotel_room_stay` (
+    `id`              bigint       NOT NULL COMMENT '主键ID(雪花算法)',
+    `tenant_id`       bigint       NOT NULL DEFAULT 1 COMMENT '租户ID',
+    `room_id`         bigint       NOT NULL COMMENT '房间ID',
+    `room_no`         varchar(32)  DEFAULT NULL COMMENT '房间号(冗余)',
+    `guest_name`      varchar(64)  DEFAULT NULL COMMENT '住客姓名(首单联系人回写,仅空时填入)',
+    `guest_phone`     varchar(32)  DEFAULT NULL COMMENT '住客电话(首单联系人回写,仅空时填入)',
+    `check_in_time`   datetime     NOT NULL COMMENT '入住时间',
+    `check_out_time`  datetime     DEFAULT NULL COMMENT '退房时间(在住时为NULL)',
+    `status`          tinyint      NOT NULL DEFAULT 0 COMMENT '会话状态(0=在住,1=已退房)',
+    `remark`          varchar(256) 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_room_id` (`room_id`),
+    KEY `idx_tenant_status` (`tenant_id`, `status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='酒店房间在住会话表';
+
+-- ------------------------------------------------------------
+-- 2. 订单表扩展:挂载会话 + 退款信息
+-- ------------------------------------------------------------
+SET @db_name = DATABASE();
+
+-- 2.1 stay_id(在住会话ID)
+SET @col_exists = (SELECT COUNT(*) FROM information_schema.COLUMNS
+                   WHERE TABLE_SCHEMA = @db_name AND TABLE_NAME = 'hotel_order' AND COLUMN_NAME = 'stay_id');
+SET @ddl = IF(@col_exists = 0,
+    'ALTER TABLE `hotel_order` ADD COLUMN `stay_id` bigint DEFAULT NULL COMMENT ''在住会话ID'' AFTER `room_id`',
+    'SELECT 1');
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- 2.2 stay_id 索引
+SET @idx_exists = (SELECT COUNT(*) FROM information_schema.STATISTICS
+                   WHERE TABLE_SCHEMA = @db_name AND TABLE_NAME = 'hotel_order' AND INDEX_NAME = 'idx_stay_id');
+SET @ddl = IF(@idx_exists = 0,
+    'ALTER TABLE `hotel_order` ADD KEY `idx_stay_id` (`stay_id`)',
+    'SELECT 1');
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- 2.3 refund_amount(实际退款金额,元)
+SET @col_exists = (SELECT COUNT(*) FROM information_schema.COLUMNS
+                   WHERE TABLE_SCHEMA = @db_name AND TABLE_NAME = 'hotel_order' AND COLUMN_NAME = 'refund_amount');
+SET @ddl = IF(@col_exists = 0,
+    'ALTER TABLE `hotel_order` ADD COLUMN `refund_amount` decimal(10,2) DEFAULT NULL COMMENT ''实际退款金额(元)''',
+    'SELECT 1');
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- 2.4 refund_trade_no(支付宝退款交易号)
+SET @col_exists = (SELECT COUNT(*) FROM information_schema.COLUMNS
+                   WHERE TABLE_SCHEMA = @db_name AND TABLE_NAME = 'hotel_order' AND COLUMN_NAME = 'refund_trade_no');
+SET @ddl = IF(@col_exists = 0,
+    'ALTER TABLE `hotel_order` ADD COLUMN `refund_trade_no` varchar(64) DEFAULT NULL COMMENT ''支付宝退款交易号''',
+    'SELECT 1');
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- 2.5 refund_status(退款状态:0=未退款,1=退款成功,2=退款失败)
+SET @col_exists = (SELECT COUNT(*) FROM information_schema.COLUMNS
+                   WHERE TABLE_SCHEMA = @db_name AND TABLE_NAME = 'hotel_order' AND COLUMN_NAME = 'refund_status');
+SET @ddl = IF(@col_exists = 0,
+    'ALTER TABLE `hotel_order` ADD COLUMN `refund_status` int DEFAULT NULL COMMENT ''退款状态(0=未退款,1=退款成功,2=退款失败)''',
+    'SELECT 1');
+PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- ------------------------------------------------------------
+-- 3. 字典数据
+-- ------------------------------------------------------------
+
+-- 3.1 在住会话状态字典
+INSERT INTO sys_dict_type (tenant_id, dict_name, dict_type, dict_status, remark, create_time, update_time)
+SELECT 1, '在住会话状态', 'hotel_room_stay_status', 1, '酒店房间在住会话状态', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'hotel_room_stay_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_room_stay_status', NULL, 'success', 'Y', 1, '在住', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'hotel_room_stay_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_room_stay_status', NULL, 'info', 'N', 1, '已退房', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'hotel_room_stay_status' AND dict_value = '1' AND tenant_id = 1);
+
+-- 3.2 退款状态字典
+INSERT INTO sys_dict_type (tenant_id, dict_name, dict_type, dict_status, remark, create_time, update_time)
+SELECT 1, '订单退款状态', 'hotel_refund_status', 1, '酒店订单退款状态', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'hotel_refund_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_refund_status', NULL, 'default', 'Y', 1, '未退款', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'hotel_refund_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_refund_status', NULL, 'success', 'N', 1, '退款成功', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'hotel_refund_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_refund_status', NULL, 'danger', 'N', 1, '退款失败(可重试)', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'hotel_refund_status' AND dict_value = '2' AND tenant_id = 1);
+
+-- 3.3 订单状态补充:11=已取消(退房联动取消待支付订单)
+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, 11, '已取消', '11', 'hotel_order_status', NULL, 'info', 'N', 1, '退房联动取消', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'hotel_order_status' AND dict_value = '11' AND tenant_id = 1);
+
+-- ------------------------------------------------------------
+-- 4. 按钮权限资源(sys_resource)
+-- ------------------------------------------------------------
+
+SET @hotel_parent_id = (SELECT id FROM sys_resource WHERE resource_name = '酒店管理' AND tenant_id = 1 AND parent_id = 0 LIMIT 1);
+
+-- 4.1 房间管理按钮权限(挂在房间管理菜单下,找不到则回退到酒店管理目录)
+SET @room_menu_id = (SELECT id FROM sys_resource WHERE path = '/hotel/room' AND tenant_id = 1 LIMIT 1);
+SET @room_menu_id = IFNULL(@room_menu_id, @hotel_parent_id);
+
+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, '房间入住', @room_menu_id, 3, 10, 'hotel:room:checkIn', 0, 0, 1, 1, '房间办理入住(开新在住会话)', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:room:checkIn' AND tenant_id = 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, '房间退房', @room_menu_id, 3, 11, 'hotel:room:checkOut', 0, 0, 1, 1, '房间办理退房(关闭在住会话+取消待支付订单)', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:room:checkOut' AND tenant_id = 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, '打扫完成', @room_menu_id, 3, 12, 'hotel:room:cleaningDone', 0, 0, 1, 1, '打扫完成(房间回到空闲)', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:room:cleaningDone' AND tenant_id = 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, '维护切换', @room_menu_id, 3, 13, 'hotel:room:maintenance', 0, 0, 1, 1, '设为维护中/解除维护', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:room:maintenance' AND tenant_id = 1);
+
+-- 4.2 订单退款按钮权限
+SET @order_menu_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, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '订单退款', @order_menu_id, 3, 7, 'hotel:order:refund', 0, 0, 1, 1, '手动发起订单退款(退房竞态等异常场景)', NOW(), NOW()
+WHERE @order_menu_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:order:refund' AND tenant_id = 1);

+ 12 - 0
forge-server/db/migration/V1.0.107__add_hotel_room_board_menu.sql

@@ -0,0 +1,12 @@
+-- ============================================================
+-- V1.0.107  新增「房态看板」菜单
+-- 将房间业务操作(入住/退房/打扫/维护/入住记录)从房间管理页拆分到独立看板页面
+-- ============================================================
+
+SET @hotel_parent_id = (SELECT id FROM sys_resource WHERE resource_name = '酒店管理' AND tenant_id = 1 AND parent_id = 0 LIMIT 1);
+
+-- 房态看板菜单(排在房间管理前面,sort=9,房间管理通常是10)
+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, 2, 9, '/hotel/board', 'hotel/board', 0, 0, 1, 1, 'mdi:view-dashboard-outline', 0, 0, '房态看板(入住/退房/打扫/维护操作)', NOW(), NOW()
+WHERE @hotel_parent_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE path = '/hotel/board' AND tenant_id = 1);

+ 36 - 0
forge-server/db/migration/V1.0.108__hotel_business_hours_day_of_week_to_varchar.sql

@@ -0,0 +1,36 @@
+-- ============================================================
+-- 营业时段 day_of_week 改为多选(int → varchar(32),逗号分隔)
+-- ============================================================
+
+-- 1. 将 day_of_week 从 int 改为 varchar(32)
+--    现有数据:NULL=每天, 0~6=具体星期 → 改为 '0'~'6' 字符串
+DELIMITER $$
+CREATE PROCEDURE migrate_day_of_week_to_varchar()
+BEGIN
+    -- 检查列类型是否已经是 varchar
+    IF EXISTS (
+        SELECT 1 FROM information_schema.COLUMNS
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME = 'hotel_business_hours'
+          AND COLUMN_NAME = 'day_of_week'
+          AND DATA_TYPE = 'varchar'
+    ) THEN
+        -- 已经是 varchar,跳过
+        SELECT 'day_of_week 已经是 varchar 类型,跳过迁移' AS info;
+    ELSE
+        -- 先将现有 int 值转为字符串(NULL 保持 NULL)
+        UPDATE hotel_business_hours
+        SET day_of_week = CAST(day_of_week AS CHAR)
+        WHERE day_of_week IS NOT NULL AND del_flag = 0;
+
+        -- 修改列类型
+        ALTER TABLE hotel_business_hours
+            MODIFY COLUMN day_of_week varchar(32) DEFAULT NULL COMMENT '适用星期(逗号分隔,如1,2,3; NULL=每天; 0=周日...6=周六)';
+
+        SELECT 'day_of_week 迁移完成: int → varchar(32)' AS info;
+    END IF;
+END$$
+DELIMITER ;
+
+CALL migrate_day_of_week_to_varchar();
+DROP PROCEDURE IF EXISTS migrate_day_of_week_to_varchar;

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

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

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

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

+ 6 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/constant/HotelQrConstants.java

@@ -49,4 +49,10 @@ public final class HotelQrConstants {
 
     /** 房间状态:维护中 */
     public static final String ROOM_MAINTENANCE = "MAINTENANCE";
+
+    /** 在住会话状态:在住(对应字典 hotel_room_stay_status = 0) */
+    public static final String STAY_ACTIVE = "0";
+
+    /** 在住会话状态:已退房(对应字典 hotel_room_stay_status = 1) */
+    public static final String STAY_CHECKED_OUT = "1";
 }

+ 98 - 1
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/HotelQrCodeController.java

@@ -8,10 +8,13 @@ import com.mdframe.forge.business.core.hotel.dto.QrCodeBatchGenerateDTO;
 import com.mdframe.forge.business.core.hotel.dto.QrCodeBindDTO;
 import com.mdframe.forge.business.core.hotel.dto.QrCodeQueryDTO;
 import com.mdframe.forge.business.core.hotel.service.HotelQrCodeService;
+import com.mdframe.forge.business.core.hotel.service.HotelRoomStayService;
 import com.mdframe.forge.business.core.hotel.vo.HotelQrBindLogVO;
 import com.mdframe.forge.business.core.hotel.vo.HotelQrCodeVO;
+import com.mdframe.forge.business.core.hotel.vo.HotelRoomStayVO;
 import com.mdframe.forge.business.core.hotel.vo.HotelRoomTypeVO;
 import com.mdframe.forge.business.core.hotel.vo.HotelRoomVO;
+import com.mdframe.forge.business.core.hotel.vo.RoomCheckOutVO;
 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;
@@ -38,6 +41,9 @@ public class HotelQrCodeController {
     @Autowired
     private HotelQrCodeService hotelQrCodeService;
 
+    @Autowired
+    private HotelRoomStayService hotelRoomStayService;
+
     // ==================== 二维码管理 ====================
 
     /**
@@ -138,7 +144,7 @@ public class HotelQrCodeController {
      * @param status 目标状态(ACTIVE=启用, DISABLED=禁用)
      * @return 操作结果
      */
-    @PutMapping("/qrcode/updateStatus")
+    @PostMapping("/qrcode/updateStatus")
     @OperationLog(module = "酒店二维码", type = OperationType.UPDATE, desc = "更新二维码状态")
     public RespInfo<Void> qrCodeUpdateStatus(@RequestParam Long id, @RequestParam String status) {
         hotelQrCodeService.qrCodeUpdateStatus(id, status);
@@ -272,6 +278,97 @@ public class HotelQrCodeController {
         return RespInfo.success(hotelQrCodeService.roomListAll());
     }
 
+    // ==================== 房间在住会话(入住/退房/维护) ====================
+    // 背景:系统与亚朵PMS不互通,入住/退房由前台在本系统手动操作。
+    // 房间状态仅通过以下操作流转,不再允许编辑表单直接改状态:
+    // 空闲/打扫中 --入住--> 入住中 --退房--> 打扫中 --打扫完成--> 空闲;空闲 <--> 维护中。
+
+    /**
+     * 办理入住。
+     * <p>
+     * 开启新的在住会话,房间状态变为入住中(OCCUPIED)。
+     * 仅空闲/打扫中的房间可入住;已有住客的房间拒绝重复入住。
+     * 入住时不采集客人信息,客人首次下单时联系人自动回写到会话。
+     *
+     * @param id     房间ID
+     * @param remark 备注(可选)
+     * @return 新建的在住会话ID
+     */
+    @PostMapping("/room/checkIn")
+    @OperationLog(module = "酒店房间", type = OperationType.UPDATE, desc = "房间入住")
+    public RespInfo<Long> roomCheckIn(@RequestParam Long id,
+                                      @RequestParam(required = false) String remark,
+                                      @RequestParam(required = false) String guestName,
+                                      @RequestParam(required = false) String guestPhone) {
+        return RespInfo.success(hotelRoomStayService.roomCheckIn(id, remark, guestName, guestPhone));
+    }
+
+    /**
+     * 办理退房(两步交互)。
+     * <p>
+     * 存在进行中订单且 force=false 时,返回 needConfirm=true 及订单清单,
+     * 前端弹窗警告,前台确认后带 force=true 重新调用。
+     * 正式退房时自动取消待支付订单,关闭在住会话,房间变为打扫中。
+     *
+     * @param id     房间ID
+     * @param force  是否强制退房(确认进行中订单后传true)
+     * @param remark 备注(可选)
+     * @return 退房结果(含是否需要二次确认、进行中订单清单)
+     */
+    @PostMapping("/room/checkOut")
+    @OperationLog(module = "酒店房间", type = OperationType.UPDATE, desc = "房间退房")
+    public RespInfo<RoomCheckOutVO> roomCheckOut(@RequestParam Long id,
+                                                 @RequestParam(required = false, defaultValue = "false") boolean force,
+                                                 @RequestParam(required = false) String remark) {
+        return RespInfo.success(hotelRoomStayService.roomCheckOut(id, force, remark));
+    }
+
+    /**
+     * 打扫完成。
+     * <p>
+     * 房间从打扫中(CLEANING)回到空闲(VACANT),可再次办理入住。
+     *
+     * @param id 房间ID
+     * @return 操作结果
+     */
+    @PostMapping("/room/cleaningDone")
+    @OperationLog(module = "酒店房间", type = OperationType.UPDATE, desc = "打扫完成")
+    public RespInfo<Void> roomCleaningDone(@RequestParam Long id) {
+        hotelRoomStayService.roomCleaningDone(id);
+        return RespInfo.success();
+    }
+
+    /**
+     * 维护切换。
+     * <p>
+     * 空闲房设为维护中,维护中的房间解除维护回到空闲;
+     * 入住中/打扫中的房间不允许切换。
+     *
+     * @param id 房间ID
+     * @return 操作结果
+     */
+    @PostMapping("/room/maintenanceToggle")
+    @OperationLog(module = "酒店房间", type = OperationType.UPDATE, desc = "维护切换")
+    public RespInfo<Void> roomMaintenanceToggle(@RequestParam Long id) {
+        hotelRoomStayService.roomMaintenanceToggle(id);
+        return RespInfo.success();
+    }
+
+    /**
+     * 分页查询房间的历史入住记录(含当前在住),按入住时间倒序。
+     * <p>
+     * 用于房间详情弹窗展示历史住客信息。
+     *
+     * @param pageQuery 分页参数(pageNum, pageSize)
+     * @param roomId    房间ID
+     * @return 入住记录分页数据
+     */
+    @GetMapping("/room/stayPage")
+    @OperationLog(module = "酒店房间", type = OperationType.QUERY, desc = "查询入住记录")
+    public RespInfo<IPage<HotelRoomStayVO>> roomStayPage(PageQuery pageQuery, @RequestParam Long roomId) {
+        return RespInfo.success(hotelRoomStayService.stayPage(pageQuery, roomId));
+    }
+
     // ==================== 房型管理 ====================
 
     /**

+ 58 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/open/AlipayAuthController.java

@@ -0,0 +1,58 @@
+package com.mdframe.forge.business.core.hotel.controller.open;
+
+import com.mdframe.forge.business.core.hotel.pay.dto.AlipayAuthDTO;
+import com.mdframe.forge.business.core.hotel.pay.service.AlipayAuthService;
+import com.mdframe.forge.business.core.hotel.pay.vo.AlipayAuthVO;
+import com.mdframe.forge.starter.core.domain.RespInfo;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+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.RestController;
+
+/**
+ * 支付宝用户授权接口(免登录,供小程序使用)。
+ * <p>
+ * 路径 /hotel/open/alipay-auth/** 已在 SaTokenConfig 中排除登录校验。
+ */
+@Slf4j
+@RestController
+@RequestMapping("/hotel/open/alipay-auth")
+public class AlipayAuthController {
+
+    @Autowired
+    private AlipayAuthService authService;
+
+    /**
+     * 支付宝用户授权(获取姓名 + 手机号)。
+     * <p>
+     * 前端流程:
+     * 1. 调用 my.getAuthCode({ scopes: 'auth_base' }) 获取 authCode
+     * 2. 调用 my.getPhoneNumber() 获取手机号加密响应
+     * 3. 将 authCode 和 phoneResponse 一起发送到本接口
+     *
+     * @param dto 授权请求(authCode + phoneResponse)
+     * @return 用户信息(姓名 + 手机号)
+     */
+    @PostMapping("/getUserInfo")
+    public RespInfo<AlipayAuthVO> getUserInfo(@RequestBody AlipayAuthDTO dto) {
+        AlipayAuthVO vo = new AlipayAuthVO();
+
+        // 获取用户姓名
+        if (dto.getAuthCode() != null && !dto.getAuthCode().isEmpty()) {
+            AlipayAuthVO userInfo = authService.getUserInfo(dto.getAuthCode());
+            vo.setUserName(userInfo.getUserName());
+            vo.setUserId(userInfo.getUserId());
+        }
+
+        // 获取手机号
+        if (dto.getPhoneResponse() != null && !dto.getPhoneResponse().isEmpty()) {
+            String phone = authService.getPhoneNumber(dto.getPhoneResponse());
+            vo.setPhone(phone);
+        }
+
+        log.info("支付宝用户授权完成: userName={}, phone={}", vo.getUserName(), vo.getPhone());
+        return RespInfo.success(vo);
+    }
+}

+ 61 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/controller/open/HotelCustomerController.java

@@ -6,12 +6,15 @@ import com.mdframe.forge.business.core.hotel.order.domain.HotelOrder;
 import com.mdframe.forge.business.core.hotel.order.dto.OrderCreateDTO;
 import com.mdframe.forge.business.core.hotel.order.service.HotelOrderService;
 import com.mdframe.forge.business.core.hotel.order.vo.HotelOrderVO;
+import com.mdframe.forge.business.core.hotel.service.HotelBusinessHoursService;
 import com.mdframe.forge.business.core.hotel.service.HotelDishCategoryService;
 import com.mdframe.forge.business.core.hotel.service.HotelDishService;
+import com.mdframe.forge.business.core.hotel.vo.BusinessHoursStatusVO;
 import com.mdframe.forge.business.core.hotel.vo.HotelDishCategoryVO;
 import com.mdframe.forge.business.core.hotel.vo.HotelDishVO;
 import com.mdframe.forge.starter.core.domain.PageQuery;
 import com.mdframe.forge.starter.core.domain.RespInfo;
+import com.mdframe.forge.starter.core.exception.BusinessException;
 import com.mdframe.forge.starter.tenant.context.TenantContextHolder;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.GetMapping;
@@ -42,6 +45,31 @@ public class HotelCustomerController {
     @Autowired
     private HotelOrderService orderService;
 
+    @Autowired
+    private HotelBusinessHoursService businessHoursService;
+
+    // ==================== 营业时段 ====================
+
+    /**
+     * 顾客端查询当前营业状态(免登录)。
+     * <p>
+     * 路径 /hotel/open/** 已在 SaTokenConfig 中排除登录校验和 API 权限校验。
+     *
+     * @param tenantId 租户ID
+     * @return 当前营业状态(含下次营业文案)
+     */
+    @GetMapping("/businessHours")
+    public RespInfo<BusinessHoursStatusVO> businessHours(@RequestParam Long tenantId) {
+        BusinessHoursStatusVO result = TenantContextHolder.executeWithTenant(tenantId,
+                new java.util.function.Supplier<BusinessHoursStatusVO>() {
+                    @Override
+                    public BusinessHoursStatusVO get() {
+                        return businessHoursService.currentStatus();
+                    }
+                });
+        return RespInfo.success(result);
+    }
+
     // ==================== 菜品分类 ====================
 
     /**
@@ -112,6 +140,7 @@ public class HotelCustomerController {
      * 顾客端创建订单(下单页使用)。
      * <p>
      * 后端从数据库重算菜品单价,不信任前端传的价格。
+     * 下单前校验营业时间:非营业时段禁止下单。
      *
      * @param tenantId 租户ID
      * @param dto      创建订单参数
@@ -124,6 +153,16 @@ public class HotelCustomerController {
                 new java.util.function.Supplier<Long>() {
                     @Override
                     public Long get() {
+                        // 营业时间校验:非营业时段禁止下单
+                        BusinessHoursStatusVO hours = businessHoursService.currentStatus();
+                        if (hours != null && Boolean.TRUE.equals(hours.getConfigured())
+                                && !Boolean.TRUE.equals(hours.getOpen())) {
+                            String tip = hours.getNextText();
+                            if (tip != null && !tip.isEmpty()) {
+                                throw new BusinessException("当前不在营业时间," + tip + ",暂不可下单");
+                            }
+                            throw new BusinessException("当前不在营业时间,暂不可下单");
+                        }
                         return orderService.orderCreate(dto);
                     }
                 });
@@ -194,4 +233,26 @@ public class HotelCustomerController {
                 });
         return RespInfo.success();
     }
+
+    /**
+     * 放弃待支付订单(物理删除)。
+     * <p>
+     * 适用场景:用户在支付页点击「返回修改」,此时订单尚未支付,
+     * 直接删除不留记录,避免在订单列表中显示"超时取消"等无效数据。
+     *
+     * @param tenantId 租户ID
+     * @param id       订单ID
+     */
+    @PostMapping("/abandonOrder")
+    public RespInfo<Void> abandonOrder(@RequestParam Long tenantId,
+                                        @RequestParam Long id) {
+        TenantContextHolder.executeWithTenant(tenantId,
+                new Runnable() {
+                    @Override
+                    public void run() {
+                        orderService.abandonOrder(id);
+                    }
+                });
+        return RespInfo.success();
+    }
 }

+ 2 - 2
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/domain/HotelBusinessHours.java

@@ -25,8 +25,8 @@ public class HotelBusinessHours extends TenantEntity {
     @TableId(value = "id", type = IdType.ASSIGN_ID)
     private Long id;
 
-    /** 星期几(0=周日,1=周一,...,6=周六,NULL=每天) */
-    private Integer dayOfWeek;
+    /** 适用星期(逗号分隔,如"1,2,3"; NULL=每天; 0=周日...6=周六) */
+    private String dayOfWeek;
 
     /** 餐段标识(breakfast/lunch/dinner/night) */
     private String mealType;

+ 2 - 2
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/domain/HotelQrBindLog.java

@@ -8,7 +8,7 @@ import lombok.Data;
 import lombok.EqualsAndHashCode;
 
 import java.io.Serial;
-import java.time.LocalDateTime;
+import java.util.Date;
 
 /**
  * 酒店二维码绑定日志实体。
@@ -47,7 +47,7 @@ public class HotelQrBindLog extends TenantEntity {
     private String operatorName;
 
     /** 操作时间 */
-    private LocalDateTime operateTime;
+    private Date operateTime;
 
     /** 备注 */
     private String remark;

+ 3 - 3
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/domain/HotelQrCode.java

@@ -9,7 +9,7 @@ import lombok.Data;
 import lombok.EqualsAndHashCode;
 
 import java.io.Serial;
-import java.time.LocalDateTime;
+import java.util.Date;
 
 /**
  * 酒店二维码实体。
@@ -51,7 +51,7 @@ public class HotelQrCode extends TenantEntity {
     private String bindUserName;
 
     /** 最近绑定时间 */
-    private LocalDateTime bindTime;
+    private Date bindTime;
 
     /** 最近解绑人ID */
     private Long unbindUserId;
@@ -60,7 +60,7 @@ public class HotelQrCode extends TenantEntity {
     private String unbindUserName;
 
     /** 最近解绑时间 */
-    private LocalDateTime unbindTime;
+    private Date unbindTime;
 
     /** 删除标记(0=未删除,删除时写入主键ID) */
     @TableLogic(value = "0", delval = "id")

+ 62 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/domain/HotelRoomStay.java

@@ -0,0 +1,62 @@
+package com.mdframe.forge.business.core.hotel.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.util.Date;
+
+/**
+ * 酒店房间在住会话实体。
+ * <p>
+ * 业务背景:本系统与亚朵PMS不互通,房间的入住/退房由前台在本系统手动操作。
+ * 在住会话(Stay Session)代表"一间房一次入住周期":
+ * 入住时新建一条 status=0 的会话,退房时关闭为 status=1。
+ * 移动端点餐的订单挂载到会话(hotel_order.stay_id),
+ * 顾客扫码时以会话ID判断是否为同一批住客,实现换客后订单/购物车隔离。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("hotel_room_stay")
+public class HotelRoomStay extends TenantEntity {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /** 主键ID(雪花算法) */
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 房间ID(关联 hotel_room.id) */
+    private Long roomId;
+
+    /** 房间号(冗余存储,便于展示和查询) */
+    private String roomNo;
+
+    /** 住客姓名(首单联系人回写,仅会话内为空时填入) */
+    private String guestName;
+
+    /** 住客电话(首单联系人回写,仅会话内为空时填入) */
+    private String guestPhone;
+
+    /** 入住时间(前台办理入住时写入) */
+    private Date checkInTime;
+
+    /** 退房时间(在住时为NULL,退房时写入) */
+    private Date checkOutTime;
+
+    /** 会话状态(0=在住, 1=已退房,对应字典 hotel_room_stay_status) */
+    private String status;
+
+    /** 备注(前台入住/退房时可填写) */
+    private String remark;
+
+    /** 删除标记(0=未删除,删除时写入主键ID) */
+    @TableLogic(value = "0", delval = "id")
+    private Long delFlag;
+}

+ 8 - 1
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/mapper/HotelBusinessHoursMapper.java

@@ -44,7 +44,14 @@ public interface HotelBusinessHoursMapper extends BaseMapper<HotelBusinessHours>
     List<HotelBusinessHours> selectEnabled(@Param("tenantId") Long tenantId);
 
     /**
-     * 查询当前营业时段(按星期匹配 + 时间区间判断)。
+     * 更新营业时段(含 day_of_week=null 的场景,updateById 的 NOT_NULL 策略无法处理)。
+     *
+     * @param entity 营业时段实体
+     */
+    int updateBusinessHours(HotelBusinessHours entity);
+    
+    /**
+     * 查询当前营业时段(星期匹配 + 时间区间判断)。
      *
      * @param tenantId    租户ID
      * @param dayOfWeek   当前星期(0=周日)

+ 53 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/mapper/HotelRoomStayMapper.java

@@ -0,0 +1,53 @@
+package com.mdframe.forge.business.core.hotel.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.domain.HotelRoomStay;
+import com.mdframe.forge.business.core.hotel.vo.HotelRoomStayVO;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 酒店房间在住会话Mapper。
+ */
+@Mapper
+public interface HotelRoomStayMapper extends BaseMapper<HotelRoomStay> {
+
+    /**
+     * 查询房间的在住会话(status=0)。
+     * <p>
+     * 正常情况下一个房间最多只有一条在住会话;
+     * 若因历史数据异常出现多条,取入住时间最新的一条。
+     *
+     * @param tenantId 租户ID
+     * @param roomId   房间ID
+     * @return 在住会话,不存在时返回null
+     */
+    HotelRoomStay selectActiveByRoomId(@Param("tenantId") Long tenantId,
+                                       @Param("roomId") Long roomId);
+
+    /**
+     * 分页查询房间的历史入住记录(含在住),按入住时间倒序。
+     *
+     * @param page     分页对象
+     * @param tenantId 租户ID
+     * @param roomId   房间ID
+     * @return 入住记录分页数据
+     */
+    IPage<HotelRoomStayVO> selectStayPage(Page<HotelRoomStayVO> page,
+                                          @Param("tenantId") Long tenantId,
+                                          @Param("roomId") Long roomId);
+
+    /**
+     * 查询全部房间的在住会话列表。
+     * <p>
+     * 用于房间列表页展示"在住客人/入住时间"列,一次查询避免逐行关联。
+     *
+     * @param tenantId 租户ID
+     * @return 在住会话列表
+     */
+    List<HotelRoomStayVO> selectAllActive(@Param("tenantId") Long tenantId);
+}

+ 15 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/constant/HotelOrderConstants.java

@@ -42,6 +42,9 @@ public final class HotelOrderConstants {
     /** 支付超时已取消 */
     public static final int STATUS_PAY_TIMEOUT = 10;
 
+    /** 已取消(退房联动取消待支付订单,区别于10支付超时取消) */
+    public static final int STATUS_CANCELLED = 11;
+
     // ==================== 支付状态 ====================
 
     /** 支付状态:待支付 */
@@ -82,4 +85,16 @@ public final class HotelOrderConstants {
 
     /** 酒店前台退单 */
     public static final String REFUND_INITIATOR_HOTEL_DESK = "HOTEL_DESK";
+
+    // ==================== 退款状态 ====================
+    // 对应字段 hotel_order.refund_status,字典 hotel_refund_status
+
+    /** 退款状态:未退款 */
+    public static final int REFUND_STATUS_NONE = 0;
+
+    /** 退款状态:退款成功 */
+    public static final int REFUND_STATUS_SUCCESS = 1;
+
+    /** 退款状态:退款失败(可重试) */
+    public static final int REFUND_STATUS_FAILED = 2;
 }

+ 25 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/controller/HotelOrderController.java

@@ -6,6 +6,7 @@ import com.mdframe.forge.business.core.hotel.order.dto.OrderCreateDTO;
 import com.mdframe.forge.business.core.hotel.order.service.HotelOrderService;
 import com.mdframe.forge.business.core.hotel.order.vo.HotelOrderVO;
 import com.mdframe.forge.business.core.hotel.order.vo.OrderDashboardVO;
+import com.mdframe.forge.business.core.hotel.pay.service.HotelPayService;
 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;
@@ -28,6 +29,9 @@ public class HotelOrderController {
     @Autowired
     private HotelOrderService hotelOrderService;
 
+    @Autowired
+    private HotelPayService hotelPayService;
+
     // ==================== 查询类接口 ====================
 
     /**
@@ -169,4 +173,25 @@ public class HotelOrderController {
         hotelOrderService.rejectRefund(id, reason);
         return RespInfo.success();
     }
+
+    // ==================== 退款相关接口 ====================
+
+    /**
+     * 手动发起订单退款(全额)。
+     * <p>
+     * 使用场景:
+     * 1. 退房竞态异常(订单已取消但客人已付款,回调被拦截后人工退款);
+     * 2. 自动退款失败(refund_status=2)后重试;
+     * 3. 其他需人工介入的退款。
+     * 仅已支付且未退款成功的订单可操作。
+     *
+     * @param id 订单ID
+     * @return 操作结果(退款失败时返回错误提示,可重试)
+     */
+    @PostMapping("/refund")
+    @OperationLog(module = "酒店订单", type = OperationType.UPDATE, desc = "手动订单退款")
+    public RespInfo<Void> orderRefund(@RequestParam Long id) {
+        hotelPayService.refundOrder(id);
+        return RespInfo.success();
+    }
 }

+ 22 - 10
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/domain/HotelOrder.java

@@ -11,7 +11,7 @@ import lombok.EqualsAndHashCode;
 
 import java.io.Serial;
 import java.math.BigDecimal;
-import java.time.LocalDateTime;
+import java.util.Date;
 import java.util.List;
 
 /**
@@ -38,6 +38,9 @@ public class HotelOrder extends TenantEntity {
     /** 房间ID */
     private Long roomId;
 
+    /** 在住会话ID(关联 hotel_room_stay,下单时写入;历史存量订单为null) */
+    private Long stayId;
+
     /** 房型名称 */
     private String roomType;
 
@@ -47,17 +50,17 @@ public class HotelOrder extends TenantEntity {
     /** 联系电话 */
     private String contactPhone;
 
-    /** 订单状态(0=待支付,1=待接单,2=已接单,3=备餐中,4=已出餐,5=配送中,6=已完成,7=已拒单,8=退单待审核,9=已退单,10=支付超时已取消) */
+    /** 订单状态(0=待支付,1=待接单,2=已接单,3=备餐中,4=已出餐,5=配送中,6=已完成,7=已拒单,8=退单待审核,9=已退单,10=支付超时已取消,11=退房联动已取消) */
     private Integer status;
 
     /** 支付状态(0=待支付,1=已支付,2=已退款,3=支付超时已取消) */
     private Integer payStatus;
 
     /** 支付截止时间 */
-    private LocalDateTime payExpireTime;
+    private Date payExpireTime;
 
     /** 实际支付时间 */
-    private LocalDateTime payTime;
+    private Date payTime;
 
     /** 第三方支付交易号 */
     private String payTradeNo;
@@ -78,22 +81,22 @@ public class HotelOrder extends TenantEntity {
     private String deliveryTimeType;
 
     /** 自定义配送时间 */
-    private LocalDateTime customDeliveryTime;
+    private Date customDeliveryTime;
 
     /** 订单备注 */
     private String note;
 
     /** 接单时间 */
-    private LocalDateTime acceptTime;
+    private Date acceptTime;
 
     /** 出餐时间 */
-    private LocalDateTime readyTime;
+    private Date readyTime;
 
     /** 配送时间 */
-    private LocalDateTime deliverTime;
+    private Date deliverTime;
 
     /** 完成时间 */
-    private LocalDateTime completeTime;
+    private Date completeTime;
 
     /** 拒单原因 */
     private String rejectReason;
@@ -105,7 +108,16 @@ public class HotelOrder extends TenantEntity {
     private String refundInitiator;
 
     /** 退单时间 */
-    private LocalDateTime refundTime;
+    private Date refundTime;
+
+    /** 实际退款金额(元,退款成功后写入) */
+    private BigDecimal refundAmount;
+
+    /** 支付宝退款交易号(退款成功后写入) */
+    private String refundTradeNo;
+
+    /** 退款状态(0=未退款,1=退款成功,2=退款失败可重试,对应字典 hotel_refund_status) */
+    private Integer refundStatus;
 
     /** 删除标记(0=未删除,删除时写入主键ID) */
     @TableLogic(value = "0", delval = "id")

+ 2 - 3
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/dto/OrderCreateDTO.java

@@ -4,7 +4,6 @@ import com.mdframe.forge.business.core.hotel.order.domain.HotelOrderItem;
 import lombok.Data;
 
 import java.math.BigDecimal;
-import java.time.LocalDateTime;
 import java.util.List;
 
 /**
@@ -31,8 +30,8 @@ public class OrderCreateDTO {
     /** 配送时间类型(ASAP=立即送达, CUSTOM=自定义) */
     private String deliveryTimeType;
 
-    /** 自定义配送时间 */
-    private LocalDateTime customDeliveryTime;
+    /** 自定义配送时间(HH:mm格式,如"18:30") */
+    private String customDeliveryTime;
 
     /** 订单备注 */
     private String note;

+ 23 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/mapper/HotelOrderMapper.java

@@ -78,4 +78,27 @@ public interface HotelOrderMapper extends BaseMapper<HotelOrder> {
      * @return 过期订单ID列表
      */
     List<Long> selectExpiredUnpaidOrderIds();
+
+    /**
+     * 查询指定在住会话的进行中订单(退房警告用)。
+     * <p>
+     * 进行中定义:2=已接单, 3=备餐中, 4=已出餐, 5=配送中, 8=退单待审核。
+     * 这些订单退房后仍需餐厅/前台跟进,因此退房前必须警告前台确认。
+     *
+     * @param stayId 在住会话ID
+     * @return 进行中订单列表(无则空列表)
+     */
+    List<HotelOrder> selectActiveByStayId(@Param("stayId") Long stayId);
+
+    /**
+     * 批量取消指定在住会话的待支付订单(退房联动)。
+     * <p>
+     * 仅取消 status=0(待支付)且 pay_status=0(未支付)的订单,
+     * 状态流转为 0 -> 11(已取消,区别于10支付超时取消)。
+     * 已支付订单不受影响(退款走独立退款流程)。
+     *
+     * @param stayId 在住会话ID
+     * @return 被取消的订单数量
+     */
+    int cancelPendingByStayId(@Param("stayId") Long stayId);
 }

+ 12 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/service/HotelOrderService.java

@@ -129,4 +129,16 @@ public interface HotelOrderService {
      * @param reason 驳回原因(选填)
      */
     void rejectRefund(Long id, String reason);
+
+    /**
+     * 放弃待支付订单:物理删除订单及明细。
+     * <p>
+     * 适用场景:用户在支付页点击「返回修改」,此时订单尚未支付,
+     * 直接删除不留记录,避免在订单列表中显示"超时取消"等无效数据。
+     * 仅允许对 status=0(待支付)且 pay_status=0(未支付)的订单执行。
+     *
+     * @param id 订单ID
+     * @throws com.mdframe.forge.starter.core.exception.BusinessException 订单不存在或状态不允许时抛出
+     */
+    void abandonOrder(Long id);
 }

+ 148 - 18
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/service/impl/HotelOrderServiceImpl.java

@@ -4,7 +4,11 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.mdframe.forge.business.core.hotel.domain.HotelDish;
+import com.mdframe.forge.business.core.hotel.domain.HotelRoom;
+import com.mdframe.forge.business.core.hotel.domain.HotelRoomStay;
 import com.mdframe.forge.business.core.hotel.mapper.HotelDishMapper;
+import com.mdframe.forge.business.core.hotel.mapper.HotelRoomMapper;
+import com.mdframe.forge.business.core.hotel.mapper.HotelRoomStayMapper;
 import com.mdframe.forge.business.core.hotel.order.constant.HotelOrderConstants;
 import com.mdframe.forge.business.core.hotel.order.domain.HotelOrder;
 import com.mdframe.forge.business.core.hotel.order.domain.HotelOrderItem;
@@ -15,6 +19,7 @@ import com.mdframe.forge.business.core.hotel.notification.manager.SseEmitterMana
 import com.mdframe.forge.business.core.hotel.order.service.HotelOrderService;
 import com.mdframe.forge.business.core.hotel.order.vo.HotelOrderVO;
 import com.mdframe.forge.business.core.hotel.order.vo.OrderDashboardVO;
+import com.mdframe.forge.business.core.hotel.pay.service.HotelPayService;
 import com.mdframe.forge.starter.core.domain.PageQuery;
 import com.mdframe.forge.starter.core.exception.BusinessException;
 import com.mdframe.forge.starter.core.session.SessionHelper;
@@ -26,10 +31,10 @@ import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.StringUtils;
 
 import java.math.BigDecimal;
-import java.time.LocalDate;
-import java.time.LocalDateTime;
-import java.time.format.DateTimeFormatter;
+import java.text.SimpleDateFormat;
 import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.Date;
 import java.util.List;
 import java.util.Objects;
 
@@ -50,9 +55,22 @@ public class HotelOrderServiceImpl implements HotelOrderService {
     private HotelDishMapper dishMapper;
 
     @Autowired
+    private HotelRoomMapper roomMapper;
+
+    @Autowired
+    private HotelRoomStayMapper roomStayMapper;
+
+    /**
+     * 支付服务(单向依赖:HotelPayService 不注入本服务,不构成循环)。
+     * 用于拒单/退单流转时自动发起退款。
+     */
+    @Autowired
+    private HotelPayService payService;
+
+    @Autowired
     private SseEmitterManager sseEmitterManager;
 
-    private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyyMMdd");
+    private static final SimpleDateFormat DATE_FMT = new SimpleDateFormat("yyyyMMdd");
 
     // ==================== 查询类 ====================
 
@@ -100,20 +118,49 @@ public class HotelOrderServiceImpl implements HotelOrderService {
 
         Long tenantId = resolveTenantId();
 
+        // 在住会话绑定:订单必须挂载到当前在住会话(stay_id),
+        // 实现"入住到退房期间客人不变,退房后新客人订单/购物车全新"的隔离需求。
+        // 后端兼容兼容旧版H5未传会话的场景:根据房间查在住会话,
+        // 房间未入住时直接拦截(与扫码页拦截双重保险)
+        HotelRoomStay activeStay = null;
+        // 房间号以数据库为准,不信任前端传值(避免旧缓存/二维码改绑导致房号串单)
+        String resolvedRoomNo = dto.getRoomNo();
+        if (dto.getRoomId() != null) {
+            HotelRoom room = roomMapper.selectById(dto.getRoomId());
+            if (room != null && StringUtils.hasText(room.getRoomNo())) {
+                resolvedRoomNo = room.getRoomNo();
+            }
+            activeStay = roomStayMapper.selectActiveByRoomId(tenantId, dto.getRoomId());
+            if (activeStay == null) {
+                throw new BusinessException("该房间暂未入住,无法下单,如需点餐请联系前台");
+            }
+        }
+
         // 构建订单实体
         HotelOrder order = new HotelOrder();
         order.setOrderNo(generateOrderNo());
-        order.setRoomNo(dto.getRoomNo());
+        order.setRoomNo(resolvedRoomNo);
         order.setRoomId(dto.getRoomId());
+        if (activeStay != null) {
+            order.setStayId(activeStay.getId());
+        }
         order.setContactName(dto.getContactName());
         order.setContactPhone(dto.getContactPhone());
         order.setDeliveryFee(dto.getDeliveryFee() != null ? dto.getDeliveryFee() : BigDecimal.ZERO);
         order.setDeliveryTimeType(dto.getDeliveryTimeType());
-        order.setCustomDeliveryTime(dto.getCustomDeliveryTime());
+        // 前端传 HH:mm 字符串(如"18:30"),拼接今天日期转为 Date 存入数据库
+        if (StringUtils.hasText(dto.getCustomDeliveryTime())) {
+            try {
+                order.setCustomDeliveryTime(new SimpleDateFormat("yyyy-MM-dd HH:mm").parse(
+                        new SimpleDateFormat("yyyy-MM-dd").format(new Date()) + " " + dto.getCustomDeliveryTime()));
+            } catch (Exception e) {
+                log.warn("自定义配送时间解析失败: {}", dto.getCustomDeliveryTime());
+            }
+        }
         order.setNote(dto.getNote());
         order.setStatus(HotelOrderConstants.STATUS_PENDING_PAY);
         order.setPayStatus(HotelOrderConstants.PAY_STATUS_PENDING);
-        order.setPayExpireTime(LocalDateTime.now().plusMinutes(HotelOrderConstants.PAY_TIMEOUT_MINUTES));
+        order.setPayExpireTime(addMinutes(new Date(), HotelOrderConstants.PAY_TIMEOUT_MINUTES));
         order.setTenantId(tenantId);
 
         // 计算总金额(从数据库查菜品单价,不信任前端传的价格)
@@ -150,6 +197,26 @@ public class HotelOrderServiceImpl implements HotelOrderService {
             orderItemMapper.batchInsert(itemsToInsert);
         }
 
+        // 首次下单联系人回写在住会话:入住时不采集客人信息,
+        // 这里把手填/支付宝授权的联系人回写到会话(仅空字段回写,不覆盖);
+        // 多人同住场景以第一位下单客人作为在住客人。
+        // 直接操作Mapper而非调用 HotelRoomStayService,避免Service互相注入
+        if (activeStay != null) {
+            boolean changed = false;
+            if (!StringUtils.hasText(activeStay.getGuestName()) && StringUtils.hasText(dto.getContactName())) {
+                activeStay.setGuestName(dto.getContactName());
+                changed = true;
+            }
+            if (!StringUtils.hasText(activeStay.getGuestPhone()) && StringUtils.hasText(dto.getContactPhone())) {
+                activeStay.setGuestPhone(dto.getContactPhone());
+                changed = true;
+            }
+            if (changed) {
+                roomStayMapper.updateById(activeStay);
+                log.info("首单联系人回写在住会话: stayId={}, roomNo={}", activeStay.getId(), dto.getRoomNo());
+            }
+        }
+
         return order.getId();
     }
 
@@ -163,7 +230,7 @@ public class HotelOrderServiceImpl implements HotelOrderService {
             throw new BusinessException("只有待接单的订单才能接单");
         }
         order.setStatus(HotelOrderConstants.STATUS_ACCEPTED);
-        order.setAcceptTime(LocalDateTime.now());
+        order.setAcceptTime(new Date());
         orderMapper.updateById(order);
         log.info("订单接单: {}", order.getOrderNo());
     }
@@ -177,8 +244,10 @@ public class HotelOrderServiceImpl implements HotelOrderService {
         }
         order.setStatus(HotelOrderConstants.STATUS_REJECTED);
         order.setRejectReason(reason);
-        order.setRefundTime(LocalDateTime.now());
+        order.setRefundTime(new Date());
         orderMapper.updateById(order);
+        // 拒单触发点:已支付订单自动全额退款(退款失败不影响拒单流转,标记重试)
+        tryAutoRefund(order);
         log.info("订单拒单: {}", order.getOrderNo());
     }
 
@@ -205,7 +274,7 @@ public class HotelOrderServiceImpl implements HotelOrderService {
             throw new BusinessException("只有备餐中的订单才能出餐");
         }
         order.setStatus(HotelOrderConstants.STATUS_READY);
-        order.setReadyTime(LocalDateTime.now());
+        order.setReadyTime(new Date());
         orderMapper.updateById(order);
         log.info("订单出餐: {}", order.getOrderNo());
     }
@@ -218,7 +287,7 @@ public class HotelOrderServiceImpl implements HotelOrderService {
             throw new BusinessException("只有已出餐的订单才能开始配送");
         }
         order.setStatus(HotelOrderConstants.STATUS_DELIVERING);
-        order.setDeliverTime(LocalDateTime.now());
+        order.setDeliverTime(new Date());
         orderMapper.updateById(order);
         log.info("订单配送: {}", order.getOrderNo());
     }
@@ -231,7 +300,7 @@ public class HotelOrderServiceImpl implements HotelOrderService {
             throw new BusinessException("只有配送中的订单才能完成");
         }
         order.setStatus(HotelOrderConstants.STATUS_COMPLETED);
-        order.setCompleteTime(LocalDateTime.now());
+        order.setCompleteTime(new Date());
         orderMapper.updateById(order);
         // 订单完成后累加菜品销量
         incrementDishSales(id);
@@ -252,16 +321,18 @@ public class HotelOrderServiceImpl implements HotelOrderService {
             order.setPayStatus(HotelOrderConstants.PAY_STATUS_TIMEOUT);
             order.setRefundReason(reason);
             order.setRefundInitiator(HotelOrderConstants.REFUND_INITIATOR_CUSTOMER);
-            order.setRefundTime(LocalDateTime.now());
+            order.setRefundTime(new Date());
             orderMapper.updateById(order);
             log.info("客户取消待支付订单: {}", order.getOrderNo());
         } else if (status == HotelOrderConstants.STATUS_PLACED) {
-            // 未接单:直接退单
+            // 未接单:直接退单(此时订单已支付,需同步退款)
             order.setStatus(HotelOrderConstants.STATUS_REFUNDED);
             order.setRefundReason(reason);
             order.setRefundInitiator(HotelOrderConstants.REFUND_INITIATOR_CUSTOMER);
-            order.setRefundTime(LocalDateTime.now());
+            order.setRefundTime(new Date());
             orderMapper.updateById(order);
+            // 顾客自助退单触发点:待接单订单已支付,自动全额退款
+            tryAutoRefund(order);
             log.info("客户自助退单: {}", order.getOrderNo());
         } else if (status == HotelOrderConstants.STATUS_ACCEPTED) {
             // 已接单:进入退单审核
@@ -303,8 +374,11 @@ public class HotelOrderServiceImpl implements HotelOrderService {
         order.setStatus(HotelOrderConstants.STATUS_REFUNDED);
         order.setRefundReason(reason);
         order.setRefundInitiator(HotelOrderConstants.REFUND_INITIATOR_HOTEL_DESK);
-        order.setRefundTime(LocalDateTime.now());
+        order.setRefundTime(new Date());
         orderMapper.updateById(order);
+        // 前台退单触发点:已支付订单(接单~配送中)自动全额退款;
+        // 待支付订单无资金可退,tryAutoRefund 内部会跳过
+        tryAutoRefund(order);
         log.info("前台退单: {}", order.getOrderNo());
     }
 
@@ -316,8 +390,10 @@ public class HotelOrderServiceImpl implements HotelOrderService {
             throw new BusinessException("只有退单待审核状态的订单才能审核");
         }
         order.setStatus(HotelOrderConstants.STATUS_REFUNDED);
-        order.setRefundTime(LocalDateTime.now());
+        order.setRefundTime(new Date());
         orderMapper.updateById(order);
+        // 退单审核通过触发点:顾客已支付的订单自动全额退款
+        tryAutoRefund(order);
         log.info("退单审核通过: {}", order.getOrderNo());
     }
 
@@ -336,9 +412,53 @@ public class HotelOrderServiceImpl implements HotelOrderService {
         log.info("退单审核驳回: {}", order.getOrderNo());
     }
 
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void abandonOrder(Long id) {
+        HotelOrder order = requireOrder(id);
+        // 仅允许放弃待支付且未支付的订单
+        if (order.getStatus() != HotelOrderConstants.STATUS_PENDING_PAY) {
+            throw new BusinessException("只能放弃待支付的订单");
+        }
+        if (order.getPayStatus() != null && order.getPayStatus() == HotelOrderConstants.PAY_STATUS_SUCCESS) {
+            throw new BusinessException("已支付订单不能放弃,请通过退单流程处理");
+        }
+
+        // 物理删除订单明细(使用 QueryWrapper)
+        com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<com.mdframe.forge.business.core.hotel.order.domain.HotelOrderItem> itemWrapper = 
+                new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<>();
+        itemWrapper.eq("order_id", id);
+        orderItemMapper.delete(itemWrapper);
+        // 物理删除订单主表
+        orderMapper.deleteById(id);
+
+        log.info("放弃待支付订单(物理删除): orderId={}, orderNo={}", id, order.getOrderNo());
+    }
+
     // ==================== 私有方法 ====================
 
     /**
+     * 已支付订单流转到拒单/退单终态时自动发起全额退款。
+     * <p>
+     * 退款失败不阻断状态流转(退款服务内部已将 refund_status 置为2可重试),
+     * 前台可通过手动退款按钮重新发起。
+     *
+     * @param order 订单(状态已更新为拒单/退单)
+     */
+    private void tryAutoRefund(HotelOrder order) {
+        // 仅已支付订单需要退款;未支付/已退款的直接跳过
+        if (order.getPayStatus() == null || order.getPayStatus() != HotelOrderConstants.PAY_STATUS_SUCCESS) {
+            return;
+        }
+        try {
+            payService.refundOrder(order.getId());
+        } catch (Exception e) {
+            log.error("拒单/退单自动退款失败,请通过手动退款重试: orderId={}, orderNo={}",
+                    order.getId(), order.getOrderNo(), e);
+        }
+    }
+
+    /**
      * 填充订单明细。
      */
     private void fillOrderItems(HotelOrderVO vo) {
@@ -394,7 +514,7 @@ public class HotelOrderServiceImpl implements HotelOrderService {
      * 使用重试机制应对并发冲突。
      */
     private String generateOrderNo() {
-        String dateStr = LocalDate.now().format(DATE_FMT);
+        String dateStr = DATE_FMT.format(new Date());
         String prefix = "RM" + dateStr;
         for (int retry = 0; retry < 3; retry++) {
             String maxNo = orderMapper.selectMaxOrderNo(prefix);
@@ -441,4 +561,14 @@ public class HotelOrderServiceImpl implements HotelOrderService {
         tenantId = SessionHelper.getTenantId();
         return tenantId == null ? 1L : tenantId;
     }
+
+    /**
+     * 给指定时间增加分钟数。
+     */
+    private Date addMinutes(Date date, int minutes) {
+        Calendar cal = Calendar.getInstance();
+        cal.setTime(date);
+        cal.add(Calendar.MINUTE, minutes);
+        return cal.getTime();
+    }
 }

+ 22 - 10
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/vo/HotelOrderVO.java

@@ -3,7 +3,7 @@ package com.mdframe.forge.business.core.hotel.order.vo;
 import lombok.Data;
 
 import java.math.BigDecimal;
-import java.time.LocalDateTime;
+import java.util.Date;
 import java.util.List;
 
 /**
@@ -24,6 +24,9 @@ public class HotelOrderVO {
     /** 房间ID */
     private Long roomId;
 
+    /** 在住会话ID(历史存量订单为null) */
+    private Long stayId;
+
     /** 房型名称 */
     private String roomType;
 
@@ -40,10 +43,10 @@ public class HotelOrderVO {
     private Integer payStatus;
 
     /** 支付截止时间 */
-    private LocalDateTime payExpireTime;
+    private Date payExpireTime;
 
     /** 实际支付时间 */
-    private LocalDateTime payTime;
+    private Date payTime;
 
     /** 第三方支付交易号 */
     private String payTradeNo;
@@ -64,22 +67,22 @@ public class HotelOrderVO {
     private String deliveryTimeType;
 
     /** 自定义配送时间 */
-    private LocalDateTime customDeliveryTime;
+    private Date customDeliveryTime;
 
     /** 订单备注 */
     private String note;
 
     /** 接单时间 */
-    private LocalDateTime acceptTime;
+    private Date acceptTime;
 
     /** 出餐时间 */
-    private LocalDateTime readyTime;
+    private Date readyTime;
 
     /** 配送时间 */
-    private LocalDateTime deliverTime;
+    private Date deliverTime;
 
     /** 完成时间 */
-    private LocalDateTime completeTime;
+    private Date completeTime;
 
     /** 拒单原因 */
     private String rejectReason;
@@ -91,10 +94,19 @@ public class HotelOrderVO {
     private String refundInitiator;
 
     /** 退单时间 */
-    private LocalDateTime refundTime;
+    private Date refundTime;
+
+    /** 实际退款金额(元) */
+    private BigDecimal refundAmount;
+
+    /** 支付宝退款交易号 */
+    private String refundTradeNo;
+
+    /** 退款状态(0=未退款,1=退款成功,2=退款失败) */
+    private Integer refundStatus;
 
     /** 创建时间 */
-    private LocalDateTime createTime;
+    private Date createTime;
 
     /** 菜品总数 */
     private Integer itemCount;

+ 55 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/config/AlipayConfig.java

@@ -0,0 +1,55 @@
+package com.mdframe.forge.business.core.hotel.pay.config;
+
+import com.alipay.api.AlipayClient;
+import com.alipay.api.DefaultAlipayClient;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * 支付宝沙箱配置。
+ * <p>
+ * 配置项从 application.yml 的 hotel.alipay 前缀读取。
+ * 沙箱环境网关地址:https://openapi-sandbox.dl.alipaydev.com/gateway.do
+ */
+@Slf4j
+@Getter
+@Configuration
+public class AlipayConfig {
+
+    @Value("${hotel.alipay.app-id}")
+    private String appId;
+
+    @Value("${hotel.alipay.private-key}")
+    private String privateKey;
+
+    @Value("${hotel.alipay.alipay-public-key}")
+    private String alipayPublicKey;
+
+    @Value("${hotel.alipay.gateway-url}")
+    private String gatewayUrl;
+
+    @Value("${hotel.alipay.notify-url}")
+    private String notifyUrl;
+
+    @Value("${hotel.alipay.return-url:}")
+    private String returnUrl;
+
+    @Value("${hotel.alipay.sandbox:true}")
+    private boolean sandbox;
+
+    private static final String FORMAT = "json";
+    private static final String CHARSET = "UTF-8";
+    private static final String SIGN_TYPE = "RSA2";
+
+    /**
+     * 创建支付宝客户端 Bean。
+     */
+    @Bean
+    public AlipayClient alipayClient() {
+        log.info("初始化支付宝客户端: appId={}, gateway={}, sandbox={}", appId, gatewayUrl, sandbox);
+        return new DefaultAlipayClient(gatewayUrl, appId, privateKey, FORMAT, CHARSET, alipayPublicKey, SIGN_TYPE);
+    }
+}

+ 4 - 4
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/domain/HotelPayLog.java

@@ -10,7 +10,7 @@ import lombok.EqualsAndHashCode;
 
 import java.io.Serial;
 import java.math.BigDecimal;
-import java.time.LocalDateTime;
+import java.util.Date;
 
 /**
  * 酒店支付流水实体。
@@ -52,10 +52,10 @@ public class HotelPayLog extends TenantEntity {
     private Integer payStatus;
 
     /** 支付成功时间 */
-    private LocalDateTime payTime;
+    private Date payTime;
 
     /** 回调通知时间 */
-    private LocalDateTime notifyTime;
+    private Date notifyTime;
 
     /** 回调通知原文 */
     private String notifyContent;
@@ -64,7 +64,7 @@ public class HotelPayLog extends TenantEntity {
     private String refundNo;
 
     /** 退款时间 */
-    private LocalDateTime refundTime;
+    private Date refundTime;
 
     /** 退款金额(元) */
     private BigDecimal refundAmount;

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

@@ -0,0 +1,16 @@
+package com.mdframe.forge.business.core.hotel.pay.dto;
+
+import lombok.Data;
+
+/**
+ * 支付宝用户授权请求DTO。
+ */
+@Data
+public class AlipayAuthDTO {
+
+    /** 支付宝授权码(my.getAuthCode 获取) */
+    private String authCode;
+
+    /** 支付宝手机号加密响应(my.getPhoneNumber 获取) */
+    private String phoneResponse;
+}

+ 3 - 3
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/mapper/HotelPayLogMapper.java

@@ -6,7 +6,7 @@ import com.mdframe.forge.business.core.hotel.pay.domain.HotelPayLog;
 import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
 
-import java.time.LocalDateTime;
+import java.util.Date;
 
 /**
  * 酒店支付流水Mapper。
@@ -49,7 +49,7 @@ public interface HotelPayLogMapper extends BaseMapper<HotelPayLog> {
     int updatePayStatusSuccessByIdempotent(
             @Param("id") Long id,
             @Param("tradeNo") String tradeNo,
-            @Param("payTime") LocalDateTime payTime,
-            @Param("notifyTime") LocalDateTime notifyTime,
+            @Param("payTime") Date payTime,
+            @Param("notifyTime") Date notifyTime,
             @Param("notifyContent") String notifyContent);
 }

+ 32 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/service/AlipayAuthService.java

@@ -0,0 +1,32 @@
+package com.mdframe.forge.business.core.hotel.pay.service;
+
+import com.mdframe.forge.business.core.hotel.pay.vo.AlipayAuthVO;
+
+/**
+ * 支付宝用户授权服务接口。
+ */
+public interface AlipayAuthService {
+
+    /**
+     * 通过支付宝授权码获取用户信息(姓名 + userId)。
+     * <p>
+     * 前端调用 my.getAuthCode({ scopes: 'auth_base' }) 获取 authCode,
+     * 后端用 alipay.system.oauth.token 换取 access_token,
+     * 再用 alipay.user.info.share 获取用户信息。
+     *
+     * @param authCode 支付宝授权码
+     * @return 用户信息
+     */
+    AlipayAuthVO getUserInfo(String authCode);
+
+    /**
+     * 通过支付宝手机号授权响应获取手机号。
+     * <p>
+     * 前端调用 my.getPhoneNumber() 获取加密响应,
+     * 后端用 alipay.user.phone.number.get 解密获取手机号。
+     *
+     * @param response 支付宝手机号加密响应(my.getPhoneNumber 返回的 response 字段)
+     * @return 手机号
+     */
+    String getPhoneNumber(String response);
+}

+ 16 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/service/HotelPayService.java

@@ -65,4 +65,20 @@ public interface HotelPayService {
      * @return 支付结果
      */
     PayResultVO queryAlipayTradeStatus(Long tenantId, Long orderId);
+
+    /**
+     * 发起订单退款(仅支持全额退款)。
+     * <p>
+     * 触发场景:拒单/退单时已支付订单自动退款,以及前台手动退款(退房竞态等异常场景)。
+     * 业务规则:
+     * 1. 仅已支付(pay_status=1)的订单可退款;已退款成功的不重复退;
+     * 2. MOCK 支付/无支付宝流水的订单直接标记退款成功(无真实资金);
+     * 3. 调用支付宝 alipay.trade.refund 全额退款,成功后更新订单:
+     *    refund_status=1、pay_status=2(已退款),记录退款金额和退款交易号;
+     * 4. 退款失败时 refund_status 置 2(可重试),并抛出异常提示。
+     *
+     * @param orderId 订单ID
+     * @throws com.mdframe.forge.starter.core.exception.BusinessException 订单不存在/未支付/已退款/退款失败时抛出
+     */
+    void refundOrder(Long orderId);
 }

+ 72 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/service/impl/AlipayAuthServiceImpl.java

@@ -0,0 +1,72 @@
+package com.mdframe.forge.business.core.hotel.pay.service.impl;
+
+import com.alipay.api.AlipayClient;
+import com.alipay.api.request.AlipaySystemOauthTokenRequest;
+import com.alipay.api.request.AlipayUserInfoShareRequest;
+import com.alipay.api.response.AlipaySystemOauthTokenResponse;
+import com.alipay.api.response.AlipayUserInfoShareResponse;
+import com.mdframe.forge.business.core.hotel.pay.service.AlipayAuthService;
+import com.mdframe.forge.business.core.hotel.pay.vo.AlipayAuthVO;
+import com.mdframe.forge.starter.core.exception.BusinessException;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+/**
+ * 支付宝用户授权服务实现。
+ */
+@Slf4j
+@Service
+public class AlipayAuthServiceImpl implements AlipayAuthService {
+
+    @Autowired
+    private AlipayClient alipayClient;
+
+    @Override
+    public AlipayAuthVO getUserInfo(String authCode) {
+        AlipayAuthVO vo = new AlipayAuthVO();
+        try {
+            // 1. 用授权码换取 access_token
+            AlipaySystemOauthTokenRequest tokenRequest = new AlipaySystemOauthTokenRequest();
+            tokenRequest.setGrantType("authorization_code");
+            tokenRequest.setCode(authCode);
+            AlipaySystemOauthTokenResponse tokenResponse = alipayClient.execute(tokenRequest);
+
+            if (!tokenResponse.isSuccess()) {
+                log.error("支付宝换取token失败: code={}, msg={}", tokenResponse.getCode(), tokenResponse.getMsg());
+                throw new BusinessException("获取用户授权失败: " + tokenResponse.getSubMsg());
+            }
+
+            String accessToken = tokenResponse.getAccessToken();
+            String userId = tokenResponse.getUserId();
+            vo.setUserId(userId);
+            log.info("支付宝授权成功: userId={}", userId);
+
+            // 2. 用 access_token 获取用户信息
+            AlipayUserInfoShareRequest infoRequest = new AlipayUserInfoShareRequest();
+            AlipayUserInfoShareResponse infoResponse = alipayClient.execute(infoRequest, accessToken);
+
+            if (infoResponse.isSuccess()) {
+                vo.setUserName(infoResponse.getUserName());
+                log.info("支付宝获取用户信息成功: userName={}", infoResponse.getUserName());
+            } else {
+                log.warn("支付宝获取用户信息失败: code={}, msg={}", infoResponse.getCode(), infoResponse.getMsg());
+            }
+        } catch (BusinessException e) {
+            throw e;
+        } catch (Exception e) {
+            log.error("支付宝获取用户信息异常", e);
+            throw new BusinessException("获取用户信息失败: " + e.getMessage());
+        }
+        return vo;
+    }
+
+    @Override
+    public String getPhoneNumber(String response) {
+        // 支付宝小程序手机号获取需要调用开放平台 OpenAPI
+        // 当前 SDK 版本不直接支持,返回空字符串
+        // TODO: 接入支付宝 OpenAPI 获取手机号
+        log.warn("支付宝手机号获取暂未实现,response={}", response);
+        return "";
+    }
+}

+ 138 - 7
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/service/impl/HotelPayServiceImpl.java

@@ -2,9 +2,12 @@ package com.mdframe.forge.business.core.hotel.pay.service.impl;
 
 import com.alipay.api.AlipayClient;
 import com.alipay.api.request.AlipayTradeQueryRequest;
+import com.alipay.api.request.AlipayTradeRefundRequest;
 import com.alipay.api.request.AlipayTradeWapPayRequest;
 import com.alipay.api.response.AlipayTradeQueryResponse;
+import com.alipay.api.response.AlipayTradeRefundResponse;
 import com.alipay.api.response.AlipayTradeWapPayResponse;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
 import com.mdframe.forge.business.core.hotel.order.constant.HotelOrderConstants;
 import com.mdframe.forge.business.core.hotel.order.domain.HotelOrder;
 import com.mdframe.forge.business.core.hotel.order.mapper.HotelOrderMapper;
@@ -23,8 +26,8 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.math.BigDecimal;
-import java.time.LocalDateTime;
-import java.time.format.DateTimeFormatter;
+import java.text.SimpleDateFormat;
+import java.util.Date;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -57,7 +60,7 @@ public class HotelPayServiceImpl implements HotelPayService {
     @Value("${server.type:admin}")
     private String serverType;
 
-    private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
+    private static final SimpleDateFormat FMT = new SimpleDateFormat("yyyyMMddHHmmss");
 
     // ==================== 发起支付 ====================
 
@@ -76,7 +79,7 @@ public class HotelPayServiceImpl implements HotelPayService {
         }
 
         // 检查是否已过期
-        if (order.getPayExpireTime() != null && order.getPayExpireTime().isBefore(LocalDateTime.now())) {
+        if (order.getPayExpireTime() != null && order.getPayExpireTime().before(new Date())) {
             throw new BusinessException("订单已超时,请重新下单");
         }
 
@@ -234,8 +237,32 @@ public class HotelPayServiceImpl implements HotelPayService {
         }
 
         if (success) {
+            // 安全校验 3:退房/取消与支付的竞态防护。
+            // 场景:客人在支付宝收银台付款期间,前台退房把待支付订单取消了,
+            // 随后支付成功回调到达;此时绝不能把已取消/已退单的订单"复活"成待接单,
+            // 否则出现"钱已扣、订单却已取消、会话已关闭"的流程断裂。
+            // 处理:不更新订单状态,记录异常日志,由前台通过手动退款接口处理(极低频场景)
+            HotelOrder callbackOrder = orderMapper.selectById(payLog.getOrderId());
+            if (callbackOrder != null) {
+                int orderStatus = callbackOrder.getStatus() != null ? callbackOrder.getStatus() : -1;
+                if (orderStatus == HotelOrderConstants.STATUS_REJECTED
+                        || orderStatus == HotelOrderConstants.STATUS_REFUNDED
+                        || orderStatus == HotelOrderConstants.STATUS_PAY_TIMEOUT
+                        || orderStatus == HotelOrderConstants.STATUS_CANCELLED) {
+                    log.error("支付回调竞态异常: 订单已处于终态仍收到支付成功回调,不复活订单,请人工退款处理。"
+                                    + " orderId={}, orderNo={}, orderStatus={}, outTradeNo={}, tradeNo={}",
+                            callbackOrder.getId(), callbackOrder.getOrderNo(), orderStatus,
+                            payLog.getOutTradeNo(), tradeNo);
+                    payLog.setPayStatus(2);
+                    payLog.setNotifyTime(new Date());
+                    payLog.setNotifyContent("[竞态异常-订单已取消] " + rawContent);
+                    payLogMapper.updateById(payLog);
+                    return;
+                }
+            }
+
             // 数据库级幂等更新:仅当 pay_status != 1 时才更新,利用数据库行锁保证原子性
-            LocalDateTime now = LocalDateTime.now();
+            Date now = new Date();
             int updated = payLogMapper.updatePayStatusSuccessByIdempotent(
                     payLog.getId(), tradeNo, now, now, rawContent);
 
@@ -285,7 +312,7 @@ public class HotelPayServiceImpl implements HotelPayService {
         } else {
             // 支付失败
             payLog.setPayStatus(2);
-            payLog.setNotifyTime(LocalDateTime.now());
+            payLog.setNotifyTime(new Date());
             payLog.setNotifyContent(rawContent);
             payLogMapper.updateById(payLog);
             log.warn("支付失败: outTradeNo={}", payLog.getOutTradeNo());
@@ -403,6 +430,110 @@ public class HotelPayServiceImpl implements HotelPayService {
         log.info("订单超时取消: orderId={}, orderNo={}", order.getId(), order.getOrderNo());
     }
 
+    // ==================== 订单退款(全额) ====================
+
+    /**
+     * 发起订单退款(仅支持全额退款)。
+     * <p>
+     * 触发场景:
+     * 1. 拒单/退单流转时已支付订单自动退款(见 HotelOrderServiceImpl 的四个触发点);
+     * 2. 前台手动退款(退房竞态等异常场景,权限 hotel:order:refund)。
+     * 注意:本方法不加事务,确保退款失败标记(refund_status=2)在抛出异常后仍被持久化,
+     * 便于前台通过手动退款按钮重试。
+     *
+     * @param orderId 订单ID
+     */
+    @Override
+    public void refundOrder(Long orderId) {
+        HotelOrder order = orderMapper.selectById(orderId);
+        if (order == null) {
+            throw new BusinessException("订单不存在");
+        }
+        // 仅已支付订单可退款;待支付订单直接取消即可,无资金可退
+        if (order.getPayStatus() == null || order.getPayStatus() != HotelOrderConstants.PAY_STATUS_SUCCESS) {
+            throw new BusinessException("仅已支付的订单可以退款");
+        }
+        // 防重复退款:已退款成功的订单不再发起(支付宝重复退款请求会报错,提前拦截)
+        if (order.getRefundStatus() != null && order.getRefundStatus() == HotelOrderConstants.REFUND_STATUS_SUCCESS) {
+            throw new BusinessException("该订单已退款成功,请勿重复退款");
+        }
+
+        // 退款金额:优先取实付金额,兼容历史数据取订单总额(全额退款)
+        BigDecimal refundAmount = order.getPaidAmount() != null ? order.getPaidAmount() : order.getTotalAmount();
+        if (refundAmount == null || refundAmount.compareTo(BigDecimal.ZERO) <= 0) {
+            throw new BusinessException("订单金额异常,无法退款");
+        }
+
+        // MOCK 渠道支付或无支付宝流水:无真实资金流转,直接标记退款成功(开发/测试环境兼容)
+        HotelPayLog payLog = payLogMapper.selectLatestByOrderId(orderId);
+        if (HotelOrderConstants.PAY_SOURCE_MOCK.equals(order.getPaySource())
+                || payLog == null
+                || !"ALIPAY".equals(payLog.getPayChannel())) {
+            markRefundResult(order, refundAmount, "MOCK_REFUND_" + orderId, true);
+            log.info("MOCK退款成功(无真实资金): orderId={}, orderNo={}, amount={}",
+                    orderId, order.getOrderNo(), refundAmount);
+            return;
+        }
+
+        // 调用支付宝退款接口 alipay.trade.refund(全额退款)
+        try {
+            AlipayTradeRefundRequest request = new AlipayTradeRefundRequest();
+            // out_request_no 退款请求号:同一笔交易多次退款时必须唯一,用流水号+时间戳保证
+            String outRequestNo = payLog.getOutTradeNo() + "R" + System.currentTimeMillis();
+            String bizContent = "{\"out_trade_no\":\"" + payLog.getOutTradeNo() + "\","
+                    + "\"refund_amount\":\"" + refundAmount.toPlainString() + "\","
+                    + "\"out_request_no\":\"" + outRequestNo + "\","
+                    + "\"refund_reason\":\"酒店客房送餐订单退款\"}";
+            request.setBizContent(bizContent);
+            AlipayTradeRefundResponse response = alipayClient.execute(request);
+
+            if (response.isSuccess()) {
+                // 退款成功:退款交易号取支付宝返回的 trade_no
+                markRefundResult(order, refundAmount, response.getTradeNo(), true);
+                log.info("支付宝退款成功: orderId={}, orderNo={}, amount={}, tradeNo={}",
+                        orderId, order.getOrderNo(), refundAmount, response.getTradeNo());
+            } else {
+                // 退款失败:标记可重试状态,前台可通过手动退款按钮重新发起
+                markRefundResult(order, refundAmount, null, false);
+                log.error("支付宝退款失败: orderId={}, orderNo={}, code={}, msg={}",
+                        orderId, order.getOrderNo(), response.getCode(), response.getSubMsg());
+                throw new BusinessException("退款失败:" + response.getSubMsg() + ",可稍后重试");
+            }
+        } catch (BusinessException e) {
+            throw e;
+        } catch (Exception e) {
+            markRefundResult(order, refundAmount, null, false);
+            log.error("调用支付宝退款接口异常: orderId={}, orderNo={}", orderId, order.getOrderNo(), e);
+            throw new BusinessException("调用支付宝退款异常:" + e.getMessage() + ",可稍后重试");
+        }
+    }
+
+    /**
+     * 更新订单退款结果。
+     * <p>
+     * 成功:refund_status=1、pay_status=2(已退款),记录退款金额和退款交易号;
+     * 失败:refund_status=2(可重试),保留退款金额供重试时展示。
+     *
+     * @param order         订单
+     * @param refundAmount  退款金额(元)
+     * @param refundTradeNo 退款交易号(失败时传null)
+     * @param success       是否退款成功
+     */
+    private void markRefundResult(HotelOrder order, BigDecimal refundAmount, String refundTradeNo, boolean success) {
+        UpdateWrapper<HotelOrder> updateWrapper = new UpdateWrapper<HotelOrder>();
+        updateWrapper.eq("id", order.getId())
+                .set("refund_status", success
+                        ? HotelOrderConstants.REFUND_STATUS_SUCCESS
+                        : HotelOrderConstants.REFUND_STATUS_FAILED)
+                .set("refund_amount", refundAmount)
+                .set("update_time", new Date());
+        if (success) {
+            updateWrapper.set("refund_trade_no", refundTradeNo)
+                    .set("pay_status", HotelOrderConstants.PAY_STATUS_REFUNDED);
+        }
+        orderMapper.update(null, updateWrapper);
+    }
+
     // ==================== 私有方法 ====================
 
     /**
@@ -448,7 +579,7 @@ public class HotelPayServiceImpl implements HotelPayService {
      * 生成商户订单号: PAY + 时间戳 + 订单ID后6位。
      */
     private String generateOutTradeNo(Long orderId) {
-        String timestamp = LocalDateTime.now().format(FMT);
+        String timestamp = FMT.format(new Date());
         String suffix = String.format("%06d", orderId % 1000000);
         return "PAY" + timestamp + suffix;
     }

+ 19 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/vo/AlipayAuthVO.java

@@ -0,0 +1,19 @@
+package com.mdframe.forge.business.core.hotel.pay.vo;
+
+import lombok.Data;
+
+/**
+ * 支付宝用户授权结果VO。
+ */
+@Data
+public class AlipayAuthVO {
+
+    /** 用户真实姓名 */
+    private String userName;
+
+    /** 用户手机号 */
+    private String phone;
+
+    /** 支付宝用户ID */
+    private String userId;
+}

+ 2 - 2
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/pay/vo/PayResultVO.java

@@ -3,7 +3,7 @@ package com.mdframe.forge.business.core.hotel.pay.vo;
 import lombok.Data;
 
 import java.math.BigDecimal;
-import java.time.LocalDateTime;
+import java.util.Date;
 
 /**
  * 支付结果VO。
@@ -24,7 +24,7 @@ public class PayResultVO {
     private BigDecimal payAmount;
 
     /** 支付时间 */
-    private LocalDateTime payTime;
+    private Date payTime;
 
     /** 支付来源 */
     private String paySource;

+ 12 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/HotelBusinessHoursService.java

@@ -2,6 +2,7 @@ package com.mdframe.forge.business.core.hotel.service;
 
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.mdframe.forge.business.core.hotel.domain.HotelBusinessHours;
+import com.mdframe.forge.business.core.hotel.vo.BusinessHoursStatusVO;
 import com.mdframe.forge.starter.core.domain.PageQuery;
 
 import java.util.List;
@@ -77,4 +78,15 @@ public interface HotelBusinessHoursService {
      * @param id 营业时段ID
      */
     void businessHoursDelete(Long id);
+
+    /**
+     * 查询当前营业状态(顾客端用)。
+     * <p>
+     * 基于启用的营业时段配置,推导当前是否在营业时段内以及下次营业时段。
+     * 未配置任何启用时段时返回 configured=false、open=true(fail-open,全天营业)。
+     * 不支持跨零点时段(如 22:00-02:00),与既有 SQL 判定语义一致。
+     *
+     * @return 当前营业状态
+     */
+    BusinessHoursStatusVO currentStatus();
 }

+ 105 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/HotelRoomStayService.java

@@ -0,0 +1,105 @@
+package com.mdframe.forge.business.core.hotel.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.mdframe.forge.business.core.hotel.domain.HotelRoomStay;
+import com.mdframe.forge.business.core.hotel.vo.HotelRoomStayVO;
+import com.mdframe.forge.business.core.hotel.vo.RoomCheckOutVO;
+import com.mdframe.forge.starter.core.domain.PageQuery;
+
+/**
+ * 酒店房间在住会话服务接口。
+ * <p>
+ * 业务背景:本系统与亚朵PMS不互通,入住/退房由前台在本系统手动操作。
+ * 本服务管理房间的完整状态流转链路:
+ * 空闲/打扫中 --入住--> 入住中 --退房--> 打扫中 --打扫完成--> 空闲,
+ * 空闲 <--设为维护/解除维护--> 维护中。
+ * 每次入住开启一条在住会话(hotel_room_stay),退房关闭会话并联动取消待支付订单。
+ */
+public interface HotelRoomStayService {
+
+    /**
+     * 办理入住:开启新的在住会话。
+     * <p>
+     * 业务规则:
+     * 1. 仅空闲(VACANT)或打扫中(CLEANING)的房间可入住;
+     * 2. 已有在住会话的房间拒绝重复入住;
+     * 3. 入住成功后房间状态变为入住中(OCCUPIED)。
+     * 入住时可录入客人姓名和电话(前台登记),未录入时留空,
+     * 客人首次下单时联系人会回写到会话(见 backfillGuest)。
+     *
+     * @param roomId    房间ID
+     * @param remark    备注(可选,前台登记说明)
+     * @param guestName 住客姓名(可选,前台录入)
+     * @param guestPhone 住客电话(可选,前台录入)
+     * @return 新建的在住会话ID
+     * @throws com.mdframe.forge.starter.core.exception.BusinessException 房间不存在/状态不允许/已有住客时抛出
+     */
+    Long roomCheckIn(Long roomId, String remark, String guestName, String guestPhone);
+
+    /**
+     * 办理退房:关闭在住会话(两步交互)。
+     * <p>
+     * 业务规则:
+     * 1. 仅入住中(OCCUPIED)且有在住会话的房间可退房;
+     * 2. 存在进行中订单(已接单~配送中、退单待审核)且 force=false 时,
+     *    不执行退房,返回 needConfirm=true 和进行中订单清单供前端弹窗警告;
+     * 3. force=true(或无进行中订单)时正式退房:
+     *    自动取消该会话的待支付订单(0 -> 11已取消),
+     *    关闭会话(写退房时间),房间状态变为打扫中(CLEANING)。
+     *
+     * @param roomId 房间ID
+     * @param force  是否强制退房(前台确认进行中订单后传true)
+     * @param remark 备注(可选)
+     * @return 退房结果(含是否需要二次确认、进行中订单清单)
+     * @throws com.mdframe.forge.starter.core.exception.BusinessException 房间不存在/未入住时抛出
+     */
+    RoomCheckOutVO roomCheckOut(Long roomId, boolean force, String remark);
+
+    /**
+     * 打扫完成:房间从打扫中(CLEANING)回到空闲(VACANT)。
+     *
+     * @param roomId 房间ID
+     * @throws com.mdframe.forge.starter.core.exception.BusinessException 房间不存在/状态非打扫中时抛出
+     */
+    void roomCleaningDone(Long roomId);
+
+    /**
+     * 维护切换:空闲(VACANT)与维护中(MAINTENANCE)互转。
+     * <p>
+     * 仅空闲房可设为维护中,维护中的房间解除后回到空闲;
+     * 入住中/打扫中的房间不允许进入维护(避免在住客人数据错乱)。
+     *
+     * @param roomId 房间ID
+     * @throws com.mdframe.forge.starter.core.exception.BusinessException 房间不存在/状态不允许切换时抛出
+     */
+    void roomMaintenanceToggle(Long roomId);
+
+    /**
+     * 查询房间的在住会话(供扫码、下单等场景获取当前住客会话)。
+     *
+     * @param roomId 房间ID
+     * @return 在住会话,未入住时返回null
+     */
+    HotelRoomStay getActiveStay(Long roomId);
+
+    /**
+     * 分页查询房间的历史入住记录(含当前在住),按入住时间倒序。
+     *
+     * @param pageQuery 分页参数
+     * @param roomId    房间ID
+     * @return 入住记录分页数据
+     */
+    IPage<HotelRoomStayVO> stayPage(PageQuery pageQuery, Long roomId);
+
+    /**
+     * 回写住客信息到在住会话(首次下单联系人回写)。
+     * <p>
+     * 仅在会话对应字段为空时写入,不覆盖已有住客信息;
+     * 多人同住场景下以第一位下单客人的联系人作为在住客人。
+     *
+     * @param stayId    在住会话ID
+     * @param guestName 住客姓名(可空)
+     * @param guestPhone 住客电话(可空)
+     */
+    void backfillGuest(Long stayId, String guestName, String guestPhone);
+}

+ 157 - 7
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/impl/HotelBusinessHoursServiceImpl.java

@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.mdframe.forge.business.core.hotel.domain.HotelBusinessHours;
 import com.mdframe.forge.business.core.hotel.mapper.HotelBusinessHoursMapper;
 import com.mdframe.forge.business.core.hotel.service.HotelBusinessHoursService;
+import com.mdframe.forge.business.core.hotel.vo.BusinessHoursStatusVO;
 import com.mdframe.forge.starter.core.domain.PageQuery;
 import com.mdframe.forge.starter.core.exception.BusinessException;
 import com.mdframe.forge.starter.tenant.context.TenantContextHolder;
@@ -14,8 +15,12 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.StringUtils;
 
+import java.time.LocalDate;
 import java.time.LocalTime;
 import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
 import java.util.List;
 import java.util.Objects;
 
@@ -29,6 +34,9 @@ public class HotelBusinessHoursServiceImpl implements HotelBusinessHoursService
     @Autowired
     private HotelBusinessHoursMapper businessHoursMapper;
 
+    /** 星期名称(下标对应 day_of_week 值:0=周日, 1=周一, ..., 6=周六) */
+    private static final String[] WEEK_NAMES = { "周日", "周一", "周二", "周三", "周四", "周五", "周六" };
+
     @Override
     public IPage<HotelBusinessHours> businessHoursPage(PageQuery pageQuery, HotelBusinessHours query) {
         Page<HotelBusinessHours> page = pageQuery.toPage();
@@ -94,13 +102,8 @@ public class HotelBusinessHoursServiceImpl implements HotelBusinessHoursService
             throw new BusinessException("营业时段不存在");
         }
         validateBusinessHours(businessHours);
-        entity.setDayOfWeek(businessHours.getDayOfWeek());
-        entity.setMealType(businessHours.getMealType());
-        entity.setMealName(businessHours.getMealName());
-        entity.setStartTime(businessHours.getStartTime());
-        entity.setEndTime(businessHours.getEndTime());
-        entity.setSortOrder(businessHours.getSortOrder());
-        businessHoursMapper.updateById(entity);
+        // 使用自定义 Mapper 方法(updateById 的 NOT_NULL 策略会跳过 null 字段,无法清除 day_of_week)
+        businessHoursMapper.updateBusinessHours(businessHours);
         log.info("编辑营业时段: {}", entity.getMealName());
     }
 
@@ -159,4 +162,151 @@ public class HotelBusinessHoursServiceImpl implements HotelBusinessHoursService
         Long tenantId = TenantContextHolder.getTenantId();
         return tenantId == null ? 1L : tenantId;
     }
+
+    @Override
+    public BusinessHoursStatusVO currentStatus() {
+        Long tenantId = resolveTenantId();
+        List<HotelBusinessHours> enabled = businessHoursMapper.selectEnabled(tenantId);
+
+        BusinessHoursStatusVO vo = new BusinessHoursStatusVO();
+
+        // 未配置任何启用时段 → fail-open(全天营业)
+        if (enabled == null || enabled.isEmpty()) {
+            vo.setConfigured(false);
+            vo.setOpen(true);
+            vo.setTodayText("全天营业");
+            vo.setTodayPeriods(new ArrayList<>());
+            vo.setNextText("");
+            return vo;
+        }
+
+        vo.setConfigured(true);
+
+        int today = LocalDate.now().getDayOfWeek().getValue() % 7;
+        String currentTime = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm"));
+
+        // 构建当天时段列表
+        List<BusinessHoursStatusVO.Period> todayPeriods = buildTodayPeriods(enabled, today);
+        vo.setTodayPeriods(todayPeriods);
+
+        // 拼接当天时段汇总文案
+        StringBuilder todayBuilder = new StringBuilder();
+        for (int i = 0; i < todayPeriods.size(); i++) {
+            if (i > 0) {
+                todayBuilder.append(" · ");
+            }
+            BusinessHoursStatusVO.Period p = todayPeriods.get(i);
+            todayBuilder.append(p.getMealName()).append(" ").append(p.getStartTime()).append("-").append(p.getEndTime());
+        }
+        vo.setTodayText(todayBuilder.length() > 0 ? todayBuilder.toString() : "全天营业");
+
+        // 复用既有方法判断当前是否营业
+        HotelBusinessHours current = checkCurrentBusinessHours();
+        if (current != null) {
+            vo.setOpen(true);
+            vo.setCurrentMealName(current.getMealName());
+            vo.setCurrentEndTime(current.getEndTime());
+            vo.setNextText("");
+            return vo;
+        }
+
+        // 当前不在营业时段,推导下次营业
+        vo.setOpen(false);
+        resolveNextPeriod(vo, enabled, today, currentTime);
+        return vo;
+    }
+
+    /**
+     * 判断营业时段的 dayOfWeek 是否包含指定星期。
+     * <p>
+     * dayOfWeek 为 NULL 表示每天,逗号分隔字符串表示多选(如 "1,2,3")。
+     *
+     * @param dayOfWeek 营业时段的 dayOfWeek(NULL 或逗号分隔字符串)
+     * @param target    目标星期(0=周日...6=周六)
+     * @return 是否包含
+     */
+    private boolean dayOfWeekContains(String dayOfWeek, int target) {
+        if (dayOfWeek == null) {
+            return true;
+        }
+        String targetStr = String.valueOf(target);
+        String[] parts = dayOfWeek.split(",");
+        for (int i = 0; i < parts.length; i++) {
+            if (targetStr.equals(parts[i].trim())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 构建当天时段列表(按开始时间升序)。
+     */
+    private List<BusinessHoursStatusVO.Period> buildTodayPeriods(List<HotelBusinessHours> enabled, int today) {
+        List<BusinessHoursStatusVO.Period> result = new ArrayList<>();
+        for (int i = 0; i < enabled.size(); i++) {
+            HotelBusinessHours bh = enabled.get(i);
+            if (dayOfWeekContains(bh.getDayOfWeek(), today)) {
+                BusinessHoursStatusVO.Period p = new BusinessHoursStatusVO.Period();
+                p.setMealName(bh.getMealName());
+                p.setStartTime(bh.getStartTime());
+                p.setEndTime(bh.getEndTime());
+                result.add(p);
+            }
+        }
+        Collections.sort(result, new Comparator<BusinessHoursStatusVO.Period>() {
+            @Override
+            public int compare(BusinessHoursStatusVO.Period a, BusinessHoursStatusVO.Period b) {
+                return a.getStartTime().compareTo(b.getStartTime());
+            }
+        });
+        return result;
+    }
+
+    /**
+     * 推导下次营业时段,填充 VO 的 nextXxx 字段。
+     * 先在当天找 startTime > currentTime 的最早时段;找不到则逐日找未来 7 天。
+     */
+    private void resolveNextPeriod(BusinessHoursStatusVO vo, List<HotelBusinessHours> enabled,
+                                    int today, String currentTime) {
+        // 先在当天时段中找 startTime > currentTime 的最早一段
+        for (int i = 0; i < vo.getTodayPeriods().size(); i++) {
+            BusinessHoursStatusVO.Period p = vo.getTodayPeriods().get(i);
+            if (p.getStartTime().compareTo(currentTime) > 0) {
+                vo.setNextMealName(p.getMealName());
+                vo.setNextStartTime(p.getStartTime());
+                vo.setNextDayOffset(0);
+                vo.setNextDateText("今天");
+                vo.setNextText("今天 " + p.getStartTime() + " 开始(" + p.getMealName() + ")");
+                return;
+            }
+        }
+
+        // 当天无剩余时段,逐日找未来 7 天
+        for (int offset = 1; offset <= 7; offset++) {
+            int dow = (today + offset) % 7;
+            HotelBusinessHours earliest = null;
+            for (int i = 0; i < enabled.size(); i++) {
+                HotelBusinessHours bh = enabled.get(i);
+                if (!dayOfWeekContains(bh.getDayOfWeek(), dow)) {
+                    continue;
+                }
+                if (earliest == null || bh.getStartTime().compareTo(earliest.getStartTime()) < 0) {
+                    earliest = bh;
+                }
+            }
+            if (earliest != null) {
+                vo.setNextMealName(earliest.getMealName());
+                vo.setNextStartTime(earliest.getStartTime());
+                vo.setNextDayOffset(offset);
+                vo.setNextDateText(offset == 1 ? "明天" : WEEK_NAMES[dow]);
+                String dateText = vo.getNextDateText();
+                vo.setNextText(dateText + " " + earliest.getStartTime() + " 开始(" + earliest.getMealName() + ")");
+                return;
+            }
+        }
+
+        // 7 天内无匹配(配置了时段但星期覆盖不全)
+        vo.setNextText("");
+    }
 }

+ 39 - 14
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/impl/HotelQrCodeServiceImpl.java

@@ -8,6 +8,7 @@ import com.mdframe.forge.business.core.hotel.constant.HotelQrConstants;
 import com.mdframe.forge.business.core.hotel.domain.HotelQrBindLog;
 import com.mdframe.forge.business.core.hotel.domain.HotelQrCode;
 import com.mdframe.forge.business.core.hotel.domain.HotelRoom;
+import com.mdframe.forge.business.core.hotel.domain.HotelRoomStay;
 import com.mdframe.forge.business.core.hotel.domain.HotelRoomType;
 import com.mdframe.forge.business.core.hotel.dto.HotelRoomDTO;
 import com.mdframe.forge.business.core.hotel.dto.HotelRoomTypeDTO;
@@ -17,6 +18,7 @@ import com.mdframe.forge.business.core.hotel.dto.QrCodeQueryDTO;
 import com.mdframe.forge.business.core.hotel.mapper.HotelQrBindLogMapper;
 import com.mdframe.forge.business.core.hotel.mapper.HotelQrCodeMapper;
 import com.mdframe.forge.business.core.hotel.mapper.HotelRoomMapper;
+import com.mdframe.forge.business.core.hotel.mapper.HotelRoomStayMapper;
 import com.mdframe.forge.business.core.hotel.mapper.HotelRoomTypeMapper;
 import com.mdframe.forge.business.core.hotel.service.HotelQrCodeService;
 import com.mdframe.forge.business.core.hotel.utils.QrCodeUtils;
@@ -37,9 +39,9 @@ import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.StringUtils;
 
 import java.io.ByteArrayOutputStream;
-import java.time.LocalDateTime;
-import java.time.format.DateTimeFormatter;
+import java.text.SimpleDateFormat;
 import java.util.ArrayList;
+import java.util.Date;
 import java.util.List;
 import java.util.Objects;
 import java.util.zip.ZipEntry;
@@ -53,13 +55,15 @@ import java.util.zip.ZipOutputStream;
 public class HotelQrCodeServiceImpl extends ServiceImpl<HotelQrCodeMapper, HotelQrCode>
         implements HotelQrCodeService {
 
-    private static final DateTimeFormatter BATCH_NO_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
+    private static final SimpleDateFormat BATCH_NO_FORMATTER = new SimpleDateFormat("yyyyMMddHHmmss");
 
     @Autowired
     private HotelQrBindLogMapper bindLogMapper;
     @Autowired
     private HotelRoomMapper roomMapper;
     @Autowired
+    private HotelRoomStayMapper roomStayMapper;
+    @Autowired
     private HotelRoomTypeMapper roomTypeMapper;
 
     @org.springframework.beans.factory.annotation.Value("${hotel.qr.base-url}")
@@ -102,7 +106,7 @@ public class HotelQrCodeServiceImpl extends ServiceImpl<HotelQrCodeMapper, Hotel
         }
 
         Long tenantId = resolveTenantId();
-        String batchNo = "QR" + LocalDateTime.now().format(BATCH_NO_FORMATTER);
+        String batchNo = "QR" + BATCH_NO_FORMATTER.format(new Date());
         List<HotelQrCode> entities = new ArrayList<>(dto.getCount());
         List<HotelQrCodeVO> result = new ArrayList<>(dto.getCount());
 
@@ -145,7 +149,7 @@ public class HotelQrCodeServiceImpl extends ServiceImpl<HotelQrCodeMapper, Hotel
         qrCode.setRoomNo(room.getRoomNo());
         qrCode.setBindUserId(SessionHelper.getUserId());
         qrCode.setBindUserName(resolveUsername());
-        qrCode.setBindTime(LocalDateTime.now());
+        qrCode.setBindTime(new Date());
         updateById(qrCode);
 
         saveBindLog(qrCode, room, HotelQrConstants.ACTION_BIND, dto.getRemark());
@@ -166,7 +170,7 @@ public class HotelQrCodeServiceImpl extends ServiceImpl<HotelQrCodeMapper, Hotel
         qrCode.setRoomNo(room.getRoomNo());
         qrCode.setBindUserId(SessionHelper.getUserId());
         qrCode.setBindUserName(resolveUsername());
-        qrCode.setBindTime(LocalDateTime.now());
+        qrCode.setBindTime(new Date());
         updateById(qrCode);
 
         saveBindLog(qrCode, room, HotelQrConstants.ACTION_REBIND, dto.getRemark());
@@ -197,7 +201,7 @@ public class HotelQrCodeServiceImpl extends ServiceImpl<HotelQrCodeMapper, Hotel
                 .set("room_no", null)
                 .set("unbind_user_id", SessionHelper.getUserId())
                 .set("unbind_user_name", resolveUsername())
-                .set("unbind_time", LocalDateTime.now());
+                .set("unbind_time", new Date());
         boolean updated = getBaseMapper().update(null, updateWrapper) > 0;
         
         log.info("解绑后更新结果: qrCodeId={}, updated={}", qrCodeId, updated);
@@ -329,12 +333,23 @@ public class HotelQrCodeServiceImpl extends ServiceImpl<HotelQrCodeMapper, Hotel
                     QrCodeScanVO.RoomInfo roomInfo = new QrCodeScanVO.RoomInfo();
                     roomInfo.setRoomId(qrCode.getRoomId());
                     roomInfo.setRoomNo(qrCode.getRoomNo());
-                    // 查询房型名称
+                    // 查询房间实体,获取房间状态和在住会话:
+                    // 前端根据 roomStatus 拦截非入住房间的点餐,
+                    // 根据 stayId 判断是否换了客人(不一致则清空购物车/订单)
                     HotelRoom room = roomMapper.selectById(qrCode.getRoomId());
-                    if (room != null && room.getRoomTypeId() != null) {
-                        HotelRoomType roomType = roomTypeMapper.selectById(room.getRoomTypeId());
-                        if (roomType != null) {
-                            roomInfo.setRoomTypeName(roomType.getTypeName());
+                    if (room != null) {
+                        roomInfo.setRoomStatus(room.getStatus());
+                        if (room.getRoomTypeId() != null) {
+                            HotelRoomType roomType = roomTypeMapper.selectById(room.getRoomTypeId());
+                            if (roomType != null) {
+                                roomInfo.setRoomTypeName(roomType.getTypeName());
+                            }
+                        }
+                        HotelRoomStay activeStay = roomStayMapper.selectActiveByRoomId(tenantId, room.getId());
+                        if (activeStay != null) {
+                            roomInfo.setStayId(activeStay.getId());
+                            roomInfo.setGuestName(activeStay.getGuestName());
+                            roomInfo.setGuestPhone(activeStay.getGuestPhone());
                         }
                     }
                     vo.setRoom(roomInfo);
@@ -342,7 +357,7 @@ public class HotelQrCodeServiceImpl extends ServiceImpl<HotelQrCodeMapper, Hotel
                     QrCodeScanVO.BindInfo bindInfo = new QrCodeScanVO.BindInfo();
                     bindInfo.setOperatorName(qrCode.getBindUserName());
                     bindInfo.setBindTime(qrCode.getBindTime() != null
-                            ? qrCode.getBindTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
+                            ? new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(qrCode.getBindTime())
                             : null);
                     vo.setBindInfo(bindInfo);
                 }
@@ -400,7 +415,11 @@ public class HotelQrCodeServiceImpl extends ServiceImpl<HotelQrCodeMapper, Hotel
         // 检查房间号唯一性(排除自身)
         checkRoomNoUnique(dto.getId(), dto.getRoomNo());
 
+        // 房间状态只允许通过入住/退房/打扫/维护操作流转,
+        // 编辑表单不允许直接修改状态,复制后强制还原状态。
+        String originalStatus = entity.getStatus();
         BeanUtils.copyProperties(dto, entity, "id");
+        entity.setStatus(originalStatus);
         roomMapper.updateById(entity);
     }
 
@@ -414,6 +433,12 @@ public class HotelQrCodeServiceImpl extends ServiceImpl<HotelQrCodeMapper, Hotel
         if (entity == null || !Objects.equals(entity.getTenantId(), resolveTenantId())) {
             throw new BusinessException("房间不存在");
         }
+        // 在住中的房间存在活跃会话和关联订单,禁止删除,避免会话/订单数据成为孤儿;
+        // 必须先办理退房并清理后才能删除。
+        HotelRoomStay activeStay = roomStayMapper.selectActiveByRoomId(resolveTenantId(), id);
+        if (activeStay != null) {
+            throw new BusinessException("该房间当前在住中,请先办理退房后再删除");
+        }
         // 检查是否有二维码绑定
         checkRoomQrBinding(id);
         roomMapper.deleteById(id);
@@ -536,7 +561,7 @@ public class HotelQrCodeServiceImpl extends ServiceImpl<HotelQrCodeMapper, Hotel
         bindLog.setActionType(actionType);
         bindLog.setOperatorId(SessionHelper.getUserId());
         bindLog.setOperatorName(resolveUsername());
-        bindLog.setOperateTime(LocalDateTime.now());
+        bindLog.setOperateTime(new Date());
         bindLog.setRemark(remark);
         bindLogMapper.insert(bindLog);
     }

+ 288 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/service/impl/HotelRoomStayServiceImpl.java

@@ -0,0 +1,288 @@
+package com.mdframe.forge.business.core.hotel.service.impl;
+
+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.mdframe.forge.business.core.hotel.constant.HotelQrConstants;
+import com.mdframe.forge.business.core.hotel.domain.HotelRoom;
+import com.mdframe.forge.business.core.hotel.domain.HotelRoomStay;
+import com.mdframe.forge.business.core.hotel.mapper.HotelRoomMapper;
+import com.mdframe.forge.business.core.hotel.mapper.HotelRoomStayMapper;
+import com.mdframe.forge.business.core.hotel.order.constant.HotelOrderConstants;
+import com.mdframe.forge.business.core.hotel.order.domain.HotelOrder;
+import com.mdframe.forge.business.core.hotel.order.mapper.HotelOrderMapper;
+import com.mdframe.forge.business.core.hotel.service.HotelRoomStayService;
+import com.mdframe.forge.business.core.hotel.vo.HotelRoomStayVO;
+import com.mdframe.forge.business.core.hotel.vo.RoomCheckOutVO;
+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 lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 酒店房间在住会话服务实现。
+ * <p>
+ * 房间状态流转链路(与亚朵PMS不互通,全部由前台手动触发):
+ * <pre>
+ *   空闲(VACANT) --入住--> 入住中(OCCUPIED) --退房--> 打扫中(CLEANING) --打扫完成--> 空闲
+ *   空闲 <--> 维护中(MAINTENANCE)(维护切换互转)
+ * </pre>
+ * 入住=新建 hotel_room_stay(status=0)会话;退房=关闭会话(status=1)+取消待支付订单。
+ * 订单通过 stay_id 挂载到会话,实现换客后订单数据隔离。
+ */
+@Slf4j
+@Service
+public class HotelRoomStayServiceImpl implements HotelRoomStayService {
+
+    @Autowired
+    private HotelRoomMapper roomMapper;
+    @Autowired
+    private HotelRoomStayMapper stayMapper;
+    @Autowired
+    private HotelOrderMapper orderMapper;
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Long roomCheckIn(Long roomId, String remark, String guestName, String guestPhone) {
+        HotelRoom room = requireRoom(roomId);
+
+        // 入住前置校验1:房间必须处于可入住状态(空闲或打扫中),
+        // 入住中说明已有住客,维护中的房间不接待客人
+        if (!HotelQrConstants.ROOM_VACANT.equals(room.getStatus())
+                && !HotelQrConstants.ROOM_CLEANING.equals(room.getStatus())) {
+            throw new BusinessException("当前房间状态不允许入住,仅空闲或打扫中的房间可办理入住");
+        }
+
+        // 入住前置校验2:防御历史数据异常(房间状态非入住中但残留status=0会话),
+        // 确保一个房间同一时刻最多一条在住会话
+        HotelRoomStay existing = stayMapper.selectActiveByRoomId(resolveTenantId(), roomId);
+        if (existing != null) {
+            throw new BusinessException("该房间已有住客在住,请先办理退房");
+        }
+
+        // 新建在住会话:前台可录入客人姓名和电话,未录入时留空
+        // 客人首次下单时联系人会回写到该会话(见 backfillGuest)
+        HotelRoomStay stay = new HotelRoomStay();
+        stay.setRoomId(room.getId());
+        stay.setRoomNo(room.getRoomNo());
+        stay.setCheckInTime(new Date());
+        stay.setStatus(HotelQrConstants.STAY_ACTIVE);
+        stay.setRemark(remark);
+        if (guestName != null && !guestName.trim().isEmpty()) {
+            stay.setGuestName(guestName.trim());
+        }
+        if (guestPhone != null && !guestPhone.trim().isEmpty()) {
+            stay.setGuestPhone(guestPhone.trim());
+        }
+        stayMapper.insert(stay);
+
+        // 房间状态流转为入住中
+        updateRoomStatus(room.getId(), HotelQrConstants.ROOM_OCCUPIED);
+
+        log.info("房间入住: roomId={}, roomNo={}, stayId={}, guestName={}, operator={}",
+                roomId, room.getRoomNo(), stay.getId(), stay.getGuestName(), resolveUsername());
+        return stay.getId();
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public RoomCheckOutVO roomCheckOut(Long roomId, boolean force, String remark) {
+        HotelRoom room = requireRoom(roomId);
+
+        // 退房前置校验:必须处于入住中状态
+        if (!HotelQrConstants.ROOM_OCCUPIED.equals(room.getStatus())) {
+            throw new BusinessException("该房间当前未入住,无需退房");
+        }
+        HotelRoomStay stay = stayMapper.selectActiveByRoomId(resolveTenantId(), roomId);
+        if (stay == null) {
+            // 房间状态为入住中但无在住会话,属于历史数据异常,拒绝操作并提示人工介入
+            throw new BusinessException("房间状态异常:已入住但未找到在住记录,请联系管理员处理");
+        }
+
+        // 第一步(非强制):检查进行中订单(已接单~配送中、退单待审核)。
+        // 存在进行中订单时不直接退房,返回订单清单让前端弹窗警告,
+        // 由前台确认后带 force=true 再次调用
+        List<HotelOrder> activeOrders = orderMapper.selectActiveByStayId(stay.getId());
+        if (!force && !activeOrders.isEmpty()) {
+            RoomCheckOutVO vo = new RoomCheckOutVO();
+            vo.setNeedConfirm(true);
+            vo.setCheckedOut(false);
+            vo.setMessage("该房间还有 " + activeOrders.size() + " 个进行中的订单,退房后订单仍需跟进处理,请确认后强制退房");
+            for (HotelOrder order : activeOrders) {
+                RoomCheckOutVO.OrderBrief brief = new RoomCheckOutVO.OrderBrief();
+                brief.setId(order.getId());
+                brief.setOrderNo(order.getOrderNo());
+                brief.setStatus(order.getStatus());
+                brief.setTotalAmount(order.getTotalAmount());
+                brief.setContactName(order.getContactName());
+                vo.getActiveOrders().add(brief);
+            }
+            return vo;
+        }
+
+        // 第二步(正式退房):
+        // 1. 自动取消该会话的待支付订单(客人已离店,未支付订单无意义),
+        //    状态 0 -> 11(已取消,区别于10支付超时取消);已支付订单不受影响
+        int cancelled = orderMapper.cancelPendingByStayId(stay.getId());
+
+        // 2. 关闭在住会话:状态置为已退房,记录退房时间
+        UpdateWrapper<HotelRoomStay> stayUpdate = new UpdateWrapper<>();
+        stayUpdate.eq("id", stay.getId())
+                .eq("status", HotelQrConstants.STAY_ACTIVE)
+                .set("status", HotelQrConstants.STAY_CHECKED_OUT)
+                .set("check_out_time", new Date());
+        if (StringUtils.hasText(remark)) {
+            stayUpdate.set("remark", remark);
+        }
+        stayMapper.update(null, stayUpdate);
+
+        // 3. 房间状态流转为打扫中(退房 -> 打扫中 -> 打扫完成 -> 空闲)
+        updateRoomStatus(room.getId(), HotelQrConstants.ROOM_CLEANING);
+
+        log.info("房间退房: roomId={}, roomNo={}, stayId={}, cancelledPendingOrders={}, force={}, operator={}",
+                roomId, room.getRoomNo(), stay.getId(), cancelled, force, resolveUsername());
+
+        RoomCheckOutVO vo = new RoomCheckOutVO();
+        vo.setNeedConfirm(false);
+        vo.setCheckedOut(true);
+        vo.setMessage(cancelled > 0 ? "退房成功,已自动取消 " + cancelled + " 个待支付订单" : "退房成功");
+        return vo;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void roomCleaningDone(Long roomId) {
+        HotelRoom room = requireRoom(roomId);
+        // 仅打扫中的房间可标记打扫完成,避免误操作覆盖在住/维护状态
+        if (!HotelQrConstants.ROOM_CLEANING.equals(room.getStatus())) {
+            throw new BusinessException("该房间当前不是打扫中状态,无需此操作");
+        }
+        updateRoomStatus(room.getId(), HotelQrConstants.ROOM_VACANT);
+        log.info("打扫完成: roomId={}, roomNo={}, operator={}", roomId, room.getRoomNo(), resolveUsername());
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void roomMaintenanceToggle(Long roomId) {
+        HotelRoom room = requireRoom(roomId);
+        // 维护切换仅允许 空闲 <-> 维护中 互转:
+        // 入住中房间有在住客人、打扫中房间即将回到空闲,均不允许进入维护
+        if (HotelQrConstants.ROOM_VACANT.equals(room.getStatus())) {
+            updateRoomStatus(room.getId(), HotelQrConstants.ROOM_MAINTENANCE);
+            log.info("房间设为维护中: roomId={}, roomNo={}, operator={}", roomId, room.getRoomNo(), resolveUsername());
+        } else if (HotelQrConstants.ROOM_MAINTENANCE.equals(room.getStatus())) {
+            updateRoomStatus(room.getId(), HotelQrConstants.ROOM_VACANT);
+            log.info("房间解除维护: roomId={}, roomNo={}, operator={}", roomId, room.getRoomNo(), resolveUsername());
+        } else {
+            throw new BusinessException("仅空闲或维护中的房间可进行维护切换");
+        }
+    }
+
+    @Override
+    public HotelRoomStay getActiveStay(Long roomId) {
+        if (roomId == null) {
+            return null;
+        }
+        return stayMapper.selectActiveByRoomId(resolveTenantId(), roomId);
+    }
+
+    @Override
+    public IPage<HotelRoomStayVO> stayPage(PageQuery pageQuery, Long roomId) {
+        if (roomId == null) {
+            throw new BusinessException("房间ID不能为空");
+        }
+        Page<HotelRoomStayVO> page = new Page<>(pageQuery.getPageNum(), pageQuery.getPageSize());
+        return stayMapper.selectStayPage(page, resolveTenantId(), roomId);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void backfillGuest(Long stayId, String guestName, String guestPhone) {
+        if (stayId == null) {
+            return;
+        }
+        HotelRoomStay stay = stayMapper.selectById(stayId);
+        // 会话不存在或已退房时不回写(退房后到达的迟到订单不应污染历史记录)
+        if (stay == null || !HotelQrConstants.STAY_ACTIVE.equals(stay.getStatus())) {
+            return;
+        }
+        // 仅空字段回写,不覆盖已有住客信息(多人同住以首位下单客人为准)
+        boolean changed = false;
+        if (!StringUtils.hasText(stay.getGuestName()) && StringUtils.hasText(guestName)) {
+            stay.setGuestName(guestName);
+            changed = true;
+        }
+        if (!StringUtils.hasText(stay.getGuestPhone()) && StringUtils.hasText(guestPhone)) {
+            stay.setGuestPhone(guestPhone);
+            changed = true;
+        }
+        if (changed) {
+            stayMapper.updateById(stay);
+            log.info("回写在住客人信息: stayId={}, roomId={}", stayId, stay.getRoomId());
+        }
+    }
+
+    // ==================== 私有辅助方法 ====================
+
+    /**
+     * 查询并校验房间存在。
+     *
+     * @param roomId 房间ID
+     * @return 房间实体
+     * @throws BusinessException 房间ID为空或房间不存在时抛出
+     */
+    private HotelRoom requireRoom(Long roomId) {
+        if (roomId == null) {
+            throw new BusinessException("房间ID不能为空");
+        }
+        HotelRoom room = roomMapper.selectById(roomId);
+        if (room == null) {
+            throw new BusinessException("房间不存在");
+        }
+        return room;
+    }
+
+    /**
+     * 更新房间状态(仅更新status字段)。
+     *
+     * @param roomId 房间ID
+     * @param status 目标状态(VACANT/OCCUPIED/CLEANING/MAINTENANCE)
+     */
+    private void updateRoomStatus(Long roomId, String status) {
+        HotelRoom update = new HotelRoom();
+        update.setId(roomId);
+        update.setStatus(status);
+        roomMapper.updateById(update);
+    }
+
+    /**
+     * 获取当前租户ID,优先从上下文获取,其次从会话获取,默认返回1。
+     *
+     * @return 租户ID
+     */
+    private Long resolveTenantId() {
+        Long tenantId = TenantContextHolder.getTenantId();
+        if (tenantId != null) {
+            return tenantId;
+        }
+        tenantId = SessionHelper.getTenantId();
+        return tenantId == null ? 1L : tenantId;
+    }
+
+    /**
+     * 获取当前操作用户名,未登录时返回"system"。
+     *
+     * @return 用户名
+     */
+    private String resolveUsername() {
+        return StringUtils.hasText(SessionHelper.getUsername()) ? SessionHelper.getUsername() : "system";
+    }
+}

+ 75 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/vo/BusinessHoursStatusVO.java

@@ -0,0 +1,75 @@
+package com.mdframe.forge.business.core.hotel.vo;
+
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * 顾客端当前营业状态。
+ * <p>
+ * 后端基于启用的营业时段配置,推导当前是否在营业时段内以及下次营业时段。
+ * 顾客端 H5 直接使用该 VO 渲染营业状态提示与下次营业文案。
+ * <p>
+ * 注意:不支持跨零点时段(如 22:00-02:00),与既有 SQL 判定语义一致。
+ * 未配置任何启用时段时,configured=false、open=true(fail-open,全天营业)。
+ */
+@Data
+public class BusinessHoursStatusVO implements Serializable {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /** 该租户是否配置了启用时段(未配置时 fail-open,视为全天营业) */
+    private Boolean configured;
+
+    /** 当前是否在营业时段内 */
+    private Boolean open;
+
+    /** 当前所在餐段名称(营业中时有值,非营业时为null) */
+    private String currentMealName;
+
+    /** 当前餐段结束时间(营业中时有值,非营业时为null) */
+    private String currentEndTime;
+
+    /** 当天营业时段汇总文案(如「早餐 07:00-10:00 · 午餐 11:00-14:00」,无配置时为「全天营业」) */
+    private String todayText;
+
+    /** 当天各时段明细 */
+    private List<Period> todayPeriods;
+
+    /** 下次营业的餐段名称(非营业时有值) */
+    private String nextMealName;
+
+    /** 下次营业的开始时间(如「11:00」) */
+    private String nextStartTime;
+
+    /** 下次营业距今天数偏移(0=今天,1=明天,2=后天...) */
+    private Integer nextDayOffset;
+
+    /** 下次营业日期文案(今天/明天/周三...) */
+    private String nextDateText;
+
+    /** 组装好的完整下次营业文案(如「今天 11:00 开始(午餐)」,前端直接渲染) */
+    private String nextText;
+
+    /**
+     * 单个营业时段。
+     */
+    @Data
+    public static class Period implements Serializable {
+
+        @Serial
+        private static final long serialVersionUID = 1L;
+
+        /** 餐段名称 */
+        private String mealName;
+
+        /** 开始时间(如「07:00」) */
+        private String startTime;
+
+        /** 结束时间(如「10:00」) */
+        private String endTime;
+    }
+}

+ 3 - 3
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/vo/HotelDishCategoryVO.java

@@ -2,7 +2,7 @@ package com.mdframe.forge.business.core.hotel.vo;
 
 import lombok.Data;
 
-import java.time.LocalDateTime;
+import java.util.Date;
 
 /**
  * 菜品分类展示对象。
@@ -35,11 +35,11 @@ public class HotelDishCategoryVO {
     private Long createBy;
 
     /** 创建时间 */
-    private LocalDateTime createTime;
+    private Date createTime;
 
     /** 更新人ID */
     private Long updateBy;
 
     /** 更新时间 */
-    private LocalDateTime updateTime;
+    private Date updateTime;
 }

+ 3 - 3
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/vo/HotelDishVO.java

@@ -3,7 +3,7 @@ package com.mdframe.forge.business.core.hotel.vo;
 import lombok.Data;
 
 import java.math.BigDecimal;
-import java.time.LocalDateTime;
+import java.util.Date;
 import java.util.List;
 
 /**
@@ -70,13 +70,13 @@ public class HotelDishVO {
     private Long createBy;
 
     /** 创建时间 */
-    private LocalDateTime createTime;
+    private Date createTime;
 
     /** 更新人ID */
     private Long updateBy;
 
     /** 更新时间 */
-    private LocalDateTime updateTime;
+    private Date updateTime;
 
     /**
      * 规格组VO。

+ 2 - 2
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/vo/HotelQrBindLogVO.java

@@ -2,7 +2,7 @@ package com.mdframe.forge.business.core.hotel.vo;
 
 import lombok.Data;
 
-import java.time.LocalDateTime;
+import java.util.Date;
 
 /**
  * 二维码绑定日志展示对象。
@@ -35,7 +35,7 @@ public class HotelQrBindLogVO {
     private String operatorName;
 
     /** 操作时间 */
-    private LocalDateTime operateTime;
+    private Date operateTime;
 
     /** 备注 */
     private String remark;

+ 5 - 5
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/vo/HotelQrCodeVO.java

@@ -2,7 +2,7 @@ package com.mdframe.forge.business.core.hotel.vo;
 
 import lombok.Data;
 
-import java.time.LocalDateTime;
+import java.util.Date;
 
 /**
  * 二维码展示对象。
@@ -44,13 +44,13 @@ public class HotelQrCodeVO {
     private String bindUserName;
 
     /** 绑定时间 */
-    private LocalDateTime bindTime;
+    private Date bindTime;
 
     /** 解绑人姓名 */
     private String unbindUserName;
 
     /** 解绑时间 */
-    private LocalDateTime unbindTime;
+    private Date unbindTime;
 
     /** 二维码URL(完整扫码地址) */
     private String qrCodeUrl;
@@ -62,11 +62,11 @@ public class HotelQrCodeVO {
     private Long createBy;
 
     /** 创建时间 */
-    private LocalDateTime createTime;
+    private Date createTime;
 
     /** 更新人ID */
     private Long updateBy;
 
     /** 更新时间 */
-    private LocalDateTime updateTime;
+    private Date updateTime;
 }

+ 47 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/vo/HotelRoomStayVO.java

@@ -0,0 +1,47 @@
+package com.mdframe.forge.business.core.hotel.vo;
+
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 在住会话展示对象。
+ * <p>
+ * 用于房间列表"在住客人/入住时间"列展示,以及房间详情中的历史入住记录列表。
+ */
+@Data
+public class HotelRoomStayVO {
+
+    /** 会话ID */
+    private Long id;
+
+    /** 租户ID */
+    private Long tenantId;
+
+    /** 房间ID */
+    private Long roomId;
+
+    /** 房间号 */
+    private String roomNo;
+
+    /** 住客姓名(首单联系人回写,可能为空) */
+    private String guestName;
+
+    /** 住客电话(首单联系人回写,可能为空) */
+    private String guestPhone;
+
+    /** 入住时间 */
+    private Date checkInTime;
+
+    /** 退房时间(在住时为null) */
+    private Date checkOutTime;
+
+    /** 会话状态(0=在住, 1=已退房) */
+    private String status;
+
+    /** 备注 */
+    private String remark;
+
+    /** 创建时间 */
+    private Date createTime;
+}

+ 3 - 3
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/vo/HotelRoomTypeVO.java

@@ -2,7 +2,7 @@ package com.mdframe.forge.business.core.hotel.vo;
 
 import lombok.Data;
 
-import java.time.LocalDateTime;
+import java.util.Date;
 
 /**
  * 房型展示对象。
@@ -29,11 +29,11 @@ public class HotelRoomTypeVO {
     private Long createBy;
 
     /** 创建时间 */
-    private LocalDateTime createTime;
+    private Date createTime;
 
     /** 更新人ID */
     private Long updateBy;
 
     /** 更新时间 */
-    private LocalDateTime updateTime;
+    private Date updateTime;
 }

+ 16 - 4
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/vo/HotelRoomVO.java

@@ -2,7 +2,7 @@ package com.mdframe.forge.business.core.hotel.vo;
 
 import lombok.Data;
 
-import java.time.LocalDateTime;
+import java.util.Date;
 
 /**
  * 房间展示对象。
@@ -28,9 +28,21 @@ public class HotelRoomVO {
     /** 楼层 */
     private String floorNo;
 
-    /** 房间状态(VACANT=空闲, OCCUPIED=占用, CLEANING=打扫中) */
+    /** 房间状态(VACANT=空闲, OCCUPIED=入住, CLEANING=打扫中, MAINTENANCE=维护中) */
     private String status;
 
+    /** 在住会话ID(未入住时为null,关联 hotel_room_stay) */
+    private Long stayId;
+
+    /** 在住客人姓名(首单联系人回写,未入住或尚未回写时为null) */
+    private String guestName;
+
+    /** 在住客人电话(首单联系人回写,未入住或尚未回写时为null) */
+    private String guestPhone;
+
+    /** 入住时间(未入住时为null) */
+    private Date checkInTime;
+
     /** 排序号 */
     private Integer sortOrder;
 
@@ -41,11 +53,11 @@ public class HotelRoomVO {
     private Long createBy;
 
     /** 创建时间 */
-    private LocalDateTime createTime;
+    private Date createTime;
 
     /** 更新人ID */
     private Long updateBy;
 
     /** 更新时间 */
-    private LocalDateTime updateTime;
+    private Date updateTime;
 }

+ 8 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/vo/QrCodeScanVO.java

@@ -38,6 +38,14 @@ public class QrCodeScanVO {
         private String roomNo;
         /** 房型名称 */
         private String roomTypeName;
+        /** 房间状态(VACANT/OCCUPIED/CLEANING/MAINTENANCE,前端据此拦截非入住房间的点餐) */
+        private String roomStatus;
+        /** 在住会话ID(未入住时为null,H5用于隔离不同住客的订单/购物车) */
+        private Long stayId;
+        /** 住客姓名(入住时前台录入,用于H5点餐自动回填联系人) */
+        private String guestName;
+        /** 住客电话(入住时前台录入,用于H5点餐自动回填联系人) */
+        private String guestPhone;
     }
 
     /** 绑定信息(已绑定时返回) */

+ 54 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/vo/RoomCheckOutVO.java

@@ -0,0 +1,54 @@
+package com.mdframe.forge.business.core.hotel.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 退房操作结果对象。
+ * <p>
+ * 退房流程分两步交互:
+ * 1. 前台点击退房时,若该房间存在进行中订单(已接单~配送中、退单待审核),
+ *    后端不直接退房,返回 needConfirm=true 及进行中订单清单,前端弹窗警告;
+ * 2. 前台确认后才带 force=true 再次调用,正式关闭会话并取消待支付订单。
+ * 待支付订单无论是否 force 都会被自动取消(客人已离店,订单无意义)。
+ */
+@Data
+public class RoomCheckOutVO {
+
+    /** 是否需要前台二次确认(存在进行中订单时为true) */
+    private boolean needConfirm;
+
+    /** 提示信息(需要确认时说明原因,退房成功时为成功文案) */
+    private String message;
+
+    /** 进行中订单清单(仅needConfirm=true时返回,供弹窗展示) */
+    private List<OrderBrief> activeOrders = new ArrayList<>();
+
+    /** 退房是否已完成(force确认或无进行中订单时为true) */
+    private boolean checkedOut;
+
+    /**
+     * 进行中订单简要信息(退房警告弹窗展示用)。
+     */
+    @Data
+    public static class OrderBrief {
+
+        /** 订单ID */
+        private Long id;
+
+        /** 订单号 */
+        private String orderNo;
+
+        /** 订单状态(2=已接单,3=备餐中,4=已出餐,5=配送中,8=退单待审核) */
+        private Integer status;
+
+        /** 订单总金额(元) */
+        private BigDecimal totalAmount;
+
+        /** 下单联系人姓名 */
+        private String contactName;
+    }
+}

+ 15 - 1
forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/HotelBusinessHoursMapper.xml

@@ -36,6 +36,20 @@
         ORDER BY sort_order ASC
     </select>
 
+    <!-- 更新营业时段(显式 set 各字段,支持 day_of_week=null) -->
+    <update id="updateBusinessHours">
+        UPDATE hotel_business_hours
+        SET day_of_week = #{dayOfWeek},
+            meal_type = #{mealType},
+            meal_name = #{mealName},
+            start_time = #{startTime},
+            end_time = #{endTime},
+            sort_order = #{sortOrder},
+            update_time = NOW()
+        WHERE id = #{id}
+          AND del_flag = 0
+    </update>
+
     <!-- 查询当前营业时段(星期匹配 + 时间区间判断) -->
     <select id="selectCurrentBusinessHours" resultType="com.mdframe.forge.business.core.hotel.domain.HotelBusinessHours">
         SELECT id, tenant_id, day_of_week, meal_type, meal_name, start_time, end_time,
@@ -44,7 +58,7 @@
         WHERE tenant_id = #{tenantId}
           AND status = 'ENABLED'
           AND del_flag = 0
-          AND (day_of_week = #{dayOfWeek} OR day_of_week IS NULL)
+          AND (FIND_IN_SET(#{dayOfWeek}, day_of_week) OR day_of_week IS NULL)
           AND #{currentTime} >= start_time
           AND #{currentTime} &lt; end_time
         ORDER BY sort_order ASC

+ 14 - 1
forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/HotelRoomMapper.xml

@@ -10,6 +10,10 @@
         <result column="room_type_name" property="roomTypeName" />
         <result column="floor_no" property="floorNo" />
         <result column="status" property="status" />
+        <result column="stay_id" property="stayId" />
+        <result column="guest_name" property="guestName" />
+        <result column="guest_phone" property="guestPhone" />
+        <result column="check_in_time" property="checkInTime" />
         <result column="sort_order" property="sortOrder" />
         <result column="remark" property="remark" />
         <result column="create_by" property="createBy" />
@@ -22,13 +26,20 @@
         r.id, r.tenant_id, r.room_no, r.room_type_id,
         rt.type_name AS room_type_name,
         r.floor_no, r.status, r.sort_order, r.remark,
-        r.create_by, r.create_time, r.update_by, r.update_time
+        r.create_by, r.create_time, r.update_by, r.update_time,
+        s.id AS stay_id, s.guest_name, s.guest_phone, s.check_in_time
+    </sql>
+
+    <!-- 关联在住会话:仅取未删除的status=0会话,展示在住客人/入住时间 -->
+    <sql id="JoinActiveStay">
+        LEFT JOIN hotel_room_stay s ON s.room_id = r.id AND s.status = '0' AND s.del_flag = 0
     </sql>
 
     <select id="selectPage" resultMap="RoomVOMap">
         SELECT <include refid="BaseColumns" />
         FROM hotel_room r
         LEFT JOIN hotel_room_type rt ON rt.id = r.room_type_id AND rt.del_flag = 0
+        <include refid="JoinActiveStay" />
         WHERE r.tenant_id = #{tenantId}
           AND r.del_flag = 0
         <if test="query.roomNo != null and query.roomNo != ''">
@@ -47,6 +58,7 @@
         SELECT <include refid="BaseColumns" />
         FROM hotel_room r
         LEFT JOIN hotel_room_type rt ON rt.id = r.room_type_id AND rt.del_flag = 0
+        <include refid="JoinActiveStay" />
         WHERE r.tenant_id = #{tenantId}
           AND r.del_flag = 0
           AND r.id = #{id}
@@ -57,6 +69,7 @@
         SELECT <include refid="BaseColumns" />
         FROM hotel_room r
         LEFT JOIN hotel_room_type rt ON rt.id = r.room_type_id AND rt.del_flag = 0
+        <include refid="JoinActiveStay" />
         WHERE r.tenant_id = #{tenantId}
           AND r.del_flag = 0
         ORDER BY r.sort_order ASC, r.room_no ASC

+ 61 - 0
forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/HotelRoomStayMapper.xml

@@ -0,0 +1,61 @@
+<?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.mapper.HotelRoomStayMapper">
+
+    <resultMap id="StayVOMap" type="com.mdframe.forge.business.core.hotel.vo.HotelRoomStayVO">
+        <id column="id" property="id" />
+        <result column="tenant_id" property="tenantId" />
+        <result column="room_id" property="roomId" />
+        <result column="room_no" property="roomNo" />
+        <result column="guest_name" property="guestName" />
+        <result column="guest_phone" property="guestPhone" />
+        <result column="check_in_time" property="checkInTime" />
+        <result column="check_out_time" property="checkOutTime" />
+        <result column="status" property="status" />
+        <result column="remark" property="remark" />
+        <result column="create_time" property="createTime" />
+    </resultMap>
+
+    <sql id="BaseColumns">
+        s.id, s.tenant_id, s.room_id, s.room_no,
+        s.guest_name, s.guest_phone,
+        s.check_in_time, s.check_out_time,
+        s.status, s.remark, s.create_time
+    </sql>
+
+    <!-- 查询房间的在住会话:一条房间最多一条status=0的记录,异常多条时取最新入住 -->
+    <select id="selectActiveByRoomId" resultType="com.mdframe.forge.business.core.hotel.domain.HotelRoomStay">
+        SELECT id, tenant_id, room_id, room_no,
+               guest_name, guest_phone,
+               check_in_time, check_out_time,
+               status, remark,
+               create_by, create_time, create_dept, update_by, update_time, del_flag
+        FROM hotel_room_stay
+        WHERE tenant_id = #{tenantId}
+          AND room_id = #{roomId}
+          AND status = '0'
+          AND del_flag = 0
+        ORDER BY check_in_time DESC
+        LIMIT 1
+    </select>
+
+    <!-- 分页查询房间的历史入住记录(含在住),按入住时间倒序 -->
+    <select id="selectStayPage" resultMap="StayVOMap">
+        SELECT <include refid="BaseColumns" />
+        FROM hotel_room_stay s
+        WHERE s.tenant_id = #{tenantId}
+          AND s.room_id = #{roomId}
+          AND s.del_flag = 0
+        ORDER BY s.check_in_time DESC
+    </select>
+
+    <!-- 查询全部房间的在住会话,供房间列表批量填充在住客人信息 -->
+    <select id="selectAllActive" resultMap="StayVOMap">
+        SELECT <include refid="BaseColumns" />
+        FROM hotel_room_stay s
+        WHERE s.tenant_id = #{tenantId}
+          AND s.status = '0'
+          AND s.del_flag = 0
+    </select>
+
+</mapper>

+ 4 - 7
forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/order/HotelOrderItemMapper.xml

@@ -29,17 +29,14 @@
     <!-- 批量插入订单明细 -->
     <insert id="batchInsert">
         INSERT INTO hotel_order_item
-        (id, order_id, dish_id, dish_name, spec_desc, additions_desc,
-         quantity, unit_price, subtotal, note, tenant_id,
-         create_by, create_time, create_dept, update_by, update_time, del_flag)
+        (order_id, dish_id, dish_name, spec_desc, additions_desc,
+         quantity, unit_price, subtotal, note, tenant_id)
         VALUES
         <foreach collection="list" item="item" separator=",">
-            (#{item.id}, #{item.orderId}, #{item.dishId}, #{item.dishName},
+            (#{item.orderId}, #{item.dishId}, #{item.dishName},
              #{item.specDesc}, #{item.additionsDesc},
              #{item.quantity}, #{item.unitPrice}, #{item.subtotal}, #{item.note},
-             #{item.tenantId},
-             #{item.createBy}, #{item.createTime}, #{item.createDept},
-             #{item.updateBy}, #{item.updateTime}, #{item.delFlag})
+             #{item.tenantId})
         </foreach>
     </insert>
 

+ 33 - 1
forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/order/HotelOrderMapper.xml

@@ -7,6 +7,7 @@
         <result column="order_no" property="orderNo" />
         <result column="room_no" property="roomNo" />
         <result column="room_id" property="roomId" />
+        <result column="stay_id" property="stayId" />
         <result column="room_type" property="roomType" />
         <result column="contact_name" property="contactName" />
         <result column="contact_phone" property="contactPhone" />
@@ -30,18 +31,22 @@
         <result column="refund_reason" property="refundReason" />
         <result column="refund_initiator" property="refundInitiator" />
         <result column="refund_time" property="refundTime" />
+        <result column="refund_amount" property="refundAmount" />
+        <result column="refund_trade_no" property="refundTradeNo" />
+        <result column="refund_status" property="refundStatus" />
         <result column="create_time" property="createTime" />
         <result column="item_count" property="itemCount" />
     </resultMap>
 
     <sql id="OrderColumns">
-        o.id, o.order_no, o.room_no, o.room_id, o.room_type,
+        o.id, o.order_no, o.room_no, o.room_id, o.stay_id, o.room_type,
         o.contact_name, o.contact_phone, o.status,
         o.pay_status, o.pay_expire_time, o.pay_time, o.pay_trade_no, o.paid_amount, o.pay_source,
         o.total_amount, o.delivery_fee,
         o.delivery_time_type, o.custom_delivery_time, o.note,
         o.accept_time, o.ready_time, o.deliver_time, o.complete_time,
         o.reject_reason, o.refund_reason, o.refund_initiator, o.refund_time,
+        o.refund_amount, o.refund_trade_no, o.refund_status,
         o.create_time,
         (SELECT IFNULL(SUM(oi.quantity), 0) FROM hotel_order_item oi WHERE oi.order_id = o.id AND oi.del_flag = 0) AS item_count
     </sql>
@@ -55,6 +60,10 @@
         <if test="query.roomNo != null and query.roomNo != ''">
             AND o.room_no = #{query.roomNo}
         </if>
+        <!-- 顾客端按在住会话过滤:同一住客会话内多人共享可见订单,换客后新会话看不到旧订单 -->
+        <if test="query.stayId != null">
+            AND o.stay_id = #{query.stayId}
+        </if>
         <if test="query.status != null">
             AND o.status = #{query.status}
         </if>
@@ -134,4 +143,27 @@
           AND pay_expire_time &lt; NOW()
     </select>
 
+    <!-- 查询在住会话的进行中订单(退房警告用):2=已接单,3=备餐中,4=已出餐,5=配送中,8=退单待审核 -->
+    <select id="selectActiveByStayId" resultType="com.mdframe.forge.business.core.hotel.order.domain.HotelOrder">
+        SELECT id, order_no, room_no, room_id, stay_id,
+               contact_name, contact_phone, status, pay_status,
+               total_amount, create_time
+        FROM hotel_order
+        WHERE del_flag = 0
+          AND stay_id = #{stayId}
+          AND status IN (2, 3, 4, 5, 8)
+        ORDER BY create_time ASC
+    </select>
+
+    <!-- 退房联动:批量取消在住会话的待支付订单(0 -> 11已取消),仅未支付的订单被取消 -->
+    <update id="cancelPendingByStayId">
+        UPDATE hotel_order
+        SET status = 11,
+            update_time = NOW()
+        WHERE del_flag = 0
+          AND stay_id = #{stayId}
+          AND status = 0
+          AND pay_status = 0
+    </update>
+
 </mapper>

+ 99 - 8
forge-server/forge-business/forge-hotel/编码习惯与规范.md

@@ -494,10 +494,14 @@ public RespInfo<HotelDishVO> dishDetail(@PathVariable Long id) { ... }
 
 ### 7.1 注释风格
 
-- 实体类每个字段必须有 Javadoc 注释
-- Controller 每个方法必须有 Javadoc(描述接口用途)
-- Service 接口方法必须有 Javadoc(含参数说明)
-- 关键业务逻辑加行内注释说明
+- **注释必须详细完整**,能让其他人快速理解代码意图和业务背景,不依赖外部文档
+- 实体类每个字段必须有 Javadoc 注释,说明字段含义、取值范围、关联关系
+- Controller 每个方法必须有 Javadoc(描述接口用途、参数含义、返回值说明)
+- Service 接口方法必须有 Javadoc(含参数说明、业务规则、异常场景)
+- ServiceImpl 中复杂业务逻辑必须加块注释,说明业务背景和决策原因
+- 状态机流转、条件分支、边界处理等关键逻辑必须加行内注释
+- 跨模块调用、特殊设计决策需加注释说明"为什么这样做"
+- 禁止无意义注释(如 `// 获取名称` 紧跟 `getName()`),注释应解释意图而非复述代码
 
 ### 7.2 安全红线
 
@@ -505,8 +509,95 @@ public RespInfo<HotelDishVO> dishDetail(@PathVariable Long id) { ... }
 - 禁止在日志中打印手机号、身份证、银行卡
 - API Key/Secret 返回前端必须脱敏(保留前4后4,中间 `****`)
 
-### 7.3 构建与验证
+### 7.3 构建与验证(Windows PowerShell 环境)
 
-- Maven 构建:`mvn compile -pl forge-business/forge-hotel -am -DskipTests`
-- 前端 Lint:`pnpm lint:fix`
-- 改完代码必须编译/Lint 验证通过
+> **本环境关键约束**:
+> - Shell:PowerShell(不支持 `&&`,必须用 `;` 分隔命令)
+> - Maven:不在 PATH 中,必须用完整路径 `D:\java_install\apache-maven-3.9.16\bin\mvn.cmd`
+> - pnpm:执行策略禁止 `.ps1` 脚本,必须用 `.cmd` 后缀(`pnpm.cmd` 而非 `pnpm`)
+> - H5 构建脚本是 `build:h5`(不是 `build`)
+
+#### 后端命令
+
+```powershell
+# 全量构建(从 forge-server 根目录)
+Set-Location D:\java_project\forge-admin\forge-server; D:\java_install\apache-maven-3.9.16\bin\mvn.cmd clean install -DskipTests
+
+# 仅编译 hotel 模块及其依赖(快速验证编译)
+Set-Location D:\java_project\forge-admin\forge-server; D:\java_install\apache-maven-3.9.16\bin\mvn.cmd compile -pl forge-business/forge-hotel -am -DskipTests
+
+# 启动 admin 服务(默认 localhost:8580)
+Set-Location D:\java_project\forge-admin\forge-server\forge-admin-server; D:\java_install\apache-maven-3.9.16\bin\mvn.cmd spring-boot:run
+
+# 启动 flow 服务(默认 localhost:8581)
+Set-Location D:\java_project\forge-admin\forge-server\forge-flow; D:\java_install\apache-maven-3.9.16\bin\mvn.cmd spring-boot:run
+```
+
+#### 前端命令(forge-admin-ui)
+
+```powershell
+Set-Location D:\java_project\forge-admin\forge-admin-ui
+
+# 安装依赖
+pnpm.cmd install
+
+# 开发模式(默认 localhost:5173)
+pnpm.cmd dev
+
+# 生产构建
+pnpm.cmd build
+
+# Lint 检查 + 自动修复
+pnpm.cmd lint:fix
+```
+
+#### H5 命令(forge-h5-ui)
+
+```powershell
+Set-Location D:\java_project\forge-admin\forge-h5-ui
+
+# 安装依赖
+pnpm.cmd install
+
+# 开发模式
+pnpm.cmd dev:h5
+
+# 生产构建(⚠️ 必须是 build:h5,不是 build)
+pnpm.cmd build:h5
+```
+
+#### PowerShell 常见坑(AI 执行前必读)
+
+| 问题 | 错误写法 | 正确写法 |
+|------|---------|----------|
+| 命令链分隔 | `cd path && mvn ...` | `Set-Location path; mvn.cmd ...` |
+| Maven 不在 PATH | `mvn clean install` | `D:\java_install\apache-maven-3.9.16\bin\mvn.cmd clean install` |
+| pnpm 被策略拦截 | `pnpm build` | `pnpm.cmd build:h5` |
+| H5 构建命令 | `pnpm build` | `pnpm.cmd build:h5`(`build` 不存在会报错) |
+| 执行 .bin 脚本 | `node node_modules/.bin/vite build` | 用 `pnpm.cmd` 统一入口,不要直接调 node_modules 下的文件 |
+
+---
+
+## 八、AI 执行纪律(防止跑偏规则)
+
+> 本节是 AI 编程助手执行任务时的强制约束,防止在终端命令、代码调查等环节浪费时间。
+
+### 8.1 终端命令纪律
+
+1. **命令失败最多重试 1 次**:第 1 次失败后,必须先查本节「7.3 PowerShell 常见坑」表,禁止盲目尝试不同写法
+2. **禁止探索文件系统找工具路径**:Maven、Node、pnpm 等工具路径已记录在 7.3 节,直接用,不要 `where.exe`、`Get-ChildItem`、`find` 等搜索
+3. **禁止用 `cmd /c` 绕过 PowerShell 限制**:所有命令必须用 PowerShell 原生语法(`;` 分隔 + `.cmd` 后缀)
+4. **构建命令统一用本文档记录的命令**:不要自己猜测命令,先读 7.3 节复制粘贴
+
+### 8.2 任务执行纪律
+
+1. **严格按待办列表顺序执行**:完成当前 Task 后直接进入下一个,不要“顺便看看”其他代码
+2. **不做任务范围外的调查**:如果当前任务是「接入营业时间校验」,不要去核实「权限机制」「数据库表结构」等无关内容
+3. **调查必须有明确目标**:只有在当前 Task 明确要求「确认某文件内容」时才去读代码,不要主动发散
+4. **遇到不确定的环境配置,先查记忆再查代码**:工具路径、数据库连接等已在记忆中的信息,直接检索,不要花时间在文件系统中搜索
+
+### 8.3 时间分配纪律
+
+1. **单个终端命令耗时不超过 30 秒**:如果构建/安装卡住超过 30 秒,检查是否命令写错,不要反复尝试
+2. **单个调查任务不超过 3 次工具调用**:如果需要读超过 3 个文件才能确认一件事,说明调查范围太大,应该缩小范围或直接问用户
+3. **文档更新集中处理**:所有文档回填集中在最后一个 Task 完成,不要每完成一个 Task 就去改文档

+ 216 - 77
forge-server/forge-business/forge-hotel/酒店模块迁移跟踪.md

@@ -1,64 +1,96 @@
+> **关联文档**:`需求说明书.html`(需求基线 v1.0,2026-07-26,状态:需求确认中)|`酒店模块需求缺口清单.md`(需求对照 + P0/P1 缺口台账 + 补齐排期)
+> **本文档职责**:代码地图与实现进度,回答「**代码里有什么**」。需求视角的缺口、验收对照与排期不在本文档维护,避免双份状态互相失真。
+
 ### 1.3 Forge 已有代码
 
 ```
 forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/
 ├── controller/
-│   ├── HotelQrCodeController.java      ✅ 二维码管理
-│   ├── HotelBusinessHoursController.java ✅ 营业时段管理
+│   ├── HotelQrCodeController.java      ✅ 二维码管理 + 在住会话操作(入住/退房/打扫/维护/入住记录)
+│   ├── HotelBusinessHoursController.java ✅ 营业时段管理(含 check-current 当前营业状态)
+│   ├── HotelDishController.java        ✅ 菜品管理(分页/详情/增删改/上下架/售罄/恢复/批量/销量排行)
+│   ├── HotelDishCategoryController.java ✅ 菜品分类管理(分页/详情/增删改/启禁用)
 │   ├── HotelDishSpecController.java    ✅ 菜品规格管理(规格组 + 选项)
-│   └── open/
-│       └── HotelOpenController.java    ✅ 开放接口(扫码查询)
+│   └── open/                           ✅ 免登录开放接口(SaTokenConfig 已排除 /hotel/open/** 的登录与 API 权限校验)
+│       ├── HotelOpenController.java    ✅ 扫码查询 GET /hotel/open/scan
+│       ├── HotelCustomerController.java ✅ 顾客端 9 接口(categoryEnabled/dishPage/dishDetail/orderCreate/orderDetail/orderPage/customerRefund/abandonOrder/businessHours)
+│       ├── HotelPayController.java     ✅ 支付 5 接口(create/notify/status/alipayQuery/mockSuccess)
+│       └── AlipayAuthController.java   ✅ 支付宝小程序用户授权 getUserInfo(姓名 + 手机号)
 ├── domain/
 │   ├── HotelQrCode.java                ✅ 二维码实体
 │   ├── HotelQrBindLog.java             ✅ 绑定日志实体
-│   ├── HotelRoom.java                  ✅ 房间实体
+│   ├── HotelRoom.java                  ✅ 房间实体(⚠️ 无「启停点餐」字段)
 │   ├── HotelRoomType.java              ✅ 房型实体
+│   ├── HotelRoomStay.java              ✅ 在住会话实体(@TableLogic(value="0", delval="id"))
 │   ├── HotelBusinessHours.java         ✅ 营业时段实体
-│   ├── HotelDishSpecGroup.java         ✅ 规格组实体
-│   └── HotelDishSpecOption.java        ✅ 规格选项实体
+│   ├── HotelDish.java                  ✅ 菜品实体(image 主图 + images 附加图)
+│   ├── HotelDishCategory.java          ✅ 菜品分类实体
+│   ├── HotelDishSpecGroup.java         ✅ 规格组实体(挂 dishId)
+│   ├── HotelDishSpecOption.java        ✅ 规格选项实体(priceExtra 加价)
+│   ├── HotelDishAddition.java          ✅ 加料实体(extraPrice 加价)
+│   └── HotelDishLog.java               ⚠️ 菜品操作日志实体(表已建,**零业务代码**:无写入、无查询)
 ├── dto/
 │   ├── QrCodeBatchGenerateDTO.java     ✅
 │   ├── QrCodeBindDTO.java              ✅
 │   ├── QrCodeQueryDTO.java             ✅
 │   ├── HotelRoomDTO.java               ✅
-│   └── HotelRoomTypeDTO.java           ✅
+│   ├── HotelRoomTypeDTO.java           ✅
+│   ├── HotelDishDTO.java               ✅ 含 specGroups + additions 嵌套
+│   ├── HotelDishCategoryDTO.java       ✅
+│   └── DishBatchOperationDTO.java      ✅ 批量上下架/售罄
 ├── vo/
 │   ├── HotelQrCodeVO.java              ✅
 │   ├── HotelQrBindLogVO.java           ✅
-│   ├── QrCodeScanVO.java               ✅
-│   ├── HotelRoomVO.java                ✅
-│   └── HotelRoomTypeVO.java            ✅
+│   ├── QrCodeScanVO.java               ✅ RoomInfo 含 roomStatus/stayId/guestName/guestPhone
+│   ├── HotelRoomVO.java                ✅ 含 stayId/guestName/guestPhone/checkInTime
+│   ├── HotelRoomTypeVO.java            ✅
+│   ├── HotelRoomStayVO.java            ✅
+│   ├── RoomCheckOutVO.java             ✅ needConfirm/message/activeOrders/checkedOut
+│   ├── HotelDishVO.java                ✅ 含 specGroups + additions
+│   ├── HotelDishCategoryVO.java        ✅
+│   └── BusinessHoursStatusVO.java      ✅ 营业状态推导(configured/open/todayPeriods/nextText 等)
 ├── mapper/
 │   ├── HotelQrCodeMapper.java          ✅
 │   ├── HotelQrBindLogMapper.java       ✅
-│   ├── HotelRoomMapper.java            ✅
+│   ├── HotelRoomMapper.java            ✅ XML 含 JoinActiveStay
 │   ├── HotelRoomTypeMapper.java        ✅
+│   ├── HotelRoomStayMapper.java        ✅ XML 显式 AND del_flag = 0
 │   ├── HotelBusinessHoursMapper.java   ✅
+│   ├── HotelDishMapper.java            ✅ XML 含 selectSalesRanking 销量排行
+│   ├── HotelDishCategoryMapper.java    ✅
 │   ├── HotelDishSpecGroupMapper.java   ✅
-│   └── HotelDishSpecOptionMapper.java  ✅
+│   ├── HotelDishSpecOptionMapper.java  ✅
+│   ├── HotelDishAdditionMapper.java    ✅
+│   └── HotelDishLogMapper.java         ⚠️ 仅接口,无调用方
 ├── service/
 │   ├── HotelQrCodeService.java         ✅
-│   ├── HotelBusinessHoursService.java  ✅
+│   ├── HotelBusinessHoursService.java  ✅ 含 checkCurrentBusinessHours() + currentStatus()
+│   ├── HotelRoomStayService.java       ✅ 入住/退房/打扫/维护/入住记录/联系人回写
+│   ├── HotelDishService.java           ✅ 含 dishSalesRanking(limit)
+│   ├── HotelDishCategoryService.java   ✅
 │   ├── HotelDishSpecGroupService.java  ✅
 │   ├── HotelDishSpecOptionService.java ✅
-│   └── impl/
-│       ├── HotelQrCodeServiceImpl.java ✅
-│       ├── HotelBusinessHoursServiceImpl.java ✅
-│       ├── HotelDishSpecGroupServiceImpl.java ✅
-│       └── HotelDishSpecOptionServiceImpl.java ✅
+│   └── impl/                           ✅ 7 个实现类与接口一一对应
 ├── constant/
-│   └── HotelQrConstants.java           ✅
-└── utils/
-    └── QrCodeUtils.java                ✅
+│   └── HotelQrConstants.java           ✅ 含 STAY_ACTIVE / STAY_CHECKED_OUT
+├── utils/
+│   └── QrCodeUtils.java                ✅
+└── notification/                       ✅ 实时通知子包
+    ├── controller/
+    │   └── NotificationController.java ✅ SSE 端点(subscribe/unsubscribe/connections/remote/notifyNewOrder)
+    └── manager/
+        └── SseEmitterManager.java      ✅ 长连接管理 + 广播(仅 NEW_ORDER / REFUND_REQUEST 两类事件)
+
+> ⚠️ 文档修正:原记录的 `OrderNotificationService.java` **不存在**,通知广播能力由 `SseEmitterManager` 直接承担。
 
 order/                                  ✅ 订单管理子包(独立于已有代码)
 ├── constant/
-│   └── HotelOrderConstants.java        ✅ 订单状态常量 0~10 + 退单发起方
+│   └── HotelOrderConstants.java        ✅ 订单状态常量 0~11 + 支付状态 0~3 + paySource + PAY_TIMEOUT_MINUTES=15 + 退单发起方 + refund_status 0~2
 ├── domain/
 │   ├── HotelOrder.java                 ✅ 订单实体(含 payStatus/payExpireTime/paySource)
 │   └── HotelOrderItem.java             ✅ 订单明细实体
 ├── dto/
-│   └── OrderCreateDTO.java             ✅ 创建订单请求(已移除 payMethod,改用 paySource
+│   └── OrderCreateDTO.java             ✅ 创建订单请求(roomNo/roomId/contactName/contactPhone/deliveryFee/deliveryTimeType/customDeliveryTime/note/items;已移除 payMethod,**无 paySource 字段**,paySource 在创建支付时传参
 ├── vo/
 │   ├── HotelOrderVO.java               ✅ 订单视图(含内部类 OrderItemVO)
 │   └── OrderDashboardVO.java           ✅ 看板统计视图
@@ -66,54 +98,67 @@ order/                                  ✅ 订单管理子包(独立于已有
 │   ├── HotelOrderMapper.java           ✅
 │   └── HotelOrderItemMapper.java       ✅
 ├── service/
-│   ├── HotelOrderService.java          ✅ 14+ 个方法接口(含支付/超时取消)
+│   ├── HotelOrderService.java          ✅ 15 个方法接口(查询/看板/创建/5 步流转/拒单/3 类退单/审核/放弃);支付与超时取消在 pay 子包的 `HotelPayService`
 │   └── impl/
 │       └── HotelOrderServiceImpl.java  ✅ 完整状态机 + 退单流程 + 支付超时处理
 ├── controller/
-│   └── HotelOrderController.java       ✅ 15+ 个 REST 接口
+│   └── HotelOrderController.java       ✅ 基路径 `/hotel/order`,15 个 REST 接口(`/page` `/detail` `/dashboard` `POST /` `/accept` `/reject` `/prepare` `/ready` `/deliver` `/complete` `/customerRefund` `/hotelRefund` `/refundApprove` `/refundReject` `/refund`)
 
-pay/                                    ✅ 支付系统子包(支付宝沙箱 H5 WAP Pay + 安全加固)
+pay/                                    ✅ 支付系统子包(支付宝沙箱 H5 WAP Pay + 安全加固 + 超时取消 + 退款
 ├── config/
 │   └── AlipayConfig.java               ✅ 支付宝配置(appId/密钥/网关/回调地址,@ConfigurationProperties)
-├── constant/
-│   └── HotelPayConstants.java          ✅ 支付常量(payStatus 0/1/3、paySource MOCK/WECHAT/ALIPAY)
 ├── domain/
-│   └── HotelPayLog.java                ✅ 支付流水实体(outTradeNo/tradeNo/payAmount/payStatus)
-── dto/
-│   └── PayCreateDTO.java               ✅ 创建支付请求(orderId + paySource)
+│   └── HotelPayLog.java                ✅ 支付流水实体(outTradeNo/tradeNo/payAmount/payStatus/payChannel/paySource)
+── dto/
+│   └── AlipayAuthDTO.java              ✅ 支付宝授权请求(authCode + phoneResponse)
 ├── vo/
-│   └── PayResultVO.java                ✅ 支付结果视图(payStatus/payForm/tradeNo)
+│   ├── PayResultVO.java                ✅ 支付结果视图(payStatus/payForm/tradeNo)
+│   └── AlipayAuthVO.java               ✅ 授权结果(userId/userName/phone)
 ├── mapper/
-│   ├── HotelPayLogMapper.java          ✅ 含 @InterceptorIgnore + 幂等更新 + 按orderId查询
-│   └── xml/HotelPayLogMapper.xml       ✅ 自定义 SQL(幂等 UPDATE WHERE pay_status != 1)
+│   └── HotelPayLogMapper.java          ✅ @InterceptorIgnore + 幂等更新 + 按 orderId 查最新流水
+│       (XML:resources/mapper/business/hotel/pay/HotelPayLogMapper.xml,幂等 UPDATE WHERE pay_status != 1)
 ├── service/
-│   ├── HotelPayService.java            ✅ 支付接口(createPay/handlePayCallback/queryAlipayTradeStatus)
+│   ├── HotelPayService.java            ✅ createPay/handlePayCallback/queryAlipayTradeStatus/refundOrder/cancelTimeoutOrder
+│   ├── AlipayAuthService.java          ✅ 支付宝用户信息 + 手机号解密
 │   └── impl/
-│       └── HotelPayServiceImpl.java    ✅ 支付宝 WAP Pay + 回调处理 + 安全加固(5层保障)
-├── controller/
-│   └── open/
-│       └── HotelPayController.java     ✅ 支付接口(create/payNotify/returnUrl/status/alipayQuery/mockSuccess)
+│       ├── HotelPayServiceImpl.java    ✅ WAP Pay + 回调 + 退款 + 5 层安全加固
+│       └── AlipayAuthServiceImpl.java  ✅
+└── task/
+    └── PayTimeoutTask.java             ✅ @Scheduled(fixedDelay=120000) 每 2 分钟跨租户扫描超时未支付订单并取消(状态 10)
+```
+
+> ⚠️ **文档修正(3 处)**:
+> 1. 原记录的 `pay/constant/HotelPayConstants.java` **不存在**,payStatus / paySource 常量统一放在 `order/constant/HotelOrderConstants.java`。
+> 2. 原记录的 `pay/dto/PayCreateDTO.java` **不存在**,创建支付为 `createPay(Long orderId, String paySource)` 直接传参。
+> 3. `HotelPayController` 与 `AlipayAuthController` 实际位于 `controller/open/` 包下,**非** `pay/controller/open/`。
+
 ```
 forge-admin-ui/src/views/hotel/
-├── qrcode.vue                          ✅ 二维码管理页面(AiCrudPage)
-├── room.vue                            ✅ 房间管理页面(AiCrudPage)
-├── dish.vue                            ✅ 菜品管理页面(AiCrudPage,含图片预览/上下架/售罄)
-├── dishCategory.vue                    ✅ 菜品分类管理页面(AiCrudPage,含启禁用)
-├── dishSpec.vue                        ✅ 菜品规格管理(AiCrudPage + 选项管理弹窗)
+├── board.vue                           ✅ 房态看板(房间卡片网格 + 状态 Tab + 入住/退房/打扫/维护/入住记录,V1.0.107 建菜单)
+├── qrcode.vue                          ✅ 二维码管理(AiCrudPage + 批量生成/绑定/解绑/批量下载 ZIP)
+├── room.vue                            ✅ 房间管理(AiCrudPage 纯 CRUD;⚠️ 在住业务操作已拆到 board.vue,编辑表单不含状态字段)
+├── roomType.vue                        ✅ 房型管理(AiCrudPage)
+├── dish.vue                            ✅ 菜品管理(AiCrudPage,图片预览/上下架/售罄;⚠️ 无加料与规格绑定编辑入口)
+├── dishCategory.vue                    ✅ 菜品分类管理(AiCrudPage,含启禁用)
+├── dishSpec.vue                        ✅ 菜品规格管理(AiCrudPage + 选项管理弹窗,规格组按 dishId 挂载)
 ├── businessHours.vue                   ✅ 营业时段管理(AiCrudPage,时间段下拉选择)
-└── order.vue                           ✅ 订单管理(AiCrudPage + 看板统计 + 流程操作 + 详情弹窗)
+└── order.vue                           ✅ 订单管理(AiCrudPage + 看板统计 + 流程操作 + 详情弹窗 + 退单审核 + 退款状态列 + 手动退款 + SSE 通知
 
 forge-admin-ui/src/api/
-└── hotel.js                            ✅ 酒店模块 API(含菜品/分类/规格/营业时段/订单接口)
+└── hotel.js                            ✅ 酒店模块 API(房间/房型/二维码/菜品/分类/规格/营业时段/订单/退款/在住会话/销量排行)
+
+forge-admin-ui/src/composables/
+└── useOrderNotification.js             ✅ SSE 订阅 + MP3 提示音 + 桌面通知 + 防抖合并 + 首次交互补播
 
 forge-h5-ui/src/pages/hotel/
-├── scan-bind.vue                       ✅ 扫码绑定页面
+├── scan-bind.vue                       ✅ 扫码绑定页面(管理侧)
+├── qr-scanner.vue                      ✅ 扫码页(html5-qrcode)
 └── customer/
-    ├── room-confirm.vue                ✅ 房号确认入口页(扫码验证/房间卡片/开始点餐)
-    ├── menu.vue                        ✅ 点餐菜单页 Tab1(配送信息栏/Banner/搜索/分类/菜品列表/购物车浮层)
+    ├── room-confirm.vue                ✅ 房号确认入口页(扫码验证/房间卡片/开始点餐 + 🕐 非营业时段整页拦截
+    ├── menu.vue                        ✅ 点餐菜单页 Tab1(配送信息栏/Banner/搜索/分类/菜品列表/购物车浮层 + 🕐 非营业菜品区拦截
     ├── dish-detail.vue                 ✅ 菜品详情页(大图/规格选择/加料/备注/数量/加入购物车)
     ├── cart.vue                        ✅ 购物车页 Tab3(商品列表/数量控制/备注/费用汇总/结算)
-    ├── order-confirm.vue               ✅ 确认下单页(配送信息/送达时间/明细/提交,已移除硬编码支付方式)
+    ├── order-confirm.vue               ✅ 确认下单页(配送信息/送达时间/明细/提交 + 🕐 提交前营业状态刷新阻断,已移除硬编码支付方式)
     ├── pay.vue                         ✅ 支付确认页(支付宝 H5 WAP Pay + 轮询 + 主动查询兜底 + 倒计时)
     ├── order-status.vue                ✅ 订单状态页 Tab2(进度条/状态卡片/10秒轮询/自定义退单弹窗)
     └── orders.vue                      ✅ 订单列表页 Tab2(订单卡片列表/分页加载/状态标签/待支付去支付按钮)
@@ -125,10 +170,10 @@ forge-h5-ui/src/styles/
 └── hotel-theme.css                     ✅ 亚朵深墨绿主题 CSS 变量(--h-pri/--h-bg/--h-txt 等)
 
 forge-h5-ui/src/store/modules/
-└── hotel-order.js                      ✅ Pinia 购物车 Store(roomInfo/cartItems/orderNote,持久化)
+└── hotel-order.js                      ✅ Pinia 购物车 Store(roomInfo/cartItems/orderNote/businessHours,持久化不含营业状态
 
 forge-h5-ui/src/api/
-└── index.js                            ✅ 新增 12+ 个顾客端 API(扫码/分类/菜品/订单CRUD/退单/支付/支付宝查询)
+└── index.js                            ✅ 顾客端 API(扫码/分类/菜品/订单 CRUD/退单/放弃订单/支付/支付宝查询/支付宝授权/营业状态 businessHours
 ```
 
 ---
@@ -141,12 +186,19 @@ forge-h5-ui/src/api/
 | 2 | qrcode(二维码管理) | ✅ | ✅ | ✅ | ✅ | **已完成** |
 | 3 | open(开放扫码接口) | ✅ | — | — | ✅ | **已完成** |
 | 4 | dish-spec(菜品规格管理) | ✅ | ✅ | ✅ | — | **已完成** |
-| 5 | business-hours(营业时段) | ✅ | ✅ | ✅ | — | **已完成** |
+| 5 | business-hours(营业时段) | ✅ | ✅ | ✅ | ✅ | **已完成**(后端 currentStatus 推导 + 开放接口 + H5 三页接入 + 下单校验) |
 | 6 | hotelconfig(酒店基础配置) | ❌ | ❌ | ❌ | — | **暂缓**(暂不需要,配送费 + 预计配送时长待讨论) |
-| 7 | dish(菜品管理) | ✅ | ✅ | ✅ | — | **核心已完成**(加料/日志待开发) |
-| 8 | order(订单管理) | ✅ | ✅ | ✅ | ✅ | **全部完成**(后端+PC管理端+H5顾客端+支付系统) |
-| 8.1 | pay(支付系统) | ✅ | ✅ | — | ✅ | **全部完成**(支付宝沙箱 H5 WAP Pay + 安全加固 + 前端轮询兜底) |
-| 9 | notification(实时通知) | ✅ | — | ✅ | — | **已完成**(SSE + Web Audio声音 + 桌面通知 + 防抖合并) |
+| 7 | dish(菜品管理) | ✅ | ✅ | ⚠️ | — | **核心已完成**(加料后端已通但 **PC 无编辑入口**;操作日志 **仅实体+Mapper,零业务代码**) |
+| 8 | order(订单管理) | ⚠️ | ✅ | ✅ | ✅ | **主链路完成**(后端+PC管理端+H5顾客端+支付系统);🔴 **金额重算未计入规格/加料加价**,见 3.3 注意事项 |
+| 8.1 | pay(支付系统) | ⚠️ | ✅ | — | ✅ | **支付宝完成**(沙箱 H5 WAP Pay + 5 层安全加固 + 轮询兜底 + 退款 + 超时取消);🔴 **微信支付未实现**,WECHAT 落到 Mock 分支 |
+| 8.2 | customer-open(顾客端开放接口) | ✅ | — | — | ✅ | **已完成**(8 个免登录接口);🔴 tenantId 由前端传参、订单不校验会话归属,见缺口清单 3.2 |
+| 8.3 | alipay-auth(支付宝授权) | ✅ | — | — | ✅ | **已完成**(getUserInfo:姓名 + 手机号,为小程序化预留) |
+| 9 | notification(实时通知) | ⚠️ | — | ✅ | — | **已完成**(SSE + MP3 声音 + 桌面通知 + 防抖合并 + 跨服务转发);仅 NEW_ORDER / REFUND_REQUEST 两类事件,**无接单/出餐通知** |
+| 9.1 | stay(在住会话 + 订单退款) | ✅ | ✅ | ✅ | ✅ | **全部完成**(入住/退房/打扫/维护 + 退房联动取消 + 自动/手动退款,见 3.10) |
+| 9.2 | board(房态看板) | ✅ | ✅ | ✅ | — | **已完成**(独立页面承接入住/退房/打扫/维护操作,V1.0.107 建菜单) |
+| 10 | kitchen(厨房端) | ❌ | — | ❌ | ❌ | **未开发**(无待备餐列表/无小票打印/无菜品语音播报) |
+
+> 🔴 标记项与全部未完成项的**需求依据、证据链、修复方向与补齐排期**统一记在 `酒店模块需求缺口清单.md`,本表只保留实现状态。
 
 ---
 
@@ -222,7 +274,9 @@ forge-h5-ui/src/api/
 - [x] Controller: `HotelDishController.java` — 菜品接口(分页/详情/新增/修改/删除/上下架/售罄/恢复)
 - [x] Controller: `HotelDishCategoryController.java` — 分类接口(分页/新增/修改/删除/启禁用)
 - [x] Controller: `HotelDishSpecController.java` — 规格组 + 规格选项统一接口
-- [ ] Controller: 加料/日志 Controller(待开发)
+- [x] 加料:随菜品新增/修改一并保存(`HotelDishDTO.additions` → `saveAdditions()`),菜品详情回传 `additions`,H5 详情页可选加料并计算加价
+- [ ] ⚠️ **PC 端无加料编辑入口**:`dish.vue` 表单不含加料与规格绑定项,等于加料数据无维护入口
+- [ ] ⚠️ **菜品操作日志未落地**:`hotel_dish_log` 表 + `HotelDishLog` 实体 + `HotelDishLogMapper` 已备,但**无任何写入与查询代码**(无需独立 Controller,应在 `HotelDishServiceImpl` 各操作埋点)
 
 #### 数据库
 
@@ -341,13 +395,16 @@ forge-h5-ui/src/api/
   - `createPay()` — 根据 paySource 创建支付(ALIPAY → AlipayTradeWapPayRequest → 返回 payForm HTML)
   - `handlePayCallback()` — 支付宝异步回调处理(拆分两步:查 payLog 获取 tenantId → TenantContextHolder.executeWithTenant 包裹业务处理)
   - `queryAlipayTradeStatus()` — 主动查询支付宝侧订单状态(回调丢失时的兜底机制)
-- [x] Controller: `HotelPayController.java` — 5 个接口
+- [x] Controller: `controller/open/HotelPayController.java` — 5 个接口(❗ 位于 `controller/open/`,非 `pay/controller/`)
   - `POST /hotel/open/pay/create` — 创建支付(返回 payForm/tradeNo)
-  - `POST /hotel/open/pay/payNotify` — 支付宝异步回调(签名验证 + 金额校验 + app_id 校验 + 幂等更新 + 异常返回 fail 触发重试)
-  - `GET /hotel/open/pay/returnUrl` — 支付宝同步跳转(前端轮询检测支付状态)
+  - `POST /hotel/open/pay/notify` — 支付宝异步回调(签名验证 + 金额校验 + app_id 校验 + 幂等更新 + 异常返回 fail 触发重试)
   - `GET /hotel/open/pay/status` — 查询支付状态(前端轮询用)
   - `GET /hotel/open/pay/alipayQuery` — 主动查询支付宝侧订单状态(兜底机制)
   - `POST /hotel/open/pay/mockSuccess` — Mock 支付(开发调试用)
+  - ⚠️ 文档修正:原文记录的 `payNotify` 实为 `notify`;`returnUrl` 接口**不存在**(`AlipayConfig.returnUrl` 仅作为支付宝同步跳转地址配置,回到 H5 后由前端轮询检测状态)
+- [x] Controller: `controller/open/AlipayAuthController.java` — `POST /hotel/open/alipay-auth/getUserInfo`(authCode → 姓名,加密响应 → 手机号)
+- [x] Task: `pay/task/PayTimeoutTask.java` — 每 2 分钟跨租户扫描超时未支付订单(`selectExpiredUnpaidOrderIds` → `cancelTimeoutOrder`,状态 10)
+- [x] 退款:`HotelPayService.refundOrder()` — 支付宝 `AlipayTradeRefundRequest` 全额退款,失败标记 `refund_status=2` 可重试;MOCK 渠道直接标记成功
 
 ##### 数据库
 
@@ -473,8 +530,9 @@ forge-h5-ui/src/api/
 #### 注意事项
 
 - ~~源框架使用 `synchronized` 防止并发接单~~ → Forge 当前版本未加锁,后续高并发场景需补充 Redisson 分布式锁
-- ✅ 源框架创建订单时自动计算金额(不信任前端价格)→ Forge 已保持此安全策略
-- ✅ 源框架订单完成后自动累加菜品销量 → 已实现(`incrementDishSales`)
+- 🔴 **金额重算不完整(资金缺陷,待修)**:`orderCreate()` 虽然不信任前端价格,但**只取 `dish.getPrice()` 基础价**重算,未计入规格选项 `priceExtra` 与加料 `extraPrice`;同时 `hotel_order_item` 只存 `specDesc`/`additionsDesc` 文本,**无规格选项 ID / 加料 ID**,后端无法重算。后果:选加价规格/加料时**少收款**,且 `pay.vue` 金额取自后端 → **支付页金额 < 下单页金额**
+- 🔴 **配送费信任前端传值**:`OrderCreateDTO.deliveryFee` → `orderCreate()` 直接落库并计入总额,而该接口免登录;当前 H5 `hotelConfig.deliveryFee` 无数据来源恒为 0,暂无实际损失,但接口层可构造任意值(含负数)
+- ✅ 源框架订单完成后自动累加菜品销量 → 已实现(`incrementDishSales`);销量排行接口 `GET /hotel/dish/ranking` 与前端 `getDishSalesRanking()` 已就绪,**但无页面调用**
 - ✅ 源框架退单流程较复杂(客户退单 + 前台退单 + 审核退单)→ 已完整迁移
 - 订单状态使用数字 0~10(0=待支付, 1~9 原有状态, 10=超时取消),非英文字符串常量
 - 金额使用 `BigDecimal` + 元(与 dish 模块一致),编码规范要求 bigint + 分但为模块一致性保持 DECIMAL
@@ -487,9 +545,10 @@ forge-h5-ui/src/api/
 
 #### 后端
 
-- [x] Manager: `SseEmitterManager.java` — SSE 长连接管理 + 广播(ConcurrentHashMap + AtomicInteger)+ 远程接收(receiveNewOrderFromRemote)
-- [x] Service: `OrderNotificationService.java` — 通知广播服务(notifyNewOrder + notifyRefundRequest)
+- [x] Manager: `SseEmitterManager.java` — SSE 长连接管理 + 广播(ConcurrentHashMap + AtomicInteger)+ 远程接收(receiveNewOrderFromRemote),**直接承担通知广播职责**
 - [x] Controller: `NotificationController.java` — SSE 端点(subscribe/unsubscribe/connections/remote/notifyNewOrder)
+- ⚠️ 文档修正:原记录的 `OrderNotificationService.java`(notifyNewOrder + notifyRefundRequest)**不存在**,该能力已内聚到 `SseEmitterManager`
+- ⚠️ 事件类型仅 `NEW_ORDER`、`REFUND_REQUEST` 两类;**无接单/出餐通知事件**(需求 4.2「出餐通知接收」未满足)
 - [x] 集成: `HotelPayServiceImpl.java` — 支付成功后发布 NEW_ORDER 通知(支持跨服务 HTTP 调用)
 - [x] 集成: `HotelOrderServiceImpl.java` — 客户申请退单时发布 REFUND_REQUEST 通知
 - [x] 跨服务通信: 8583(AppServer)支付成功后通过 HTTP POST 调用 8580(AdminServer)`/hotel/notification/remote/notifyNewOrder` 接口,解决双服务 SSE 内存不共享问题
@@ -519,6 +578,59 @@ forge-h5-ui/src/api/
 
 ---
 
+### 3.10 stay — 在住会话机制 + 订单退款 ⭐⭐⭐⭐(已完成)
+
+> **背景**:系统与亚朵PMS不互通,房间入住/退房由前台在本系统手动操作。核心诉求:入住到退房期间房间客人不变;移动端订单/购物车与当前住客绑定;退房后新客人扫码进来订单和购物车必须是全新的。
+> 方案:引入“在住会话(hotel_room_stay)”——入住=开新会话,退房=关闭会话;订单挂载到会话(stay_id);H5 按会话隔离订单可见性与购物车。
+
+#### 后端(已完成)
+
+- [x] 实体:`HotelRoomStay.java`(roomId/guestName/guestPhone/checkInTime/checkOutTime/status/remark,`@TableLogic(value="0", delval="id")`)
+- [x] Mapper:`HotelRoomStayMapper.java` + XML(selectActiveByRoomId/selectStayPage/selectAllActive,均显式 `AND del_flag = 0`)
+- [x] VO:`HotelRoomStayVO.java`、`RoomCheckOutVO.java`(needConfirm/message/activeOrders/checkedOut)
+- [x] Service:`HotelRoomStayService(Impl).java` — roomCheckIn/roomCheckOut(两步交互)/roomCleaningDone/roomMaintenanceToggle/stayPage/backfillGuest
+- [x] Controller:`HotelQrCodeController` 新增 5 接口(/room/checkIn、/room/checkOut?force、/room/cleaningDone、/room/maintenanceToggle、/room/stayPage)
+- [x] 房间列表带在住客人:`HotelRoomMapper.xml` JoinActiveStay + `HotelRoomVO` 增 stayId/guestName/guestPhone/checkInTime
+- [x] 扫码增强:`QrCodeScanVO.RoomInfo` 增 roomStatus/stayId(H5 拦截与隔离依据)
+- [x] 房间删除保护:在住中房间禁止删除;`roomUpdate` 禁止直接改状态(状态仅通过操作流转)
+- [x] 订单绑定会话:`orderCreate` 写 stay_id + 未入住拦截 + 首单联系人仅空字段回写会话 + 房号以数据库为准;分页支持 stayId 过滤
+- [x] 退房联动:`cancelPendingByStayId` 批量取消待支付订单(状态 11),进行中订单需二次确认(force)
+- [x] 退款:`HotelPayService.refundOrder`(全额退款,`AlipayTradeRefundRequest`,失败标记可重试);4 个自动触发点(拒单/顾客退单/前台退单/审核通过);手动退款接口 `POST /hotel/order/refund`
+- [x] 回调竞态防护:已取消订单(7/9/10/11)收到支付回调不复活,标记异常流水,由前台手动退款
+- [x] 常量:`HotelOrderConstants` 新增 11=退房联动已取消 + 退款状态 0/1/2;`HotelQrConstants` 新增 STAY_ACTIVE/STAY_CHECKED_OUT
+
+#### 前端 PC(已完成)
+
+- [x] `board.vue`(房态看板):房间卡片网格 + 状态 Tab + 房间号/房型/楼层筛选 + 入住登记弹窗(住客姓名/电话/备注)+ 退房二次确认弹窗(进行中订单清单)+ 历史入住记录弹窗;菜单由 V1.0.107 写入
+- [x] `room.vue`:回归为 AiCrudPage **纯 CRUD**(房间号/房型/楼层/状态列/排序/备注);在住业务操作已拆到看板页,编辑表单不含状态字段
+- [x] `order.vue`:退款状态列(DictTag hotel_refund_status)+ 手动退款按钮(已支付未退款且已取消/退款失败场景)
+- [x] `api/hotel.js`:新增 6 个接口(roomCheckIn/roomCheckOut/roomCleaningDone/roomMaintenanceToggle/getRoomStayPage/orderRefund)
+- ⚠️ `board.vue` 与 `room.vue` 的房间状态标签/颜色为前端硬编码映射(`statusLabelMap`/`statusColorMap`),与 AGENTS.md 5.7「字典禁止硬编码」不一致,待补 `hotel_room_status` 字典后改 DictTag
+
+#### 前端 H5(已完成)
+
+- [x] `store/hotel-order.js`:roomInfo 增 stayId/roomStatus;setRoomInfo 检测会话变更自动清空购物车(退房后新客人全新购物车)
+- [x] `room-confirm.vue`:非入住中房间拦截点餐(“该房间暂未入住,如需点餐请联系前台”)
+- [x] `orders.vue`:订单列表改为按 stayId 过滤(同会话共享可见,退房后隔离),无会话时退回按房间号兼容存量数据;状态映射补 11=已取消
+- [x] `order-status.vue`:状态 11 文案/图标/提示补齐(“房间已退房,该订单已自动取消”)
+- [x] `order-confirm.vue`:下单已传 roomId,后端负责会话查找与拦截(双重保险)
+
+#### 房间状态流转规则(仅通过操作流转,禁止编辑表单直接改)
+
+```
+空闲/打扫中 --入住--> 入住中 --退房--> 打扫中 --打扫完成--> 空闲;空闲 <--> 维护中。
+退房时:待支付订单自动取消(11);进行中订单(2/3/4/5/8)需前台二次确认后强制退房。
+```
+
+#### 技术决策(本模块)
+
+- 入住不采集客人信息,首单联系人仅空字段回写会话(多人同住以第一位下单客人作为在住客人)
+- 退房两步交互:后端返回 needConfirm + 订单清单,前端弹窗确认后 force=true 重调,避免误操作丢失进行中订单
+- 退款仅全额退款;退款失败不阻断订单状态流转(内部持久化 refund_status=2),PC 订单页可手动重试
+- 退房竞态(退房瞬间顾客支付成功):回调不复活已取消订单,流水标记异常,由前台在订单页手动退款
+
+---
+
 ## 四、框架适配速查表
 
 | 适配项 | jeeplus(源) | Forge(目标) |
@@ -551,6 +663,7 @@ forge-h5-ui/src/api/
  7. dish(加料/日志)         ← 待开发
 ✅ 8. order(订单管理)         ← 全部完成(后端+PC管理端+H5顾客端+支付系统)
 ✅ 9. notification(实时通知)  ← 已完成(SSE + 声音提醒 + 桌面通知)
+✅ 9.1 stay(在住会话+退款)   ← 全部完成(入住/退房/打扫/维护 + 退房联动 + 自动/手动退款)
 ```
 
 ---
@@ -578,6 +691,11 @@ forge-h5-ui/src/api/
 | 表名 | 说明 | 迁移脚本 |
 |------|------|--------|
 | `hotel_pay_log` | 支付流水表(outTradeNo/tradeNo/payAmount/payStatus/payTime/notifyContent) | V1.0.106 |
+| `hotel_room_stay` | 房间在住会话表(roomId/guestName/guestPhone/checkInTime/checkOutTime/status 0=在住|1=已退房) | V1.0.106 |
+
+**V1.0.107__add_hotel_room_board_menu.sql**:无建表,仅新增「房态看板」菜单(`/hotel/board` → `hotel/board`,sort=9,动态查找 `酒店管理` 父菜单 + `NOT EXISTS` 防重)
+
+**hotel_order 扩展字段(V1.0.106)**:`stay_id`(在住会话ID + 索引)、`refund_amount`(实际退款金额)、`refund_trade_no`(支付宝退款交易号)、`refund_status`(0=未退款/1=成功/2=失败可重试)
 
 ### 待创建
 
@@ -585,7 +703,7 @@ forge-h5-ui/src/api/
 |------|------|--------|
 | `hotel_config` | 酒店基础配置 | hotelconfig(暂缓) |
 
-### 已完成(dish + dish-spec + business-hours 模块)![img.png](img.png)
+### 已完成(dish + dish-spec + business-hours 模块)
 
 | 表名 | 说明 | 迁移脚本 |
 |------|------|--------|
@@ -610,9 +728,11 @@ forge-h5-ui/src/api/
 
 | 字典类型 | 说明 | 迁移脚本 |
 |---------|------|--------|
-| `hotel_order_status` | 订单状态(0~10,含待支付/超时取消) | V1.0.104 + V1.0.105 |
+| `hotel_order_status` | 订单状态(0~11,含待支付/超时取消/11=退房联动已取消) | V1.0.104 + V1.0.105 + V1.0.106 |
 | `hotel_pay_method` | 支付方式(MOCK/CASH/ONLINE/CREDIT) | V1.0.104 |
 | `hotel_delivery_time_type` | 配送时间类型(ASAP/CUSTOM) | V1.0.104 |
+| `hotel_room_stay_status` | 在住会话状态(0=在住/1=已退房) | V1.0.106 |
+| `hotel_refund_status` | 订单退款状态(0=未退款/1=退款成功/2=退款失败可重试) | V1.0.106 |
 
 ### 待新增字典
 
@@ -628,38 +748,43 @@ forge-h5-ui/src/api/
 
 | 页面 | 路径 | 组件 | 说明 |
 |------|------|------|------|
-| 二维码管理 | `views/hotel/qrcode.vue` | AiCrudPage | 批量生成/绑定/解绑/下载 |
-| 房间管理 | `views/hotel/room.vue` | AiCrudPage | 房间/房型 CRUD |
+| 二维码管理 | `views/hotel/qrcode.vue` | AiCrudPage | 批量生成/绑定/解绑/批量下载 ZIP |
+| 房态看板 | `views/hotel/board.vue` | 自定义卡片网格 | 房间状态 Tab + 筛选 + 入住/退房/打扫/维护 + 退房二次确认 + 入住记录弹窗 |
+| 房间管理 | `views/hotel/room.vue` | AiCrudPage | 房间纯 CRUD(业务操作已拆到房态看板) |
+| 房型管理 | `views/hotel/roomType.vue` | AiCrudPage | 房型 CRUD |
 | 菜品管理 | `views/hotel/dish.vue` | AiCrudPage | 图片预览/上下架/售罄/主厨推荐 |
 | 菜品分类 | `views/hotel/dishCategory.vue` | AiCrudPage | 分类 CRUD + 启禁用 |
 | 菜品规格 | `views/hotel/dishSpec.vue` | AiCrudPage | 规格组列表 + 选项管理弹窗 |
 | 营业时段 | `views/hotel/businessHours.vue` | AiCrudPage | 时段列表 + 时间段下拉选择 + 启禁用 |
-| 扫码绑定 (H5) | `forge-h5-ui/src/pages/hotel/scan-bind.vue` | UniApp | 扫码绑定房间 |
+| 扫码绑定 (H5) | `forge-h5-ui/src/pages/hotel/scan-bind.vue` | UniApp | 扫码绑定房间(管理侧) |
+| 扫码页 (H5) | `forge-h5-ui/src/pages/hotel/qr-scanner.vue` | UniApp | html5-qrcode 相机扫码 |
 
 ### 已完成(order 模块 — PC 管理端)
 
 | 页面 | 路径 | 组件 | 说明 |
 |------|------|------|------|
-| 订单管理 | `views/hotel/order.vue` | AiCrudPage | 看板统计 + 流程操作 + 详情弹窗 + 退单审核 |
+| 订单管理 | `views/hotel/order.vue` | AiCrudPage | 看板统计 + 流程操作 + 详情弹窗 + 退单审核 + 退款状态列 + 手动退款 |
 
 ### 已完成(order 模块 — H5 顾客端)
 
 | 页面 | 路径 | Tab | 说明 |
 |------|------|-----|------|
-| 房号确认 | `forge-h5-ui/src/pages/hotel/customer/room-confirm.vue` | 入口 | 扫码验证房间、房间卡片、开始点餐 |
+| 房号确认 | `forge-h5-ui/src/pages/hotel/customer/room-confirm.vue` | 入口 | 扫码验证房间、非入住拦截、房间卡片、开始点餐 |
 | 点餐菜单 | `forge-h5-ui/src/pages/hotel/customer/menu.vue` | Tab1 | 分类导航、菜品列表、搜索、购物车浮层 |
 | 菜品详情 | `forge-h5-ui/src/pages/hotel/customer/dish-detail.vue` | 子页 | 大图、规格选择、加料、备注、加入购物车 |
 | 购物车 | `forge-h5-ui/src/pages/hotel/customer/cart.vue` | Tab3 | 商品列表、数量控制、备注、费用汇总、结算 |
 | 确认下单 | `forge-h5-ui/src/pages/hotel/customer/order-confirm.vue` | 子页 | 配送信息、送达时间、明细、提交订单(已移除硬编码支付方式) |
 | 支付确认 | `forge-h5-ui/src/pages/hotel/customer/pay.vue` | 子页 | 支付宝 H5 WAP Pay + 轮询 + 主动查询兜底 + 倒计时 |
 | 订单状态 | `forge-h5-ui/src/pages/hotel/customer/order-status.vue` | Tab2 | 进度条、状态卡片、10秒轮询、自定义退单弹窗 |
-| 历史订单 | `forge-h5-ui/src/pages/hotel/customer/orders.vue` | Tab2 | 订单卡片列表、分页加载、状态标签、待支付"去支付"按钮 |
+| 历史订单 | `forge-h5-ui/src/pages/hotel/customer/orders.vue` | Tab2 | 订单卡片列表(按在住会话 stayId 过滤)、分页加载、状态标签、待支付"去支付"按钮 |
 
 ### 待开发
 
 | 页面 | 路径 | 说明 | 源框架参考 |
 |------|------|------|-----------|
 | 酒店配置 | `views/hotel/config.vue` | 单例配置表单(暂缓) | `HotelConfigManagement.vue` |
+| 厨房待备餐大屏 | 待定 | 待备餐列表 + 出餐 + 语音播报 + 小票打印(需求 4.3,未开发) | `KitchenOrders.vue` |
+| 数据看板 | 待定 | 热门菜品/平均送达时长/趋势图(需求 4.5,现有 5 指标集成在 order.vue) | 无直接参考 |
 | ~~订单看板~~ | ~~`views/hotel/orderDashboard.vue`~~ | ~~已集成到 order.vue 工具栏,无需单独页面~~ | 无直接参考 |
 
 ---
@@ -680,7 +805,7 @@ forge-h5-ui/src/api/
 | 营业时段时间选择 | 使用下拉选择(09:00~23:30,每 30 分钟一档),而非时间选择器 |
 | 餐段类型 | 仅保留早餐/午餐/晚餐三种,移除夜宵 |
 | 前端组件规范 | 统一使用 AiCrudPage 配置化页面,保持分页/筛选/操作列格式一致 |
-| 订单状态值 | 使用数字 0~10 连续编号(0=待支付, 1~9 原有状态, 10=超时取消),非英文字符串常量 |
+| 订单状态值 | 使用数字 0~11 连续编号(0=待支付, 1~9 原有状态, 10=超时取消, 11=退房联动取消),非英文字符串常量 |
 | 订单金额 | 使用 `BigDecimal` + 元(`DECIMAL(10,2)`),与 dish 模块保持一致 |
 | 订单模块包结构 | 独立 `order/` 子包,与已有 hotel 代码分离 |
 | 看板统计集成 | 订单看板统计集成到 AiCrudPage 工具栏 `#toolbar-start` slot,不单独建页面 |
@@ -691,7 +816,7 @@ forge-h5-ui/src/api/
 | H5 退单弹窗 | 自定义弹窗替代 `uni.showModal`,包含标题/提示/原因输入框/取消确定按钮,样式统一 |
 | H5 购物车 Store | Pinia + `pinia-plugin-persistedstate` 持久化,`specKey` 合并相同规格商品 |
 | H5 订单轮询 | `order-status.vue` 对未完成订单每 10 秒轮询刷新,`onUnmounted` 清理定时器 |
-| H5 后端对接 | 订单状态使用数字 0~10,后端不信任前端价格(自动从数据库重算),`unitPrice` 含规格/加料加价 |
+| H5 后端对接 | 订单状态使用数字 0~11;后端按 `dishId` 从数据库重算 `unitPrice`,不信任前端价格。🔴 **当前重算只取菜品基础价,规格加价 `price_extra` 与加料加价 `extra_price` 被丢弃**(`hotel_order_item` 无规格/加料 ID 字段),导致少收款且支付页金额小于下单页 —— 详见 `酒店模块需求缺口清单.md` 3.1,属待修资金缺陷 |
 | 支付宝 H5 WAP Pay | 使用 `AlipayTradeWapPayRequest` + `pageExecute()` 返回 HTML 表单,前端渲染后自动提交跳转支付宝收银台 |
 | 支付回调租户上下文 | 支付宝回调无登录态/租户上下文,需从 payLog 提取 tenantId 后用 `TenantContextHolder.executeWithTenant()` 包裹后续 DB 操作 |
 | 支付回调幂等 | `UPDATE WHERE pay_status != 1` 利用数据库行锁保证原子性,替代 Java 层 if 检查 |
@@ -699,7 +824,21 @@ forge-h5-ui/src/api/
 | 支付轮询机制 | 递归 setTimeout(非 setInterval),首次延迟 3 秒给回调留处理时间,超时后主动查支付宝兜底 |
 | vConsole 调试工具 | 开发阶段通过 CDN + 环境变量开启,联调完成后从 `index.html`、`App.vue`、`.env.production` 三处彻底移除 |
 | H5 按钮越界修复 | `width: 100%` + `padding` 组合需配合 `box-sizing: border-box` 防止溢出(cart/order-confirm/pay 三个页面) |
+| 在住会话隔离 | 入住=开新会话、退房=关会话,订单挂 stay_id;H5 订单按 stayId 过滤、购物车随 stayId 变更自动清空,实现退房后新客人全新体验 |
+| 房间状态只走操作流转 | 编辑表单/接口禁止直接改房间状态,仅通过入住/退房/打扫/维护接口流转;在住中房间禁止删除 |
+| 退房两步交互 | 存在进行中订单时后端返回 needConfirm + 订单清单,前台确认后 force=true 强制退房;待支付订单直接自动取消(11) |
+| 退款策略 | 仅全额退款;拒单/顾客退单/前台退单/审核通过 4 个触发点自动退;退款失败标记可重试不阻断流转;退房竞态异常单由前台手动退款 |
+| 支付回调竞态 | 已取消订单(7/9/10/11)收到回调不复活,流水标记 `[竞态异常-订单已取消]`,人工介入退款 |
+| 下单房号以数据库为准 | orderCreate 不信任前端传的 roomNo,按 roomId 查房间实体回填,避免旧缓存/二维码改绑串单 |
+| 房态看板与房间管理拆分 | 房间业务操作(入住/退房/打扫/维护/入住记录)独立为 `board.vue`,`room.vue` 回归 AiCrudPage 纯 CRUD;菜单由 V1.0.107 写入(sort=9,动态查父菜单 + `NOT EXISTS` 防重) |
+| 支付超时取消用定时任务 | `PayTimeoutTask` 以 `@Scheduled(fixedDelay=120000, initialDelay=30000)` 轮询,配 `TenantContextHolder.executeIgnore` 跨租户扫描 `selectExpiredUnpaidOrderIds` → `cancelTimeoutOrder`(状态 10),不引入延迟消息中间件 |
+| 放弃待支付订单物理删除 | `abandonOrder` 对未支付订单做物理删除(订单 + 明细),依据 AGENTS.md 5.11「无恢复/审计要求的中间态」例外;已支付订单一律走逻辑删除 + 退款 |
+| 支付宝授权接口预留 | `/hotel/open/alipay-auth/getUserInfo`(authCode → 姓名,加密响应 → 手机号)先落地,为后续小程序化取用户信息预留,当前 H5 未调用 |
+| 跨服务 SSE 通知转发 | 8583 AppServer 与 8580 AdminServer 双 JVM 内存不共享,支付成功后由 AppServer HTTP POST 调 AdminServer `/hotel/notification/remote/notifyNewOrder`,以 `server.type` 配置区分服务角色 |
+| 🔴 开放接口鉴权现状(待加固) | `/hotel/open/**` 在 `SaTokenConfig` 中同时排除登录与 API 权限校验,`tenantId` 由前端传参、订单不校验 `stayId` 归属、`deliveryFee` 采用前端传值 —— 详见 `酒店模块需求缺口清单.md` 3.2,需签名 token 加固 |
+| 文档职责拆分 | 本文档只承载「代码地图与实现进度」;需求验收对照、P0/P1 缺口台账、补齐顺序一律进 `酒店模块需求缺口清单.md`,缺口关闭后细节转入 `code-copilot/changes/<变更名>/` |
+| 营业时间校验接入顾客端 | 后端新增 `BusinessHoursStatusVO` + `currentStatus()` 推导 + `GET /hotel/open/customer/businessHours` 免登录接口;H5 三页接入(`room-confirm` 入口整页拦截 / `menu` 菜品区独立校验 / `order-confirm` 提交阻断)+ 后端 `orderCreate()` 兜底校验;删除 `room-confirm.vue` 硬编码 `'10:00 - 22:00'`。变更目录:`code-copilot/changes/hotel-customer-business-hours-check/` |
 
 ---
 
-*文档最后更新:2026-09-01(支付系统完成:支付宝沙箱 H5 WAP Pay + 5层安全加固 + 前端轮询兜底 + H5 UI 清理(vConsole 移除/支付方式卡片移除/按钮越界修复),待开发:加料/日志)*
+*文档最后更新:2026-09-03(营业时间校验接入顾客端:后端新增 `BusinessHoursStatusVO` + `currentStatus()` + 开放接口 + 下单校验;H5 三页接入入口整页拦截/菜品区拦截/提交阻断;删除硬编码假营业时间;后端编译通过 + H5 构建通过。详见 `code-copilot/changes/hotel-customer-business-hours-check/`。需求视角的验收对照见同目录 `酒店模块需求缺口清单.md`)*

+ 296 - 0
forge-server/forge-business/forge-hotel/酒店模块需求缺口清单.md

@@ -0,0 +1,296 @@
+# 酒店模块需求缺口清单
+
+> **需求基线**:`需求说明书.html` v1.0(2026-07-26,状态:**需求确认中**)
+> **代码基线**:master @ 2026-09-03 实地核对(非文档推断)
+> **配套文档**:`酒店模块迁移跟踪.md` — 代码地图与实现进度(回答「代码里有什么」)
+> **本文档职责**:需求视角的验收对照与缺口台账(回答「需求要什么、还差什么」)
+> **条目归宿**:缺口关闭后不在本文档堆积实现细节,转入 `code-copilot/changes/<变更名>/`,本文档只留状态与链接
+
+---
+
+## 一、结论速览
+
+需求定义**五端协同**(顾客端 / 餐厅前台 / 厨房端 / 宾馆前台 / 后台管理),当前实际落地为**「1 个顾客端 H5 + 1 个 PC 管理端(兼餐厅前台与宾馆前台职能)」**。
+
+| 判断 | 结论 |
+|------|------|
+| 主链路 | ✅ 扫码 → 房号确认 → 点餐 → 购物车 → 下单 → 支付 → 接单/备餐/出餐/配送/完成 → 退单退款,**已跑通** |
+| 厨房端 | ❌ **完全缺失**(0 页面、0 打印、0 菜品语音播报) |
+| 微信支付 | ❌ **缺失**,微信环境实际落到 Mock 分支 |
+| 小程序载体 | ❌ **缺失**,`manifest.json` 中 `mp-weixin.appid` 为空、`mp-alipay` 无 appid,实际以 H5 运行 |
+| 品牌风格设置 | ❌ **缺失**(三套配色 / 五端同步),对应 `hotelconfig` 模块整体暂缓 |
+| **订单金额正确性** | 🔴 **存在资金缺陷**:规格加价与加料加价在后端被丢弃,详见 3.1 |
+| **开放接口越权** | 🔴 **存在数据隔离缺陷**:`tenantId` 由前端传参、订单详情不校验会话归属,详见 3.2 |
+| 超出需求的增量 | ➕ 在住会话 / 房态看板 / 退房联动 / 退款全链路 / 支付超时取消,详见第七节 |
+
+---
+
+## 二、五端落地矩阵
+
+| 端 | 需求要求 | 落地情况 | 载体 |
+|---|---|---|---|
+| 📱 顾客端 | 微信/支付宝小程序,8 页 | **8 页全部实现**,但运行在 H5,小程序未发布 | `forge-h5-ui/src/pages/hotel/customer/` |
+| 🖥️ 餐厅前台 | PAD / 移动 / PC,4 页 | **无独立端**,职能由 PC 订单页承担;缺「临时暂停接单」「移动端」 | `views/hotel/order.vue`、`views/hotel/businessHours.vue` |
+| 👨‍🍳 厨房端 | 大屏 + 打印机,2 页 | **完全未实现**(仅「出餐」动作可在 PC 订单页点击) | — |
+| 🏨 宾馆前台 | PC / 移动,2 页 | **无独立端**,PC 订单页覆盖全订单查看/筛选/任意阶段退单/手动退款;缺移动端 | `views/hotel/order.vue` |
+| ⚙️ 后台管理 | PC,5 页 | 菜品/订单/房间三页齐备且更强;**数据看板指标不全**、**系统设置缺失** | `views/hotel/` |
+
+---
+
+## 三、🔴 P0 缺口(阻塞上线,含资金与安全)
+
+### 3.1 订单金额未计入规格/加料加价 —— 资金缺陷 ⚠️
+
+> **AGENTS.md 5.9 安全红线:涉及资金,必须在 Spec 中标注并经人工审查。**
+
+**证据链**
+
+| 环节 | 位置 | 行为 |
+|------|------|------|
+| 前端算价 | `forge-h5-ui/src/pages/hotel/customer/dish-detail.vue` L317-319 | `unitPrice = 菜品基础价 + Σ priceExtra(规格) + Σ extraPrice(加料)` |
+| 前端提交 | `forge-h5-ui/src/pages/hotel/customer/order-confirm.vue` L141-148 | items 只带 `specDesc` / `additionsDesc` **文本**,不带规格选项 ID / 加料 ID |
+| 后端重算 | `HotelOrderServiceImpl.orderCreate()` L158-175 | `unitPrice = dish.getPrice()` **仅基础价**,覆盖前端传值;`subtotal = 基础价 × quantity` |
+| 表结构 | `HotelOrderItem` / `hotel_order_item` | 只有 `specDesc`、`additionsDesc` 冗余文本,**无 `spec_option_ids` / `addition_ids`** → 后端即使想重算也缺少关联依据 |
+
+**后果(当前即可复现)**
+
+1. **少收款**:顾客选加价规格/加料,实际订单金额按基础价计算。
+2. **金额前后不一致**:`pay.vue` 的金额取自后端 `orderDetail`,因此**支付页金额 < 下单页/购物车金额**,用户可见。
+3. **明细不可追溯**:只存文本描述,规格选项被改名/删除后历史订单无法还原加价构成。
+
+**修复方向**
+
+- Flyway `V1.0.108`:`hotel_order_item` 增加 `spec_option_ids`、`addition_ids`(或 JSON 价格快照列)
+- `OrderCreateDTO.items` 增加规格选项 ID 列表 + 加料 ID 列表
+- `orderCreate()` 按 ID 从 `hotel_dish_spec_option.price_extra` + `hotel_dish_addition.extra_price` **服务端重算** unitPrice,继续不信任前端价格
+- H5 提交时携带 ID(`specDesc` / `additionsDesc` 保留为冗余展示)
+
+---
+
+### 3.2 开放接口租户与订单越权风险 —— 数据隔离缺陷 ⚠️
+
+> 需求 7.安全要求:「订单数据隔离,各端仅可见本端权限范围内数据」。
+
+**证据链**
+
+- `SaTokenConfig` L79 / L104:`/hotel/open/**` 同时排除**登录校验**与 **API 权限校验**
+- `HotelCustomerController` 全部 8 个接口的 `tenantId` 由**请求参数**传入,服务端无凭证校验
+- `HotelOpenController#scan` 返回的 `tenantId` 无签名、无时效,可被任意复用
+- `HotelOrderServiceImpl.requireOrder()` L478-487:仅校验 `tenantId` 一致,**不校验订单是否属于当前 `stayId`**
+- `orderPage` 的 `stayId` 过滤条件由前端传入,**不是安全边界**
+
+**后果**
+
+1. 构造任意 `tenantId` 可跨租户读取菜品/订单。
+2. 知道 `orderId` 即可查看任意订单详情(含房号、联系人、联系电话)—— 违反需求「接单前顾客自助退」的权限边界。
+3. `deliveryFee` 由前端传值(`OrderCreateDTO.deliveryFee` → `orderCreate()` L149 直接落库、L174 计入总额),免登录接口可构造任意值(含负数)。当前 H5 `hotel-order.js` L17 的 `hotelConfig.deliveryFee` 无数据来源恒为 0,**暂无实际资金损失,但接口层可篡改**。
+
+**修复方向**
+
+- 扫码下发**短时签名 token**(含 `tenantId` + `roomId` + `stayId` + 过期时间),后续 open 接口从 token 解析上下文,取消前端传 `tenantId`
+- `orderDetail` / `orderPage` / `customerRefund` / `abandonOrder` 强制按 token 中的 `stayId` 校验归属
+- `deliveryFee` 改为服务端配置读取(依赖 `hotelconfig` 模块),过渡期至少做 `>= 0` 与上限校验
+
+---
+
+### 3.3 微信支付渠道缺失
+
+| 项 | 现状 |
+|---|---|
+| 前端 | `pay.vue` L126-136:条件编译 + UA 检测(`micromessenger`)→ `paySource = 'WECHAT'` |
+| 后端 | `HotelPayServiceImpl.createPay()` L113:`if ("ALIPAY".equals(paySource))` … **else 全部落到 Mock 分支**(L148-149) |
+| 前端兜底 | `pay.vue` L258-276:非 ALIPAY 分支直接调 `hotelPayMockSuccess` → **显示「支付成功」但没有真实扣款** |
+| 退款 | `refundOrder()` L467-473:MOCK 渠道直接标记退款成功,无真实资金流 |
+
+**修复方向**:补 `WECHAT` 分支(微信内 H5 用 JSAPI 支付,需 openid;扫码公众号/小程序另议)+ 微信回调验签 + 微信退款接口。
+**前置依赖**:微信商户号资质(当前无公司资质,见第八节待确认项)。
+
+---
+
+### 3.4 厨房端完全缺失(需求 4.3 四项中三项未做)
+
+| 需求项 | 优先级 | 状态 |
+|---|---|---|
+| 待做订单列表(按接单时间排序) | P0 | ❌ 无页面 |
+| 语音播报菜品内容 | P0 | ❌ 仅固定 MP3 提示音,无 TTS 播报菜品名 + 房号 |
+| 自动打印小票(菜品/房号/备注) | P0 | ❌ 全仓无任何打印相关代码 |
+| 出餐操作 | P0 | ✅ 借道 PC `order.vue` → `/hotel/order/ready` |
+
+**修复方向**:新建厨房大屏页面(PC 端或 H5 横屏),SSE 订阅接单事件;小票可先做浏览器 `window.print()` 模板,热敏打印机(需求要求 1920×1080 大屏 + 打印机)后置。
+
+---
+
+### 3.5 出餐/接单通知事件缺失(需求 4.2「出餐通知接收」)
+
+- `SseEmitterManager` 当前只有 `NEW_ORDER`、`REFUND_REQUEST` 两类事件
+- 需求 3.5 流程图要求:接单 → **通知厨房**(语音播报 + 打印小票);出餐 → **通知餐厅前台**安排送餐
+- 现状:`orderAccept` / `orderReady` 均无任何通知发布
+
+**修复方向**:`SseEmitterManager` 增加 `ORDER_ACCEPTED`、`ORDER_READY` 事件;`orderAccept()` / `orderReady()` 发布;PC `useOrderNotification.js` 增加对应提示;厨房端订阅。
+
+---
+
+### 3.6 顾客端小程序载体缺失(需求 4.1「扫码进入」、7.兼容性)
+
+- `forge-h5-ui/src/manifest.json`:`mp-weixin.appid = ""`,`mp-alipay` 无 appid 配置
+- 需求要求「扫聚合码 → 环境判断 → **自动进入微信/支付宝小程序**」,现状是进入 H5 页面
+- 已有预留:`AlipayAuthController#getUserInfo`(支付宝授权取姓名 + 手机号)、`pay.vue` 中 `#ifdef MP-ALIPAY` 的 `my.tradePay` 分支
+
+**已知外部约束**(历史结论,需复核):支付宝沙箱 App 扫码不识别普通 URL 二维码;小程序 web-view 需**已备案业务域名**,临时隧道域名不满足。
+
+**修复方向**:注册小程序账号 → 配置 appid → 原生页面或 web-view 嵌入 H5 → 购买并备案域名 → 接入真实支付。
+
+---
+
+## 四、功能项逐条对照(需求 4.1 ~ 4.5)
+
+### 4.1 顾客端(11 项 → 10 完成 / 1 部分)
+
+| 需求项 | 优先级 | 状态 | 实现位置与说明 |
+|---|---|---|---|
+| 扫码进入 | P0 | ✅ | 短码机制(URL 带 `c` 参数)+ `GET /hotel/open/scan`;一房一码、批量生成/绑定/解绑/ZIP 下载 |
+| 房号确认 | P0 | ✅ | `room-confirm.vue`,额外做了「非入住房间拦截点餐」 |
+| 菜单浏览 | P0 | ✅ | `menu.vue`:分类横滚、搜索、售罄标识、购物车浮层 |
+| 菜品详情 | P0 | ✅ | `dish-detail.vue`:大图轮播/规格(必选/可选)/**加料**/备注/数量(超出需求) |
+| 购物车 | P0 | ✅ | `cart.vue` + Pinia 持久化,`specKey` 合并同规格 |
+| 确认下单 | P0 | ✅ | `order-confirm.vue`:房号/联系人/立即或预约送达/备注 |
+| 在线支付 | P0 | ⚠️ | 支付宝沙箱 H5 WAP Pay 全链路(payForm 自动提交 + 轮询 + 主动查询兜底 + 5 层安全校验);**微信支付未实现**(见 3.3) |
+| 订单状态追踪 | P0 | ✅ | `order-status.vue`:进度条 + 10 秒轮询;状态 0~11,比需求 6 节点更细 |
+| 退单(接单前) | P0 | ✅ | `customerRefund`:待支付直接取消、待接单直接退 + 自动全额退款 |
+| 我的订单 | P1 | ✅ | `orders.vue`:分页、状态标签、待支付「去支付」 |
+| **营业时间提示** | P1 | ✅ | 后端新增 `BusinessHoursStatusVO` + `currentStatus()` 推导 + `GET /hotel/open/customer/businessHours` 免登录接口;H5 三页接入:`room-confirm.vue` 入口整页拦截、`menu.vue` 菜品区独立校验(防 TabBar 绕过)、`order-confirm.vue` 提交前刷新阻断,后端 `orderCreate()` 兜底校验。变更目录:`code-copilot/changes/hotel-customer-business-hours-check/` |
+
+### 4.2 餐厅前台(7 项 → 4 完成 / 2 部分 / 1 未做)
+
+| 需求项 | 优先级 | 状态 | 说明 |
+|---|---|---|---|
+| 新订单语音提醒 | P0 | ✅ | SSE + MP3(`女生-订单提醒.mp3`)+ 桌面通知 + 3 秒防抖合并 + Chrome 自动播放策略绕过 |
+| 接单/拒单 | P0 | ✅ | `/hotel/order/accept`、`/reject`(拒单必填原因,且自动退款) |
+| 订单处理流转 | P0 | ✅ | accept → prepare → ready → deliver → complete |
+| 营业时间设置 | P0 | ✅ | `businessHours.vue`:多时段、按星期、启禁用 |
+| 出餐通知接收 | P0 | ⚠️ | 见 3.5,无出餐通知事件 |
+| 当日订单看板 | P1 | ⚠️ | `order.vue` 工具栏 5 指标(待接单/备餐中/配送中/今日订单/今日营收);**缺「预计送达时间」一览** |
+| **临时暂停接单** | P1 | ❌ | 无字段、无接口、无开关 |
+
+> 需求要求语音提醒「可暂停/调整音量」,实际 `useOrderNotification.js` L144 `audio.volume = 0.8` 硬编码,无任何控制 UI。
+
+### 4.3 厨房端(4 项 → 1 完成 / 3 未做)
+
+见 3.4。
+
+### 4.4 宾馆前台(4 项 → 全部完成,部分形态不同)
+
+| 需求项 | 优先级 | 状态 | 说明 |
+|---|---|---|---|
+| 全订单查看 | P0 | ✅ | `/hotel/order/page`,不限状态 |
+| 订单状态总览 | P0 | ✅ | 房间号/状态/联系人/支付方式筛选 |
+| 退单(任何阶段) | P0 | ✅ **更强** | `hotelRefund`(任意阶段强制退)+ `refundApprove`/`refundReject`(审核)+ 自动全额退款 + **手动退款重试**(`refund_status=2`) |
+| 订单详情查看 | P1 | ⚠️ | 详情弹窗有基本信息/金额/时间记录/菜品明细,**无可视化「状态时间线」** |
+
+### 4.5 后台管理(5 项 → 3 完成 / 1 部分 / 1 未做)
+
+| 需求项 | 优先级 | 状态 | 说明 |
+|---|---|---|---|
+| 菜品管理 | P0 | ✅ | 上下架/售罄恢复/价格/主厨推荐/多图预览/分类启禁用/规格组 + 选项(按菜品挂载);⚠️ **PC 无加料编辑入口**(见 5.10) |
+| 订单管理 | P0 | ✅ | `order.vue` |
+| 房间管理 | P0 | ⚠️ | 房间/房型 CRUD + 二维码批量生成绑定下载 + **房态看板**(`board.vue`,V1.0.107 建菜单);缺「二维码重生成/补打」独立动作、缺「启停点餐」字段(`HotelRoom` 无该字段) |
+| **数据看板** | P1 | ⚠️ | 今日订单数、营业额 ✅;**热门菜品 ❌、平均送达时长 ❌、趋势图 ❌**。后端 `GET /hotel/dish/ranking`(`dishSalesRanking`)与前端 `getDishSalesRanking()` **均已就绪但无任何页面调用** |
+| **系统设置(品牌风格)** | P1 | ❌ | `hotelconfig` 模块整体暂缓;H5 主题硬编码 `styles/hotel-theme.css`(墨绿 `#2D5016`),三套配色与五端同步都没有 |
+
+---
+
+## 五、🟡 P1 缺口台账
+
+| # | 缺口 | 落点 | 备注 |
+|---|---|---|---|
+| 5.1 | ~~营业时间校验接入顾客端~~ | ~~已关闭~~ | ✅ **已完成**。后端 `BusinessHoursStatusVO` + `currentStatus()` + 开放接口 + 下单校验;H5 三页接入(入口拦截 / 菜品区拦截 / 提交阻断)。变更目录:`code-copilot/changes/hotel-customer-business-hours-check/` |
+| 5.2 | 数据看板补齐(热门菜品/平均送达时长/趋势图) | `OrderDashboardVO` 扩字段 + 新增看板页 | `ranking` 接口可直接复用 |
+| 5.3 | 系统设置 / 品牌风格配色(三套方案、五端同步) | 重启 `hotelconfig` 模块(跟踪文档 3.1) | 含待讨论的配送费 / 配送时长 |
+| 5.4 | 临时暂停接单 | `hotel_business_hours` 或酒店配置加开关 + 下单前校验 | 需 Flyway 新增开关字段。与 5.1 **不合并**(人为暂停 vs 时段外语义独立,合并会使 fail-open 判定复杂化),但可复用 5.1 建好的提示条与阻断链路 |
+| 5.5 | 语音提醒可控(音量/暂停)+ TTS 播报菜品名与房号 | `useOrderNotification.js` | 需求 7.语音要求 |
+| 5.6 | 当日看板显示预计送达时间 | `order.vue` 看板区 | 数据已有 `customDeliveryTime` |
+| 5.7 | 房间「启停点餐」开关 + 二维码重生成/补打 | `HotelRoom` 加字段、`qrcode.vue` 加动作 | 需 Flyway |
+| 5.8 | 订单状态时间线可视化 | `order.vue` 详情弹窗 | 时间字段齐备 |
+| 5.9 | 餐厅前台 / 宾馆前台移动端(PAD) | H5 工程新增管理侧页面 | 需求 2.角色架构 |
+| 5.10 | PC 端菜品加料编辑入口 | `views/hotel/dish.vue` 表单 | 后端 `HotelDishDTO.additions` + `saveAdditions()` 已支持,H5 已在读 `dish.additions`,等于**数据无入口维护** |
+| 5.11 | 菜品操作日志 | `HotelDishServiceImpl` 各操作埋点 + 查询接口 | `hotel_dish_log` 表与 `HotelDishLogMapper` 已建,**零业务代码**(无写入、无查询) |
+| 5.12 | 顾客端状态更新延迟 | 轮询改推送或缩短间隔 | 需求要求 < 3 秒,现状 10 秒轮询**不达标** |
+
+---
+
+## 六、非功能性需求对照(需求第 7 章)
+
+| 类别 | 需求 | 现状 |
+|---|---|---|
+| 性能 | 扫码进入 < 2s、菜单加载 < 1.5s、状态更新 < 3s | ⚠️ 未做专项压测;状态更新为 **10 秒轮询,不达标**(PC 端 SSE 实时达标) |
+| 兼容 | 微信/支付宝小程序 | ❌ 未落地(H5 替代) |
+| 兼容 | Chrome 90+ / Safari 14+ / 移动端浏览器 | ✅ PC 管理端 + H5 满足 |
+| 兼容 | 厨房大屏 1920×1080 + 热敏打印机 | ❌ 无厨房端、无打印 |
+| 语音 | 前台新订单语音提醒(可暂停/调音量) | ⚠️ 有提醒,**无音量/暂停控制** |
+| 语音 | 厨房播报菜品名称和房号 | ❌ 未实现 |
+| 视觉 | 简洁商务风、留白多、扁平化 | ✅ H5 亚朵墨绿主题符合 |
+| 视觉 | 三套候选配色(商务深蓝/暖金雅致/墨绿清新)待客户确认 | ❌ 仅墨绿一套且硬编码 |
+| 视觉 | 品牌风格设置后五端同步生效 | ❌ 未实现 |
+| 视觉 | 移动端底部 Tab 导航 / PC 左导航右内容 | ✅ `HotelTabBar` 3-tab + Forge 后台布局 |
+| 安全 | 支付环境自动判断,防支付方式注入 | ⚠️ **判断在前端**(UA + 条件编译),`paySource` 由前端传,非服务端环境判断 |
+| 安全 | 退单权限控制(接单前顾客自退、接单后仅宾馆前台) | ⚠️ 后端状态机规则正确,但**开放接口无身份凭证**,权限边界可被绕过(见 3.2) |
+| 安全 | 订单数据隔离 | ⚠️ 租户 + `stayId` 双重隔离已设计,但 `stayId` 由前端传参,**非安全边界**(见 3.2) |
+
+---
+
+## 七、超出需求的增量成果(避免被误判为超范围)
+
+需求文档 v1.0 未包含、但已实现并构成产品竞争力的部分:
+
+1. **在住会话机制** `hotel_room_stay`:入住/退房/打扫完成/维护切换 + 退房联动取消待支付订单(状态 11)+ 退房二次确认(`needConfirm` + 进行中订单清单)+ 订单挂 `stay_id` + 退房后购物车自动清空
+2. **房态看板** `views/hotel/board.vue`(V1.0.107 建菜单):房间卡片网格 + 状态 Tab + 楼层/房型筛选 + 入住登记/退房/打扫/维护/入住记录
+3. **支付超时自动取消** `PayTimeoutTask`:`@Scheduled(fixedDelay=120000)` 每 2 分钟跨租户扫描 `selectExpiredUnpaidOrderIds` → `cancelTimeoutOrder`(状态 10)
+4. **放弃待支付订单** `abandonOrder`:物理删除订单与明细(未支付无审计留痕要求),H5 侧恢复购物车
+5. **退款全链路**:支付宝 `AlipayTradeRefundRequest` + `refund_status` 0/1/2 + 4 个自动触发点(拒单/顾客退单/前台退单/审核通过)+ 手动重试 + **回调竞态防护**(已取消订单 7/9/10/11 收到回调不复活,流水标记异常)
+6. **支付宝小程序用户授权** `/hotel/open/alipay-auth/getUserInfo`(authCode → 姓名,加密响应 → 手机号),为小程序化预留
+7. **跨服务 SSE 通知**:8583 AppServer 支付成功后 HTTP POST 调 8580 AdminServer `/hotel/notification/remote/notifyNewOrder`,解决双 JVM 内存不共享;`server.type` 配置区分服务角色
+8. **多租户隔离**:需求只要求「各端仅可见本端权限范围数据」,实际做到租户级 + 在住会话级双重隔离
+
+---
+
+## 八、待客户/业务方确认项
+
+| # | 待确认 | 影响的缺口 |
+|---|---|---|
+| 1 | **顾客端载体**:真小程序(需资质 + 备案域名)还是继续 H5? | 3.3、3.6 |
+| 2 | **微信支付资质**:是否有商户号?无则微信内扫码如何收款 | 3.3 |
+| 3 | **厨房端硬件**:大屏规格、热敏打印机型号与对接方式(驱动/ESC-POS/云打印) | 3.4 |
+| 4 | **三套配色方案**选哪套,是否真需要「五端同步生效」 | 5.3 |
+| 5 | **配送费规则**:按距离/按区域/固定?是否阶梯定价 | 3.2、5.3 |
+| 6 | **预计配送时长**:固定值还是动态计算 | 5.3、5.6 |
+| 7 | **临时暂停接单**粒度:全店暂停还是按餐段暂停 | 5.4 |
+| 8 | **规格/加料加价**是否为正式业务规则(决定 3.1 修复优先级) | 3.1 |
+
+---
+
+## 九、建议补齐顺序
+
+**第 0 批(资金与安全,最高优先,不等需求确认)**
+1. `3.1` 订单金额计入规格/加料加价 —— **少收款 + 支付页金额不一致,属线上缺陷**
+2. `3.2` 开放接口签名 token + 订单归属校验 + `deliveryFee` 服务端化
+
+**第 1 批(打通真实收款 + 厨房闭环)**
+3. `3.3` 微信支付分支 → 4. `3.4` 厨房端待备餐/出餐页 → 5. `3.5` SSE 接单/出餐事件 → 6. 小票打印(先浏览器打印模板)
+
+**第 2 批(顾客端合规上线)**
+7. ~~`5.1` 营业时间校验接入 H5~~ ✅ 已关闭 → 8. `3.6` 小程序 appid + 备案域名 + web-view → 9. `5.12` 状态推送满足 < 3s
+
+**第 3 批(运营与管理完善)**
+10. `5.2` 数据看板补齐 → 11. `5.3` `hotelconfig` + 品牌配色 → 12. `5.4` 临时暂停接单 → 13. `5.10` 加料 PC 入口 + `5.11` 操作日志埋点 → 14. `5.7` 房间启停点餐 + 二维码补打 → 15. `5.9` 前台移动端
+
+> 每一项落地时按 AGENTS.md 2.1 走 `/propose <需求>` → `code-copilot/changes/<变更名>/spec.md + tasks.md`,本文档只更新状态列。
+
+---
+
+## 十、变更记录
+
+| 日期 | 变更 | 说明 |
+|------|------|------|
+| 2026-09-03 | 建档 | 基于 `需求说明书.html` v1.0 与 master 代码实地核对生成;同步修订 `酒店模块迁移跟踪.md` 中与代码不一致的 7 处 |
+| 2026-09-03 | 5.1 出提案 | 新增变更目录 `code-copilot/changes/hotel-customer-business-hours-check/`(spec.md + tasks.md);补记 `room-confirm.vue` L107 营业时间硬编码且从未赋值的新发现;修正 5.4 与 5.1 的关系为「不合并」 |
+| 2026-09-03 | 5.1 ✅ 已关闭 | 后端新增 `BusinessHoursStatusVO` + `currentStatus()` + 开放接口 + 下单校验;H5 三页接入(入口整页拦截 / 菜品区独立校验 / 提交阻断);后端编译通过 + H5 构建通过 + 硬编码零残留 |

BIN
forge-server/forge-business/forge-hotel/需求说明书.docx


+ 981 - 0
forge-server/forge-business/forge-hotel/需求说明书.html

@@ -0,0 +1,981 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>酒店客房送餐系统 · 需求说明书</title>
+<style>
+  :root {
+    --pri: #1E3A5F;
+    --pri-lt: #2C5282;
+    --acc: #2563EB;
+    --bg: #F8FAFC;
+    --bg-card: #FFFFFF;
+    --txt: #1E293B;
+    --txt-sub: #64748B;
+    --bdr: #E2E8F0;
+    --succ: #16A34A;
+    --warn: #D97706;
+    --dang: #DC2626;
+  }
+  * { margin: 0; padding: 0; box-sizing: border-box; }
+  body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; background: var(--bg); color: var(--txt); line-height: 1.8; }
+  
+  /* 封面 */
+  .cover {
+    background: linear-gradient(135deg, #1E3A5F 0%, #2C5282 50%, #1A365D 100%);
+    color: #fff;
+    padding: 80px 60px;
+    text-align: center;
+    page-break-after: always;
+  }
+  .cover .tag {
+    display: inline-block;
+    border: 1px solid rgba(255,255,255,.4);
+    color: rgba(255,255,255,.8);
+    padding: 4px 16px;
+    border-radius: 20px;
+    font-size: 13px;
+    letter-spacing: 2px;
+    margin-bottom: 24px;
+  }
+  .cover h1 { font-size: 36px; font-weight: 700; margin-bottom: 12px; letter-spacing: 4px; }
+  .cover h2 { font-size: 18px; font-weight: 400; opacity: .8; margin-bottom: 40px; }
+  .cover .meta { display: flex; justify-content: center; gap: 48px; font-size: 13px; opacity: .7; }
+  .cover .meta span { display: flex; align-items: center; gap: 6px; }
+
+  /* 内容区 */
+  .container { max-width: 900px; margin: 0 auto; padding: 48px 40px; }
+
+  /* 章节 */
+  .section { margin-bottom: 56px; }
+  .section-title {
+    font-size: 22px;
+    font-weight: 700;
+    color: var(--pri);
+    border-left: 4px solid var(--acc);
+    padding-left: 14px;
+    margin-bottom: 20px;
+    line-height: 1.4;
+  }
+  .section-subtitle {
+    font-size: 16px;
+    font-weight: 600;
+    color: var(--pri-lt);
+    margin: 28px 0 12px;
+  }
+
+  /* 文字 */
+  p { color: var(--txt-sub); margin-bottom: 10px; }
+  p strong { color: var(--txt); }
+
+  /* 卡片 */
+  .card {
+    background: var(--bg-card);
+    border: 1px solid var(--bdr);
+    border-radius: 10px;
+    padding: 24px;
+    margin-bottom: 16px;
+  }
+
+  /* 角色卡片 */
+  .role-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; }
+  .role-card {
+    background: var(--bg-card);
+    border: 1px solid var(--bdr);
+    border-radius: 10px;
+    padding: 20px;
+    border-top: 3px solid var(--acc);
+  }
+  .role-card .role-icon { font-size: 28px; margin-bottom: 8px; }
+  .role-card .role-name { font-size: 15px; font-weight: 700; color: var(--pri); margin-bottom: 4px; }
+  .role-card .role-device { font-size: 12px; color: var(--txt-sub); margin-bottom: 8px; }
+  .role-card .role-desc { font-size: 13px; color: var(--txt-sub); line-height: 1.6; }
+
+  /* 表格 */
+  table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 13px; }
+  thead th {
+    background: #F1F5F9;
+    color: var(--pri);
+    font-weight: 600;
+    padding: 10px 14px;
+    text-align: left;
+    font-size: 12px;
+    text-transform: uppercase;
+    letter-spacing: .5px;
+    border-bottom: 2px solid var(--bdr);
+  }
+  tbody td {
+    padding: 10px 14px;
+    border-bottom: 1px solid var(--bdr);
+    color: var(--txt-sub);
+  }
+  tbody tr:hover td { background: #F8FAFC; }
+
+  /* 流程图容器 */
+  .flow-container {
+    background: var(--bg-card);
+    border: 1px solid var(--bdr);
+    border-radius: 10px;
+    padding: 20px;
+    margin: 20px 0;
+    overflow-x: auto;
+  }
+  .flow-container .flow-title {
+    font-size: 14px;
+    font-weight: 600;
+    color: var(--pri);
+    margin-bottom: 16px;
+    padding-bottom: 10px;
+    border-bottom: 1px solid var(--bdr);
+  }
+
+  /* 标签 */
+  .tag {
+    display: inline-block;
+    padding: 2px 8px;
+    border-radius: 4px;
+    font-size: 11px;
+    font-weight: 600;
+  }
+  .tag-blue { background: #DBEAFE; color: #1E40AF; }
+  .tag-green { background: #DCFCE7; color: #166534; }
+  .tag-orange { background: #FEF3C7; color: #92400E; }
+  .tag-red { background: #FEE2E2; color: #991B1B; }
+  .tag-gray { background: #F1F5F9; color: #475569; }
+
+  /* 要点列表 */
+  .point-list { list-style: none; padding: 0; }
+  .point-list li {
+    padding: 8px 0;
+    padding-left: 20px;
+    position: relative;
+    color: var(--txt-sub);
+    font-size: 14px;
+  }
+  .point-list li::before {
+    content: '';
+    position: absolute;
+    left: 0;
+    top: 16px;
+    width: 6px;
+    height: 6px;
+    background: var(--acc);
+    border-radius: 50%;
+  }
+
+  /* 状态时间轴 */
+  .status-bar {
+    display: flex;
+    gap: 0;
+    margin: 16px 0;
+    background: var(--bg-card);
+    border: 1px solid var(--bdr);
+    border-radius: 10px;
+    overflow: hidden;
+  }
+  .status-step {
+    flex: 1;
+    text-align: center;
+    padding: 14px 8px;
+    font-size: 12px;
+    color: var(--txt-sub);
+    position: relative;
+    background: #F8FAFC;
+  }
+  .status-step + .status-step { border-left: 1px solid var(--bdr); }
+  .status-step.active { background: #DBEAFE; color: var(--pri); font-weight: 600; }
+  .status-step .step-num {
+    display: block;
+    width: 22px; height: 22px;
+    border-radius: 50%;
+    background: #E2E8F0;
+    color: var(--txt-sub);
+    line-height: 22px;
+    margin: 0 auto 6px;
+    font-size: 11px;
+    font-weight: 700;
+  }
+  .status-step.active .step-num { background: var(--acc); color: #fff; }
+
+  /* 打印 */
+  @media print {
+    .cover { page-break-after: always; }
+    .section { page-break-inside: avoid; }
+    .flow-container { page-break-inside: avoid; }
+  }
+
+  /* 页脚 */
+  .footer {
+    text-align: center;
+    padding: 40px;
+    color: var(--txt-sub);
+    font-size: 12px;
+    border-top: 1px solid var(--bdr);
+    margin-top: 40px;
+  }
+
+  /* TOC */
+  .toc { margin: 24px 0; }
+  .toc a { display: block; padding: 6px 0; color: var(--acc); text-decoration: none; font-size: 14px; }
+  .toc a:hover { text-decoration: underline; }
+</style>
+</head>
+<body>
+
+<!-- ==================== 封面 ==================== -->
+<div class="cover">
+  <div class="tag">需求说明书</div>
+  <h1>酒店客房送餐系统</h1>
+  <h2>智能点餐 · 五端协同 · 高效服务</h2>
+  <div class="meta">
+    <span>版本 v1.0</span>
+    <span>2026年7月26日</span>
+    <span>状态:需求确认中</span>
+  </div>
+</div>
+
+<div class="container">
+
+<!-- ==================== 目录 ==================== -->
+<div class="section">
+  <div class="section-title">目录</div>
+  <div class="toc">
+    <a href="#s1">1. 项目概述</a>
+    <a href="#s2">2. 系统角色与架构</a>
+    <a href="#s3">3. 核心业务流程图</a>
+    <a href="#s4">4. 功能模块清单</a>
+    <a href="#s5">5. 各端页面结构</a>
+    <a href="#s6">6. 订单状态定义</a>
+    <a href="#s7">7. 非功能性需求</a>
+  </div>
+</div>
+
+<!-- ==================== 1. 项目概述 ==================== -->
+<div class="section" id="s1">
+  <div class="section-title">1. 项目概述</div>
+  <p><strong>项目名称:</strong>酒店客房送餐系统(Hotel Room Service Ordering System)</p>
+  <p><strong>项目定位:</strong>面向宾馆/酒店客房的数字化点餐解决方案。客人通过客房内扫码进入小程序完成点餐、支付、实时追踪订单状态;餐厅前台、厨房、宾馆前台、后台管理协同处理,实现从点单到送达的全链路闭环。</p>
+
+  <div class="section-subtitle">1.1 核心特点</div>
+  <ul class="point-list">
+    <li><strong>聚合二维码:</strong>一个房间一个码,同时支持微信和支付宝扫码,系统自动判断入口环境并匹配对应支付方式。</li>
+    <li><strong>房号自动绑定:</strong>二维码生成时绑定房间号,客人扫码后无需手动输入,确认即可。</li>
+    <li><strong>五端协同:</strong>顾客端、餐厅前台、厨房端、宾馆前台、后台管理——五个端各司其职,形成完整服务链路。</li>
+    <li><strong>实时状态追踪:</strong>6 节点订单状态 + 进度可视化,客人随时掌握餐食进度。</li>
+    <li><strong>智能退单:</strong>接单前顾客自助退、接单后宾馆前台可介入退,灵活处理异常。</li>
+  </ul>
+</div>
+
+<!-- ==================== 2. 系统角色 ==================== -->
+<div class="section" id="s2">
+  <div class="section-title">2. 系统角色与架构</div>
+  <p>本系统共涉及 <strong>五个端</strong>、<strong>四个角色</strong>(餐厅前台与厨房端同属餐厅角色),覆盖从客人点餐到后台管理的完整链路。</p>
+
+  <div class="role-grid">
+    <div class="role-card">
+      <div class="role-icon">📱</div>
+      <div class="role-name">顾客端</div>
+      <div class="role-device">微信小程序 / 支付宝小程序</div>
+      <div class="role-desc">扫码点餐、下单支付、实时追踪订单状态</div>
+    </div>
+    <div class="role-card">
+      <div class="role-icon">🖥️</div>
+      <div class="role-name">餐厅前台</div>
+      <div class="role-device">PAD / 移动端 / PC端</div>
+      <div class="role-desc">接单/拒单、语音提醒、营业时间管理、安排送餐</div>
+    </div>
+    <div class="role-card">
+      <div class="role-icon">👨‍🍳</div>
+      <div class="role-name">厨房端</div>
+      <div class="role-device">厨房大屏 + 打印机</div>
+      <div class="role-desc">语音播报、小票打印、备餐、出餐</div>
+    </div>
+    <div class="role-card">
+      <div class="role-icon">🏨</div>
+      <div class="role-name">宾馆前台</div>
+      <div class="role-device">PC端 / 移动端</div>
+      <div class="role-desc">查看全部订单、任何阶段退单、状态总览</div>
+    </div>
+    <div class="role-card">
+      <div class="role-icon">⚙️</div>
+      <div class="role-name">后台管理</div>
+      <div class="role-device">PC端</div>
+      <div class="role-desc">数据看板、菜品/订单/房间管理、品牌风格设置</div>
+    </div>
+  </div>
+</div>
+
+<!-- ==================== 3. 核心流程图 ==================== -->
+<div class="section" id="s3">
+  <div class="section-title">3. 核心业务流程图</div>
+
+  <!-- 3.1 扫码进入 -->
+  <div class="flow-container">
+    <div class="flow-title">3.1 扫码进入流程</div>
+    <svg viewBox="0 0 860 520" style="width:100%; max-width:860px;">
+      <defs>
+        <marker id="arrow1" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
+          <path d="M0,0 L8,3 L0,6 Z" fill="#64748B"/>
+        </marker>
+        <marker id="arrow1g" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
+          <path d="M0,0 L8,3 L0,6 Z" fill="#16A34A"/>
+        </marker>
+        <marker id="arrow1r" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
+          <path d="M0,0 L8,3 L0,6 Z" fill="#DC2626"/>
+        </marker>
+      </defs>
+      
+      <!-- 扫码 -->
+      <rect x="330" y="20" width="140" height="44" rx="22" fill="#DBEAFE" stroke="#2563EB" stroke-width="2"/>
+      <text x="400" y="48" text-anchor="middle" font-size="14" font-weight="700" fill="#1E40AF">客人扫聚合码</text>
+      
+      <line x1="400" y1="64" x2="400" y2="90" stroke="#64748B" stroke-width="1.5" marker-end="url(#arrow1)"/>
+      
+      <!-- 环境判断 -->
+      <polygon points="400,95 480,125 400,155 320,125" fill="#FEF3C7" stroke="#D97706" stroke-width="1.5"/>
+      <text x="400" y="130" text-anchor="middle" font-size="12" font-weight="600" fill="#92400E">环境判断</text>
+      
+      <!-- 微信分支 -->
+      <line x1="320" y1="125" x2="200" y2="125" stroke="#64748B" stroke-width="1.5"/>
+      <line x1="200" y1="125" x2="200" y2="200" stroke="#64748B" stroke-width="1.5" marker-end="url(#arrow1)"/>
+      <rect x="120" y="200" width="160" height="40" rx="8" fill="#E8F5E9" stroke="#43A047" stroke-width="1.5"/>
+      <text x="200" y="225" text-anchor="middle" font-size="13" font-weight="600" fill="#2E7D32">微信小程序</text>
+      <text x="200" y="255" text-anchor="middle" font-size="10" fill="#64748B">→ 微信支付</text>
+
+      <!-- 支付宝分支 -->
+      <line x1="480" y1="125" x2="600" y2="125" stroke="#64748B" stroke-width="1.5"/>
+      <line x1="600" y1="125" x2="600" y2="200" stroke="#64748B" stroke-width="1.5" marker-end="url(#arrow1)"/>
+      <rect x="520" y="200" width="160" height="40" rx="8" fill="#E3F2FD" stroke="#1E88E5" stroke-width="1.5"/>
+      <text x="600" y="225" text-anchor="middle" font-size="13" font-weight="600" fill="#1565C0">支付宝小程序</text>
+      <text x="600" y="255" text-anchor="middle" font-size="10" fill="#64748B">→ 支付宝支付</text>
+
+      <!-- 小程序→房号确认 -->
+      <line x1="200" y1="260" x2="200" y2="300" stroke="#64748B" stroke-width="1.5"/>
+      <line x1="600" y1="260" x2="600" y2="300" stroke="#64748B" stroke-width="1.5"/>
+      <line x1="200" y1="300" x2="600" y2="300" stroke="#64748B" stroke-width="1.5"/>
+      <line x1="400" y1="300" x2="400" y2="330" stroke="#64748B" stroke-width="1.5" marker-end="url(#arrow1)"/>
+
+      <!-- 营业时间判断 -->
+      <polygon points="400,335 480,365 400,395 320,365" fill="#FFF7ED" stroke="#EA580C" stroke-width="1.5"/>
+      <text x="400" y="370" text-anchor="middle" font-size="11" font-weight="600" fill="#9A3412">营业时间?</text>
+
+      <!-- 在营业 -->
+      <line x1="320" y1="365" x2="170" y2="365" stroke="#16A34A" stroke-width="1.5"/>
+      <line x1="170" y1="365" x2="170" y2="420" stroke="#16A34A" stroke-width="1.5" marker-end="url(#arrow1g)"/>
+      <rect x="90" y="420" width="160" height="40" rx="8" fill="#DCFCE7" stroke="#16A34A" stroke-width="1.5"/>
+      <text x="170" y="445" text-anchor="middle" font-size="13" font-weight="600" fill="#166534">房号确认页</text>
+      
+      <line x1="170" y1="460" x2="170" y2="490" stroke="#16A34A" stroke-width="1.5" marker-end="url(#arrow1g)"/>
+      <rect x="90" y="490" width="160" height="40" rx="8" fill="#DCFCE7" stroke="#16A34A" stroke-width="1.5"/>
+      <text x="170" y="515" text-anchor="middle" font-size="13" font-weight="600" fill="#166534">进入菜单</text>
+
+      <!-- 不在营业 -->
+      <line x1="480" y1="365" x2="660" y2="365" stroke="#DC2626" stroke-width="1.5"/>
+      <line x1="660" y1="365" x2="660" y2="420" stroke="#DC2626" stroke-width="1.5" marker-end="url(#arrow1r)"/>
+      <rect x="580" y="420" width="160" height="54" rx="8" fill="#FEE2E2" stroke="#DC2626" stroke-width="1.5"/>
+      <text x="660" y="443" text-anchor="middle" font-size="12" font-weight="600" fill="#991B1B">非营业时间</text>
+      <text x="660" y="460" text-anchor="middle" font-size="10" fill="#991B1B">提示下次营业时段</text>
+
+      <!-- 环境判断下方标签 -->
+      <text x="120" y="170" font-size="9" fill="#64748B">User-Agent</text>
+      <text x="680" y="170" font-size="9" fill="#64748B">User-Agent</text>
+    </svg>
+  </div>
+
+  <!-- 3.2 点餐下单 -->
+  <div class="flow-container">
+    <div class="flow-title">3.2 点餐下单流程</div>
+    <svg viewBox="0 0 860 580" style="width:100%; max-width:860px;">
+      <defs>
+        <marker id="arrow2" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
+          <path d="M0,0 L8,3 L0,6 Z" fill="#64748B"/>
+        </marker>
+      </defs>
+
+      <!-- 进入菜单 -->
+      <rect x="330" y="15" width="140" height="44" rx="22" fill="#DBEAFE" stroke="#2563EB" stroke-width="2"/>
+      <text x="400" y="43" text-anchor="middle" font-size="14" font-weight="700" fill="#1E40AF">进入菜单首页</text>
+
+      <line x1="400" y1="59" x2="400" y2="88" stroke="#64748B" stroke-width="1.5" marker-end="url(#arrow2)"/>
+
+      <!-- 分类浏览 -->
+      <rect x="290" y="88" width="220" height="36" rx="6" fill="#F8FAFC" stroke="#94A3B8" stroke-width="1"/>
+      <text x="400" y="111" text-anchor="middle" font-size="12" fill="#475569">按分类浏览 → 菜品列表</text>
+
+      <line x1="400" y1="124" x2="400" y2="150" stroke="#64748B" stroke-width="1.5" marker-end="url(#arrow2)"/>
+
+      <!-- 菜品详情 -->
+      <rect x="290" y="150" width="220" height="36" rx="6" fill="#F8FAFC" stroke="#94A3B8" stroke-width="1"/>
+      <text x="400" y="173" text-anchor="middle" font-size="12" fill="#475569">点击菜品 → 详情页(规格/数量)</text>
+
+      <line x1="400" y1="186" x2="400" y2="212" stroke="#64748B" stroke-width="1.5" marker-end="url(#arrow2)"/>
+
+      <!-- 加购 -->
+      <rect x="290" y="212" width="220" height="36" rx="6" fill="#FFF7ED" stroke="#EA580C" stroke-width="1"/>
+      <text x="400" y="235" text-anchor="middle" font-size="12" fill="#9A3412">加入购物车</text>
+
+      <!-- 循环回选菜 -->
+      <line x1="510" y1="230" x2="580" y2="230" stroke="#64748B" stroke-width="1" stroke-dasharray="4,2"/>
+      <line x1="580" y1="230" x2="580" y2="106" stroke="#64748B" stroke-width="1" stroke-dasharray="4,2"/>
+      <line x1="580" y1="106" x2="510" y2="106" stroke="#64748B" stroke-width="1" stroke-dasharray="4,2" marker-end="url(#arrow2)"/>
+      <text x="590" y="170" font-size="10" fill="#94A3B8">继续选购</text>
+
+      <line x1="400" y1="248" x2="400" y2="278" stroke="#64748B" stroke-width="1.5" marker-end="url(#arrow2)"/>
+
+      <!-- 购物车 -->
+      <rect x="290" y="278" width="220" height="36" rx="6" fill="#F8FAFC" stroke="#94A3B8" stroke-width="1"/>
+      <text x="400" y="301" text-anchor="middle" font-size="12" fill="#475569">购物车(增减/备注/房号确认)</text>
+
+      <line x1="400" y1="314" x2="400" y2="344" stroke="#64748B" stroke-width="1.5" marker-end="url(#arrow2)"/>
+
+      <!-- 确认下单 -->
+      <rect x="290" y="344" width="220" height="44" rx="8" fill="#DCFCE7" stroke="#16A34A" stroke-width="1.5"/>
+      <text x="400" y="372" text-anchor="middle" font-size="13" font-weight="600" fill="#166534">确认下单页</text>
+
+      <!-- 下单详情 -->
+      <text x="400" y="408" text-anchor="middle" font-size="10" fill="#64748B">房号 · 联系人 · 送达时间 · 备注</text>
+
+      <line x1="400" y1="416" x2="400" y2="446" stroke="#16A34A" stroke-width="1.5" marker-end="url(#arrow2)"/>
+
+      <!-- 支付 -->
+      <rect x="290" y="446" width="220" height="36" rx="6" fill="#E8F5E9" stroke="#43A047" stroke-width="1"/>
+      <text x="400" y="469" text-anchor="middle" font-size="12" fill="#2E7D32">选择支付(微信/支付宝→自动匹配)</text>
+
+      <line x1="400" y1="482" x2="400" y2="510" stroke="#16A34A" stroke-width="1.5" marker-end="url(#arrow2)"/>
+
+      <!-- 支付成功 -->
+      <rect x="290" y="510" width="220" height="44" rx="22" fill="#16A34A"/>
+      <text x="400" y="538" text-anchor="middle" font-size="14" font-weight="700" fill="#FFFFFF">✅ 下单成功</text>
+
+      <!-- 右侧标注 -->
+      <text x="580" y="370" font-size="10" fill="#94A3B8">可选:立即送达</text>
+      <text x="580" y="385" font-size="10" fill="#94A3B8">或预约送餐时间</text>
+    </svg>
+  </div>
+
+  <!-- 3.3 订单状态流转 -->
+  <div class="flow-container">
+    <div class="flow-title">3.3 订单状态流转(6节点)</div>
+    <svg viewBox="0 0 860 220" style="width:100%; max-width:860px;">
+      <defs>
+        <marker id="arrow3" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
+          <path d="M0,0 L8,3 L0,6 Z" fill="#64748B"/>
+        </marker>
+      </defs>
+
+      <!-- 状态: 已下单 -->
+      <rect x="30" y="50" width="110" height="60" rx="8" fill="#DBEAFE" stroke="#2563EB" stroke-width="2"/>
+      <text x="85" y="76" text-anchor="middle" font-size="12" font-weight="700" fill="#1E40AF">已下单</text>
+      <text x="85" y="96" text-anchor="middle" font-size="10" fill="#3B82F6">顾客完成支付</text>
+
+      <line x1="140" y1="80" x2="175" y2="80" stroke="#64748B" stroke-width="1.5" marker-end="url(#arrow3)"/>
+
+      <!-- 已接单 -->
+      <rect x="180" y="50" width="110" height="60" rx="8" fill="#DBEAFE" stroke="#2563EB" stroke-width="2"/>
+      <text x="235" y="76" text-anchor="middle" font-size="12" font-weight="700" fill="#1E40AF">已接单</text>
+      <text x="235" y="96" text-anchor="middle" font-size="10" fill="#3B82F6">餐厅前台确认</text>
+
+      <line x1="290" y1="80" x2="325" y2="80" stroke="#64748B" stroke-width="1.5" marker-end="url(#arrow3)"/>
+
+      <!-- 备餐中 -->
+      <rect x="330" y="50" width="110" height="60" rx="8" fill="#FEF3C7" stroke="#D97706" stroke-width="2"/>
+      <text x="385" y="76" text-anchor="middle" font-size="12" font-weight="700" fill="#92400E">备餐中</text>
+      <text x="385" y="96" text-anchor="middle" font-size="10" fill="#B45309">厨房正在制作</text>
+
+      <line x1="440" y1="80" x2="475" y2="80" stroke="#64748B" stroke-width="1.5" marker-end="url(#arrow3)"/>
+
+      <!-- 已出餐 -->
+      <rect x="480" y="50" width="110" height="60" rx="8" fill="#FEF3C7" stroke="#D97706" stroke-width="2"/>
+      <text x="535" y="76" text-anchor="middle" font-size="12" font-weight="700" fill="#92400E">已出餐</text>
+      <text x="535" y="96" text-anchor="middle" font-size="10" fill="#B45309">厨师点击出餐</text>
+
+      <line x1="590" y1="80" x2="625" y2="80" stroke="#64748B" stroke-width="1.5" marker-end="url(#arrow3)"/>
+
+      <!-- 配送中 -->
+      <rect x="630" y="50" width="110" height="60" rx="8" fill="#DCFCE7" stroke="#16A34A" stroke-width="2"/>
+      <text x="685" y="76" text-anchor="middle" font-size="12" font-weight="700" fill="#166534">配送中</text>
+      <text x="685" y="96" text-anchor="middle" font-size="10" fill="#22C55E">前台安排送餐</text>
+
+      <line x1="740" y1="80" x2="775" y2="80" stroke="#16A34A" stroke-width="1.5" marker-end="url(#arrow3)"/>
+
+      <!-- 已送达 -->
+      <rect x="780" y="50" width="70" height="60" rx="8" fill="#16A34A"/>
+      <text x="815" y="76" text-anchor="middle" font-size="12" font-weight="700" fill="#FFFFFF">已完成</text>
+      <text x="815" y="96" text-anchor="middle" font-size="10" fill="#DCFCE7">送达客房</text>
+
+      <!-- 角色标注栏 -->
+      <text x="85" y="160" text-anchor="middle" font-size="10" fill="#64748B">顾客</text>
+      <text x="235" y="160" text-anchor="middle" font-size="10" fill="#64748B">餐厅前台</text>
+      <text x="385" y="160" text-anchor="middle" font-size="10" fill="#64748B">厨房</text>
+      <text x="535" y="160" text-anchor="middle" font-size="10" fill="#64748B">厨房</text>
+      <text x="685" y="160" text-anchor="middle" font-size="10" fill="#64748B">餐厅前台</text>
+      <text x="815" y="160" text-anchor="middle" font-size="10" fill="#64748B">✓</text>
+
+      <!-- 分隔线 -->
+      <line x1="155" y1="125" x2="155" y2="145" stroke="#E2E8F0" stroke-width="1"/>
+      <line x1="305" y1="125" x2="305" y2="145" stroke="#E2E8F0" stroke-width="1"/>
+      <line x1="455" y1="125" x2="455" y2="145" stroke="#E2E8F0" stroke-width="1"/>
+      <line x1="605" y1="125" x2="605" y2="145" stroke="#E2E8F0" stroke-width="1"/>
+      <line x1="755" y1="125" x2="755" y2="145" stroke="#E2E8F0" stroke-width="1"/>
+    </svg>
+  </div>
+
+  <!-- 3.4 退单流程 -->
+  <div class="flow-container">
+    <div class="flow-title">3.4 退单流程</div>
+    <svg viewBox="0 0 860 380" style="width:100%; max-width:860px;">
+      <defs>
+        <marker id="arrow4" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
+          <path d="M0,0 L8,3 L0,6 Z" fill="#64748B"/>
+        </marker>
+        <marker id="arrow4g" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
+          <path d="M0,0 L8,3 L0,6 Z" fill="#16A34A"/>
+        </marker>
+        <marker id="arrow4r" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
+          <path d="M0,0 L8,3 L0,6 Z" fill="#DC2626"/>
+        </marker>
+      </defs>
+
+      <!-- 顾客发起退单 -->
+      <rect x="290" y="20" width="220" height="44" rx="22" fill="#FEE2E2" stroke="#DC2626" stroke-width="2"/>
+      <text x="400" y="48" text-anchor="middle" font-size="14" font-weight="700" fill="#991B1B">顾客发起退单</text>
+
+      <line x1="400" y1="64" x2="400" y2="92" stroke="#DC2626" stroke-width="1.5" marker-end="url(#arrow4r)"/>
+
+      <!-- 判断 -->
+      <polygon points="400,97 480,127 400,157 320,127" fill="#FEF3C7" stroke="#D97706" stroke-width="1.5"/>
+      <text x="400" y="132" text-anchor="middle" font-size="11" font-weight="600" fill="#92400E">已接单?</text>
+
+      <!-- 分支: 未接单 -->
+      <line x1="320" y1="127" x2="170" y2="127" stroke="#16A34A" stroke-width="1.5"/>
+      <line x1="170" y1="127" x2="170" y2="205" stroke="#16A34A" stroke-width="1.5" marker-end="url(#arrow4g)"/>
+      <text x="120" y="168" font-size="10" fill="#16A34A">未接单</text>
+
+      <rect x="70" y="210" width="200" height="44" rx="8" fill="#DCFCE7" stroke="#16A34A" stroke-width="1.5"/>
+      <text x="170" y="228" text-anchor="middle" font-size="12" font-weight="600" fill="#166534">系统自动退单</text>
+      <text x="170" y="245" text-anchor="middle" font-size="10" fill="#16A34A">原路退款(微信/支付宝)</text>
+
+      <line x1="170" y1="254" x2="170" y2="290" stroke="#16A34A" stroke-width="1.5" marker-end="url(#arrow4g)"/>
+      <rect x="90" y="290" width="160" height="40" rx="20" fill="#16A34A"/>
+      <text x="170" y="316" text-anchor="middle" font-size="13" font-weight="700" fill="#FFFFFF">退款完成</text>
+
+      <!-- 分支: 已接单 -->
+      <line x1="480" y1="127" x2="660" y2="127" stroke="#DC2626" stroke-width="1.5"/>
+      <line x1="660" y1="127" x2="660" y2="205" stroke="#DC2626" stroke-width="1.5" marker-end="url(#arrow4r)"/>
+      <text x="700" y="168" font-size="10" fill="#DC2626">已接单</text>
+
+      <rect x="560" y="210" width="200" height="44" rx="8" fill="#FEE2E2" stroke="#DC2626" stroke-width="1.5"/>
+      <text x="660" y="228" text-anchor="middle" font-size="12" font-weight="600" fill="#991B1B">仅宾馆前台可退</text>
+      <text x="660" y="245" text-anchor="middle" font-size="10" fill="#DC2626">任何阶段均可介入</text>
+
+      <line x1="660" y1="254" x2="660" y2="290" stroke="#DC2626" stroke-width="1.5" marker-end="url(#arrow4r)"/>
+      <rect x="560" y="290" width="200" height="40" rx="8" fill="#FFF7ED" stroke="#EA580C" stroke-width="1.5"/>
+      <text x="660" y="308" text-anchor="middle" font-size="11" font-weight="600" fill="#9A3412">前台确认退单</text>
+      <text x="660" y="323" text-anchor="middle" font-size="9" fill="#EA580C">→ 强制退款</text>
+
+      <!-- 标注 -->
+      <text x="160" y="360" text-anchor="middle" font-size="10" fill="#94A3B8">客人自助操作</text>
+      <text x="660" y="360" text-anchor="middle" font-size="10" fill="#94A3B8">宾馆前台人工介入</text>
+    </svg>
+  </div>
+
+  <!-- 3.5 餐厅端协作 -->
+  <div class="flow-container">
+    <div class="flow-title">3.5 餐厅端协���流程(前台+厨房)</div>
+    <svg viewBox="0 0 860 420" style="width:100%; max-width:860px;">
+      <defs>
+        <marker id="arrow5" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
+          <path d="M0,0 L8,3 L0,6 Z" fill="#64748B"/>
+        </marker>
+        <marker id="arrow5b" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
+          <path d="M0,0 L8,3 L0,6 Z" fill="#2563EB"/>
+        </marker>
+      </defs>
+
+      <!-- 角色标签 -->
+      <text x="100" y="22" font-size="11" font-weight="600" fill="#2563EB">顾客</text>
+      <text x="310" y="22" font-size="11" font-weight="600" fill="#EA580C">餐厅前台</text>
+      <text x="550" y="22" font-size="11" font-weight="600" fill="#16A34A">厨房</text>
+      
+      <line x1="0" y1="32" x2="860" y2="32" stroke="#E2E8F0" stroke-width="1"/>
+
+      <!-- 顾客下单 -->
+      <rect x="20" y="45" width="140" height="40" rx="6" fill="#DBEAFE" stroke="#2563EB" stroke-width="1.5"/>
+      <text x="90" y="70" text-anchor="middle" font-size="12" font-weight="600" fill="#1E40AF">顾客完成下单</text>
+
+      <line x1="160" y1="65" x2="230" y2="65" stroke="#64748B" stroke-width="1.5" marker-end="url(#arrow5)"/>
+
+      <!-- 前台语音提醒 -->
+      <rect x="235" y="45" width="170" height="40" rx="6" fill="#FFF7ED" stroke="#EA580C" stroke-width="1.5"/>
+      <text x="320" y="65" text-anchor="middle" font-size="12" font-weight="600" fill="#9A3412">🔔 语音提醒新订单</text>
+
+      <line x1="320" y1="85" x2="320" y2="120" stroke="#64748B" stroke-width="1.5" marker-end="url(#arrow5)"/>
+
+      <!-- 接单/拒单 -->
+      <polygon points="320,125 400,157 320,189 240,157" fill="#FEF3C7" stroke="#D97706" stroke-width="1.5"/>
+      <text x="320" y="160" text-anchor="middle" font-size="11" font-weight="600" fill="#92400E">接单?</text>
+
+      <!-- 拒单 -->
+      <line x1="240" y1="157" x2="120" y2="157" stroke="#DC2626" stroke-width="1.5"/>
+      <line x1="120" y1="157" x2="120" y2="220" stroke="#DC2626" stroke-width="1.5" marker-end="url(#arrow4r)"/>
+      <rect x="40" y="220" width="160" height="44" rx="8" fill="#FEE2E2" stroke="#DC2626" stroke-width="1.5"/>
+      <text x="120" y="240" text-anchor="middle" font-size="11" font-weight="600" fill="#991B1B">拒单(填写原因)</text>
+      <text x="120" y="256" text-anchor="middle" font-size="9" fill="#DC2626">→ 通知顾客退款</text>
+
+      <!-- 接单 -->
+      <line x1="320" y1="189" x2="320" y2="240" stroke="#2563EB" stroke-width="1.5" marker-end="url(#arrow5b)"/>
+      <rect x="235" y="240" width="170" height="40" rx="6" fill="#DBEAFE" stroke="#2563EB" stroke-width="1.5"/>
+      <text x="320" y="265" text-anchor="middle" font-size="12" font-weight="600" fill="#1E40AF">接单确认</text>
+
+      <!-- 通知厨房 -->
+      <line x1="405" y1="260" x2="470" y2="260" stroke="#64748B" stroke-width="1.5" marker-end="url(#arrow5)"/>
+
+      <!-- 厨房收到 -->
+      <rect x="475" y="97" width="180" height="40" rx="6" fill="#DCFCE7" stroke="#16A34A" stroke-width="1.5"/>
+      <text x="565" y="113" text-anchor="middle" font-size="12" font-weight="600" fill="#166534">🔊 语音播报 + 打印小票</text>
+
+      <line x1="565" y1="137" x2="565" y2="175" stroke="#64748B" stroke-width="1.5" marker-end="url(#arrow5)"/>
+
+      <!-- 备餐 -->
+      <rect x="475" y="175" width="180" height="40" rx="6" fill="#FEF3C7" stroke="#D97706" stroke-width="1.5"/>
+      <text x="565" y="200" text-anchor="middle" font-size="12" font-weight="600" fill="#92400E">厨师开始备餐</text>
+
+      <line x1="565" y1="215" x2="565" y2="250" stroke="#64748B" stroke-width="1.5" marker-end="url(#arrow5)"/>
+
+      <!-- 出餐 -->
+      <rect x="475" y="250" width="180" height="44" rx="6" fill="#16A34A" stroke="#16A34A" stroke-width="1.5"/>
+      <text x="565" y="268" text-anchor="middle" font-size="12" font-weight="700" fill="#FFFFFF">点击「出餐」</text>
+      <text x="565" y="284" text-anchor="middle" font-size="10" fill="#DCFCE7">→ 通知餐厅前台</text>
+
+      <!-- 前台送餐 -->
+      <line x1="475" y1="272" x2="420" y2="272" stroke="#64748B" stroke-width="1.5"/>
+      <line x1="420" y1="272" x2="420" y2="320" stroke="#64748B" stroke-width="1.5" marker-end="url(#arrow5)"/>
+      <rect x="235" y="315" width="170" height="40" rx="6" fill="#FFF7ED" stroke="#EA580C" stroke-width="1.5"/>
+      <text x="320" y="340" text-anchor="middle" font-size="12" font-weight="600" fill="#9A3412">安排服务员送餐</text>
+
+      <!-- 送达 -->
+      <line x1="320" y1="355" x2="320" y2="390" stroke="#16A34A" stroke-width="1.5" marker-end="url(#arrow4g)"/>
+      <rect x="235" y="385" width="170" height="40" rx="20" fill="#16A34A"/>
+      <text x="320" y="410" text-anchor="middle" font-size="12" font-weight="700" fill="#FFFFFF">送达客房 ✓</text>
+    </svg>
+  </div>
+
+  <!-- 3.6 五端全景 -->
+  <div class="flow-container">
+    <div class="flow-title">3.6 五端协作全景图</div>
+    <svg viewBox="0 0 860 480" style="width:100%; max-width:860px;">
+      <defs>
+        <marker id="arrow6" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
+          <path d="M0,0 L8,3 L0,6 Z" fill="#94A3B8"/>
+        </marker>
+      </defs>
+
+      <!-- 顾客端 - 左上 -->
+      <rect x="30" y="30" width="200" height="130" rx="12" fill="#DBEAFE" stroke="#2563EB" stroke-width="1.5"/>
+      <text x="130" y="55" text-anchor="middle" font-size="14" font-weight="700" fill="#1E40AF">📱 顾客端</text>
+      <text x="130" y="75" text-anchor="middle" font-size="11" fill="#3B82F6">微信/支付宝小程序</text>
+      <line x1="50" y1="85" x2="210" y2="85" stroke="#BFDBFE" stroke-width="1"/>
+      <text x="130" y="102" text-anchor="middle" font-size="11" fill="#475569">扫码 → 点餐 → 支付</text>
+      <text x="130" y="120" text-anchor="middle" font-size="11" fill="#475569">实时追踪 → 退单</text>
+      <text x="130" y="145" text-anchor="middle" font-size="10" fill="#94A3B8">(移动端)</text>
+
+      <!-- 餐厅前台 - 右上 -->
+      <rect x="330" y="30" width="200" height="130" rx="12" fill="#FFF7ED" stroke="#EA580C" stroke-width="1.5"/>
+      <text x="430" y="55" text-anchor="middle" font-size="14" font-weight="700" fill="#9A3412">🖥️ 餐厅前台</text>
+      <text x="430" y="75" text-anchor="middle" font-size="11" fill="#C2410C">PAD / 移动 / PC</text>
+      <line x1="350" y1="85" x2="510" y2="85" stroke="#FED7AA" stroke-width="1"/>
+      <text x="430" y="102" text-anchor="middle" font-size="11" fill="#475569">接单/拒单 · 语音提醒</text>
+      <text x="430" y="120" text-anchor="middle" font-size="11" fill="#475569">营业时间 · 安排送餐</text>
+      <text x="430" y="145" text-anchor="middle" font-size="10" fill="#94A3B8">(多端)</text>
+
+      <!-- 厨房 - 右中 -->
+      <rect x="630" y="30" width="200" height="130" rx="12" fill="#DCFCE7" stroke="#16A34A" stroke-width="1.5"/>
+      <text x="730" y="55" text-anchor="middle" font-size="14" font-weight="700" fill="#166534">👨‍🍳 厨房端</text>
+      <text x="730" y="75" text-anchor="middle" font-size="11" fill="#22C55E">屏幕 + 打印机</text>
+      <line x1="650" y1="85" x2="810" y2="85" stroke="#BBF7D0" stroke-width="1"/>
+      <text x="730" y="102" text-anchor="middle" font-size="11" fill="#475569">语音播报 · 打印小票</text>
+      <text x="730" y="120" text-anchor="middle" font-size="11" fill="#475569">备餐 → 出餐</text>
+      <text x="730" y="145" text-anchor="middle" font-size="10" fill="#94A3B8">(厨房端)</text>
+
+      <!-- 宾馆前台 - 左下 -->
+      <rect x="30" y="250" width="200" height="130" rx="12" fill="#F3E8FF" stroke="#9333EA" stroke-width="1.5"/>
+      <text x="130" y="275" text-anchor="middle" font-size="14" font-weight="700" fill="#6B21A8">🏨 宾馆前台</text>
+      <text x="130" y="295" text-anchor="middle" font-size="11" fill="#A855F7">PC / 移动</text>
+      <line x1="50" y1="305" x2="210" y2="305" stroke="#E9D5FF" stroke-width="1"/>
+      <text x="130" y="322" text-anchor="middle" font-size="11" fill="#475569">全订单查看 · 状态总览</text>
+      <text x="130" y="340" text-anchor="middle" font-size="11" fill="#475569">任何阶段退单</text>
+      <text x="130" y="365" text-anchor="middle" font-size="10" fill="#94A3B8">(多端)</text>
+
+      <!-- 后台管理 - 右下 -->
+      <rect x="330" y="250" width="200" height="130" rx="12" fill="#F1F5F9" stroke="#475569" stroke-width="1.5"/>
+      <text x="430" y="275" text-anchor="middle" font-size="14" font-weight="700" fill="#334155">⚙️ 后台管理</text>
+      <text x="430" y="295" text-anchor="middle" font-size="11" fill="#64748B">PC</text>
+      <line x1="350" y1="305" x2="510" y2="305" stroke="#E2E8F0" stroke-width="1"/>
+      <text x="430" y="322" text-anchor="middle" font-size="11" fill="#475569">数据看板 · 订单管理</text>
+      <text x="430" y="340" text-anchor="middle" font-size="11" fill="#475569">菜品/房间/品牌设置</text>
+      <text x="430" y="365" text-anchor="middle" font-size="10" fill="#94A3B8">(PC端)</text>
+
+      <!-- 虚线连接 -->
+      <!-- 顾客 → 餐厅前台 -->
+      <line x1="230" y1="95" x2="330" y2="95" stroke="#94A3B8" stroke-width="1" stroke-dasharray="5,3" marker-end="url(#arrow6)"/>
+      <text x="280" y="88" text-anchor="middle" font-size="9" fill="#94A3B8">下单</text>
+
+      <!-- 餐厅前台 → 厨房 -->
+      <line x1="530" y1="95" x2="630" y2="95" stroke="#94A3B8" stroke-width="1" stroke-dasharray="5,3" marker-end="url(#arrow6)"/>
+      <text x="580" y="88" text-anchor="middle" font-size="9" fill="#94A3B8">接单通知</text>
+
+      <!-- 厨房 → 餐厅前台 -->
+      <line x1="630" y1="140" x2="530" y2="140" stroke="#94A3B8" stroke-width="1" stroke-dasharray="5,3" marker-end="url(#arrow6)"/>
+      <text x="580" y="133" text-anchor="middle" font-size="9" fill="#94A3B8">出餐通知</text>
+
+      <!-- 顾客 → 宾馆前台 (退单) -->
+      <path d="M30,200 Q30,225 130,225 Q230,225 230,250" stroke="#94A3B8" stroke-width="1" stroke-dasharray="5,3" fill="none" marker-end="url(#arrow6)"/>
+      <text x="50" y="233" font-size="9" fill="#94A3B8">退单请求</text>
+
+      <!-- 所有端 → 后台 -->
+      <line x1="230" y1="315" x2="330" y2="315" stroke="#94A3B8" stroke-width="1" stroke-dasharray="5,3" marker-end="url(#arrow6)"/>
+      <text x="280" y="308" text-anchor="middle" font-size="9" fill="#94A3B8">数据上报</text>
+
+      <!-- 后台 → 顾客 -->
+      <line x1="330" y1="360" x2="230" y2="360" stroke="#94A3B8" stroke-width="1" stroke-dasharray="5,3" marker-end="url(#arrow6)"/>
+      <text x="280" y="353" text-anchor="middle" font-size="9" fill="#94A3B8">配置下发</text>
+
+      <!-- 中央:核心数据 -->
+      <rect x="630" y="260" width="200" height="100" rx="12" fill="#F8FAFC" stroke="#CBD5E1" stroke-width="1" stroke-dasharray="4,2"/>
+      <text x="730" y="290" text-anchor="middle" font-size="12" font-weight="600" fill="#475569">核心数据</text>
+      <text x="730" y="312" text-anchor="middle" font-size="10" fill="#94A3B8">订单 · 菜品 · 房间</text>
+      <text x="730" y="330" text-anchor="middle" font-size="10" fill="#94A3B8">状态 · 营业时间</text>
+    </svg>
+  </div>
+
+</div>
+
+<!-- ==================== 4. 功能模块清单 ==================== -->
+<div class="section" id="s4">
+  <div class="section-title">4. 功能模块清单</div>
+
+  <div class="section-subtitle">4.1 顾客端</div>
+  <table>
+    <thead><tr><th>功能模块</th><th>说明</th><th>优先级</th></tr></thead>
+    <tbody>
+      <tr><td>扫码进入</td><td>扫聚合码 → 环境判断 → 自动进入微信/支付宝小程序</td><td><span class="tag tag-blue">P0</span></td></tr>
+      <tr><td>房号确认</td><td>扫码后展示房号+欢迎语,客人确认后进入菜单</td><td><span class="tag tag-blue">P0</span></td></tr>
+      <tr><td>营业时间提示</td><td>非营业时段扫码,展示提示页+下次营业时间</td><td><span class="tag tag-green">P1</span></td></tr>
+      <tr><td>菜单浏览</td><td>分类导航、菜品列表、搜索、售罄标识</td><td><span class="tag tag-blue">P0</span></td></tr>
+      <tr><td>菜品详情</td><td>大图、描述、规格选择、数量加减</td><td><span class="tag tag-blue">P0</span></td></tr>
+      <tr><td>购物车</td><td>菜品清单、增减、备注、房号确认</td><td><span class="tag tag-blue">P0</span></td></tr>
+      <tr><td>确认下单</td><td>房号、联系人、备注、立即/预约送达时间</td><td><span class="tag tag-blue">P0</span></td></tr>
+      <tr><td>在线支付</td><td>根据扫码环境自动匹配微信支付或支付宝支付</td><td><span class="tag tag-blue">P0</span></td></tr>
+      <tr><td>订单状态追踪</td><td>6节点进度可视化,实时更新状态</td><td><span class="tag tag-blue">P0</span></td></tr>
+      <tr><td>退单(接单前)</td><td>接单前客人可自助退单,原路退款</td><td><span class="tag tag-blue">P0</span></td></tr>
+      <tr><td>我的订单</td><td>历史订单列表,状态标签,订单详情</td><td><span class="tag tag-green">P1</span></td></tr>
+    </tbody>
+  </table>
+
+  <div class="section-subtitle">4.2 餐厅前台</div>
+  <table>
+    <thead><tr><th>功能模块</th><th>说明</th><th>优先级</th></tr></thead>
+    <tbody>
+      <tr><td>新订单语音提醒</td><td>新订单到达时语音播报+弹窗提醒</td><td><span class="tag tag-blue">P0</span></td></tr>
+      <tr><td>接单/拒单</td><td>查看订单详情后接单确认;拒单需填写原因</td><td><span class="tag tag-blue">P0</span></td></tr>
+      <tr><td>订单处理流转</td><td>待接单→已接单→备餐中→已出餐→配送中→已送达</td><td><span class="tag tag-blue">P0</span></td></tr>
+      <tr><td>出餐通知接收</td><td>收到厨房出餐通知 → 安排服务员送餐</td><td><span class="tag tag-blue">P0</span></td></tr>
+      <tr><td>营业时间设置</td><td>多时段设置(如早7-10、午11:30-14、晚17-21),每天固定</td><td><span class="tag tag-blue">P0</span></td></tr>
+      <tr><td>临时暂停接单</td><td>一键暂停/恢复接单(厨房故障等突发情况)</td><td><span class="tag tag-green">P1</span></td></tr>
+      <tr><td>当日订单看板</td><td>进行中订单一览、预计送达时间</td><td><span class="tag tag-green">P1</span></td></tr>
+    </tbody>
+  </table>
+
+  <div class="section-subtitle">4.3 厨房端</div>
+  <table>
+    <thead><tr><th>功能模块</th><th>说明</th><th>优先级</th></tr></thead>
+    <tbody>
+      <tr><td>语音播报</td><td>新订单到达时语音播报菜品内容</td><td><span class="tag tag-blue">P0</span></td></tr>
+      <tr><td>自动打印小票</td><td>接单后自动打印厨房小票(菜品、房号、备注)</td><td><span class="tag tag-blue">P0</span></td></tr>
+      <tr><td>待做订单列表</td><td>按接单时间排序的待备餐订单</td><td><span class="tag tag-blue">P0</span></td></tr>
+      <tr><td>出餐操作</td><td>厨师完成备餐后点击出餐 → 通知餐厅前台</td><td><span class="tag tag-blue">P0</span></td></tr>
+    </tbody>
+  </table>
+
+  <div class="section-subtitle">4.4 宾馆前台</div>
+  <table>
+    <thead><tr><th>功能模块</th><th>说明</th><th>优先级</th></tr></thead>
+    <tbody>
+      <tr><td>全订单查看</td><td>查看所有订餐信息,不限状态</td><td><span class="tag tag-blue">P0</span></td></tr>
+      <tr><td>订单状态总览</td><td>各状态订单列表、筛选、搜索</td><td><span class="tag tag-blue">P0</span></td></tr>
+      <tr><td>退单(任何阶段)</td><td>可在任意订单阶段介入退单,含接单后、备餐后</td><td><span class="tag tag-blue">P0</span></td></tr>
+      <tr><td>订单详情查看</td><td>查看单个订单的完整信息和状态时间线</td><td><span class="tag tag-green">P1</span></td></tr>
+    </tbody>
+  </table>
+
+  <div class="section-subtitle">4.5 后台管理</div>
+  <table>
+    <thead><tr><th>功能模块</th><th>说明</th><th>优先级</th></tr></thead>
+    <tbody>
+      <tr><td>数据看板</td><td>今日订单数、营业额、热门菜品、平均送达时长</td><td><span class="tag tag-green">P1</span></td></tr>
+      <tr><td>菜品管理</td><td>菜品列表、上架/下架、价格编辑、售罄设置</td><td><span class="tag tag-blue">P0</span></td></tr>
+      <tr><td>订单管理</td><td>全部订单、状态筛选、订单详情</td><td><span class="tag tag-blue">P0</span></td></tr>
+      <tr><td>房间管理</td><td>房间列表、二维码生成/重生成/补打、启停点餐</td><td><span class="tag tag-blue">P0</span></td></tr>
+      <tr><td>系统设置</td><td>品牌风格(配色方案选择,五端同步生效)</td><td><span class="tag tag-green">P1</span></td></tr>
+    </tbody>
+  </table>
+</div>
+
+<!-- ==================== 5. 各端页面结构 ==================== -->
+<div class="section" id="s5">
+  <div class="section-title">5. 各端页面结构</div>
+
+  <div class="section-subtitle">5.1 顾客端(小程序)— 8页</div>
+  <table>
+    <thead><tr><th width="40">#</th><th>页面</th><th>路由</th><th>说明</th></tr></thead>
+    <tbody>
+      <tr><td>1</td><td>房号确认页</td><td>/room-confirm</td><td>显示房号、酒店欢迎语、营业时间;确认进入</td></tr>
+      <tr><td>2</td><td>菜单首页</td><td>/menu</td><td>分类导航、推荐banner、菜品卡片、售罄标识</td></tr>
+      <tr><td>3</td><td>菜品详情页</td><td>/dish/:id</td><td>大图、描述、规格选择、数量、加购按钮</td></tr>
+      <tr><td>4</td><td>购物车</td><td>/cart</td><td>菜品清单、数量增减、备注、去结算</td></tr>
+      <tr><td>5</td><td>确认下单页</td><td>/order-confirm</td><td>房号、联系人、送达时间、支付方式</td></tr>
+      <tr><td>6</td><td>支付页面</td><td>/pay/:id</td><td>微信支付/支付宝(根据环境自动匹配)</td></tr>
+      <tr><td>7</td><td>订单状态页</td><td>/order/:id</td><td>6节点进度条、预计送达时间、退单按钮(未接单时)</td></tr>
+      <tr><td>8</td><td>我的订单</td><td>/orders</td><td>历史订单列表、状态标签</td></tr>
+    </tbody>
+  </table>
+
+  <div class="section-subtitle">5.2 餐厅前台 — 4页</div>
+  <table>
+    <thead><tr><th width="40">#</th><th>页面</th><th>说明</th></tr></thead>
+    <tbody>
+      <tr><td>1</td><td>订单提醒弹窗</td><td>新订单语音+弹窗提醒</td></tr>
+      <tr><td>2</td><td>订单处理页</td><td>待接单列表、订单详情、接单/拒单操作</td></tr>
+      <tr><td>3</td><td>当日订单看板</td><td>进行中订单总览、送达时间</td></tr>
+      <tr><td>4</td><td>营业时间设置</td><td>多时段编辑、临时暂停开关</td></tr>
+    </tbody>
+  </table>
+
+  <div class="section-subtitle">5.3 厨房端 — 2页</div>
+  <table>
+    <thead><tr><th width="40">#</th><th>页面</th><th>说明</th></tr></thead>
+    <tbody>
+      <tr><td>1</td><td>待备餐列表</td><td>按时间排序的待做列表、语音播报、小票打印</td></tr>
+      <tr><td>2</td><td>订单详情</td><td>菜品明细、房号、备注、出餐按钮</td></tr>
+    </tbody>
+  </table>
+
+  <div class="section-subtitle">5.4 宾馆前台 — 2页</div>
+  <table>
+    <thead><tr><th width="40">#</th><th>页面</th><th>说明</th></tr></thead>
+    <tbody>
+      <tr><td>1</td><td>订单总览</td><td>全部订单列表、状态筛选、搜索</td></tr>
+      <tr><td>2</td><td>订单详情+退单</td><td>订单完整信息、状态时间线、退单操作</td></tr>
+    </tbody>
+  </table>
+
+  <div class="section-subtitle">5.5 后台管理 — 5页</div>
+  <table>
+    <thead><tr><th width="40">#</th><th>页面</th><th>说明</th></tr></thead>
+    <tbody>
+      <tr><td>1</td><td>数据看板</td><td>今日订单、营业额、热门菜品、趋势图</td></tr>
+      <tr><td>2</td><td>菜品管理</td><td>列表、上下架、价格编辑、售罄</td></tr>
+      <tr><td>3</td><td>订单管理</td><td>全部订单、筛选、详情</td></tr>
+      <tr><td>4</td><td>房间管理</td><td>房间列表、二维码生成/重生成、启停</td></tr>
+      <tr><td>5</td><td>系统设置</td><td>品牌风格配色切换</td></tr>
+    </tbody>
+  </table>
+</div>
+
+<!-- ==================== 6. 订单状态定义 ==================== -->
+<div class="section" id="s6">
+  <div class="section-title">6. 订单状态定义</div>
+
+  <div class="status-bar">
+    <div class="status-step active"><span class="step-num">1</span>已下单</div>
+    <div class="status-step"><span class="step-num">2</span>已接单</div>
+    <div class="status-step"><span class="step-num">3</span>备餐中</div>
+    <div class="status-step"><span class="step-num">4</span>已出餐</div>
+    <div class="status-step"><span class="step-num">5</span>配送中</div>
+    <div class="status-step"><span class="step-num">6</span>已完成</div>
+  </div>
+
+  <table>
+    <thead><tr><th>状态</th><th>触发人</th><th>触发动作</th><th>下一步</th><th>可退单</th></tr></thead>
+    <tbody>
+      <tr><td><span class="tag tag-blue">已下单</span></td><td>顾客</td><td>完成支付</td><td>等待餐厅接单</td><td><span class="tag tag-green">✓ 自助退</span></td></tr>
+      <tr><td><span class="tag tag-blue">已接单</span></td><td>餐厅前台</td><td>点击接单</td><td>通知厨房</td><td><span class="tag tag-orange">仅宾馆前台</span></td></tr>
+      <tr><td><span class="tag tag-orange">备餐中</span></td><td>厨房</td><td>开始制作</td><td>完成后出餐</td><td><span class="tag tag-orange">仅宾馆前台</span></td></tr>
+      <tr><td><span class="tag tag-orange">已出餐</span></td><td>厨房</td><td>点击出餐</td><td>通知前台送餐</td><td><span class="tag tag-orange">仅宾馆前台</span></td></tr>
+      <tr><td><span class="tag tag-green">配送中</span></td><td>餐厅前台</td><td>安排送餐</td><td>送达客房</td><td><span class="tag tag-orange">仅宾馆前台</span></td></tr>
+      <tr><td><span class="tag tag-green">已完成</span></td><td>—</td><td>送达确认</td><td>终态</td><td><span class="tag tag-gray">不可退</span></td></tr>
+    </tbody>
+  </table>
+
+  <div class="section-subtitle">拒单状态(异常分支)</div>
+  <table>
+    <thead><tr><th>状态</th><th>触发人</th><th>触发动作</th><th>结果</th></tr></thead>
+    <tbody>
+      <tr><td><span class="tag tag-red">已拒单</span></td><td>餐厅前台</td><td>拒绝接单(填写原因)</td><td>通知顾客退款,自动原路返回</td></tr>
+    </tbody>
+  </table>
+</div>
+
+<!-- ==================== 7. 非功能性需求 ==================== -->
+<div class="section" id="s7">
+  <div class="section-title">7. 非功能性需求</div>
+
+  <div class="card">
+    <strong>性能要求</strong>
+    <ul class="point-list">
+      <li>扫码到进入小程序 &lt; 2 秒</li>
+      <li>菜单加载 &lt; 1.5 秒</li>
+      <li>订单状态更新延迟 &lt; 3 秒</li>
+    </ul>
+  </div>
+
+  <div class="card">
+    <strong>兼容性要求</strong>
+    <ul class="point-list">
+      <li>顾客端:微信小程序(iOS 14+ / Android 8+)、支付宝小程序</li>
+      <li>餐厅前台/宾馆前台:Chrome 90+ / Safari 14+ / 移动端浏览器</li>
+      <li>厨房端:大屏显示器(1920×1080)+ 热敏打印机</li>
+      <li>后台管理:Chrome 90+ / Edge 90+</li>
+    </ul>
+  </div>
+
+  <div class="card">
+    <strong>语音要求</strong>
+    <ul class="point-list">
+      <li>餐厅前台:新订单语音提醒(可暂停/调整音量)</li>
+      <li>厨房端:新订单语音播报菜品名称和房号</li>
+    </ul>
+  </div>
+
+  <div class="card">
+    <strong>视觉要求</strong>
+    <ul class="point-list">
+      <li>整体风格:简洁商务风,留白多、扁平化</li>
+      <li>三套候选配色方案(商务深蓝 / 暖金雅致 / 墨绿清新),待客户确认</li>
+      <li>品牌风格设置后五端同步生效</li>
+      <li>移动端(顾客、PAD):手机/平板外框模拟,底部 Tab 导航</li>
+      <li>PC 端(后台、宾馆前台):左侧导航 + 右侧内容区</li>
+    </ul>
+  </div>
+
+  <div class="card">
+    <strong>安全要求</strong>
+    <ul class="point-list">
+      <li>支付环境自动判断,防止支付方式注入攻击</li>
+      <li>退单操作权限控制(接单前顾客自退、接单后仅宾馆前台)</li>
+      <li>订单数据隔离,各端仅可见本端权限范围内数据</li>
+    </ul>
+  </div>
+</div>
+
+<!-- ==================== 页脚 ==================== -->
+<div class="footer">
+  <p>酒店客房送餐系统 · 需���说明书 v1.0</p>
+  <p>2026年7月26日 · 需求确认阶段</p>
+  <p style="margin-top:8px;color:#94A3B8;">本文档为需求阶段产物,页面结构及流程以最终原型稿为准</p>
+</div>
+
+</div>
+</body>
+</html>