Kaynağa Gözat

pc端餐厅管理界面

徐滕 2 hafta önce
ebeveyn
işleme
ead336d4fe
21 değiştirilmiş dosya ile 2796 ekleme ve 289 silme
  1. 9 2
      forge-admin-ui/src/api/hotel.js
  2. 584 0
      forge-admin-ui/src/views/hotel/dashboard.vue
  3. 51 237
      forge-admin-ui/src/views/hotel/order.vue
  4. 6 4
      forge-admin-ui/src/views/hotel/orderRefund.vue
  5. 1517 0
      forge-admin-ui/src/views/hotel/restaurantOrder.vue
  6. 39 0
      forge-server/db/migration/V1.0.115__fix_hotel_order_uk_order_no_include_del_flag.sql
  7. 20 0
      forge-server/db/migration/V1.0.116__add_hotel_dashboard_menu.sql
  8. 35 0
      forge-server/db/migration/V1.0.117__add_restaurant_order_menu.sql
  9. 1 1
      forge-server/forge-admin-server/src/main/resources/application-dev.yml
  10. 1 1
      forge-server/forge-app-server/src/main/resources/application-dev.yml
  11. 34 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/controller/HotelDashboardController.java
  12. 5 2
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/controller/HotelOrderController.java
  13. 6 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/domain/HotelOrder.java
  14. 40 1
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/mapper/HotelOrderMapper.java
  15. 12 1
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/service/HotelOrderService.java
  16. 188 25
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/service/impl/HotelOrderServiceImpl.java
  17. 112 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/vo/DashboardVO.java
  18. 3 0
      forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/vo/OrderDashboardVO.java
  19. 113 9
      forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/order/HotelOrderMapper.xml
  20. 15 2
      forge-server/forge-business/forge-hotel/酒店模块迁移跟踪.md
  21. 5 4
      forge-server/forge-business/forge-hotel/酒店模块需求缺口清单.md

+ 9 - 2
forge-admin-ui/src/api/hotel.js

@@ -344,8 +344,8 @@ export function getOrderDetail(id) {
 }
 
 /** 订单看板统计 */
-export function getOrderDashboard() {
-  return request.get('/hotel/order/dashboard')
+export function getOrderDashboard(params) {
+  return request.get('/hotel/order/dashboard', { params })
 }
 
 /** 创建订单 */
@@ -419,3 +419,10 @@ export function getPauseStatus() {
 export function togglePause() {
   return request.post('/hotel/order/pause/toggle')
 }
+
+// ==================== 营业数据看板 ====================
+
+/** 获取营业数据看板(KPI/趋势/热门菜品/餐段分布) */
+export function getDashboard() {
+  return request.get('/hotel/dashboard')
+}

+ 584 - 0
forge-admin-ui/src/views/hotel/dashboard.vue

@@ -0,0 +1,584 @@
+<template>
+  <div class="dashboard-page">
+    <!-- 顶部标题栏 -->
+    <div class="dashboard-header">
+      <div class="header-left">
+        <h2 class="header-title">营业数据看板</h2>
+        <NTag type="success" size="medium" round>
+          今日 {{ todayStr }}
+        </NTag>
+      </div>
+      <NButton :loading="loading" @click="loadDashboard">
+        <template #icon><NIcon><RefreshIcon /></NIcon></template>
+        刷新数据
+      </NButton>
+    </div>
+
+    <!-- KPI 卡片行 -->
+    <div class="kpi-grid">
+      <div class="kpi-card">
+        <div class="kpi-label">今日订单数</div>
+        <div class="kpi-value">{{ kpi.orderCount }}<span class="kpi-unit">单</span></div>
+        <div class="kpi-growth" :class="growthClass(kpi.orderCountGrowth)">
+          <span v-if="kpi.orderCountGrowth !== null">{{ formatGrowth(kpi.orderCountGrowth) }} 环比昨日</span>
+          <span v-else class="text-gray-400">昨日无数据</span>
+        </div>
+      </div>
+      <div class="kpi-card kpi-card--revenue">
+        <div class="kpi-label">今日营业额</div>
+        <div class="kpi-value kpi-value--revenue">¥{{ formatMoney(kpi.revenue) }}</div>
+        <div class="kpi-growth" :class="growthClass(kpi.revenueGrowth)">
+          <span v-if="kpi.revenueGrowth !== null">{{ formatGrowth(kpi.revenueGrowth) }} 环比昨日</span>
+          <span v-else class="text-gray-400">昨日无数据</span>
+        </div>
+      </div>
+      <div class="kpi-card">
+        <div class="kpi-label">平均送达时长</div>
+        <div class="kpi-value">{{ kpi.avgDeliveryMinutes }}<span class="kpi-unit">分钟</span></div>
+        <div class="kpi-growth" :class="growthClass(kpi.avgDeliveryGrowth, true)">
+          <span v-if="kpi.avgDeliveryGrowth !== null">{{ formatGrowth(kpi.avgDeliveryGrowth) }} 环比昨日</span>
+          <span v-else class="text-gray-400">昨日无数据</span>
+        </div>
+      </div>
+      <div class="kpi-card">
+        <div class="kpi-label">退单率</div>
+        <div class="kpi-value">{{ formatPercent(kpi.refundRate) }}</div>
+        <div class="kpi-growth" :class="growthClass(kpi.refundRateGrowth, true)">
+          <span v-if="kpi.refundRateGrowth !== null">{{ formatGrowth(kpi.refundRateGrowth) }} 环比昨日</span>
+          <span v-else class="text-gray-400">昨日无数据</span>
+        </div>
+      </div>
+    </div>
+
+    <!-- 中间区域:趋势图 + 热门菜品 -->
+    <div class="middle-grid">
+      <!-- 近7天订单趋势 -->
+      <div class="chart-card chart-card--trend">
+        <div class="chart-title">近7天订单趋势</div>
+        <div ref="trendChartRef" class="chart-container"></div>
+      </div>
+      <!-- 本周热门菜品 -->
+      <div class="chart-card chart-card--hot">
+        <div class="chart-title">本周热门菜品 Top5</div>
+        <div v-if="hotDishes.length === 0" class="empty-tip">
+          本周暂无订单数据
+        </div>
+        <div v-else class="hot-dish-list">
+          <div v-for="dish in hotDishes" :key="dish.rank" class="hot-dish-item">
+            <span class="rank-badge" :class="dish.rank <= 3 ? 'rank-top' : ''">{{ dish.rank }}</span>
+            <span class="dish-name">{{ dish.dishName }}</span>
+            <span class="dish-count">{{ dish.salesCount }} 份</span>
+            <div class="dish-bar-wrap">
+              <div class="dish-bar" :style="{ width: dishBarWidth(dish.salesCount) }"></div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 底部:餐段订单分布 -->
+    <div class="chart-card chart-card--meal">
+      <div class="chart-title">今日餐段订单分布</div>
+      <div v-if="mealDistribution.length === 0" class="empty-tip">
+        暂无营业时段配置
+      </div>
+      <div v-else class="meal-grid">
+        <div v-for="meal in mealDistribution" :key="meal.mealType" class="meal-item" :class="{ 'meal-active': meal.isInProgress }">
+          <div class="meal-header">
+            <span class="meal-name">{{ meal.mealName }}</span>
+            <NTag v-if="meal.isInProgress" type="success" size="small" round>进行中</NTag>
+          </div>
+          <div class="meal-count">{{ meal.orderCount }}<span class="meal-unit">单</span></div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
+import { NButton, NIcon, NTag, useMessage } from 'naive-ui'
+import { RefreshOutline as RefreshIcon } from '@vicons/ionicons5'
+import { getDashboard } from '@/api/hotel'
+import * as echarts from 'echarts'
+
+defineOptions({ name: 'HotelDashboard' })
+
+const message = useMessage()
+const loading = ref(false)
+
+// ==================== 数据 ====================
+
+const kpi = ref({
+  orderCount: 0,
+  orderCountGrowth: null,
+  revenue: 0,
+  revenueGrowth: null,
+  avgDeliveryMinutes: 0,
+  avgDeliveryGrowth: null,
+  refundRate: 0,
+  refundRateGrowth: null,
+})
+const trend = ref([])
+const hotDishes = ref([])
+const mealDistribution = ref([])
+
+// ==================== 日期 ====================
+
+const todayStr = computed(() => {
+  const d = new Date()
+  const m = String(d.getMonth() + 1).padStart(2, '0')
+  const day = String(d.getDate()).padStart(2, '0')
+  return m + '/' + day
+})
+
+// ==================== 加载数据 ====================
+
+async function loadDashboard() {
+  loading.value = true
+  try {
+    const res = await getDashboard()
+    if (res.code === 200 && res.data) {
+      const d = res.data
+      kpi.value = d.todayKpi || kpi.value
+      trend.value = d.trend || []
+      hotDishes.value = d.hotDishes || []
+      mealDistribution.value = d.mealDistribution || []
+      renderTrendChart()
+    } else {
+      message.error(res.msg || '加载看板数据失败')
+    }
+  } catch (e) {
+    message.error('加载看板数据失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+// ==================== KPI 格式化 ====================
+
+function formatMoney(val) {
+  if (val == null) return '0.00'
+  return Number(val).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
+}
+
+function formatPercent(val) {
+  if (val == null || val === 0) return '0%'
+  return (val * 100).toFixed(1) + '%'
+}
+
+function formatGrowth(val) {
+  if (val == null) return ''
+  const pct = (val * 100).toFixed(1)
+  if (val > 0) return '↑' + pct + '%'
+  if (val < 0) return '↓' + Math.abs(pct) + '%'
+  return '→ 0%'
+}
+
+/**
+ * 环比增长率样式。
+ * @param val 增长率
+ * @param invertColor 是否反转颜色(送达时长/退单率:上升为红色,下降为绿色)
+ */
+function growthClass(val, invertColor) {
+  if (val == null) return 'text-gray-400'
+  if (val === 0) return 'text-gray-400'
+  if (invertColor) {
+    return val > 0 ? 'text-error' : 'text-success'
+  }
+  return val > 0 ? 'text-success' : 'text-error'
+}
+
+// ==================== 热门菜品 ====================
+
+function dishBarWidth(count) {
+  if (hotDishes.value.length === 0) return '0%'
+  const max = hotDishes.value[0].salesCount
+  if (max === 0) return '0%'
+  return Math.max(10, (count / max) * 100) + '%'
+}
+
+// ==================== ECharts 趋势图 ====================
+
+const trendChartRef = ref(null)
+let trendChart = null
+
+function renderTrendChart() {
+  if (!trendChartRef.value) return
+  if (!trendChart) {
+    trendChart = echarts.init(trendChartRef.value)
+  }
+  const dates = []
+  const orders = []
+  const revenues = []
+  for (let i = 0; i < trend.value.length; i++) {
+    dates.push(trend.value[i].date)
+    orders.push(trend.value[i].orderCount)
+    revenues.push(trend.value[i].revenue)
+  }
+  const option = {
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: { type: 'cross' },
+      textStyle: { fontSize: 13 },
+    },
+    legend: {
+      data: ['订单数', '营业额'],
+      bottom: 4,
+      textStyle: { fontSize: 13, color: '#666' },
+    },
+    grid: {
+      left: 60,
+      right: 60,
+      top: 20,
+      bottom: 44,
+    },
+    xAxis: {
+      type: 'category',
+      data: dates,
+      axisLine: { lineStyle: { color: '#ddd' } },
+      axisLabel: { color: '#666', fontSize: 12 },
+    },
+    yAxis: [
+      {
+        type: 'value',
+        name: '订单数',
+        nameTextStyle: { color: '#999', fontSize: 12 },
+        axisLabel: { color: '#666', fontSize: 12 },
+        splitLine: { lineStyle: { type: 'dashed', color: '#f0f0f0' } },
+      },
+      {
+        type: 'value',
+        name: '营业额(元)',
+        nameTextStyle: { color: '#999', fontSize: 12 },
+        axisLabel: {
+          color: '#666',
+          fontSize: 12,
+          formatter: function (value) {
+            return value >= 1000 ? (value / 1000).toFixed(1) + 'k' : value
+          },
+        },
+        splitLine: { show: false },
+      },
+    ],
+    series: [
+      {
+        name: '订单数',
+        type: 'line',
+        data: orders,
+        smooth: true,
+        symbol: 'circle',
+        symbolSize: 8,
+        lineStyle: { width: 2.5 },
+        itemStyle: { color: '#2080f0' },
+        areaStyle: {
+          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: 'rgba(32,128,240,0.20)' },
+            { offset: 1, color: 'rgba(32,128,240,0.01)' },
+          ]),
+        },
+      },
+      {
+        name: '营业额',
+        type: 'line',
+        yAxisIndex: 1,
+        data: revenues,
+        smooth: true,
+        symbol: 'circle',
+        symbolSize: 8,
+        lineStyle: { width: 2.5 },
+        itemStyle: { color: '#18a058' },
+        areaStyle: {
+          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: 'rgba(24,160,88,0.15)' },
+            { offset: 1, color: 'rgba(24,160,88,0.01)' },
+          ]),
+        },
+      },
+    ],
+  }
+  trendChart.setOption(option, true)
+}
+
+// ==================== 窗口 resize ====================
+
+function handleResize() {
+  if (trendChart) {
+    trendChart.resize()
+  }
+}
+
+// ==================== 生命周期 ====================
+
+onMounted(() => {
+  loadDashboard()
+  window.addEventListener('resize', handleResize)
+})
+
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', handleResize)
+  if (trendChart) {
+    trendChart.dispose()
+    trendChart = null
+  }
+})
+</script>
+
+<style scoped>
+/* ==================== 页面容器 ==================== */
+.dashboard-page {
+  height: 100%;
+  min-height: 100%;
+  overflow-y: auto;
+  padding: 20px 24px 32px;
+  background: #f5f6f8;
+}
+
+/* ==================== 顶部标题栏 ==================== */
+.dashboard-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 20px;
+}
+
+.header-left {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.header-title {
+  margin: 0;
+  font-size: 20px;
+  font-weight: 700;
+  color: #1a1a1a;
+}
+
+/* ==================== KPI 卡片 ==================== */
+.kpi-grid {
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  gap: 16px;
+  margin-bottom: 20px;
+}
+
+.kpi-card {
+  background: #fff;
+  border-radius: 12px;
+  padding: 24px;
+  border: 1px solid #e8e8ec;
+  transition: box-shadow 0.2s, transform 0.15s;
+}
+
+.kpi-card:hover {
+  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
+  transform: translateY(-2px);
+}
+
+.kpi-label {
+  font-size: 14px;
+  color: #8a8a8e;
+  margin-bottom: 12px;
+}
+
+.kpi-value {
+  font-size: 32px;
+  font-weight: 700;
+  color: #1a1a1a;
+  line-height: 1.2;
+}
+
+.kpi-value--revenue {
+  color: #2080f0;
+}
+
+.kpi-unit {
+  font-size: 14px;
+  font-weight: 400;
+  color: #aaa;
+  margin-left: 4px;
+}
+
+.kpi-growth {
+  font-size: 13px;
+  margin-top: 10px;
+  line-height: 1.4;
+}
+
+/* ==================== 中间区域 ==================== */
+.middle-grid {
+  display: grid;
+  grid-template-columns: 1.8fr 1fr;
+  gap: 16px;
+  margin-bottom: 20px;
+}
+
+/* ==================== 通用卡片 ==================== */
+.chart-card {
+  background: #fff;
+  border-radius: 12px;
+  padding: 24px;
+  border: 1px solid #e8e8ec;
+}
+
+.chart-title {
+  font-size: 16px;
+  font-weight: 700;
+  color: #1a1a1a;
+  margin-bottom: 16px;
+  padding-bottom: 12px;
+  border-bottom: 1px solid #f0f0f0;
+}
+
+.chart-container {
+  width: 100%;
+  height: 340px;
+}
+
+.empty-tip {
+  text-align: center;
+  padding: 48px 0;
+  color: #bbb;
+  font-size: 14px;
+}
+
+/* ==================== 热门菜品 ==================== */
+.hot-dish-item {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 12px 0;
+  border-bottom: 1px solid #f5f5f5;
+}
+
+.hot-dish-item:last-child {
+  border-bottom: none;
+}
+
+.rank-badge {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 26px;
+  height: 26px;
+  border-radius: 50%;
+  font-size: 13px;
+  font-weight: 700;
+  background: #f0f0f0;
+  color: #999;
+  flex-shrink: 0;
+}
+
+.rank-badge.rank-top {
+  background: #2080f0;
+  color: #fff;
+}
+
+.dish-name {
+  flex: 1;
+  font-size: 14px;
+  color: #333;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.dish-count {
+  font-size: 14px;
+  font-weight: 700;
+  color: #2080f0;
+  flex-shrink: 0;
+}
+
+.dish-bar-wrap {
+  width: 80px;
+  height: 8px;
+  background: #f0f0f0;
+  border-radius: 4px;
+  overflow: hidden;
+  flex-shrink: 0;
+}
+
+.dish-bar {
+  height: 100%;
+  background: linear-gradient(90deg, #2080f0, #409eff);
+  border-radius: 4px;
+  transition: width 0.6s ease;
+}
+
+/* ==================== 餐段分布 ==================== */
+.meal-grid {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 16px;
+}
+
+.meal-item {
+  background: #fafbfc;
+  border-radius: 12px;
+  padding: 24px;
+  border: 2px solid #eee;
+  transition: border-color 0.2s, background 0.2s;
+}
+
+.meal-item.meal-active {
+  border-color: #18a058;
+  background: #f0faf4;
+}
+
+.meal-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12px;
+}
+
+.meal-name {
+  font-size: 16px;
+  font-weight: 700;
+  color: #1a1a1a;
+}
+
+.meal-count {
+  font-size: 36px;
+  font-weight: 700;
+  color: #1a1a1a;
+  line-height: 1;
+}
+
+.meal-unit {
+  font-size: 14px;
+  font-weight: 400;
+  color: #aaa;
+  margin-left: 4px;
+}
+
+/* ==================== 响应式 ==================== */
+@media (max-width: 1400px) {
+  .kpi-grid {
+    grid-template-columns: repeat(2, 1fr);
+  }
+}
+
+@media (max-width: 1200px) {
+  .middle-grid {
+    grid-template-columns: 1fr;
+  }
+}
+
+@media (max-width: 768px) {
+  .dashboard-page {
+    padding: 12px;
+  }
+  .kpi-grid {
+    grid-template-columns: 1fr;
+  }
+  .meal-grid {
+    grid-template-columns: 1fr;
+  }
+  .kpi-value {
+    font-size: 26px;
+  }
+}
+</style>

+ 51 - 237
forge-admin-ui/src/views/hotel/order.vue

@@ -55,121 +55,27 @@
       </template>
     </AiCrudPage>
 
-    <!-- 订单详情弹窗 -->
+    <!-- 取消订单确认弹窗(待支付订单) -->
     <NModal
-      v-model:show="detailVisible"
-      title="订单详情"
-      preset="card"
-      style="width: 680px;"
-      :mask-closable="true"
+      v-model:show="cancelVisible"
+      title="取消订单"
+      preset="dialog"
+      style="width: 420px;"
+      :show-icon="false"
+      :mask-closable="false"
+      positive-text="确认取消"
+      negative-text="返回"
+      @positive-click="handleCancelConfirm"
     >
-      <NSpin :show="detailLoading">
-        <div v-if="detailData" class="order-detail">
-          <!-- 基本信息 -->
-          <div class="detail-section">
-            <div class="detail-header">
-              <span class="detail-order-no">{{ detailData.orderNo }}</span>
-              <DictTag dict-type="hotel_order_status" :value="String(detailData.status)" size="small" />
-            </div>
-            <div class="detail-grid">
-              <div class="detail-item">
-                <span class="detail-label">房间号</span>
-                <span class="detail-value">{{ detailData.roomNo }}</span>
-              </div>
-              <div class="detail-item">
-                <span class="detail-label">联系人</span>
-                <span class="detail-value">{{ detailData.contactName }}</span>
-              </div>
-              <div class="detail-item">
-                <span class="detail-label">联系电话</span>
-                <span class="detail-value">{{ detailData.contactPhone }}</span>
-              </div>
-              <div class="detail-item">
-                <span class="detail-label">支付方式</span>
-                <span class="detail-value">
-                  <DictTag v-if="detailData.payMethod" dict-type="hotel_pay_method" :value="detailData.payMethod" size="small" />
-                </span>
-              </div>
-              <div class="detail-item">
-                <span class="detail-label">配送时间</span>
-                <span class="detail-value">
-                  <template v-if="detailData.deliveryTimeType === 'CUSTOM'">
-                    {{ detailData.customDeliveryTime }}
-                  </template>
-                  <template v-else>
-                    立即送达
-                  </template>
-                </span>
-              </div>
-              <div class="detail-item">
-                <span class="detail-label">下单时间</span>
-                <span class="detail-value">{{ detailData.createTime }}</span>
-              </div>
-            </div>
-            <div v-if="detailData.note" class="detail-note">
-              <span class="detail-label">备注:</span>{{ detailData.note }}
-            </div>
-          </div>
-
-          <!-- 金额信息 -->
-          <div class="detail-section">
-            <div class="detail-amount-row">
-              <span>菜品总额:<strong>¥{{ calcItemsTotal }}</strong></span>
-              <span>配送费:<strong>¥{{ detailData.deliveryFee }}</strong></span>
-              <span class="detail-total">合计:<strong>¥{{ detailData.totalAmount }}</strong></span>
-            </div>
-          </div>
-
-          <!-- 时间线 -->
-          <div v-if="hasTimeInfo" class="detail-section">
-            <div class="detail-label" style="margin-bottom: 6px;">
-              时间记录
-            </div>
-            <div class="detail-grid">
-              <div v-if="detailData.acceptTime" class="detail-item">
-                <span class="detail-label">接单</span>
-                <span class="detail-value">{{ detailData.acceptTime }}</span>
-              </div>
-              <div v-if="detailData.readyTime" class="detail-item">
-                <span class="detail-label">出餐</span>
-                <span class="detail-value">{{ detailData.readyTime }}</span>
-              </div>
-              <div v-if="detailData.deliverTime" class="detail-item">
-                <span class="detail-label">配送</span>
-                <span class="detail-value">{{ detailData.deliverTime }}</span>
-              </div>
-              <div v-if="detailData.completeTime" class="detail-item">
-                <span class="detail-label">完成</span>
-                <span class="detail-value">{{ detailData.completeTime }}</span>
-              </div>
-              <div v-if="detailData.refundTime" class="detail-item">
-                <span class="detail-label">退单</span>
-                <span class="detail-value">{{ detailData.refundTime }}</span>
-              </div>
-            </div>
-            <div v-if="detailData.rejectReason" class="detail-note">
-              <span class="detail-label">拒单原因:</span>{{ detailData.rejectReason }}
-            </div>
-            <div v-if="detailData.refundReason" class="detail-note">
-              <span class="detail-label">退单原因:</span>{{ detailData.refundReason }}
-            </div>
-          </div>
-
-          <!-- 菜品明细 -->
-          <div class="detail-section">
-            <div class="detail-label" style="margin-bottom: 6px;">
-              菜品明细
-            </div>
-            <NDataTable
-              :columns="detailItemColumns"
-              :data="detailData.items || []"
-              :bordered="true"
-              size="small"
-              :pagination="false"
-            />
-          </div>
-        </div>
-      </NSpin>
+      <div style="margin-bottom: 12px; font-size: 14px; color: #666;">
+        确定要取消订单 <strong>{{ cancelOrder?.orderNo }}</strong> 吗?
+      </div>
+      <NInput
+        v-model:value="cancelReason"
+        type="textarea"
+        placeholder="取消原因(选填)"
+        :rows="3"
+      />
     </NModal>
 
     <!-- 原因输入弹窗(拒单/退单审核) -->
@@ -195,7 +101,7 @@
 </template>
 
 <script setup>
-import { NDataTable, NInput, NModal, NSpin, useDialog, useMessage } from 'naive-ui'
+import { NInput, NModal, useDialog, useMessage } from 'naive-ui'
 import { computed, h, onMounted, onUnmounted, ref, watch } from 'vue'
 import { useRouter } from 'vue-router'
 import {
@@ -205,8 +111,8 @@ import {
   deliverOrder,
   getPauseStatus,
   getOrderDashboard,
-  getOrderDetail,
   getOrderPage,
+  hotelRefund,
   orderRefund,
   prepareOrder,
   readyOrder,
@@ -446,10 +352,10 @@ const tableColumns = computed(() => [
       const actions = []
       const s = row.status
 
-      // 查看详情(所有状态)
+      // 查看详情(所有状态)→ 跳转订单详情页(与退单页同布局,无退单操作区)
       actions.push(h('a', {
         class: 'text-info cursor-pointer hover:opacity-80',
-        onClick: () => openDetail(row),
+        onClick: () => router.push({ path: '/hotel/orderRefund', query: { id: row.id, mode: 'detail' } }),
       }, '详情'))
 
       // 接单(仅待接单)
@@ -630,7 +536,16 @@ function handleAction(action, row) {
       })
       break
     case 'hotelRefund':
-      router.push({ path: '/hotel/orderRefund', query: { id: row.id } })
+      if (row.status === 0) {
+        // 待支付订单:无资金流转,弹窗确认后直接取消
+        cancelOrder.value = row
+        cancelReason.value = ''
+        cancelVisible.value = true
+      }
+      else {
+        // 已支付订单:跳转退单页面
+        router.push({ path: '/hotel/orderRefund', query: { id: row.id } })
+      }
       break
     case 'approveRefund':
       dialog.warning({
@@ -686,6 +601,24 @@ function handleAction(action, row) {
   }
 }
 
+// ==================== 取消订单(待支付) ====================
+const cancelVisible = ref(false)
+const cancelOrder = ref(null)
+const cancelReason = ref('')
+
+async function handleCancelConfirm() {
+  try {
+    await hotelRefund(cancelOrder.value.id, cancelReason.value || undefined)
+    message.success('订单已取消')
+    cancelVisible.value = false
+    crudRef.value?.refresh()
+    loadDashboard()
+  }
+  catch {
+    // 错误详情已由请求拦截器统一弹窗展示
+  }
+}
+
 function openReasonModal(title, callback) {
   reasonTitle.value = title
   reasonText.value = ''
@@ -699,52 +632,6 @@ async function handleReasonConfirm() {
   }
 }
 
-// ==================== 订单详情 ====================
-const detailVisible = ref(false)
-const detailLoading = ref(false)
-const detailData = ref(null)
-
-const detailItemColumns = [
-  { title: '菜品名称', key: 'dishName', minWidth: 120 },
-  { title: '规格', key: 'specDesc', width: 120, render: row => row.specDesc || '' },
-  { title: '加料', key: 'additionsDesc', width: 120, render: row => row.additionsDesc || '' },
-  { title: '数量', key: 'quantity', width: 60 },
-  { title: '单价', key: 'unitPrice', width: 80, render: row => `¥${row.unitPrice}` },
-  { title: '小计', key: 'subtotal', width: 80, render: row => `¥${row.subtotal}` },
-  { title: '备注', key: 'note', width: 100, render: row => row.note || '' },
-]
-
-const calcItemsTotal = computed(() => {
-  if (!detailData.value?.items)
-    return '0.00'
-  const sum = detailData.value.items.reduce((acc, item) => acc + Number(item.subtotal || 0), 0)
-  return sum.toFixed(2)
-})
-
-const hasTimeInfo = computed(() => {
-  if (!detailData.value)
-    return false
-  const d = detailData.value
-  return d.acceptTime || d.readyTime || d.deliverTime || d.completeTime || d.refundTime || d.rejectReason || d.refundReason
-})
-
-async function openDetail(row) {
-  detailVisible.value = true
-  detailLoading.value = true
-  detailData.value = null
-  try {
-    const res = await getOrderDetail(row.id)
-    if (res.code === 200) {
-      detailData.value = res.data
-    }
-  }
-  catch {
-    message.error('加载订单详情失败')
-  }
-  finally {
-    detailLoading.value = false
-  }
-}
 </script>
 
 <style scoped>
@@ -829,77 +716,4 @@ async function openDetail(row) {
   }
 }
 
-/* ==================== 订单详情 ==================== */
-.order-detail {
-  display: flex;
-  flex-direction: column;
-  gap: 16px;
-}
-
-.detail-section {
-  padding-bottom: 12px;
-  border-bottom: 1px solid #f0f0f0;
-}
-
-.detail-section:last-child {
-  border-bottom: none;
-  padding-bottom: 0;
-}
-
-.detail-header {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  margin-bottom: 12px;
-}
-
-.detail-order-no {
-  font-size: 16px;
-  font-weight: 700;
-  color: #333;
-}
-
-.detail-grid {
-  display: grid;
-  grid-template-columns: repeat(3, 1fr);
-  gap: 8px 16px;
-}
-
-.detail-item {
-  display: flex;
-  flex-direction: column;
-  gap: 2px;
-}
-
-.detail-label {
-  font-size: 12px;
-  color: #999;
-}
-
-.detail-value {
-  font-size: 14px;
-  color: #333;
-}
-
-.detail-note {
-  margin-top: 8px;
-  font-size: 13px;
-  color: #666;
-  padding: 6px 10px;
-  background: #fafafa;
-  border-radius: 4px;
-}
-
-.detail-amount-row {
-  display: flex;
-  gap: 24px;
-  font-size: 14px;
-  color: #333;
-}
-
-.detail-total {
-  margin-left: auto;
-  font-size: 16px;
-  color: #f0a020;
-}
 </style>

+ 6 - 4
forge-admin-ui/src/views/hotel/orderRefund.vue

@@ -4,10 +4,10 @@
       <NButton text @click="handleBack">
         ← 返回列表
       </NButton>
-      <span class="page-title">订单详情 · {{ orderData?.orderNo || '' }}</span>
+      <span class="page-title">{{ isDetailMode ? '订单详情' : '退单操作' }} · {{ orderData?.orderNo || '' }}</span>
     </div>
     <div class="page-subtitle">
-      宾馆前台可查看完整信息,并可在此页面进行退单操作
+      {{ isDetailMode ? '查看订单完整信息与状态流转记录' : '宾馆前台可查看完整信息,并可在此页面进行退单操作' }}
     </div>
 
     <NSpin :show="loading">
@@ -65,8 +65,8 @@
           </div>
         </div>
 
-        <!-- 退单操作区域 -->
-        <div class="refund-action-card">
+        <!-- 退单操作区域(仅退单模式显示) -->
+        <div v-if="!isDetailMode" class="refund-action-card">
           <div class="refund-warning-title">
             ⚠ 退单操作
           </div>
@@ -188,6 +188,8 @@ const orderData = ref(null)
 const refundReason = ref('')
 const submitting = ref(false)
 
+const isDetailMode = computed(() => route.query.mode === 'detail')
+
 const hasTimeInfo = computed(() => {
   if (!orderData.value)
     return false

Dosya farkı çok büyük olduğundan ihmal edildi
+ 1517 - 0
forge-admin-ui/src/views/hotel/restaurantOrder.vue


+ 39 - 0
forge-server/db/migration/V1.0.115__fix_hotel_order_uk_order_no_include_del_flag.sql

@@ -0,0 +1,39 @@
+-- ============================================================
+-- 修复 hotel_order 唯一索引:uk_order_no 必须包含 del_flag,
+-- 否则逻辑删除后 order_no 仍占用唯一键,导致同序号新订单插入失败。
+-- ============================================================
+
+-- 1. 删除旧的 uk_order_no 唯一索引(如果存在)
+SET @uk_exists = (
+    SELECT COUNT(*)
+    FROM information_schema.STATISTICS
+    WHERE TABLE_SCHEMA = DATABASE()
+      AND TABLE_NAME = 'hotel_order'
+      AND INDEX_NAME = 'uk_order_no'
+);
+
+SET @drop_sql = IF(@uk_exists > 0,
+    'ALTER TABLE `hotel_order` DROP INDEX `uk_order_no`',
+    'SELECT 1'
+);
+PREPARE stmt FROM @drop_sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 2. 创建联合唯一索引 (order_no, del_flag)
+--    允许同一 order_no 在 del_flag=0(有效)和 del_flag!=0(已删除)共存
+SET @new_uk_exists = (
+    SELECT COUNT(*)
+    FROM information_schema.STATISTICS
+    WHERE TABLE_SCHEMA = DATABASE()
+      AND TABLE_NAME = 'hotel_order'
+      AND INDEX_NAME = 'uk_order_no_del'
+);
+
+SET @create_sql = IF(@new_uk_exists = 0,
+    'ALTER TABLE `hotel_order` ADD UNIQUE INDEX `uk_order_no_del` (`order_no`, `del_flag`)',
+    'SELECT 1'
+);
+PREPARE stmt FROM @create_sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;

+ 20 - 0
forge-server/db/migration/V1.0.116__add_hotel_dashboard_menu.sql

@@ -0,0 +1,20 @@
+-- ============================================================
+-- V1.0.116  新增「数据看板」菜单
+-- 酒店营业数据看板独立页面:今日KPI/订单趋势/热门菜品/餐段分布
+-- ============================================================
+
+SET @hotel_parent_id = (SELECT id FROM sys_resource WHERE resource_name = '酒店管理' AND tenant_id = 1 AND parent_id = 0 LIMIT 1);
+
+-- 数据看板菜单(sort=8,排在房态看板sort=9前面,作为酒店模块第一个菜单)
+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, 8, '/hotel/dashboard', 'hotel/dashboard', 0, 0, 1, 1, 'mdi:chart-box-outline', 0, 0, '营业数据看板(KPI/趋势/热门菜品/餐段分布)', NOW(), NOW()
+WHERE @hotel_parent_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE path = '/hotel/dashboard' AND tenant_id = 1);
+
+-- 看板查询按钮权限
+SET @dashboard_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, '看板查询', @dashboard_menu_id, 3, 1, 'hotel:dashboard:query', 0, 0, 1, 1, '查询营业数据看板', NOW(), NOW()
+WHERE @dashboard_menu_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'hotel:dashboard:query' AND parent_id = @dashboard_menu_id);

+ 35 - 0
forge-server/db/migration/V1.0.117__add_restaurant_order_menu.sql

@@ -0,0 +1,35 @@
+-- ============================================================
+-- V1.0.117  新增「餐厅管理」一级菜单及「餐厅订单」子菜单
+-- 餐厅前台 PC 端订单管理:全部订单 / 订单看板 / 营业时间
+-- ============================================================
+
+-- 餐厅管理(一级菜单,parent_id = 0,与「酒店管理」同级)
+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, '餐厅管理', 0, 1, 11, '/restaurant', NULL, 0, 0, 1, 1, 'mdi:food-restaurant', 0, 0, '餐厅前台管理(订单/看板/营业时间)', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_resource WHERE resource_name = '餐厅管理' AND tenant_id = 1 AND parent_id = 0);
+
+-- 餐厅订单(子菜单,挂在「餐厅管理」下)
+SET @restaurant_parent_id = (SELECT id FROM sys_resource WHERE resource_name = '餐厅管理' AND tenant_id = 1 AND parent_id = 0 LIMIT 1);
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, path, component, is_external, is_public, menu_status, visible, icon, keep_alive, always_show, remark, create_time, update_time)
+SELECT 1, '餐厅订单', @restaurant_parent_id, 2, 1, '/hotel/restaurantOrder', 'hotel/restaurantOrder', 0, 0, 1, 1, 'mdi:clipboard-text-clock', 0, 0, '餐厅前台订单管理(全部订单/订单看板/营业时间)', NOW(), NOW()
+WHERE @restaurant_parent_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE path = '/hotel/restaurantOrder' AND tenant_id = 1);
+
+-- 餐厅订单按钮权限(使用 restaurant:order:* 独立权限标识,避免与 hotel:order:* 唯一约束冲突)
+SET @restaurant_order_menu_id = (SELECT id FROM sys_resource WHERE resource_name = '餐厅订单' AND parent_id = @restaurant_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, '订单查询', @restaurant_order_menu_id, 3, 1, 'restaurant:order:query', 0, 0, 1, 1, '查询餐厅订单', NOW(), NOW()
+WHERE @restaurant_order_menu_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'restaurant:order:query');
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '接单', @restaurant_order_menu_id, 3, 2, 'restaurant:order:accept', 0, 0, 1, 1, '餐厅接单', NOW(), NOW()
+WHERE @restaurant_order_menu_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'restaurant:order:accept');
+
+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, '拒单', @restaurant_order_menu_id, 3, 3, 'restaurant:order:reject', 0, 0, 1, 1, '餐厅拒单', NOW(), NOW()
+WHERE @restaurant_order_menu_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'restaurant:order:reject');

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

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

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

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

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

@@ -0,0 +1,34 @@
+package com.mdframe.forge.business.core.hotel.order.controller;
+
+import com.mdframe.forge.business.core.hotel.order.service.HotelOrderService;
+import com.mdframe.forge.business.core.hotel.order.vo.DashboardVO;
+import com.mdframe.forge.starter.core.annotation.log.OperationLog;
+import com.mdframe.forge.starter.core.domain.OperationType;
+import com.mdframe.forge.starter.core.domain.RespInfo;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 酒店营业数据看板接口。
+ * <p>
+ * 独立页面菜单,聚合今日KPI(含环比昨日)、近7天订单趋势、本周热门菜品Top5、今日餐段订单分布。
+ * 数据全部从订单表和营业时段表聚合,不涉及写操作。
+ */
+@RestController
+@RequestMapping("/hotel/dashboard")
+public class HotelDashboardController {
+
+    @Autowired
+    private HotelOrderService hotelOrderService;
+
+    /**
+     * 获取营业数据看板(聚合KPI/趋势/热门菜品/餐段分布)。
+     */
+    @GetMapping
+    @OperationLog(module = "酒店看板", type = OperationType.QUERY, desc = "查询营业数据看板")
+    public RespInfo<DashboardVO> dashboard() {
+        return RespInfo.success(hotelOrderService.dashboard());
+    }
+}

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

@@ -13,6 +13,7 @@ import com.mdframe.forge.starter.core.domain.OperationType;
 import com.mdframe.forge.starter.core.domain.PageQuery;
 import com.mdframe.forge.starter.core.domain.RespInfo;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.format.annotation.DateTimeFormat;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
@@ -20,6 +21,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
+import java.util.Date;
+
 /**
  * 酒店订单管理接口。
  */
@@ -61,8 +64,8 @@ public class HotelOrderController {
      */
     @GetMapping("/dashboard")
     @OperationLog(module = "酒店订单", type = OperationType.QUERY, desc = "订单看板统计")
-    public RespInfo<OrderDashboardVO> orderDashboard() {
-        return RespInfo.success(hotelOrderService.orderDashboard());
+    public RespInfo<OrderDashboardVO> orderDashboard(@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") Date queryDate) {
+        return RespInfo.success(hotelOrderService.orderDashboard(queryDate));
     }
 
     // ==================== 订单操作接口 ====================

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

@@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.annotation.TableName;
 import com.mdframe.forge.starter.tenant.core.TenantEntity;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
+import org.springframework.format.annotation.DateTimeFormat;
 
 import java.io.Serial;
 import java.math.BigDecimal;
@@ -138,6 +139,11 @@ public class HotelOrder extends TenantEntity {
     @TableLogic(value = "0", delval = "id")
     private Long delFlag;
 
+    /** 查询日期(非DB字段,用于按日期筛选) */
+    @TableField(exist = false)
+    @DateTimeFormat(pattern = "yyyy-MM-dd")
+    private Date queryDate;
+
     /** 订单明细(非DB字段) */
     @TableField(exist = false)
     private List<HotelOrderItem> items;

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

@@ -10,7 +10,9 @@ import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
 
 import java.math.BigDecimal;
+import java.util.Date;
 import java.util.List;
+import java.util.Map;
 
 /**
  * 酒店订单Mapper。
@@ -46,7 +48,8 @@ public interface HotelOrderMapper extends BaseMapper<HotelOrder> {
      * @param tenantId 租户ID
      * @return 看板统计数据
      */
-    OrderDashboardVO selectDashboard(@Param("tenantId") Long tenantId);
+    OrderDashboardVO selectDashboard(@Param("tenantId") Long tenantId,
+                                     @Param("queryDate") Date queryDate);
 
     /**
      * 统计今日有效订单数(排除拒单和退单)。
@@ -101,4 +104,40 @@ public interface HotelOrderMapper extends BaseMapper<HotelOrder> {
      * @return 被取消的订单数量
      */
     int cancelPendingByStayId(@Param("stayId") Long stayId);
+
+    /**
+     * 看板KPI:同时返回今日和昨日的订单数/营业额/平均送达时长/退单数,Service层计算环比。
+     *
+     * @param tenantId 租户ID
+     * @return Map包含todayOrderCount/yesterdayOrderCount/todayRevenue/yesterdayRevenue等字段
+     */
+    Map<String, Object> selectDashboardKpi(@Param("tenantId") Long tenantId);
+
+    /**
+     * 近7天订单趋势(含今天):按日期分组统计有效订单数和营业额。
+     *
+     * @param tenantId 租户ID
+     * @return 每日统计数据列表(statDate/orderCount/revenue)
+     */
+    List<Map<String, Object>> selectLast7DaysTrend(@Param("tenantId") Long tenantId);
+
+    /**
+     * 本周热门菜品Top5:按本周有效订单明细统计菜品销量。
+     *
+     * @param tenantId 租户ID
+     * @return 热门菜品列表(dishName/salesCount)
+     */
+    List<Map<String, Object>> selectWeeklyHotDishes(@Param("tenantId") Long tenantId);
+
+    /**
+     * 按餐段时间范围统计今日订单数(看板餐段分布用)。
+     *
+     * @param tenantId  租户ID
+     * @param startTime 餐段开始时间(HH:mm格式)
+     * @param endTime   餐段结束时间(HH:mm格式)
+     * @return 订单数
+     */
+    int countOrdersByMealTime(@Param("tenantId") Long tenantId,
+                              @Param("startTime") String startTime,
+                              @Param("endTime") String endTime);
 }

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

@@ -3,10 +3,13 @@ package com.mdframe.forge.business.core.hotel.order.service;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 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.vo.DashboardVO;
 import com.mdframe.forge.business.core.hotel.order.vo.HotelOrderVO;
 import com.mdframe.forge.business.core.hotel.order.vo.OrderDashboardVO;
 import com.mdframe.forge.starter.core.domain.PageQuery;
 
+import java.util.Date;
+
 /**
  * 酒店订单服务接口。
  */
@@ -32,9 +35,17 @@ public interface HotelOrderService {
     /**
      * 订单看板统计。
      *
+     * @param queryDate 查询日期(null 则默认今天)
      * @return 看板统计数据
      */
-    OrderDashboardVO orderDashboard();
+    OrderDashboardVO orderDashboard(Date queryDate);
+
+    /**
+     * 营业数据看板(独立页面):聚合今日KPI(含环比)、近7天趋势、本周热门菜品、今日餐段分布。
+     *
+     * @return 看板聚合数据
+     */
+    DashboardVO dashboard();
 
     /**
      * 创建订单(客户端下单)。

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

@@ -1,16 +1,17 @@
 package com.mdframe.forge.business.core.hotel.order.service.impl;
 
-import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.core.toolkit.IdWorker;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.mdframe.forge.business.core.hotel.constant.HotelConfigKeys;
+import com.mdframe.forge.business.core.hotel.domain.HotelBusinessHours;
 import com.mdframe.forge.business.core.hotel.domain.HotelDish;
 import com.mdframe.forge.business.core.hotel.domain.HotelDishAddition;
 import com.mdframe.forge.business.core.hotel.domain.HotelDishSpecOption;
 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.HotelBusinessHoursMapper;
 import com.mdframe.forge.business.core.hotel.mapper.HotelConfigMapper;
 import com.mdframe.forge.business.core.hotel.mapper.HotelDishAdditionMapper;
 import com.mdframe.forge.business.core.hotel.mapper.HotelDishMapper;
@@ -25,6 +26,7 @@ import com.mdframe.forge.business.core.hotel.order.mapper.HotelOrderItemMapper;
 import com.mdframe.forge.business.core.hotel.order.mapper.HotelOrderMapper;
 import com.mdframe.forge.business.core.hotel.notification.manager.SseEmitterManager;
 import com.mdframe.forge.business.core.hotel.order.service.HotelOrderService;
+import com.mdframe.forge.business.core.hotel.order.vo.DashboardVO;
 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;
@@ -39,12 +41,14 @@ import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.StringUtils;
 
 import java.math.BigDecimal;
+import java.math.RoundingMode;
 import java.text.SimpleDateFormat;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Calendar;
 import java.util.Date;
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
 import java.util.stream.Collectors;
 
@@ -79,6 +83,9 @@ public class HotelOrderServiceImpl implements HotelOrderService {
     @Autowired
     private HotelConfigMapper configMapper;
 
+    @Autowired
+    private HotelBusinessHoursMapper businessHoursMapper;
+
     /**
      * 支付服务(单向依赖:HotelPayService 不注入本服务,不构成循环)。
      * 用于拒单/退单流转时自动发起退款。
@@ -115,8 +122,8 @@ public class HotelOrderServiceImpl implements HotelOrderService {
     }
 
     @Override
-    public OrderDashboardVO orderDashboard() {
-        OrderDashboardVO vo = orderMapper.selectDashboard(resolveTenantId());
+    public OrderDashboardVO orderDashboard(Date queryDate) {
+        OrderDashboardVO vo = orderMapper.selectDashboard(resolveTenantId(), queryDate);
         if (vo == null) {
             vo = new OrderDashboardVO();
             vo.setPendingCount(0);
@@ -128,6 +135,118 @@ public class HotelOrderServiceImpl implements HotelOrderService {
         return vo;
     }
 
+    // ==================== 营业数据看板(独立页面) ====================
+
+    @Override
+    public DashboardVO dashboard() {
+        Long tenantId = resolveTenantId();
+        DashboardVO vo = new DashboardVO();
+
+        // ---- 1. 今日KPI(含环比昨日) ----
+        Map<String, Object> kpiMap = orderMapper.selectDashboardKpi(tenantId);
+        DashboardVO.TodayKpiVO kpi = new DashboardVO.TodayKpiVO();
+        if (kpiMap != null) {
+            int todayOrders = toInt(kpiMap.get("todayOrderCount"));
+            int yesterdayOrders = toInt(kpiMap.get("yesterdayOrderCount"));
+            BigDecimal todayRevenue = toBigDecimal(kpiMap.get("todayRevenue"));
+            BigDecimal yesterdayRevenue = toBigDecimal(kpiMap.get("yesterdayRevenue"));
+            int todayAvgDelivery = toInt(kpiMap.get("todayAvgDelivery"));
+            int yesterdayAvgDelivery = toInt(kpiMap.get("yesterdayAvgDelivery"));
+            int todayRefundCount = toInt(kpiMap.get("todayRefundCount"));
+            int yesterdayRefundCount = toInt(kpiMap.get("yesterdayRefundCount"));
+
+            kpi.setOrderCount(todayOrders);
+            kpi.setOrderCountGrowth(calcGrowth(todayOrders, yesterdayOrders));
+            kpi.setRevenue(todayRevenue);
+            kpi.setRevenueGrowth(calcGrowthDecimal(todayRevenue, yesterdayRevenue));
+            kpi.setAvgDeliveryMinutes(todayAvgDelivery);
+            kpi.setAvgDeliveryGrowth(calcGrowth(todayAvgDelivery, yesterdayAvgDelivery));
+
+            double todayRefundRate = todayOrders > 0 ? (double) todayRefundCount / todayOrders : 0.0;
+            double yesterdayRefundRate = yesterdayOrders > 0 ? (double) yesterdayRefundCount / yesterdayOrders : 0.0;
+            kpi.setRefundRate(todayRefundRate);
+            kpi.setRefundRateGrowth(yesterdayRefundRate > 0
+                    ? (todayRefundRate - yesterdayRefundRate) / yesterdayRefundRate
+                    : null);
+        } else {
+            kpi.setOrderCount(0);
+            kpi.setOrderCountGrowth(null);
+            kpi.setRevenue(BigDecimal.ZERO);
+            kpi.setRevenueGrowth(null);
+            kpi.setAvgDeliveryMinutes(0);
+            kpi.setAvgDeliveryGrowth(null);
+            kpi.setRefundRate(0.0);
+            kpi.setRefundRateGrowth(null);
+        }
+        vo.setTodayKpi(kpi);
+
+        // ---- 2. 近7天订单趋势 ----
+        List<Map<String, Object>> trendRows = orderMapper.selectLast7DaysTrend(tenantId);
+        List<DashboardVO.DailyTrendVO> trendList = new ArrayList<>();
+        SimpleDateFormat dayFmt = new SimpleDateFormat("MM/dd");
+        if (trendRows != null) {
+            for (Map<String, Object> row : trendRows) {
+                DashboardVO.DailyTrendVO trend = new DashboardVO.DailyTrendVO();
+                Object statDate = row.get("statDate");
+                if (statDate instanceof java.sql.Date) {
+                    trend.setDate(dayFmt.format(new Date(((java.sql.Date) statDate).getTime())));
+                } else if (statDate != null) {
+                    trend.setDate(statDate.toString().length() >= 10
+                            ? statDate.toString().substring(5, 7) + "/" + statDate.toString().substring(8, 10)
+                            : statDate.toString());
+                }
+                trend.setOrderCount(toInt(row.get("orderCount")));
+                trend.setRevenue(toBigDecimal(row.get("revenue")));
+                trendList.add(trend);
+            }
+        }
+        vo.setTrend(trendList);
+
+        // ---- 3. 本周热门菜品Top5 ----
+        List<Map<String, Object>> dishRows = orderMapper.selectWeeklyHotDishes(tenantId);
+        List<DashboardVO.HotDishVO> hotDishList = new ArrayList<>();
+        if (dishRows != null) {
+            int rank = 1;
+            for (Map<String, Object> row : dishRows) {
+                DashboardVO.HotDishVO dish = new DashboardVO.HotDishVO();
+                dish.setRank(rank++);
+                dish.setDishName(row.get("dishName") != null ? row.get("dishName").toString() : "");
+                dish.setSalesCount(toInt(row.get("salesCount")));
+                hotDishList.add(dish);
+            }
+        }
+        vo.setHotDishes(hotDishList);
+
+        // ---- 4. 今日餐段订单分布(从营业时段表获取餐段时间范围) ----
+        List<HotelBusinessHours> hoursList = businessHoursMapper.selectEnabled(tenantId);
+        List<DashboardVO.MealDistributionVO> mealList = new ArrayList<>();
+        // 获取当前时间 HH:mm,用于判断当前处于哪个餐段
+        SimpleDateFormat timeFmt = new SimpleDateFormat("HH:mm");
+        String nowTime = timeFmt.format(new Date());
+        if (hoursList != null) {
+            for (HotelBusinessHours bh : hoursList) {
+                DashboardVO.MealDistributionVO meal = new DashboardVO.MealDistributionVO();
+                meal.setMealType(bh.getMealType());
+                meal.setMealName(bh.getMealName());
+                // 根据餐段时间范围统计今日订单数
+                if (bh.getStartTime() != null && bh.getEndTime() != null) {
+                    int count = orderMapper.countOrdersByMealTime(tenantId, bh.getStartTime(), bh.getEndTime());
+                    meal.setOrderCount(count);
+                    // 判断当前是否处于该餐段营业时间内
+                    meal.setIsInProgress(nowTime.compareTo(bh.getStartTime()) >= 0
+                            && nowTime.compareTo(bh.getEndTime()) < 0);
+                } else {
+                    meal.setOrderCount(0);
+                    meal.setIsInProgress(false);
+                }
+                mealList.add(meal);
+            }
+        }
+        vo.setMealDistribution(mealList);
+
+        return vo;
+    }
+
     // ==================== 创建订单 ====================
 
     @Override
@@ -617,34 +736,22 @@ public class HotelOrderServiceImpl implements HotelOrderService {
 
     /**
      * 生成订单号: RM + YYYYMMDD + 4位序号。
-     * 使用重试机制应对并发冲突。
+     * selectMaxOrderNo 已查询所有行(含逻辑删除),确保序号单调递增、永不回收,
+     * 已放弃/已删除的订单号不会被新订单复用,支持未来恢复场景。
      */
     private String generateOrderNo() {
         String dateStr = DATE_FMT.format(new Date());
         String prefix = "RM" + dateStr;
-        for (int retry = 0; retry < 3; retry++) {
-            String maxNo = orderMapper.selectMaxOrderNo(prefix);
-            int seq = 1;
-            if (maxNo != null && maxNo.length() > prefix.length()) {
-                try {
-                    seq = Integer.parseInt(maxNo.substring(prefix.length())) + 1;
-                } catch (NumberFormatException ignored) {
-                    // 解析失败使用默认序号1
-                }
-            }
-            String orderNo = prefix + String.format("%04d", seq);
-            // 检查是否已存在(防御性检查)
-            QueryWrapper<HotelOrder> wrapper = new QueryWrapper<HotelOrder>();
-            wrapper.eq("order_no", orderNo);
-            Long count = orderMapper.selectCount(wrapper);
-            if (count == null || count == 0) {
-                return orderNo;
+        String maxNo = orderMapper.selectMaxOrderNo(prefix);
+        int seq = 1;
+        if (maxNo != null && maxNo.length() > prefix.length()) {
+            try {
+                seq = Integer.parseInt(maxNo.substring(prefix.length())) + 1;
+            } catch (NumberFormatException ignored) {
+                // 解析失败使用默认序号1
             }
-            // 已存在则重试
-            log.warn("订单号冲突,重试生成: {}", orderNo);
         }
-        // 3次重试后使用时间戳后缀
-        return prefix + String.format("%04d", (int) (System.currentTimeMillis() % 10000));
+        return prefix + String.format("%04d", seq);
     }
 
     /**
@@ -736,4 +843,60 @@ public class HotelOrderServiceImpl implements HotelOrderService {
                 .filter(Objects::nonNull)
                 .collect(Collectors.toList());
     }
+
+    /**
+     * 安全转换 Map 结果中的数值为 int(null/非数字返回0)。
+     */
+    private int toInt(Object value) {
+        if (value == null) {
+            return 0;
+        }
+        if (value instanceof Number) {
+            return ((Number) value).intValue();
+        }
+        try {
+            return Integer.parseInt(value.toString());
+        } catch (NumberFormatException e) {
+            return 0;
+        }
+    }
+
+    /**
+     * 安全转换 Map 结果中的数值为 BigDecimal(null返回0)。
+     */
+    private BigDecimal toBigDecimal(Object value) {
+        if (value == null) {
+            return BigDecimal.ZERO;
+        }
+        if (value instanceof BigDecimal) {
+            return (BigDecimal) value;
+        }
+        try {
+            return new BigDecimal(value.toString());
+        } catch (NumberFormatException e) {
+            return BigDecimal.ZERO;
+        }
+    }
+
+    /**
+     * 计算环比增长率(整数版):(today - yesterday) / yesterday。
+     * yesterday为0或null时返回null(无意义)。
+     */
+    private Double calcGrowth(int today, int yesterday) {
+        if (yesterday == 0) {
+            return null;
+        }
+        return (double) (today - yesterday) / yesterday;
+    }
+
+    /**
+     * 计算环比增长率(BigDecimal版):(today - yesterday) / yesterday。
+     * yesterday为0或null时返回null。
+     */
+    private Double calcGrowthDecimal(BigDecimal today, BigDecimal yesterday) {
+        if (yesterday == null || yesterday.compareTo(BigDecimal.ZERO) == 0) {
+            return null;
+        }
+        return today.subtract(yesterday).divide(yesterday, 4, RoundingMode.HALF_UP).doubleValue();
+    }
 }

+ 112 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/order/vo/DashboardVO.java

@@ -0,0 +1,112 @@
+package com.mdframe.forge.business.core.hotel.order.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 营业数据看板VO。
+ * <p>
+ * 聚合今日KPI(含环比)、近7天订单趋势、本周热门菜品、今日餐段订单分布,
+ * 供PC管理端「数据看板」独立页面使用。
+ */
+@Data
+public class DashboardVO {
+
+    /** 今日KPI指标(含昨日环比) */
+    private TodayKpiVO todayKpi;
+
+    /** 近7天订单趋势 */
+    private List<DailyTrendVO> trend;
+
+    /** 本周热门菜品Top5 */
+    private List<HotDishVO> hotDishes;
+
+    /** 今日餐段订单分布 */
+    private List<MealDistributionVO> mealDistribution;
+
+    // ==================== 内部类 ====================
+
+    /**
+     * 今日KPI指标。
+     */
+    @Data
+    public static class TodayKpiVO {
+
+        /** 今日订单数(已支付且排除终态) */
+        private Integer orderCount;
+
+        /** 今日订单数环比昨日增长率(如0.12表示+12%) */
+        private Double orderCountGrowth;
+
+        /** 今日营业额(元) */
+        private BigDecimal revenue;
+
+        /** 今日营业额环比昨日增长率 */
+        private Double revenueGrowth;
+
+        /** 平均送达时长(分钟,从接单到完成) */
+        private Integer avgDeliveryMinutes;
+
+        /** 平均送达时长环比昨日增长率 */
+        private Double avgDeliveryGrowth;
+
+        /** 退单率(退单数/总订单数) */
+        private Double refundRate;
+
+        /** 退单率环比昨日增长率 */
+        private Double refundRateGrowth;
+    }
+
+    /**
+     * 每日趋势数据(近7天)。
+     */
+    @Data
+    public static class DailyTrendVO {
+
+        /** 日期标签(MM/dd格式) */
+        private String date;
+
+        /** 当日订单数 */
+        private Integer orderCount;
+
+        /** 当日营业额(元) */
+        private BigDecimal revenue;
+    }
+
+    /**
+     * 热门菜品。
+     */
+    @Data
+    public static class HotDishVO {
+
+        /** 排名(1-based) */
+        private Integer rank;
+
+        /** 菜品名称 */
+        private String dishName;
+
+        /** 本周销量(份) */
+        private Integer salesCount;
+    }
+
+    /**
+     * 餐段订单分布。
+     */
+    @Data
+    public static class MealDistributionVO {
+
+        /** 餐段标识(breakfast/lunch/dinner) */
+        private String mealType;
+
+        /** 餐段名称(早餐/午餐/晚餐) */
+        private String mealName;
+
+        /** 该餐段订单数 */
+        private Integer orderCount;
+
+        /** 当前是否处于该餐段营业时间内 */
+        private Boolean isInProgress;
+    }
+}

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

@@ -24,4 +24,7 @@ public class OrderDashboardVO {
 
     /** 今日营收(元) */
     private BigDecimal todayRevenue;
+
+    /** 今日已完成订单数(status=6) */
+    private Integer todayCompletedCount;
 }

+ 113 - 9
forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/order/HotelOrderMapper.xml

@@ -82,6 +82,9 @@
         <if test="query.paySource != null and query.paySource != ''">
             AND o.pay_source = #{query.paySource}
         </if>
+        <if test="query.queryDate != null">
+            AND DATE(o.create_time) = #{query.queryDate}
+        </if>
         ORDER BY o.create_time DESC
     </select>
 
@@ -95,14 +98,15 @@
         LIMIT 1
     </select>
 
-    <!-- 订单看板统计(只统计已支付订单) -->
+    <!-- 订单看板统计(只统计已支付订单,支持按日期筛选) -->
     <select id="selectDashboard" resultType="com.mdframe.forge.business.core.hotel.order.vo.OrderDashboardVO">
         SELECT
-            SUM(CASE WHEN o.status = 1 THEN 1 ELSE 0 END) AS pendingCount,
-            SUM(CASE WHEN o.status = 3 THEN 1 ELSE 0 END) AS preparingCount,
-            SUM(CASE WHEN o.status = 5 THEN 1 ELSE 0 END) AS deliveringCount,
-            SUM(CASE WHEN DATE(o.create_time) = CURDATE() AND o.pay_status = 1 AND o.status NOT IN (7, 9, 10, 11) THEN 1 ELSE 0 END) AS todayTotal,
-            IFNULL(SUM(CASE WHEN DATE(o.create_time) = CURDATE() AND o.pay_status = 1 AND o.status NOT IN (7, 9, 10, 11) THEN o.total_amount ELSE 0 END), 0) AS todayRevenue
+            SUM(CASE WHEN o.status = 1 AND DATE(o.create_time) = IFNULL(#{queryDate}, CURDATE()) THEN 1 ELSE 0 END) AS pendingCount,
+            SUM(CASE WHEN o.status = 3 AND DATE(o.create_time) = IFNULL(#{queryDate}, CURDATE()) THEN 1 ELSE 0 END) AS preparingCount,
+            SUM(CASE WHEN o.status = 5 AND DATE(o.create_time) = IFNULL(#{queryDate}, CURDATE()) THEN 1 ELSE 0 END) AS deliveringCount,
+            SUM(CASE WHEN DATE(o.create_time) = IFNULL(#{queryDate}, CURDATE()) AND o.pay_status = 1 AND o.status NOT IN (7, 9, 10, 11) THEN 1 ELSE 0 END) AS todayTotal,
+            IFNULL(SUM(CASE WHEN DATE(o.create_time) = IFNULL(#{queryDate}, CURDATE()) AND o.pay_status = 1 AND o.status NOT IN (7, 9, 10, 11) THEN o.total_amount ELSE 0 END), 0) AS todayRevenue,
+            SUM(CASE WHEN o.status = 6 AND DATE(o.create_time) = IFNULL(#{queryDate}, CURDATE()) THEN 1 ELSE 0 END) AS todayCompletedCount
         FROM hotel_order o
         WHERE o.tenant_id = #{tenantId}
           AND o.del_flag = 0
@@ -130,12 +134,11 @@
           AND status NOT IN (7, 9, 10, 11)
     </select>
 
-    <!-- 查询当天最大订单号 -->
+    <!-- 查询当天最大订单号(不过滤 del_flag,确保序号永不回收,已删除订单号不会被复用) -->
     <select id="selectMaxOrderNo" resultType="java.lang.String">
         SELECT MAX(order_no)
         FROM hotel_order
-        WHERE del_flag = 0
-          AND order_no LIKE CONCAT(#{prefix}, '%')
+        WHERE order_no LIKE CONCAT(#{prefix}, '%')
     </select>
 
     <!-- 查询支付超时的未支付订单ID -->
@@ -173,4 +176,105 @@
           AND pay_status = 0
     </update>
 
+    <!-- ==================== 营业数据看板 SQL ==================== -->
+
+    <!--
+      看板KPI:同时返回今日和昨日的核心指标,Service层计算环比增长率。
+      有效订单 = 已支付(pay_status=1) 且排除终态(status NOT IN 7拒单/9退单/10超时取消/11已取消)。
+      平均送达时长 = AVG(accept_time -> complete_time),仅统计已完成的订单。
+      退单率 = 退单数( status=9 ) / 有效订单总数。
+    -->
+    <select id="selectDashboardKpi" resultType="java.util.Map">
+        SELECT
+            SUM(CASE WHEN DATE(o.create_time) = CURDATE()
+                      AND o.pay_status = 1 AND o.status NOT IN (7, 9, 10, 11) THEN 1 ELSE 0 END) AS todayOrderCount,
+            SUM(CASE WHEN DATE(o.create_time) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)
+                      AND o.pay_status = 1 AND o.status NOT IN (7, 9, 10, 11) THEN 1 ELSE 0 END) AS yesterdayOrderCount,
+            IFNULL(SUM(CASE WHEN DATE(o.create_time) = CURDATE()
+                             AND o.pay_status = 1 AND o.status NOT IN (7, 9, 10, 11)
+                        THEN o.total_amount ELSE 0 END), 0) AS todayRevenue,
+            IFNULL(SUM(CASE WHEN DATE(o.create_time) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)
+                             AND o.pay_status = 1 AND o.status NOT IN (7, 9, 10, 11)
+                        THEN o.total_amount ELSE 0 END), 0) AS yesterdayRevenue,
+            (SELECT IFNULL(ROUND(AVG(TIMESTAMPDIFF(MINUTE, o2.accept_time, o2.complete_time))), 0)
+             FROM hotel_order o2
+             WHERE o2.tenant_id = #{tenantId}
+               AND o2.del_flag = 0
+               AND o2.status = 6
+               AND DATE(o2.create_time) = CURDATE()
+               AND o2.accept_time IS NOT NULL
+               AND o2.complete_time IS NOT NULL) AS todayAvgDelivery,
+            (SELECT IFNULL(ROUND(AVG(TIMESTAMPDIFF(MINUTE, o3.accept_time, o3.complete_time))), 0)
+             FROM hotel_order o3
+             WHERE o3.tenant_id = #{tenantId}
+               AND o3.del_flag = 0
+               AND o3.status = 6
+               AND DATE(o3.create_time) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)
+               AND o3.accept_time IS NOT NULL
+               AND o3.complete_time IS NOT NULL) AS yesterdayAvgDelivery,
+            SUM(CASE WHEN DATE(o.create_time) = CURDATE()
+                      AND o.pay_status = 1 AND o.status = 9 THEN 1 ELSE 0 END) AS todayRefundCount,
+            SUM(CASE WHEN DATE(o.create_time) = DATE_SUB(CURDATE(), INTERVAL 1 DAY)
+                      AND o.pay_status = 1 AND o.status = 9 THEN 1 ELSE 0 END) AS yesterdayRefundCount
+        FROM hotel_order o
+        WHERE o.tenant_id = #{tenantId}
+          AND o.del_flag = 0
+          AND o.create_time >= CONCAT(DATE_SUB(CURDATE(), INTERVAL 1 DAY), ' 00:00:00')
+    </select>
+
+    <!--
+      近7天订单趋势(含今天):按日期分组统计有效订单数和营业额。
+      结果按日期升序排列,Service层格式化为 MM/dd 标签。
+    -->
+    <select id="selectLast7DaysTrend" resultType="java.util.Map">
+        SELECT
+            DATE(o.create_time) AS statDate,
+            SUM(CASE WHEN o.pay_status = 1 AND o.status NOT IN (7, 9, 10, 11) THEN 1 ELSE 0 END) AS orderCount,
+            IFNULL(SUM(CASE WHEN o.pay_status = 1 AND o.status NOT IN (7, 9, 10, 11)
+                        THEN o.total_amount ELSE 0 END), 0) AS revenue
+        FROM hotel_order o
+        WHERE o.tenant_id = #{tenantId}
+          AND o.del_flag = 0
+          AND o.create_time >= CONCAT(DATE_SUB(CURDATE(), INTERVAL 6 DAY), ' 00:00:00')
+        GROUP BY DATE(o.create_time)
+        ORDER BY statDate ASC
+    </select>
+
+    <!--
+      本周热门菜品Top5:按本周有效订单的明细统计菜品销量。
+      使用 YEARWEEK(..., 1) 匹配ISO周(周一起始),仅统计已支付且非终态的订单。
+    -->
+    <select id="selectWeeklyHotDishes" resultType="java.util.Map">
+        SELECT
+            oi.dish_name AS dishName,
+            SUM(oi.quantity) AS salesCount
+        FROM hotel_order_item oi
+        INNER JOIN hotel_order o ON o.id = oi.order_id
+        WHERE o.tenant_id = #{tenantId}
+          AND o.del_flag = 0
+          AND oi.del_flag = 0
+          AND o.pay_status = 1
+          AND o.status NOT IN (7, 9, 10, 11)
+          AND YEARWEEK(o.create_time, 1) = YEARWEEK(NOW(), 1)
+        GROUP BY oi.dish_name
+        ORDER BY salesCount DESC
+        LIMIT 5
+    </select>
+
+    <!--
+      今日餐段订单分布:根据传入的餐段时间范围统计今日各餐段订单数。
+      startTime/endTime 来自 hotel_business_hours 表的营业时段配置。
+    -->
+    <select id="countOrdersByMealTime" resultType="int">
+        SELECT COUNT(*)
+        FROM hotel_order
+        WHERE tenant_id = #{tenantId}
+          AND del_flag = 0
+          AND pay_status = 1
+          AND status NOT IN (7, 9, 10, 11)
+          AND DATE(create_time) = CURDATE()
+          AND TIME(create_time) >= #{startTime}
+          AND TIME(create_time) &lt; #{endTime}
+    </select>
+
 </mapper>

Dosya farkı çok büyük olduğundan ihmal edildi
+ 15 - 2
forge-server/forge-business/forge-hotel/酒店模块迁移跟踪.md


Dosya farkı çok büyük olduğundan ihmal edildi
+ 5 - 4
forge-server/forge-business/forge-hotel/酒店模块需求缺口清单.md