ソースを参照

pc端厨房管理界面

徐滕 1 週間 前
コミット
1d24bbb00b

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

@@ -420,6 +420,33 @@ export function togglePause() {
   return request.post('/hotel/order/pause/toggle')
 }
 
+// ==================== 厨房端接口 ====================
+
+/** 厨房订单列表(已接单 + 备餐中) */
+export function getKitchenOrderList(date) {
+  return request.get('/hotel/kitchen/list', { params: { date } })
+}
+
+/** 厨房端统计(待备餐/制作中/今日完成) */
+export function getKitchenStats(date) {
+  return request.get('/hotel/kitchen/stats', { params: { date } })
+}
+
+/** 厨房端订单详情 */
+export function getKitchenDetail(id) {
+  return request.get('/hotel/kitchen/detail', { params: { id } })
+}
+
+/** 厨房开始备餐 */
+export function kitchenPrepare(id) {
+  return request.post('/hotel/kitchen/prepare', null, { params: { id } })
+}
+
+/** 厨房出餐完成 */
+export function kitchenReady(id) {
+  return request.post('/hotel/kitchen/ready', null, { params: { id } })
+}
+
 // ==================== 营业数据看板 ====================
 
 /** 获取营业数据看板(KPI/趋势/热门菜品/餐段分布) */

+ 59 - 0
forge-admin-ui/src/composables/useOrderNotification.js

@@ -8,6 +8,7 @@ import { useAuthStore } from '@/store'
  * @param {object} options 回调选项
  * @param {Function} options.onNewOrder     收到新订单通知时的回调
  * @param {Function} options.onRefundRequest 收到退单申请通知时的回调
+ * @param {Function} options.onOrderReady   收到出餐完成通知时的回调
  * @returns {object} { connected, connect, disconnect }
  */
 export function useOrderNotification(options = {}) {
@@ -23,6 +24,7 @@ export function useOrderNotification(options = {}) {
   // ==================== 防抖合并队列 ====================
   const newOrderQueue = ref([])
   const refundQueue = ref([])
+  const orderReadyQueue = ref([])
   let mergeTimer = null
   const MERGE_WINDOW = 3000 // 3秒防抖窗口
 
@@ -74,6 +76,21 @@ export function useOrderNotification(options = {}) {
         options.onRefundRequest(lastRefund)
       }
     }
+
+    if (orderReadyQueue.value.length > 0) {
+      const count = orderReadyQueue.value.length
+      const lastReady = orderReadyQueue.value[orderReadyQueue.value.length - 1]
+      if (count === 1) {
+        showInPageNotification('order-ready', `房间 ${lastReady.roomNo} 订单已出餐,请及时配送`, 'success')
+      }
+      else {
+        showInPageNotification('order-ready', `您有 ${count} 个订单已出餐,请及时配送`, 'success')
+      }
+      orderReadyQueue.value = []
+      if (typeof options.onOrderReady === 'function') {
+        options.onOrderReady(lastReady)
+      }
+    }
   }
 
   // ==================== 声音提醒(使用 MP3 文件) ====================
@@ -174,6 +191,30 @@ export function useOrderNotification(options = {}) {
     }
   }
 
+  // ==================== 语音播报(SpeechSynthesis) ====================
+
+  /**
+   * 语音播报出餐完成通知。
+   * 使用浏览器内置 SpeechSynthesis API,播报"XX房间订单已出餐,请及时配送"。
+   * @param {object} orderData 订单数据(包含 roomNo)
+   */
+  function speakOrderReady(orderData) {
+    if (!('speechSynthesis' in window))
+      return
+    try {
+      const roomNo = orderData.roomNo || '未知'
+      const text = `${roomNo}房间订单已出餐,请及时配送`
+      const utterance = new SpeechSynthesisUtterance(text)
+      utterance.lang = 'zh-CN'
+      utterance.rate = 0.9
+      utterance.volume = 1.0
+      window.speechSynthesis.speak(utterance)
+    }
+    catch (e) {
+      console.error('[SSE] 出餐语音播报失败:', e)
+    }
+  }
+
   // ==================== 浏览器桌面通知 ====================
 
   /**
@@ -310,6 +351,24 @@ export function useOrderNotification(options = {}) {
       }
     })
 
+    // 出餐完成通知(厨房点击"备餐完成·出餐"后触发)
+    eventSource.addEventListener('ORDER_READY', (event) => {
+      try {
+        const data = JSON.parse(event.data)
+        const orderData = data.data || {}
+        // 语音播报:XX房间订单已出餐,请及时配送(不再播放MP3提示音,避免重复)
+        speakOrderReady(orderData)
+        // 桌面通知
+        showDesktopNotification('出餐完成', `房间 ${orderData.roomNo} 订单已出餐,请及时配送`)
+        // 加入防抖队列
+        orderReadyQueue.value.push(orderData)
+        scheduleMergeFlush()
+      }
+      catch (e) {
+        console.warn('[SSE] 解析ORDER_READY消息失败:', e)
+      }
+    })
+
     // 连接错误(自动重连由 EventSource 内置处理)
     eventSource.onerror = () => {
       connected.value = false

ファイルの差分が大きいため隠しています
+ 1033 - 0
forge-admin-ui/src/views/hotel/kitchenOrder.vue


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

@@ -187,14 +187,23 @@ const { connected, connect, disconnect, playSound } = useOrderNotification({
     crudRef.value?.refresh()
     loadDashboard()
   },
+  onOrderReady: () => {
+    crudRef.value?.refresh()
+    loadDashboard()
+  },
 })
 
 /** 监听页面内通知事件(由 useOrderNotification 派发) */
 function handleHotelNotification(e) {
   const { type, message: msg, level } = e.detail
-  const nType = level === 'warning' ? 'warning' : 'info'
+  let title = '新订单'
+  if (type === 'refund')
+    title = '退单申请'
+  else if (type === 'order-ready')
+    title = '出餐完成'
+  const nType = level === 'warning' ? 'warning' : level === 'success' ? 'success' : 'info'
   $notification[nType]({
-    title: type === 'new-order' ? '新订单' : '退单申请',
+    title,
     content: msg,
     duration: 5000,
   })

+ 361 - 26
forge-admin-ui/src/views/hotel/restaurantOrder.vue

@@ -63,7 +63,7 @@
               <span class="section-count">{{ pendingOrders.length }}</span>
             </h3>
             <div class="order-list">
-              <div v-for="order in pendingOrders" :key="order.id" class="order-row order-row-pending">
+              <div v-for="order in pendingOrders" :key="order.id" class="order-row order-row-pending" @click="openDetail(order)">
                 <div class="order-room">
                   {{ order.roomNo || '--' }}
                 </div>
@@ -79,7 +79,7 @@
                     <span class="order-amount">¥{{ order.totalAmount }}</span>
                   </div>
                 </div>
-                <div class="order-actions">
+                <div class="order-actions" @click.stop>
                   <NButton size="small" type="success" @click="handleAccept(order)">
                     接单
                   </NButton>
@@ -99,7 +99,7 @@
               <span class="section-count">{{ inProgressOrders.length }}</span>
             </h3>
             <div class="order-list">
-              <div v-for="order in inProgressOrders" :key="order.id" class="order-row">
+              <div v-for="order in inProgressOrders" :key="order.id" class="order-row" @click="openDetail(order)">
                 <div class="order-room">
                   {{ order.roomNo || '--' }}
                 </div>
@@ -117,17 +117,8 @@
                     <span class="order-amount">¥{{ order.totalAmount }}</span>
                   </div>
                 </div>
-                <div class="order-actions">
-                  <template v-if="order.status === 2">
-                    <NButton size="small" type="primary" @click="handlePrepare(order)">
-                      备餐
-                    </NButton>
-                  </template>
-                  <template v-if="order.status === 3">
-                    <NButton size="small" type="success" @click="handleReady(order)">
-                      出餐
-                    </NButton>
-                  </template>
+                <div class="order-actions" @click.stop>
+                  <!-- 前厅只有出餐后的配送/完成操作,备餐/出餐归厨房端 -->
                   <template v-if="order.status === 4">
                     <NButton size="small" type="primary" @click="handleDeliver(order)">
                       配送
@@ -151,7 +142,7 @@
               <span class="section-count">{{ rejectedOrders.length }}</span>
             </h3>
             <div class="order-list">
-              <div v-for="order in rejectedOrders" :key="order.id" class="order-row order-row-rejected">
+              <div v-for="order in rejectedOrders" :key="order.id" class="order-row order-row-rejected" @click="openDetail(order)">
                 <div class="order-room">
                   {{ order.roomNo || '--' }}
                 </div>
@@ -227,6 +218,7 @@
                   :key="order.id"
                   class="kanban-card"
                   :style="{ borderTopColor: col.color }"
+                  @click="openDetail(order)"
                 >
                   <div class="kc-top flex items-center justify-between">
                     <span class="kc-room">{{ order.roomNo || '--' }}</span>
@@ -245,7 +237,8 @@
                   <div v-if="col.key === 'rejected' && order.rejectReason" class="kc-reject-reason text-xs mt-1 text-gray-400">
                     拒单原因:{{ order.rejectReason }}
                   </div>
-                  <div class="kc-actions">
+                  <div class="kc-actions" @click.stop>
+                    <!-- 前厅只有接单/拒单 + 出餐后的配送/完成,备餐/出餐归厨房端 -->
                     <template v-if="col.key === 'pending'">
                       <NButton size="tiny" type="success" @click="handleAccept(order)">
                         接单
@@ -254,16 +247,6 @@
                         拒单
                       </NButton>
                     </template>
-                    <template v-if="col.key === 'preparing' && order.status === 2">
-                      <NButton size="tiny" type="primary" @click="handlePrepare(order)">
-                        备餐
-                      </NButton>
-                    </template>
-                    <template v-if="col.key === 'preparing' && order.status === 3">
-                      <NButton size="tiny" type="success" @click="handleReady(order)">
-                        出餐
-                      </NButton>
-                    </template>
                     <template v-if="col.key === 'ready'">
                       <NButton size="tiny" type="primary" @click="handleDeliver(order)">
                         配送
@@ -435,6 +418,153 @@
         :rows="3"
       />
     </NModal>
+
+    <!-- ==================== 订单详情弹窗 ==================== -->
+    <NModal
+      v-model:show="showDetail"
+      preset="card"
+      :title="null"
+      style="width: 720px; max-width: 95vw;"
+      :bordered="false"
+      :segmented="{ content: true }"
+      class="order-detail-modal"
+    >
+      <div v-if="detailOrder" class="receipt-full">
+        <!-- 顶部标题栏 -->
+        <div class="receipt-header-bar mb-6 rounded-t-lg p-4 flex items-center justify-between">
+          <div class="flex items-center gap-3">
+            <i class="i-mdi:arrow-left text-xl cursor-pointer hover:text-white/80" @click="showDetail = false" />
+            <span class="text-white text-base font-semibold">订单详情</span>
+          </div>
+          <DictTag dict-type="hotel_order_status" :value="String(detailOrder.status)" />
+        </div>
+
+        <!-- 房间号区域 -->
+        <div class="room-hero-section text-center mb-6">
+          <div class="room-icon-wrapper inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-100 mb-3">
+            <i class="i-mdi:home-city text-3xl text-blue-600" />
+          </div>
+          <div class="room-number text-5xl font-bold text-gray-800 mb-2">
+            {{ detailOrder.roomNo || '--' }}
+          </div>
+          <div class="room-meta text-sm text-gray-500">
+            {{ detailOrder.orderNo }} · {{ formatDetailTime(detailOrder.createTime) }}下单
+          </div>
+        </div>
+
+        <!-- 小票主体(虚线边框) -->
+        <div class="receipt-body mb-6 rounded-lg border-2 border-dashed border-gray-300 p-6 bg-white">
+          <!-- 小票头部 -->
+          <div class="receipt-title text-center mb-4 pb-4 border-b border-dashed border-gray-300">
+            <div class="text-lg font-bold text-gray-800 mb-1">璞石酒店 · 前台联</div>
+            <div class="text-sm text-gray-500">{{ detailOrder.orderNo }}</div>
+            <div class="text-xs text-gray-400 mt-1">{{ formatDateTime(detailOrder.createTime) }}</div>
+          </div>
+
+          <!-- 桌号与备注 -->
+          <div class="receipt-info mb-4 pb-4 border-b border-dashed border-gray-300">
+            <div class="flex items-center gap-2 mb-2">
+              <span class="text-sm text-gray-600">桌号:</span>
+              <span class="text-base font-semibold text-gray-800">{{ detailOrder.roomNo }}</span>
+              <span class="text-sm text-gray-600 ml-2">{{ detailOrder.roomType || '' }}</span>
+            </div>
+            <div v-if="detailOrder.contactName" class="flex items-center gap-2 mb-2">
+              <span class="text-sm text-gray-600">联系人:</span>
+              <span class="text-sm text-gray-700">{{ detailOrder.contactName }}</span>
+            </div>
+            <div class="flex items-center gap-2">
+              <span class="text-sm text-gray-600">备注:</span>
+              <span class="text-sm text-gray-700">{{ detailOrder.note || '无' }}</span>
+            </div>
+          </div>
+
+          <!-- 菜品明细 -->
+          <div class="receipt-items mb-4 pb-4 border-b border-dashed border-gray-300">
+            <div v-for="(item, idx) in (detailOrder.items || [])" :key="item.id" class="receipt-item-row mb-3 last:mb-0">
+              <div class="item-header flex justify-between items-start mb-1">
+                <div class="flex-1">
+                  <span class="item-index text-sm text-gray-500 mr-2">[{{ idx + 1 }}]</span>
+                  <span class="item-name text-base font-medium text-gray-800">{{ item.dishName }}</span>
+                </div>
+                <span class="item-price text-right font-semibold text-gray-800 whitespace-nowrap ml-3">
+                  ¥{{ item.subtotal || '0.00' }}
+                </span>
+              </div>
+              <div v-if="item.specDesc || item.additionsDesc" class="item-details text-xs text-gray-500 ml-6">
+                <span v-if="item.specDesc">{{ item.specDesc }}</span>
+                <span v-if="item.specDesc && item.additionsDesc"> / </span>
+                <span v-if="item.additionsDesc">{{ item.additionsDesc }}</span>
+              </div>
+              <div v-if="item.note" class="item-note text-xs text-orange-600 ml-6 mt-1">
+                备注: {{ item.note }}
+              </div>
+            </div>
+          </div>
+
+          <!-- 合计 -->
+          <div class="receipt-total text-right text-lg font-bold text-gray-800">
+            合计:¥{{ detailOrder.totalAmount || '0.00' }}
+          </div>
+        </div>
+
+        <!-- 底部操作按钮(前厅视角) -->
+        <div class="receipt-actions">
+          <NButton
+            v-if="detailOrder.status === 1"
+            type="success"
+            block
+            size="large"
+            class="action-btn-primary"
+            @click="handleAcceptFromDetail"
+          >
+            <template #icon><i class="i-mdi:check-circle" /></template>
+            接单
+          </NButton>
+          <NButton
+            v-if="detailOrder.status === 1"
+            type="error"
+            block
+            size="large"
+            class="mt-3"
+            @click="handleRejectFromDetail"
+          >
+            <template #icon><i class="i-mdi:close-circle" /></template>
+            拒单
+          </NButton>
+          <NButton
+            v-if="detailOrder.status === 4"
+            type="primary"
+            block
+            size="large"
+            class="action-btn-primary"
+            @click="handleDeliverFromDetail"
+          >
+            <template #icon><i class="i-mdi:truck-delivery" /></template>
+            开始配送
+          </NButton>
+          <NButton
+            v-if="detailOrder.status === 5"
+            type="success"
+            block
+            size="large"
+            class="action-btn-primary"
+            @click="handleCompleteFromDetail"
+          >
+            <template #icon><i class="i-mdi:flag-checkered" /></template>
+            完成配送
+          </NButton>
+          <div v-if="detailOrder.status >= 2 && detailOrder.status <= 3" class="status-hint text-center text-sm text-gray-400 py-3">
+            <i class="i-mdi:chef-hat mr-1" />订单正在厨房制作中,请稍候
+          </div>
+          <div v-if="detailOrder.status === 6" class="status-hint text-center text-sm text-green-600 py-3">
+            <i class="i-mdi:check-circle mr-1" />订单已完成
+          </div>
+          <div v-if="detailOrder.status === 7" class="status-hint text-center text-sm text-gray-400 py-3">
+            <i class="i-mdi:close-circle mr-1" />订单已拒单
+          </div>
+        </div>
+      </div>
+    </NModal>
   </div>
 </template>
 
@@ -1088,6 +1218,106 @@ function handleComplete(order) {
     },
   })
 }
+
+// ==================== 订单详情弹窗 ====================
+const showDetail = ref(false)
+const detailOrder = ref(null)
+
+function openDetail(order) {
+  detailOrder.value = order
+  showDetail.value = true
+}
+
+function formatDetailTime(dateStr) {
+  if (!dateStr) return '--'
+  const d = new Date(dateStr)
+  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
+}
+
+function formatDateTime(dateStr) {
+  if (!dateStr) return '--'
+  const d = new Date(dateStr)
+  const hh = String(d.getHours()).padStart(2, '0')
+  const mm = String(d.getMinutes()).padStart(2, '0')
+  const ss = String(d.getSeconds()).padStart(2, '0')
+  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${hh}:${mm}:${ss}`
+}
+
+function handleAcceptFromDetail() {
+  if (!detailOrder.value) return
+  const order = detailOrder.value
+  dialog.warning({
+    title: '确认接单',
+    content: `确认接受订单 ${order.orderNo} 吗?`,
+    positiveText: '确认接单',
+    negativeText: '取消',
+    async onPositiveClick() {
+      try {
+        await acceptOrder(order.id)
+        message.success('接单成功')
+        showDetail.value = false
+        refreshAll()
+      }
+      catch (err) {
+        message.error('接单失败')
+        console.error('[餐厅订单] 接单失败:', err)
+      }
+    },
+  })
+}
+
+function handleRejectFromDetail() {
+  if (!detailOrder.value) return
+  rejectOrderId = detailOrder.value.id
+  reasonText.value = ''
+  reasonVisible.value = true
+}
+
+function handleDeliverFromDetail() {
+  if (!detailOrder.value) return
+  const order = detailOrder.value
+  dialog.warning({
+    title: '开始配送',
+    content: `确认开始配送订单 ${order.orderNo}?`,
+    positiveText: '确认配送',
+    negativeText: '取消',
+    async onPositiveClick() {
+      try {
+        await deliverOrder(order.id)
+        message.success('已开始配送')
+        showDetail.value = false
+        refreshAll()
+      }
+      catch (err) {
+        message.error('操作失败')
+        console.error('[餐厅订单] 配送失败:', err)
+      }
+    },
+  })
+}
+
+function handleCompleteFromDetail() {
+  if (!detailOrder.value) return
+  const order = detailOrder.value
+  dialog.warning({
+    title: '完成配送',
+    content: `确认订单 ${order.orderNo} 配送完成?`,
+    positiveText: '确认完成',
+    negativeText: '取消',
+    async onPositiveClick() {
+      try {
+        await completeOrder(order.id)
+        message.success('订单已完成')
+        showDetail.value = false
+        refreshAll()
+      }
+      catch (err) {
+        message.error('操作失败')
+        console.error('[餐厅订单] 完成失败:', err)
+      }
+    },
+  })
+}
 </script>
 
 <style scoped>
@@ -1514,4 +1744,109 @@ function handleComplete(order) {
   color: #999;
   margin-top: 2px;
 }
+
+/* ==================== 小票详情弹窗 ==================== */
+.order-detail-modal :deep(.n-card__content) {
+  padding: 0 !important;
+}
+
+.receipt-full {
+  background: #f5f7fa;
+  min-height: 500px;
+}
+
+.receipt-header-bar {
+  background: linear-gradient(135deg, #1a6b8c 0%, #0d526e);
+  color: white;
+}
+
+.room-hero-section {
+  padding-top: 12px;
+}
+
+.room-icon-wrapper {
+  box-shadow: 0 4px 12px rgba(32, 128, 240, 0.15);
+}
+
+.room-number {
+  letter-spacing: 2px;
+  text-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
+}
+
+.receipt-body {
+  background: #fff;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+}
+
+.receipt-title {
+  font-family: 'Microsoft YaHei', sans-serif;
+}
+
+.receipt-info {
+  line-height: 1.8;
+}
+
+.receipt-item-row {
+  padding-bottom: 12px;
+  margin-bottom: 12px;
+  border-bottom: 1px dashed #e5e7eb;
+}
+
+.receipt-item-row:last-child {
+  border-bottom: none;
+  margin-bottom: 0;
+  padding-bottom: 0;
+}
+
+.item-index {
+  font-weight: 600;
+  color: #f0a020;
+}
+
+.item-name {
+  font-size: 15px;
+}
+
+.item-price {
+  font-size: 15px;
+}
+
+.item-details {
+  line-height: 1.6;
+}
+
+.item-note {
+  line-height: 1.6;
+}
+
+.receipt-total {
+  padding-top: 16px;
+  border-top: 2px solid #e5e7eb;
+  font-size: 18px;
+}
+
+.receipt-actions {
+  margin-top: 8px;
+}
+
+.action-btn-primary {
+  height: 52px;
+  font-size: 16px;
+  font-weight: 600;
+  border-radius: 12px;
+  transition: all 0.3s ease;
+}
+
+.action-btn-primary:hover {
+  transform: translateY(-2px);
+  box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
+}
+
+.action-btn-primary:active {
+  transform: translateY(0);
+}
+
+.status-hint {
+  padding: 12px 0;
+}
 </style>

+ 29 - 0
forge-server/db/migration/V1.0.118__add_kitchen_order_menu.sql

@@ -0,0 +1,29 @@
+-- V1.0.118  新增「厨房订单」子菜单(挂在「餐厅管理」下)
+-- 厨房端:后厨厨师查看待制作订单、开始备餐、出餐完成
+-- ============================================================
+
+-- 厨房订单(子菜单,挂在「餐厅管理」下)
+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, 2, '/hotel/kitchenOrder', 'hotel/kitchenOrder', 0, 0, 1, 1, 'mdi:chef-hat', 0, 0, '厨房端订单管理(待制作/备餐中/出餐)', NOW(), NOW()
+WHERE @restaurant_parent_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE path = '/hotel/kitchenOrder' AND tenant_id = 1);
+
+-- 厨房订单按钮权限(使用 restaurant:kitchen:* 权限标识)
+SET @kitchen_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, '厨房查询', @kitchen_menu_id, 3, 1, 'restaurant:kitchen:query', 0, 0, 1, 1, '查询厨房订单', NOW(), NOW()
+WHERE @kitchen_menu_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'restaurant:kitchen: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, '厨房备餐', @kitchen_menu_id, 3, 2, 'restaurant:kitchen:prepare', 0, 0, 1, 1, '厨房开始备餐', NOW(), NOW()
+WHERE @kitchen_menu_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'restaurant:kitchen:prepare');
+
+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, '厨房出餐', @kitchen_menu_id, 3, 3, 'restaurant:kitchen:ready', 0, 0, 1, 1, '厨房出餐完成', NOW(), NOW()
+WHERE @kitchen_menu_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'restaurant:kitchen:ready');

+ 26 - 0
forge-server/forge-business/forge-hotel/src/main/java/com/mdframe/forge/business/core/hotel/notification/manager/SseEmitterManager.java

@@ -212,6 +212,32 @@ public class SseEmitterManager {
     }
 
     /**
+     * 发布出餐完成通知(厨房点击“备餐完成·出餐”后触发)。
+     * 前台收到后刷新配送列表并播放提示音。
+     *
+     * @param orderId      订单ID
+     * @param orderNo      订单号
+     * @param roomNo       房间号
+     * @param contactName  联系人
+     * @param totalAmount  订单金额
+     */
+    public void notifyOrderReady(Long orderId, String orderNo, String roomNo,
+                                 String contactName, String totalAmount) {
+        String json = "{\"type\":\"ORDER_READY\""
+                + ",\"data\":{"
+                + "\"orderId\":" + orderId
+                + ",\"orderNo\":\"" + escapeJson(orderNo) + "\""
+                + ",\"roomNo\":\"" + escapeJson(roomNo) + "\""
+                + ",\"contactName\":\"" + escapeJson(contactName) + "\""
+                + ",\"totalAmount\":" + totalAmount
+                + "}"
+                + ",\"timestamp\":" + System.currentTimeMillis()
+                + "}";
+        broadcast("ORDER_READY", json);
+        log.info("发布出餐完成通知: orderNo={}, roomNo={}", orderNo, roomNo);
+    }
+
+    /**
      * JSON字符串转义(防止特殊字符破坏JSON结构)。
      *
      * @param value 原始字符串

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

@@ -0,0 +1,98 @@
+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.HotelOrderVO;
+import com.mdframe.forge.business.core.hotel.order.vo.KitchenStatsVO;
+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.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 厨房端订单接口。
+ * 后厨厨师专用页面:查看待制作订单列表、统计、开始备餐、出餐完成。
+ */
+@RestController
+@RequestMapping("/hotel/kitchen")
+public class HotelKitchenController {
+
+    @Autowired
+    private HotelOrderService hotelOrderService;
+
+    /**
+     * 厨房端订单列表(已接单 + 备餐中)。
+     * 按创建时间升序,先接单的排在前面,含订单明细。
+     *
+     * @param date 筛选日期(yyyy-MM-dd),默认今天
+     */
+    @GetMapping("/list")
+    @OperationLog(module = "厨房订单", type = OperationType.QUERY, desc = "查询厨房订单列表")
+    public RespInfo<List<HotelOrderVO>> kitchenOrderList(@RequestParam(required = false) String date) {
+        Date queryDate = parseDate(date);
+        return RespInfo.success(hotelOrderService.kitchenOrderList(queryDate));
+    }
+
+    /**
+     * 厨房端统计(待备餐数、制作中数、今日完成数)。
+     *
+     * @param date 筛选日期(yyyy-MM-dd),默认今天
+     */
+    @GetMapping("/stats")
+    @OperationLog(module = "厨房订单", type = OperationType.QUERY, desc = "查询厨房统计")
+    public RespInfo<KitchenStatsVO> kitchenStats(@RequestParam(required = false) String date) {
+        Date queryDate = parseDate(date);
+        return RespInfo.success(hotelOrderService.kitchenStats(queryDate));
+    }
+
+    /**
+     * 厨房端订单详情(复用通用订单详情,含菜品明细)。
+     */
+    @GetMapping("/detail")
+    @OperationLog(module = "厨房订单", type = OperationType.QUERY, desc = "查询厨房订单详情")
+    public RespInfo<HotelOrderVO> kitchenDetail(@RequestParam Long id) {
+        return RespInfo.success(hotelOrderService.orderDetail(id));
+    }
+
+    /**
+     * 开始备餐(厨房操作)。
+     */
+    @PostMapping("/prepare")
+    @OperationLog(module = "厨房订单", type = OperationType.UPDATE, desc = "厨房开始备餐")
+    public RespInfo<Void> orderPrepare(@RequestParam Long id) {
+        hotelOrderService.orderPrepare(id);
+        return RespInfo.success();
+    }
+
+    /**
+     * 出餐完成(厨房操作)。
+     */
+    @PostMapping("/ready")
+    @OperationLog(module = "厨房订单", type = OperationType.UPDATE, desc = "厨房出餐完成")
+    public RespInfo<Void> orderReady(@RequestParam Long id) {
+        hotelOrderService.orderReady(id);
+        return RespInfo.success();
+    }
+
+    /**
+     * 解析日期字符串,为空时返回今天。
+     */
+    private Date parseDate(String date) {
+        if (date == null || date.trim().isEmpty()) {
+            return new Date();
+        }
+        try {
+            return new SimpleDateFormat("yyyy-MM-dd").parse(date);
+        } catch (Exception e) {
+            return new Date();
+        }
+    }
+}

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

@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.mdframe.forge.business.core.hotel.order.domain.HotelOrder;
 import com.mdframe.forge.business.core.hotel.order.vo.HotelOrderVO;
+import com.mdframe.forge.business.core.hotel.order.vo.KitchenStatsVO;
 import com.mdframe.forge.business.core.hotel.order.vo.OrderDashboardVO;
 import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
@@ -140,4 +141,25 @@ public interface HotelOrderMapper extends BaseMapper<HotelOrder> {
     int countOrdersByMealTime(@Param("tenantId") Long tenantId,
                               @Param("startTime") String startTime,
                               @Param("endTime") String endTime);
+
+    // ==================== 厨房端查询 ====================
+
+    /**
+     * 查询厨房待制作订单列表(status=2已接单 + status=3备餐中)。
+     * 按创建时间升序排列,先接单的排在前面。
+     *
+     * @param tenantId  租户ID
+     * @param queryDate 筛选日期(为空则查全部)
+     * @return 厨房订单列表(含房型、菜品数量)
+     */
+    List<HotelOrderVO> selectKitchenOrderList(@Param("tenantId") Long tenantId, @Param("queryDate") Date queryDate);
+
+    /**
+     * 厨房端统计:待备餐数、制作中数、今日完成数。
+     *
+     * @param tenantId  租户ID
+     * @param queryDate 筛选日期(为空则查全部)
+     * @return 统计数据
+     */
+    KitchenStatsVO selectKitchenStats(@Param("tenantId") Long tenantId, @Param("queryDate") Date queryDate);
 }

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

@@ -5,10 +5,12 @@ 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.KitchenStatsVO;
 import com.mdframe.forge.business.core.hotel.order.vo.OrderDashboardVO;
 import com.mdframe.forge.starter.core.domain.PageQuery;
 
 import java.util.Date;
+import java.util.List;
 
 /**
  * 酒店订单服务接口。
@@ -152,4 +154,23 @@ public interface HotelOrderService {
      * @throws com.mdframe.forge.starter.core.exception.BusinessException 订单不存在或状态不允许时抛出
      */
     void abandonOrder(Long id);
+
+    // ==================== 厨房端接口 ====================
+
+    /**
+     * 厨房端订单列表(已接单 + 备餐中)。
+     * 按创建时间升序,先接单的排在前面,含订单明细。
+     *
+     * @param queryDate 筛选日期(为空则查全部)
+     * @return 厨房订单列表
+     */
+    List<HotelOrderVO> kitchenOrderList(Date queryDate);
+
+    /**
+     * 厨房端统计(待备餐数、制作中数、今日完成数)。
+     *
+     * @param queryDate 筛选日期(为空则查全部)
+     * @return 统计数据
+     */
+    KitchenStatsVO kitchenStats(Date queryDate);
 }

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

@@ -28,6 +28,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.DashboardVO;
 import com.mdframe.forge.business.core.hotel.order.vo.HotelOrderVO;
+import com.mdframe.forge.business.core.hotel.order.vo.KitchenStatsVO;
 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;
@@ -448,6 +449,18 @@ public class HotelOrderServiceImpl implements HotelOrderService {
         order.setReadyTime(new Date());
         orderMapper.updateById(order);
         log.info("订单出餐: {}", order.getOrderNo());
+
+        // 发布出餐完成SSE通知,前台收到后刷新配送列表
+        try {
+            sseEmitterManager.notifyOrderReady(
+                    order.getId(),
+                    order.getOrderNo(),
+                    order.getRoomNo(),
+                    order.getContactName(),
+                    order.getTotalAmount() != null ? order.getTotalAmount().toPlainString() : "0");
+        } catch (Exception e) {
+            log.error("发布出餐完成通知失败: orderId={}", order.getId(), e);
+        }
     }
 
     @Override
@@ -650,6 +663,29 @@ public class HotelOrderServiceImpl implements HotelOrderService {
         log.info("放弃待支付订单(物理删除): orderId={}, orderNo={}", id, order.getOrderNo());
     }
 
+    // ==================== 厨房端接口 ====================
+
+    @Override
+    public List<HotelOrderVO> kitchenOrderList(Date queryDate) {
+        List<HotelOrderVO> list = orderMapper.selectKitchenOrderList(resolveTenantId(), queryDate);
+        for (HotelOrderVO vo : list) {
+            fillOrderItems(vo);
+        }
+        return list;
+    }
+
+    @Override
+    public KitchenStatsVO kitchenStats(Date queryDate) {
+        KitchenStatsVO stats = orderMapper.selectKitchenStats(resolveTenantId(), queryDate);
+        if (stats == null) {
+            stats = new KitchenStatsVO();
+            stats.setPendingCount(0);
+            stats.setPreparingCount(0);
+            stats.setTodayCompletedCount(0);
+        }
+        return stats;
+    }
+
     // ==================== 私有方法 ====================
 
     /**

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

@@ -0,0 +1,20 @@
+package com.mdframe.forge.business.core.hotel.order.vo;
+
+import lombok.Data;
+
+/**
+ * 厨房端统计VO。
+ * 展示待备餐数(已接单未开始制作)、制作中数(已开始备餐)、今日完成数。
+ */
+@Data
+public class KitchenStatsVO {
+
+    /** 待备餐数(status=2 已接单,等待厨房开始制作) */
+    private Integer pendingCount;
+
+    /** 制作中数(status=3 备餐中) */
+    private Integer preparingCount;
+
+    /** 今日已完成数(status=6 已完成,今日) */
+    private Integer todayCompletedCount;
+}

+ 36 - 0
forge-server/forge-business/forge-hotel/src/main/resources/mapper/business/hotel/order/HotelOrderMapper.xml

@@ -277,4 +277,40 @@
           AND TIME(create_time) &lt; #{endTime}
     </select>
 
+    <!-- ==================== 厨房端查询 SQL ==================== -->
+
+    <!--
+      厨房端当天所有订单列表:展示当天全部订单(含已出餐/已完成),方便厨房查看当日全貌。
+      排除终态(已取消/已退单/超时取消/已拒单),按创建时间升序。
+    -->
+    <select id="selectKitchenOrderList" resultMap="OrderVOMap">
+        SELECT <include refid="OrderColumns" />
+        FROM hotel_order o
+        WHERE o.tenant_id = #{tenantId}
+          AND o.del_flag = 0
+          AND o.status NOT IN (7, 9, 10, 11)
+          <if test="queryDate != null">
+              AND DATE(o.create_time) = DATE(#{queryDate})
+          </if>
+        ORDER BY o.create_time ASC
+    </select>
+
+    <!--
+      厨房端统计:待备餐数(status=2)、制作中数(status=3)、今日完成数(status>=4 即已出餐及之后)。
+      厨房视角:出餐即完成,不需要等到配送/最终完成。
+      三条指标一条 SQL 查出,减少数据库往返。
+    -->
+    <select id="selectKitchenStats" resultType="com.mdframe.forge.business.core.hotel.order.vo.KitchenStatsVO">
+        SELECT
+            SUM(CASE WHEN o.status = 2 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 >= 4 AND DATE(o.create_time) = COALESCE(DATE(#{queryDate}), CURDATE()) THEN 1 ELSE 0 END) AS todayCompletedCount
+        FROM hotel_order o
+        WHERE o.tenant_id = #{tenantId}
+          AND o.del_flag = 0
+          <if test="queryDate != null">
+              AND DATE(o.create_time) = DATE(#{queryDate})
+          </if>
+    </select>
+
 </mapper>