Просмотр исходного кода

点菜订单 部分功能代码提交

徐滕 2 недель назад
Родитель
Сommit
0a6390bbda
44 измененных файлов с 6003 добавлено и 83 удалено
  1. 54 0
      forge-admin-ui/src/api/restaurant.js
  2. 255 0
      forge-admin-ui/src/views/restaurant/creditCustomer.vue
  3. 9 33
      forge-admin-ui/src/views/restaurant/dishLibrary.vue
  4. 202 11
      forge-admin-ui/src/views/restaurant/dishMenu.vue
  5. 2041 0
      forge-admin-ui/src/views/restaurant/dishOrder.vue
  6. 775 0
      forge-admin-ui/src/views/restaurant/dishOrderAdd.vue
  7. 283 0
      forge-admin-ui/src/views/restaurant/dishOrderIndex.vue
  8. 216 0
      forge-server/db/migration/V1.0.129__add_restaurant_order_and_credit.sql
  9. 215 0
      forge-server/db/migration/V1.0.130__change_restaurant_price_from_cents_to_yuan.sql
  10. 26 0
      forge-server/db/migration/V1.0.131__add_special_requirement_dict.sql
  11. 81 0
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/controller/CreditCustomerController.java
  12. 84 0
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/controller/DishOrderController.java
  13. 97 0
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/domain/CreditCustomer.java
  14. 6 5
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/domain/DishLibrary.java
  15. 5 4
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/domain/DishMenuDish.java
  16. 96 0
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/domain/DishOrder.java
  17. 91 0
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/domain/DishOrderDetail.java
  18. 3 2
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/domain/DishRoom.java
  19. 58 0
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/dto/CreditCustomerDTO.java
  20. 6 5
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/dto/DishLibraryDTO.java
  21. 3 2
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/dto/DishMenuAddDishDTO.java
  22. 3 2
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/dto/DishMenuPriceDTO.java
  23. 51 0
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/dto/DishOrderSubmitDTO.java
  24. 3 1
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/dto/DishRoomDTO.java
  25. 37 0
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/mapper/CreditCustomerMapper.java
  26. 47 0
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/mapper/DishOrderDetailMapper.java
  27. 35 0
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/mapper/DishOrderMapper.java
  28. 37 0
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/service/CreditCustomerService.java
  29. 47 0
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/service/DishOrderService.java
  30. 165 0
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/service/impl/CreditCustomerServiceImpl.java
  31. 1 1
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/service/impl/DishLibraryServiceImpl.java
  32. 4 3
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/service/impl/DishMenuServiceImpl.java
  33. 524 0
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/service/impl/DishOrderServiceImpl.java
  34. 64 0
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/vo/CreditCustomerVO.java
  35. 6 5
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/vo/DishLibraryVO.java
  36. 4 2
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/vo/DishMenuAvailableDishVO.java
  37. 6 5
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/vo/DishMenuDishVO.java
  38. 65 0
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/vo/DishOrderDetailVO.java
  39. 51 0
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/vo/DishOrderItemVO.java
  40. 67 0
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/vo/DishOrderVO.java
  41. 3 2
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/vo/DishRoomVO.java
  42. 56 0
      forge-server/forge-business/forge-restaurant/src/main/resources/mapper/business/restaurant/CreditCustomerMapper.xml
  43. 65 0
      forge-server/forge-business/forge-restaurant/src/main/resources/mapper/business/restaurant/DishOrderDetailMapper.xml
  44. 56 0
      forge-server/forge-business/forge-restaurant/src/main/resources/mapper/business/restaurant/DishOrderMapper.xml

+ 54 - 0
forge-admin-ui/src/api/restaurant.js

@@ -157,3 +157,57 @@ export function deleteMenuDish(id) {
 export function updateMenu(remark) {
 export function updateMenu(remark) {
   return request.post('/restaurant/dishMenu/updateMenu', null, { params: { remark } })
   return request.post('/restaurant/dishMenu/updateMenu', null, { params: { remark } })
 }
 }
+
+// ==================== 点菜订单 ====================
+
+/** 分页查询订单列表 */
+export function getOrderPage(params) {
+  return request.get('/restaurant/dishOrder/page', { params })
+}
+
+/** 查询订单详情 */
+export function getOrderDetail(id) {
+  return request.get('/restaurant/dishOrder/detail', { params: { id } })
+}
+
+/** 查询包房当前订单 */
+export function getCurrentOrder(roomId) {
+  return request.get('/restaurant/dishOrder/current', { params: { roomId } })
+}
+
+/** 提交订单(下单/加菜/退菜/结账) */
+export function submitOrder(data) {
+  return request.post('/restaurant/dishOrder/submit', data)
+}
+
+/** 取消订单 */
+export function cancelOrder(id) {
+  return request.post('/restaurant/dishOrder/cancel', null, { params: { id } })
+}
+
+// ==================== 挂账客户 ====================
+
+/** 分页查询挂账客户 */
+export function getCreditPage(params) {
+  return request.get('/restaurant/creditCustomer/page', { params })
+}
+
+/** 查询挂账客户详情 */
+export function getCreditDetail(id) {
+  return request.get('/restaurant/creditCustomer/detail', { params: { id } })
+}
+
+/** 保存挂账客户(新增/修改) */
+export function saveCreditCustomer(data) {
+  return request.post('/restaurant/creditCustomer/save', data)
+}
+
+/** 删除挂账客户 */
+export function deleteCreditCustomer(id) {
+  return request.post('/restaurant/creditCustomer/remove', null, { params: { id } })
+}
+
+/** 切换挂账状态 */
+export function toggleCreditStatus(id) {
+  return request.post('/restaurant/creditCustomer/toggleStatus', null, { params: { id } })
+}

+ 255 - 0
forge-admin-ui/src/views/restaurant/creditCustomer.vue

@@ -0,0 +1,255 @@
+<template>
+  <div class="restaurant-credit-page">
+    <AiCrudPage
+      ref="crudRef"
+      api="/restaurant/creditCustomer"
+      :api-config="{
+        list: 'get@/restaurant/creditCustomer/page',
+        add: 'post@/restaurant/creditCustomer/save',
+        edit: 'post@/restaurant/creditCustomer/save',
+        delete: 'post@/restaurant/creditCustomer/remove?id=:id',
+      }"
+      :search-schema="searchSchema"
+      :columns="tableColumns"
+      :form-schema="formSchema"
+      :page-size="20"
+      add-button-text="新增挂账客户"
+      @before-submit="handleBeforeSubmit"
+    />
+  </div>
+</template>
+
+<script setup>
+import { NTag } from 'naive-ui'
+import { computed, h, ref } from 'vue'
+import { AiCrudPage } from '@/components/ai-form'
+
+defineOptions({ name: 'RestaurantCreditCustomer' })
+
+const crudRef = ref(null)
+
+// ==================== 字典映射 ====================
+const creditStatusOptions = [
+  { label: '正常挂账', value: 'NORMAL' },
+  { label: '暂停挂账', value: 'SUSPENDED' },
+  { label: '黑名单', value: 'BLACKLIST' },
+]
+
+const creditColorMap = {
+  NORMAL: 'success',
+  SUSPENDED: 'warning',
+  BLACKLIST: 'error',
+}
+
+const creditLabelMap = {
+  NORMAL: '正常挂账',
+  SUSPENDED: '暂停挂账',
+  BLACKLIST: '黑名单',
+}
+
+// ==================== 搜索配置 ====================
+const searchSchema = computed(() => [
+  {
+    field: 'customerName',
+    label: '客户姓名',
+    type: 'input',
+    props: { placeholder: '请输入客户姓名' },
+  },
+  {
+    field: 'mobile',
+    label: '手机号',
+    type: 'input',
+    props: { placeholder: '请输入手机号' },
+  },
+  {
+    field: 'creditStatus',
+    label: '挂账状态',
+    type: 'select',
+    props: { placeholder: '请选择状态', options: creditStatusOptions, clearable: true },
+  },
+])
+
+// ==================== 表格列 ====================
+function formatMoney(cents) {
+  if (cents == null)
+    return '0.00'
+  return (cents / 100).toFixed(2)
+}
+
+const tableColumns = computed(() => [
+  { prop: '_index', label: '序号', width: 60, render: (row, rowIndex) => rowIndex + 1 },
+  { prop: 'customerName', label: '客户姓名', width: 120 },
+  { prop: 'mobile', label: '手机号', width: 130, render: row => row.mobile || '-' },
+  { prop: 'companyName', label: '公司名称', width: 160, render: row => row.companyName || '-' },
+  {
+    prop: 'totalArrearsAmount',
+    label: '累计挂账',
+    width: 110,
+    render: row => `${formatMoney(row.totalArrearsAmount)} 元`,
+  },
+  { prop: 'unsettledBillCount', label: '未结账单', width: 90, render: row => row.unsettledBillCount || 0 },
+  {
+    prop: 'creditStatus',
+    label: '挂账状态',
+    width: 110,
+    render: row => h(NTag, {
+      type: creditColorMap[row.creditStatus] || 'default',
+      size: 'small',
+    }, { default: () => creditLabelMap[row.creditStatus] || '-' }),
+  },
+  { prop: 'createTime', label: '创建时间', width: 170, render: row => row.createTime || '-' },
+  {
+    prop: 'action',
+    label: '操作',
+    width: 200,
+    fixed: 'right',
+    render: (row) => {
+      const btns = []
+      btns.push(h('a', {
+        class: 'text-primary cursor-pointer hover:opacity-80',
+        onClick: () => handleEdit(row),
+      }, '编辑'))
+      btns.push(h('a', {
+        class: 'text-warning cursor-pointer hover:opacity-80',
+        style: 'margin-left: 12px',
+        onClick: () => handleToggleStatus(row),
+      }, row.creditStatus === 'NORMAL' ? '暂停挂账' : '恢复正常'))
+      btns.push(h('a', {
+        class: 'text-error cursor-pointer hover:opacity-80',
+        style: 'margin-left: 12px',
+        onClick: () => handleDelete(row),
+      }, '删除'))
+      return h('div', null, btns)
+    },
+  },
+])
+
+// ==================== 表单配置 ====================
+const formSchema = computed(() => [
+  {
+    field: 'customerName',
+    label: '客户姓名',
+    type: 'input',
+    rules: [{ required: true, message: '客户姓名不能为空' }],
+    props: { placeholder: '请输入客户姓名' },
+  },
+  {
+    field: 'mobile',
+    label: '手机号',
+    type: 'input',
+    props: { placeholder: '请输入手机号' },
+  },
+  {
+    field: 'companyName',
+    label: '公司名称',
+    type: 'input',
+    props: { placeholder: '请输入公司名称' },
+  },
+  {
+    field: 'address',
+    label: '地址',
+    type: 'input',
+    props: { placeholder: '请输入地址' },
+  },
+  {
+    field: 'birthday',
+    label: '生日',
+    type: 'datePicker',
+    props: { placeholder: '请选择生日', valueFormat: 'yyyy-MM-dd' },
+  },
+  {
+    field: 'idCard',
+    label: '身份证',
+    type: 'input',
+    props: { placeholder: '请输入身份证号' },
+  },
+  {
+    field: 'occupation',
+    label: '职业',
+    type: 'input',
+    props: { placeholder: '请输入职业' },
+  },
+  {
+    field: 'emergencyContact',
+    label: '紧急联系人',
+    type: 'input',
+    props: { placeholder: '请输入紧急联系人' },
+  },
+  {
+    field: 'emergencyContactPhone',
+    label: '紧急联系电话',
+    type: 'input',
+    props: { placeholder: '请输入联系电话' },
+  },
+  {
+    field: 'invoiceTitle',
+    label: '发票抬头',
+    type: 'input',
+    span: 2,
+    props: { placeholder: '请输入发票抬头' },
+  },
+  {
+    field: 'taxNo',
+    label: '税号',
+    type: 'input',
+    props: { placeholder: '请输入税号' },
+  },
+  {
+    field: 'bankName',
+    label: '银行名称',
+    type: 'input',
+    props: { placeholder: '请输入银行名称' },
+  },
+  {
+    field: 'bankAccount',
+    label: '银行账号',
+    type: 'input',
+    props: { placeholder: '请输入银行账号' },
+  },
+  {
+    field: 'invoiceAddressPhone',
+    label: '发票地址电话',
+    type: 'input',
+    span: 2,
+    props: { placeholder: '请输入发票地址和电话' },
+  },
+  {
+    field: 'remark',
+    label: '备注',
+    type: 'textarea',
+    span: 2,
+    props: { placeholder: '请输入备注', maxlength: 500, showCount: true, rows: 3 },
+  },
+])
+
+// ==================== 事件处理 ====================
+function handleEdit(row) {
+  crudRef.value?.openEdit(row)
+}
+
+function handleDelete(row) {
+  crudRef.value?.openDelete(row)
+}
+
+function handleToggleStatus(row) {
+  import('@/api/restaurant').then(({ toggleCreditStatus }) => {
+    toggleCreditStatus(row.id).then((res) => {
+      if (res.code === 200) {
+        crudRef.value?.refresh()
+      }
+    })
+  })
+}
+
+function handleBeforeSubmit(data) {
+  // 提交前不做额外转换
+  return data
+}
+</script>
+
+<style scoped>
+.restaurant-credit-page {
+  padding: 16px;
+  height: 100%;
+}
+</style>

+ 9 - 33
forge-admin-ui/src/views/restaurant/dishLibrary.vue

@@ -169,11 +169,11 @@ const searchSchema = computed(() => [
 
 
 // ==================== 表格列配置 ====================
 // ==================== 表格列配置 ====================
 
 
-/** 分转元(展示用) */
-function formatPrice(priceInCents) {
-  if (priceInCents === null || priceInCents === undefined)
+/** 格式化价格(展示用) */
+function formatPrice(price) {
+  if (price === null || price === undefined)
     return '-'
     return '-'
-  return (priceInCents / 100).toFixed(2)
+  return Number(price).toFixed(2)
 }
 }
 
 
 const tableColumns = computed(() => [
 const tableColumns = computed(() => [
@@ -182,14 +182,14 @@ const tableColumns = computed(() => [
   {
   {
     prop: 'image',
     prop: 'image',
     label: '图片',
     label: '图片',
-    width: 80,
+    width: 100,
     render: (row) => {
     render: (row) => {
       if (!row.image)
       if (!row.image)
         return '-'
         return '-'
-      return h(AuthImage, {
+      return h('div', { style: 'overflow: hidden; display: flex; align-items: center;' }, [h(AuthImage, {
         src: row.image,
         src: row.image,
         style: 'width: 36px; height: 36px; border-radius: 4px; object-fit: cover;',
         style: 'width: 36px; height: 36px; border-radius: 4px; object-fit: cover;',
-      })
+      })])
     },
     },
   },
   },
   { prop: 'typeName', label: '分类', width: 120 },
   { prop: 'typeName', label: '分类', width: 120 },
@@ -324,39 +324,15 @@ const editSchema = computed(() => [
   },
   },
 ])
 ])
 
 
-// ==================== 提交前钩子:元转分 ====================
+// ==================== 提交前钩子 ====================
 
 
 function beforeSubmit(data) {
 function beforeSubmit(data) {
-  // 销售价:元 -> 分
-  if (data.salePrice !== null && data.salePrice !== undefined) {
-    data.salePrice = Math.round(data.salePrice * 100)
-  }
-  // 成本价:元 -> 分
-  if (data.costPrice !== null && data.costPrice !== undefined) {
-    data.costPrice = Math.round(data.costPrice * 100)
-  }
   return data
   return data
 }
 }
 
 
-// ==================== 详情渲染前钩子:分转元 ====================
+// ==================== 详情渲染前钩子 ====================
 
 
 function beforeRenderDetail(data) {
 function beforeRenderDetail(data) {
-  if (!data)
-    return data
-  // 销售价:分 -> 元(0 视为未填写,保持空)
-  if (data.salePrice !== null && data.salePrice !== undefined && data.salePrice !== 0) {
-    data.salePrice = data.salePrice / 100
-  }
-  else {
-    data.salePrice = null
-  }
-  // 成本价:分 -> 元(0 视为未填写,保持空)
-  if (data.costPrice !== null && data.costPrice !== undefined && data.costPrice !== 0) {
-    data.costPrice = data.costPrice / 100
-  }
-  else {
-    data.costPrice = null
-  }
   return data
   return data
 }
 }
 </script>
 </script>

+ 202 - 11
forge-admin-ui/src/views/restaurant/dishMenu.vue

@@ -242,6 +242,32 @@
         />
         />
       </NSpin>
       </NSpin>
     </NModal>
     </NModal>
+
+    <!-- ==================== 图片全屏预览 ==================== -->
+    <Teleport to="body">
+      <Transition name="fade">
+        <div v-if="previewVisible" class="image-preview-overlay" @click.self="closePreview" @keydown.esc="closePreview">
+          <div class="preview-close" @click="closePreview">
+            &times;
+          </div>
+          <div class="preview-nav preview-nav-left" @click.stop="prevImage">
+            &#8249;
+          </div>
+          <div class="preview-main">
+            <img v-if="previewUrls.length > 0" :src="previewUrls[previewIndex]" class="preview-img">
+          </div>
+          <div class="preview-nav preview-nav-right" @click.stop="nextImage">
+            &#8250;
+          </div>
+          <div v-if="previewUrls.length > 1" class="preview-dots">
+            <span v-for="(_, i) in previewUrls" :key="i" class="preview-dot" :class="{ active: i === previewIndex }" @click.stop="previewIndex = i" />
+          </div>
+          <div class="preview-counter">
+            {{ previewIndex + 1 }} / {{ previewUrls.length }}
+          </div>
+        </div>
+      </Transition>
+    </Teleport>
   </div>
   </div>
 </template>
 </template>
 
 
@@ -262,6 +288,7 @@ import {
 } from '@/api/restaurant'
 } from '@/api/restaurant'
 import { AiCrudPage } from '@/components/ai-form'
 import { AiCrudPage } from '@/components/ai-form'
 import AuthImage from '@/components/common/AuthImage.vue'
 import AuthImage from '@/components/common/AuthImage.vue'
+import { resolveRenderableFileUrl } from '@/utils'
 
 
 defineOptions({ name: 'RestaurantDishMenu' })
 defineOptions({ name: 'RestaurantDishMenu' })
 
 
@@ -335,11 +362,60 @@ const historyDetailVisible = ref(false)
 const historyDetailLoading = ref(false)
 const historyDetailLoading = ref(false)
 const historyDetailList = ref([])
 const historyDetailList = ref([])
 
 
+// ==================== 图片预览 ====================
+const previewVisible = ref(false)
+const previewUrls = ref([])
+const previewIndex = ref(0)
+
+function openPreview(row) {
+  const urls = getImageUrls(row.image)
+  if (urls.length === 0)
+    return
+  previewUrls.value = urls
+  previewIndex.value = 0
+  previewVisible.value = true
+  Promise.all(urls.map(url => getPreviewUrl(url))).then((list) => {
+    previewUrls.value = list
+  })
+}
+
+function getImageUrls(value) {
+  if (!value)
+    return []
+  return value.split(',').map(item => item.trim()).filter(item => item)
+}
+
+function getPreviewUrl(url) {
+  if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:') || url.startsWith('blob:'))
+    return Promise.resolve(url)
+  return resolveRenderableFileUrl(url).catch(() => url)
+}
+
+function prevImage() {
+  if (previewIndex.value > 0)
+    previewIndex.value--
+  else
+    previewIndex.value = previewUrls.value.length - 1
+}
+
+function nextImage() {
+  if (previewIndex.value < previewUrls.value.length - 1)
+    previewIndex.value++
+  else
+    previewIndex.value = 0
+}
+
+function closePreview() {
+  previewVisible.value = false
+  previewUrls.value = []
+  previewIndex.value = 0
+}
+
 // ==================== 工具方法 ====================
 // ==================== 工具方法 ====================
 function formatPrice(price) {
 function formatPrice(price) {
   if (price == null)
   if (price == null)
     return '-'
     return '-'
-  return (price / 100).toFixed(2)
+  return Number(price).toFixed(2)
 }
 }
 
 
 // ==================== 分类树 ====================
 // ==================== 分类树 ====================
@@ -391,14 +467,15 @@ const tableColumns = computed(() => [
   {
   {
     prop: 'image',
     prop: 'image',
     label: '菜品图片',
     label: '菜品图片',
-    width: 80,
+    width: 100,
     render: (row) => {
     render: (row) => {
       if (!row.image)
       if (!row.image)
         return '-'
         return '-'
-      return h(AuthImage, {
+      return h('div', { style: 'overflow: hidden; display: flex; align-items: center;' }, [h(AuthImage, {
         src: row.image,
         src: row.image,
-        style: 'width: 40px; height: 40px; border-radius: 4px; object-fit: cover;',
-      })
+        style: 'width: 40px; height: 40px; border-radius: 4px; object-fit: cover; cursor: pointer;',
+        onClick: () => openPreview(row),
+      })])
     },
     },
   },
   },
   { prop: 'dishCode', label: '菜品编码', width: 150, render: row => row.dishCode || '-' },
   { prop: 'dishCode', label: '菜品编码', width: 150, render: row => row.dishCode || '-' },
@@ -648,7 +725,7 @@ async function confirmAddDishes() {
 // ==================== 修改价格 ====================
 // ==================== 修改价格 ====================
 function handleEditPrice(row) {
 function handleEditPrice(row) {
   priceEditRow.value = row
   priceEditRow.value = row
-  priceEditValue.value = row.salePrice != null ? +(row.salePrice / 100).toFixed(2) : 0
+  priceEditValue.value = row.salePrice != null ? Number(row.salePrice) : 0
   priceVisible.value = true
   priceVisible.value = true
 }
 }
 
 
@@ -660,7 +737,7 @@ async function submitUpdatePrice() {
   try {
   try {
     const res = await updateMenuPrice({
     const res = await updateMenuPrice({
       id: priceEditRow.value.id,
       id: priceEditRow.value.id,
-      salePrice: Math.round(priceEditValue.value * 100),
+      salePrice: priceEditValue.value,
     })
     })
     if (res.code === 200) {
     if (res.code === 200) {
       message.success('价格修改成功')
       message.success('价格修改成功')
@@ -811,14 +888,15 @@ const historyDetailColumns = [
   {
   {
     title: '菜品图片',
     title: '菜品图片',
     key: 'image',
     key: 'image',
-    width: 80,
+    width: 100,
     render(row) {
     render(row) {
       if (!row.image)
       if (!row.image)
         return '-'
         return '-'
-      return h(AuthImage, {
+      return h('div', { style: 'overflow: hidden; display: flex; align-items: center;' }, [h(AuthImage, {
         src: row.image,
         src: row.image,
-        style: 'width: 40px; height: 40px; border-radius: 4px; object-fit: cover;',
-      })
+        style: 'width: 40px; height: 40px; border-radius: 4px; object-fit: cover; cursor: pointer;',
+        onClick: () => openPreview(row),
+      })])
     },
     },
   },
   },
   { title: '菜品名称', key: 'dishName', width: 150 },
   { title: '菜品名称', key: 'dishName', width: 150 },
@@ -990,4 +1068,117 @@ onMounted(() => {
   gap: 12px;
   gap: 12px;
   padding: 0 8px;
   padding: 0 8px;
 }
 }
+
+/* ==================== 图片预览 ==================== */
+.image-preview-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 9999;
+  background: rgba(0, 0, 0, 0.85);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.preview-close {
+  position: absolute;
+  top: 16px;
+  right: 20px;
+  font-size: 32px;
+  color: #fff;
+  cursor: pointer;
+  z-index: 10;
+  line-height: 1;
+  opacity: 0.7;
+  transition: opacity 0.2s;
+}
+
+.preview-close:hover {
+  opacity: 1;
+}
+
+.preview-main {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  max-width: 85vw;
+  max-height: 80vh;
+  overflow: hidden;
+}
+
+.preview-img {
+  max-width: 85vw;
+  max-height: 80vh;
+  object-fit: contain;
+}
+
+.preview-nav {
+  position: absolute;
+  top: 50%;
+  transform: translateY(-50%);
+  font-size: 48px;
+  color: #fff;
+  cursor: pointer;
+  user-select: none;
+  opacity: 0.6;
+  transition: opacity 0.2s;
+  z-index: 10;
+  line-height: 1;
+}
+
+.preview-nav:hover {
+  opacity: 1;
+}
+
+.preview-nav-left {
+  left: 24px;
+}
+
+.preview-nav-right {
+  right: 24px;
+}
+
+.preview-dots {
+  position: absolute;
+  bottom: 40px;
+  left: 50%;
+  transform: translateX(-50%);
+  display: flex;
+  gap: 8px;
+}
+
+.preview-dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  background: rgba(255, 255, 255, 0.4);
+  cursor: pointer;
+  transition: background 0.2s;
+}
+
+.preview-dot.active {
+  background: #fff;
+}
+
+.preview-counter {
+  position: absolute;
+  bottom: 16px;
+  left: 50%;
+  transform: translateX(-50%);
+  font-size: 13px;
+  color: rgba(255, 255, 255, 0.6);
+}
+
+.fade-enter-active,
+.fade-leave-active {
+  transition: opacity 0.25s ease;
+}
+
+.fade-enter-from,
+.fade-leave-to {
+  opacity: 0;
+}
 </style>
 </style>

Разница между файлами не показана из-за своего большого размера
+ 2041 - 0
forge-admin-ui/src/views/restaurant/dishOrder.vue


+ 775 - 0
forge-admin-ui/src/views/restaurant/dishOrderAdd.vue

@@ -0,0 +1,775 @@
+<template>
+  <div class="dish-order-add-page">
+    <!-- ==================== 顶部信息栏 ==================== -->
+    <div class="order-header">
+      <div>
+        <div class="order-room-name">
+          {{ currentRoom.roomName || '-' }}
+        </div>
+        <div class="order-room-sub">
+          包房号:{{ currentRoom.roomNo || '-' }}
+        </div>
+      </div>
+      <NButton type="primary" @click="backOrderDetail">
+        <template #icon>
+          <NIcon><ArrowBackOutline /></NIcon>
+        </template>
+        返回订单
+      </NButton>
+    </div>
+
+    <!-- ==================== 主体区域 ==================== -->
+    <div class="order-main">
+      <!-- 左侧:菜品选择面板 -->
+      <div class="dish-panel">
+        <div class="dish-search">
+          <NInput v-model:value="dishSearchName" placeholder="搜索菜品名称" clearable @keyup.enter="searchDish" @clear="searchDish">
+            <template #suffix>
+              <NIcon style="cursor:pointer" @click="searchDish">
+                <SearchOutline />
+              </NIcon>
+            </template>
+          </NInput>
+        </div>
+        <div class="dish-content">
+          <div class="category-list">
+            <div
+              v-for="item in categoryList"
+              :key="item.id"
+              class="category-item"
+              :class="{ 'category-active': activeTypeId === item.id }"
+              @click="changeType(item)"
+            >
+              {{ item.name }}
+            </div>
+          </div>
+          <div class="dish-list">
+            <NSpin :show="dishLoading">
+              <div v-if="dishList.length === 0" class="dish-empty">
+                暂无菜品
+              </div>
+              <div v-else class="dish-grid">
+                <div v-for="item in dishList" :key="item.id" class="dish-card">
+                  <div v-if="item.imageUrl" class="dish-image">
+                    <AuthImage :src="item.imageUrl" style="width:100%;height:100%;object-fit:cover;" />
+                  </div>
+                  <div v-else class="dish-image dish-image-empty">
+                    暂无图片
+                  </div>
+                  <div class="dish-info">
+                    <div class="dish-name">
+                      {{ item.dishName }}
+                    </div>
+                    <div class="dish-desc">
+                      {{ item.spec || item.taste || item.remarks || '暂无说明' }}
+                    </div>
+                    <div class="dish-bottom">
+                      <span class="dish-price">¥{{ formatMoney(item.salePrice) }}</span>
+                      <div v-if="orderedRow(item)" class="card-stepper">
+                        <span class="step-btn" @click="reduceDish(orderedRow(item))">-</span>
+                        <span class="step-qty">{{ orderedRow(item).quantity }}</span>
+                        <span class="step-btn plus" @click="increaseOrderedDish(orderedRow(item))">+</span>
+                      </div>
+                      <NButton v-else type="primary" size="small" quaternary @click="addDish(item)">
+                        点菜
+                      </NButton>
+                    </div>
+                  </div>
+                </div>
+              </div>
+            </NSpin>
+          </div>
+        </div>
+      </div>
+
+      <!-- 右侧:本次加菜面板 -->
+      <div class="ordered-panel">
+        <div class="ordered-title">
+          本次加菜
+        </div>
+        <div v-if="orderedList.length === 0" class="ordered-empty">
+          <div class="ordered-empty-title">
+            暂未加菜
+          </div>
+          <div class="ordered-empty-sub">
+            从左侧选择菜品后会显示在这里
+          </div>
+        </div>
+        <div v-else class="ordered-list">
+          <div class="ordered-head">
+            <span style="text-align:center;">菜品</span>
+            <span style="text-align:center;">单价</span>
+            <span style="text-align:center;">总价</span>
+            <span style="text-align:center;">数量</span>
+          </div>
+          <div>
+            <div v-for="item in orderedList" :key="item.dishId" class="ordered-item">
+              <div class="ordered-dish">
+                <div class="ordered-dish-name">
+                  {{ item.dishName }}
+                </div>
+              </div>
+              <div class="ordered-price">
+                ¥{{ formatMoney(item.salePrice) }}
+              </div>
+              <div class="ordered-total">
+                ¥{{ formatMoney(itemTotal(item)) }}
+              </div>
+              <div class="ordered-qty">
+                <NButton size="tiny" circle @click="reduceDish(item)">
+                  <template #icon>
+                    <NIcon><RemoveOutline /></NIcon>
+                  </template>
+                </NButton>
+                <span>{{ item.quantity }}</span>
+                <NButton size="tiny" circle type="primary" @click="increaseOrderedDish(item)">
+                  <template #icon>
+                    <NIcon><AddOutline /></NIcon>
+                  </template>
+                </NButton>
+              </div>
+            </div>
+          </div>
+        </div>
+        <div class="ordered-footer">
+          <div class="ordered-summary">
+            <span>共 {{ orderedList.length }} 项,本次加菜金额</span>
+            <strong>¥{{ formatMoney(orderTotal) }}</strong>
+          </div>
+          <NButton type="primary" block :loading="submitting" @click="confirmSubmitOrder">
+            下单
+          </NButton>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { AddOutline, ArrowBackOutline, RemoveOutline, SearchOutline } from '@vicons/ionicons5'
+import { NButton, NIcon, NInput, NSpin, useDialog, useMessage } from 'naive-ui'
+import { computed, onActivated, onMounted, ref } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import {
+  getCurrentOrder,
+  getDishRoomDetail,
+  getMenuOrderDishList,
+  getMenuOrderTypeList,
+  submitOrder,
+} from '@/api/restaurant'
+import AuthImage from '@/components/common/AuthImage.vue'
+
+defineOptions({ name: 'RestaurantDishOrderAdd' })
+
+const message = useMessage()
+const dialog = useDialog()
+const router = useRouter()
+const route = useRoute()
+
+// ==================== 基础数据 ====================
+const currentRoom = ref({})
+const currentOrder = ref(null)
+const categoryList = ref([])
+const activeTypeId = ref('all')
+const dishSearchName = ref('')
+const dishList = ref([])
+const orderedList = ref([])
+const existDishSortMap = ref({})
+const maxDishSort = ref(0)
+const dishLoading = ref(false)
+const orderLoading = ref(false)
+const submitting = ref(false)
+
+// ==================== 计算属性 ====================
+const orderTotal = computed(() => {
+  return orderedList.value.reduce((total, item) => total + itemTotal(item), 0)
+})
+
+const submitDetailList = computed(() => {
+  let appendIndex = 0
+  return orderedList.value.map((item) => {
+    let dishSort = existDishSortMap.value[item.dishId]
+    if (!dishSort) {
+      appendIndex += 1
+      dishSort = maxDishSort.value + appendIndex
+    }
+    return {
+      dishId: item.dishId,
+      dishName: item.dishName,
+      quantity: item.quantity,
+      dishSort,
+    }
+  })
+})
+
+// ==================== 页面初始化 ====================
+function initPage() {
+  const roomId = route.query.roomId
+  if (!roomId) {
+    backRoomList()
+    return
+  }
+  orderedList.value = []
+  activeTypeId.value = 'all'
+  dishSearchName.value = ''
+  loadRoom(roomId)
+  loadCurrentOrder(roomId)
+  loadCategoryList()
+  loadDishList()
+}
+
+function loadRoom(roomId) {
+  getDishRoomDetail(roomId).then((res) => {
+    if (res.code === 200) {
+      currentRoom.value = res.data || {}
+    }
+  })
+}
+
+function loadCurrentOrder(roomId) {
+  orderLoading.value = true
+  getCurrentOrder(roomId).then((res) => {
+    if (res.code === 200) {
+      const data = res.data || null
+      currentOrder.value = data
+      if (!currentOrder.value || !currentOrder.value.id) {
+        message.warning('当前包房暂无未结账订单,请先下单')
+        backOrderDetail()
+        return
+      }
+      buildDishSortMap(currentOrder.value.detailList || [])
+    }
+    orderLoading.value = false
+  }).catch(() => {
+    orderLoading.value = false
+  })
+}
+
+function buildDishSortMap(detailList) {
+  existDishSortMap.value = {}
+  maxDishSort.value = 0
+  detailList.forEach((item) => {
+    const sort = Number(item.dishSort || 0)
+    if (item.dishId && sort > 0) {
+      existDishSortMap.value[item.dishId] = sort
+    }
+    if (sort > maxDishSort.value) {
+      maxDishSort.value = sort
+    }
+  })
+}
+
+function backRoomList() {
+  router.push({ path: '/restaurant/dishOrderIndex' })
+}
+
+function backOrderDetail() {
+  router.push({
+    path: '/restaurant/dishOrder',
+    query: {
+      roomId: route.query.roomId,
+      refresh: Date.now(),
+    },
+  })
+}
+
+// ==================== 分类与菜品 ====================
+function loadCategoryList() {
+  getMenuOrderTypeList().then((res) => {
+    if (res.code === 200) {
+      const data = res.data || []
+      categoryList.value = [
+        { id: 'all', name: '所有菜品' },
+        ...data.map(item => ({ id: item.id, name: item.name })),
+      ]
+    }
+  })
+}
+
+function changeType(item) {
+  activeTypeId.value = item.id
+  dishSearchName.value = ''
+  loadDishList()
+}
+
+function searchDish() {
+  activeTypeId.value = 'all'
+  loadDishList()
+}
+
+function loadDishList() {
+  dishLoading.value = true
+  getMenuOrderDishList({
+    typeId: activeTypeId.value === 'all' ? '' : activeTypeId.value,
+    dishName: dishSearchName.value || undefined,
+  }).then((res) => {
+    if (res.code === 200) {
+      dishList.value = res.data || []
+    }
+    dishLoading.value = false
+  }).catch(() => {
+    dishLoading.value = false
+  })
+}
+
+// ==================== 点菜操作 ====================
+function addDish(dish) {
+  const row = orderedList.value.find(item => item.dishId === dish.id)
+  if (row) {
+    row.quantity += 1
+    return
+  }
+  orderedList.value.push(toOrderedItem(dish, 1))
+}
+
+function increaseOrderedDish(item) {
+  item.quantity += 1
+}
+
+function reduceDish(item) {
+  if (item.quantity <= 1) {
+    orderedList.value = orderedList.value.filter(row => row.dishId !== item.dishId)
+    return
+  }
+  item.quantity -= 1
+}
+
+function orderedRow(dish) {
+  return orderedList.value.find(item => item.dishId === dish.id) || null
+}
+
+function toOrderedItem(item, quantity) {
+  return {
+    dishId: item.dishId || item.id,
+    dishName: item.dishName,
+    salePrice: Number(item.salePrice || 0),
+    quantity,
+  }
+}
+
+function itemTotal(item) {
+  return Number(item.salePrice || 0) * Number(item.quantity || 0)
+}
+
+function formatMoney(value) {
+  return Number(value || 0).toFixed(2)
+}
+
+// ==================== 下单 ====================
+function confirmSubmitOrder() {
+  if (orderedList.value.length === 0) {
+    message.warning('请先选择菜品')
+    return
+  }
+  dialog.warning({
+    title: '提示',
+    content: '是否确认下单?',
+    positiveText: '确定',
+    negativeText: '取消',
+    onPositiveClick: () => {
+      submitOrderData()
+    },
+  })
+}
+
+function submitOrderData() {
+  submitting.value = true
+  submitOrder({
+    id: currentOrder.value ? currentOrder.value.id : route.query.orderId,
+    roomId: route.query.roomId,
+    settleFlag: 'ORDER',
+    detailList: submitDetailList.value,
+  }).then((res) => {
+    if (res.code === 200) {
+      message.success(res.msg || '加菜成功')
+      backOrderDetail()
+    }
+    else {
+      message.error(res.msg || '加菜失败')
+    }
+  }).catch(() => {
+    message.error('加菜失败')
+  }).finally(() => {
+    submitting.value = false
+  })
+}
+
+// ==================== 生命周期 ====================
+onMounted(() => {
+  initPage()
+})
+
+onActivated(() => {
+  initPage()
+})
+</script>
+
+<style scoped>
+.dish-order-add-page {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+/* ==================== 顶部信息栏 ==================== */
+.order-header {
+  height: 64px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border-bottom: 1px solid #ebeef5;
+  padding: 0 4px 12px;
+  flex-shrink: 0;
+}
+
+.order-room-name {
+  font-size: 20px;
+  font-weight: 600;
+  color: #303133;
+  line-height: 28px;
+}
+
+.order-room-sub {
+  font-size: 13px;
+  color: #909399;
+  line-height: 20px;
+}
+
+/* ==================== 主体区域 ==================== */
+.order-main {
+  flex: 1;
+  min-height: 0;
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) 480px;
+  gap: 16px;
+  padding-top: 16px;
+}
+
+.dish-panel,
+.ordered-panel {
+  min-height: 0;
+  border: 1px solid #ebeef5;
+  border-radius: 8px;
+  background: #fff;
+  display: flex;
+  flex-direction: column;
+}
+
+/* ==================== 菜品面板 ==================== */
+.dish-search {
+  padding: 14px;
+  border-bottom: 1px solid #ebeef5;
+}
+
+.dish-content {
+  flex: 1;
+  min-height: 0;
+  display: grid;
+  grid-template-columns: 150px minmax(0, 1fr);
+}
+
+.category-list {
+  background: #f7f9fb;
+  border-right: 1px solid #ebeef5;
+  overflow: auto;
+}
+
+.category-item {
+  min-height: 46px;
+  display: flex;
+  align-items: center;
+  padding: 0 14px;
+  cursor: pointer;
+  color: #606266;
+  border-left: 3px solid transparent;
+  font-size: 14px;
+}
+
+.category-item:hover {
+  color: #409eff;
+  background: #eef5ff;
+}
+
+.category-active {
+  color: #409eff;
+  background: #fff;
+  border-left-color: #409eff;
+  font-weight: 600;
+}
+
+.dish-list {
+  min-height: 0;
+  overflow: auto;
+  padding: 14px;
+}
+
+.dish-grid {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 14px;
+}
+
+.dish-card {
+  min-width: 0;
+  border: 1px solid #ebeef5;
+  border-radius: 8px;
+  overflow: hidden;
+  background: #fff;
+}
+
+.dish-image {
+  width: 100%;
+  aspect-ratio: 4 / 3;
+  background: #f2f3f5;
+}
+
+.dish-image-empty {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #909399;
+  font-size: 13px;
+}
+
+.dish-info {
+  padding: 10px;
+}
+
+.dish-name {
+  font-size: 15px;
+  font-weight: 600;
+  color: #303133;
+  line-height: 22px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+
+.dish-desc {
+  height: 36px;
+  margin-top: 6px;
+  color: #606266;
+  font-size: 13px;
+  line-height: 18px;
+  overflow: hidden;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+}
+
+.dish-bottom {
+  margin-top: 10px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+}
+
+.dish-price {
+  color: #f56c6c;
+  font-size: 18px;
+  font-weight: 600;
+  line-height: 26px;
+}
+
+.card-stepper {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.step-btn {
+  width: 26px;
+  height: 26px;
+  border-radius: 6px;
+  font-size: 16px;
+  font-weight: 700;
+  line-height: 26px;
+  text-align: center;
+  color: #409eff;
+  background: #ecf5ff;
+  cursor: pointer;
+  user-select: none;
+}
+
+.step-btn:hover {
+  opacity: 0.85;
+}
+
+.step-btn.plus {
+  color: #fff;
+  background: #409eff;
+}
+
+.step-qty {
+  min-width: 22px;
+  text-align: center;
+  font-size: 14px;
+  font-weight: 700;
+  color: #303133;
+}
+
+.dish-empty {
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #909399;
+}
+
+/* ==================== 已点菜面板 ==================== */
+.ordered-title {
+  height: 54px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 16px;
+  font-size: 16px;
+  font-weight: 600;
+  border-bottom: 1px solid #ebeef5;
+  flex-shrink: 0;
+}
+
+.ordered-empty {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  color: #909399;
+  text-align: center;
+  padding: 24px;
+}
+
+.ordered-empty-title {
+  font-size: 16px;
+  color: #606266;
+  line-height: 24px;
+}
+
+.ordered-empty-sub {
+  margin-top: 6px;
+  font-size: 13px;
+  line-height: 20px;
+}
+
+.ordered-list {
+  flex: 1;
+  min-height: 0;
+  overflow: auto;
+  padding: 12px;
+}
+
+.ordered-head,
+.ordered-item {
+  display: grid;
+  grid-template-columns: minmax(0, 1.35fr) 72px 86px 104px;
+  gap: 8px;
+  align-items: center;
+}
+
+.ordered-head {
+  height: 32px;
+  padding: 0 8px;
+  font-size: 12px;
+  color: #909399;
+  background: #f7f9fb;
+  border-radius: 6px;
+}
+
+.ordered-item {
+  min-height: 64px;
+  padding: 10px 8px;
+  border-bottom: 1px solid #ebeef5;
+}
+
+.ordered-dish {
+  min-width: 0;
+}
+
+.ordered-dish-name {
+  font-size: 14px;
+  font-weight: 600;
+  color: #303133;
+  line-height: 20px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+
+.ordered-price,
+.ordered-total {
+  font-size: 13px;
+  color: #606266;
+}
+
+.ordered-total {
+  color: #f56c6c;
+  font-weight: 600;
+}
+
+.ordered-qty {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 8px;
+}
+
+.ordered-qty span {
+  min-width: 22px;
+  text-align: center;
+  font-weight: 600;
+  color: #303133;
+}
+
+.ordered-footer {
+  border-top: 1px solid #ebeef5;
+  padding: 12px;
+  flex-shrink: 0;
+}
+
+.ordered-summary {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12px;
+  font-size: 14px;
+  color: #606266;
+}
+
+.ordered-summary strong {
+  font-size: 22px;
+  color: #f56c6c;
+}
+
+/* ==================== 响应式 ==================== */
+@media (max-width: 1280px) {
+  .order-main {
+    grid-template-columns: minmax(0, 1fr) 380px;
+  }
+
+  .dish-grid {
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+  }
+}
+
+@media (max-width: 960px) {
+  .order-main {
+    grid-template-columns: 1fr;
+  }
+
+  .dish-grid {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .dish-content {
+    grid-template-columns: 120px minmax(0, 1fr);
+  }
+}
+
+@media (max-width: 640px) {
+  .dish-grid {
+    grid-template-columns: 1fr;
+  }
+}
+</style>

+ 283 - 0
forge-admin-ui/src/views/restaurant/dishOrderIndex.vue

@@ -0,0 +1,283 @@
+<template>
+  <div class="dish-order-index-page">
+    <!-- 搜索栏 -->
+    <div class="index-search-bar">
+      <NInput
+        v-model:value="searchRoomName"
+        placeholder="请输入包房名称"
+        clearable
+        style="width: 220px;"
+        @keyup.enter="loadRoomList"
+        @clear="loadRoomList"
+      />
+      <NInput
+        v-model:value="searchRoomNo"
+        placeholder="请输入包房编号"
+        clearable
+        style="width: 220px; margin-left: 12px;"
+        @keyup.enter="loadRoomList"
+        @clear="loadRoomList"
+      />
+      <NButton type="primary" style="margin-left: 12px;" @click="loadRoomList">
+        <template #icon>
+          <NIcon><SearchOutline /></NIcon>
+        </template>
+        查询
+      </NButton>
+      <NButton style="margin-left: 8px;" @click="handleReset">
+        <template #icon>
+          <NIcon><RefreshOutline /></NIcon>
+        </template>
+        重置
+      </NButton>
+    </div>
+
+    <!-- 卡片网格 -->
+    <NSpin :show="loading">
+      <div v-if="dataList.length === 0 && !loading" class="index-empty">
+        暂无包房数据
+      </div>
+      <div v-else class="room-grid">
+        <div
+          v-for="item in dataList"
+          :key="item.id"
+          class="room-card"
+          :class="{ 'room-card-disabled': item.status === 'DISABLED' }"
+          @click="handleEnterOrder(item)"
+        >
+          <div class="room-card-top">
+            <div>
+              <div class="room-name">
+                {{ item.roomName }}
+              </div>
+              <div class="room-no">
+                包房号:{{ item.roomNo || '-' }}
+              </div>
+            </div>
+            <NTag
+              :type="getUseStatusTagType(item.useStatus)"
+              size="small"
+              :bordered="false"
+            >
+              {{ getUseStatusLabel(item.useStatus) }}
+            </NTag>
+          </div>
+
+          <div class="room-meta">
+            <div>
+              <span class="meta-label">容纳人数</span>
+              <span class="meta-value">{{ item.capacity || 0 }} 人</span>
+            </div>
+          </div>
+
+          <div class="room-remarks">
+            {{ item.remark || '暂无备注' }}
+          </div>
+        </div>
+      </div>
+    </NSpin>
+  </div>
+</template>
+
+<script setup>
+import { RefreshOutline, SearchOutline } from '@vicons/ionicons5'
+import { NButton, NIcon, NInput, NSpin, NTag, useMessage } from 'naive-ui'
+import { onMounted, ref } from 'vue'
+import { useRouter } from 'vue-router'
+import { getDishRoomList } from '@/api/restaurant'
+
+defineOptions({ name: 'RestaurantDishOrderIndex' })
+
+const message = useMessage()
+const router = useRouter()
+
+// ==================== 搜索 ====================
+const searchRoomName = ref('')
+const searchRoomNo = ref('')
+
+function handleReset() {
+  searchRoomName.value = ''
+  searchRoomNo.value = ''
+  loadRoomList()
+}
+
+// ==================== 列表 ====================
+const loading = ref(false)
+const dataList = ref([])
+
+function loadRoomList() {
+  loading.value = true
+  const params = {}
+  if (searchRoomName.value)
+    params.roomName = searchRoomName.value
+  if (searchRoomNo.value)
+    params.roomNo = searchRoomNo.value
+  getDishRoomList(params).then((res) => {
+    if (res.code === 200) {
+      dataList.value = res.data || []
+    }
+  }).catch(() => {
+    message.error('加载包房列表失败')
+  }).finally(() => {
+    loading.value = false
+  })
+}
+
+onMounted(() => {
+  loadRoomList()
+})
+
+// ==================== 进入点菜 ====================
+function handleEnterOrder(row) {
+  if (row.status === 'DISABLED') {
+    message.warning('该包房已停用,无法点菜')
+    return
+  }
+  router.push({
+    path: '/restaurant/dishOrder',
+    query: { roomId: row.id },
+  })
+}
+
+// ==================== 状态标签 ====================
+const useStatusTagTypeMap = {
+  IN_USE: 'warning',
+  IDLE: 'success',
+}
+
+const useStatusLabelMap = {
+  IN_USE: '使用中',
+  IDLE: '空闲中',
+}
+
+function getUseStatusTagType(status) {
+  return useStatusTagTypeMap[status] || 'default'
+}
+
+function getUseStatusLabel(status) {
+  return useStatusLabelMap[status] || status || '-'
+}
+</script>
+
+<style scoped>
+.dish-order-index-page {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+/* ==================== 搜索栏 ==================== */
+.index-search-bar {
+  display: flex;
+  align-items: center;
+  padding: 14px 4px;
+  border-bottom: 1px solid #ebeef5;
+  flex-shrink: 0;
+}
+
+/* ==================== 卡片网格 ==================== */
+.room-grid {
+  flex: 1;
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 16px;
+  padding: 16px 4px;
+  overflow-y: auto;
+  align-content: start;
+}
+
+.index-empty {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #909399;
+  font-size: 14px;
+}
+
+.room-card {
+  position: relative;
+  min-height: 160px;
+  border: 2px solid #e5e7eb;
+  border-radius: 8px;
+  background: #fff;
+  padding: 18px;
+  cursor: pointer;
+  transition:
+    border-color 0.2s,
+    box-shadow 0.2s,
+    transform 0.15s;
+}
+
+.room-card:hover {
+  border-color: #409eff;
+  box-shadow: 0 4px 16px rgba(64, 158, 255, 0.12);
+  transform: translateY(-2px);
+}
+
+.room-card-disabled {
+  background: #f7f8fa;
+  cursor: not-allowed;
+  opacity: 0.65;
+}
+
+.room-card-disabled:hover {
+  border-color: #e5e7eb;
+  box-shadow: none;
+  transform: none;
+}
+
+.room-card-top {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 12px;
+}
+
+.room-name {
+  font-size: 20px;
+  font-weight: 600;
+  line-height: 28px;
+  color: #1f2937;
+}
+
+.room-no {
+  margin-top: 4px;
+  font-size: 13px;
+  color: #909399;
+}
+
+.room-meta {
+  margin-top: 14px;
+}
+
+.room-meta > div {
+  border-radius: 6px;
+  background: #f7f9fb;
+  padding: 10px 12px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.meta-label {
+  font-size: 13px;
+  color: #909399;
+}
+
+.meta-value {
+  font-size: 14px;
+  font-weight: 600;
+  color: #303133;
+}
+
+.room-remarks {
+  margin-top: 12px;
+  font-size: 13px;
+  color: #909399;
+  line-height: 20px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+</style>

+ 216 - 0
forge-server/db/migration/V1.0.129__add_restaurant_order_and_credit.sql

@@ -0,0 +1,216 @@
+-- ============================================================
+-- V1.0.129  新增餐饮模块:点菜订单 + 订单明细 + 挂账客户 + 字典 + 菜单权限
+-- ============================================================
+
+-- ==================== 1. 建表 ====================
+
+-- 点菜订单表
+CREATE TABLE IF NOT EXISTS `restaurant_dish_order` (
+  `id` bigint NOT NULL COMMENT '主键ID(雪花算法)',
+  `tenant_id` bigint NOT NULL COMMENT '租户ID',
+  `order_no` varchar(50) NOT NULL COMMENT '订单号(DC+时间戳)',
+  `room_id` bigint NOT NULL COMMENT '包房ID',
+  `room_no` varchar(50) DEFAULT NULL COMMENT '包房编号(冗余)',
+  `room_name` varchar(100) DEFAULT NULL COMMENT '包房名称(冗余)',
+  `order_status` varchar(20) NOT NULL DEFAULT 'ORDERING' COMMENT '订单状态:ORDERING-用餐中, CHECKED_OUT-已结账, CANCELLED-已取消',
+  `total_amount` bigint NOT NULL DEFAULT 0 COMMENT '总金额(单位:分)',
+  `settle_type` varchar(20) DEFAULT NULL COMMENT '结账方式:NORMAL-普通, DISCOUNT-折扣, PROMOTION-优惠',
+  `discount_rate` int DEFAULT 100 COMMENT '折扣比例(如 80 表示 8 折)',
+  `discount_amount` bigint DEFAULT 0 COMMENT '优惠金额(单位:分)',
+  `payable_amount` bigint DEFAULT 0 COMMENT '实收金额(单位:分)',
+  `settle_time` datetime DEFAULT NULL COMMENT '结账时间',
+  `taste_remark` varchar(500) DEFAULT NULL COMMENT '口味备注',
+  `special_requirements` varchar(500) DEFAULT NULL COMMENT '特殊要求',
+  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
+  `del_flag` bigint NOT NULL DEFAULT 0 COMMENT '删除标记(0=未删除,删除时写入主键ID)',
+  `create_by` bigint DEFAULT NULL COMMENT '创建人ID',
+  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+  `create_dept` bigint DEFAULT NULL COMMENT '创建部门ID',
+  `update_by` bigint DEFAULT NULL COMMENT '更新人ID',
+  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_room_id` (`room_id`),
+  KEY `idx_order_status` (`order_status`),
+  KEY `idx_order_no` (`order_no`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='点菜订单表';
+
+-- 订单明细表(退菜时 quantity 为负数,汇总通过 SUM(quantity) HAVING > 0 过滤)
+CREATE TABLE IF NOT EXISTS `restaurant_dish_order_detail` (
+  `id` bigint NOT NULL COMMENT '主键ID(雪花算法)',
+  `tenant_id` bigint NOT NULL COMMENT '租户ID',
+  `order_id` bigint NOT NULL COMMENT '订单ID',
+  `room_id` bigint DEFAULT NULL COMMENT '包房ID',
+  `dish_id` bigint NOT NULL COMMENT '菜品ID',
+  `dish_code` varchar(50) DEFAULT NULL COMMENT '菜品编码(冗余)',
+  `dish_name` varchar(100) DEFAULT NULL COMMENT '菜品名称(冗余)',
+  `type_id` bigint DEFAULT NULL COMMENT '分类ID(冗余)',
+  `type_name` varchar(100) DEFAULT NULL COMMENT '分类名称(冗余)',
+  `unit` varchar(20) DEFAULT NULL COMMENT '单位(冗余)',
+  `spec` varchar(50) DEFAULT NULL COMMENT '规格(冗余)',
+  `sale_price` bigint NOT NULL DEFAULT 0 COMMENT '单价(单位:分,来自菜单价)',
+  `quantity` int NOT NULL DEFAULT 0 COMMENT '数量(退菜时为负数)',
+  `amount` bigint NOT NULL DEFAULT 0 COMMENT '金额 = sale_price × quantity(单位:分)',
+  `dish_sort` int DEFAULT 0 COMMENT '排序号',
+  `is_add_dish` varchar(1) NOT NULL DEFAULT '0' COMMENT '是否加菜:0-否, 1-是',
+  `reason` varchar(500) DEFAULT NULL COMMENT '退菜原因',
+  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
+  `del_flag` bigint NOT NULL DEFAULT 0 COMMENT '删除标记(0=未删除,删除时写入主键ID)',
+  `create_by` bigint DEFAULT NULL COMMENT '创建人ID',
+  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+  `create_dept` bigint DEFAULT NULL COMMENT '创建部门ID',
+  `update_by` bigint DEFAULT NULL COMMENT '更新人ID',
+  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_order_id` (`order_id`),
+  KEY `idx_dish_id` (`dish_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';
+
+-- 挂账客户表
+CREATE TABLE IF NOT EXISTS `restaurant_credit_customer` (
+  `id` bigint NOT NULL COMMENT '主键ID(雪花算法)',
+  `tenant_id` bigint NOT NULL COMMENT '租户ID',
+  `customer_name` varchar(100) NOT NULL COMMENT '客户姓名',
+  `mobile` varchar(20) DEFAULT NULL COMMENT '手机号(唯一校验)',
+  `birthday` date DEFAULT NULL COMMENT '生日',
+  `id_card` varchar(20) DEFAULT NULL COMMENT '身份证',
+  `occupation` varchar(50) DEFAULT NULL COMMENT '职业',
+  `company_name` varchar(200) DEFAULT NULL COMMENT '公司名称',
+  `address` varchar(500) DEFAULT NULL COMMENT '地址',
+  `emergency_contact` varchar(50) DEFAULT NULL COMMENT '紧急联系人',
+  `emergency_contact_phone` varchar(20) DEFAULT NULL COMMENT '紧急联系电话',
+  `invoice_title` varchar(200) DEFAULT NULL COMMENT '发票抬头',
+  `tax_no` varchar(50) DEFAULT NULL COMMENT '税号',
+  `bank_name` varchar(100) DEFAULT NULL COMMENT '银行名称',
+  `bank_account` varchar(50) DEFAULT NULL COMMENT '银行账号',
+  `invoice_address_phone` varchar(200) DEFAULT NULL COMMENT '发票地址电话',
+  `credit_status` varchar(20) NOT NULL DEFAULT 'NORMAL' COMMENT '挂账状态:NORMAL-正常挂账, SUSPENDED-暂停挂账, BLACKLIST-黑名单',
+  `total_arrears_amount` bigint DEFAULT 0 COMMENT '累计挂账金额(单位:分)',
+  `unsettled_bill_count` int DEFAULT 0 COMMENT '未结账单数',
+  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
+  `del_flag` bigint NOT NULL DEFAULT 0 COMMENT '删除标记(0=未删除,删除时写入主键ID)',
+  `create_by` bigint DEFAULT NULL COMMENT '创建人ID',
+  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+  `create_dept` bigint DEFAULT NULL COMMENT '创建部门ID',
+  `update_by` bigint DEFAULT NULL COMMENT '更新人ID',
+  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_mobile` (`mobile`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='挂账客户表';
+
+-- ==================== 2. 字典数据 ====================
+
+-- 订单状态字典
+INSERT INTO sys_dict_type (tenant_id, dict_name, dict_type, dict_status, remark, create_time, update_time)
+SELECT 1, '餐厅订单状态', 'restaurant_order_status', 1, '点菜订单状态', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'restaurant_order_status' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 1, '用餐中', 'ORDERING', 'restaurant_order_status', NULL, 'primary', 'N', 1, '用餐中', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'restaurant_order_status' AND dict_value = 'ORDERING' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 2, '已结账', 'CHECKED_OUT', 'restaurant_order_status', NULL, 'success', 'N', 1, '已结账', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'restaurant_order_status' AND dict_value = 'CHECKED_OUT' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 3, '已取消', 'CANCELLED', 'restaurant_order_status', NULL, 'default', 'N', 1, '已取消', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'restaurant_order_status' AND dict_value = 'CANCELLED' AND tenant_id = 1);
+
+-- 结账方式字典
+INSERT INTO sys_dict_type (tenant_id, dict_name, dict_type, dict_status, remark, create_time, update_time)
+SELECT 1, '餐厅结账方式', 'restaurant_settle_type', 1, '点菜订单结账方式', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'restaurant_settle_type' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 1, '普通结账', 'NORMAL', 'restaurant_settle_type', NULL, 'default', 'N', 1, '普通结账', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'restaurant_settle_type' AND dict_value = 'NORMAL' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 2, '折扣结账', 'DISCOUNT', 'restaurant_settle_type', NULL, 'warning', 'N', 1, '折扣结账', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'restaurant_settle_type' AND dict_value = 'DISCOUNT' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 3, '优惠结账', 'PROMOTION', 'restaurant_settle_type', NULL, 'info', 'N', 1, '优惠结账', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'restaurant_settle_type' AND dict_value = 'PROMOTION' AND tenant_id = 1);
+
+-- 挂账状态字典
+INSERT INTO sys_dict_type (tenant_id, dict_name, dict_type, dict_status, remark, create_time, update_time)
+SELECT 1, '餐厅挂账状态', 'restaurant_credit_status', 1, '挂账客户状态', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'restaurant_credit_status' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 1, '正常挂账', 'NORMAL', 'restaurant_credit_status', NULL, 'success', 'N', 1, '正常挂账', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'restaurant_credit_status' AND dict_value = 'NORMAL' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 2, '暂停挂账', 'SUSPENDED', 'restaurant_credit_status', NULL, 'warning', 'N', 1, '暂停挂账', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'restaurant_credit_status' AND dict_value = 'SUSPENDED' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 3, '黑名单', 'BLACKLIST', 'restaurant_credit_status', NULL, 'error', 'N', 1, '黑名单', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'restaurant_credit_status' AND dict_value = 'BLACKLIST' AND tenant_id = 1);
+
+-- ==================== 3. 菜单权限 ====================
+
+-- 获取「餐厅管理」一级菜单ID
+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, 5, '/restaurant/dishOrderIndex', 'restaurant/dishOrderIndex', 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 resource_name = '点菜订单' AND parent_id = @restaurant_parent_id AND tenant_id = 1);
+
+SET @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, '订单查询', @order_menu_id, 3, 1, 'restaurant:order:query', 0, 0, 1, 1, '查询订单', NOW(), NOW()
+WHERE @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, '订单下单', @order_menu_id, 3, 2, 'restaurant:order:submit', 0, 0, 1, 1, '下单/加菜/退菜/结账', NOW(), NOW()
+WHERE @order_menu_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'restaurant:order:submit');
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '订单取消', @order_menu_id, 3, 3, 'restaurant:order:cancel', 0, 0, 1, 1, '取消订单', NOW(), NOW()
+WHERE @order_menu_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'restaurant:order:cancel');
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '订单导出', @order_menu_id, 3, 4, 'restaurant:order:export', 0, 0, 1, 1, '导出订单Excel', NOW(), NOW()
+WHERE @order_menu_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'restaurant:order:export');
+
+-- 点菜详情页(隐藏路由,从入口页带 roomId 跳转)
+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, 98, '/restaurant/dishOrder', 'restaurant/dishOrder', 0, 0, 1, 0, '', 0, 0, '点菜详情页(隐藏路由)', NOW(), NOW()
+WHERE @restaurant_parent_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE path = '/restaurant/dishOrder' AND tenant_id = 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, 99, '/restaurant/dishOrderAdd', 'restaurant/dishOrderAdd', 0, 0, 1, 0, '', 0, 0, '加菜页面(隐藏路由)', NOW(), NOW()
+WHERE @restaurant_parent_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE path = '/restaurant/dishOrderAdd' AND tenant_id = 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, 6, '/restaurant/creditCustomer', 'restaurant/creditCustomer', 0, 0, 1, 1, 'mdi:account-cash', 0, 0, '挂账客户管理', NOW(), NOW()
+WHERE @restaurant_parent_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE resource_name = '挂账客户' AND parent_id = @restaurant_parent_id AND tenant_id = 1);
+
+SET @credit_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, '挂账查询', @credit_menu_id, 3, 1, 'restaurant:credit:query', 0, 0, 1, 1, '查询挂账客户', NOW(), NOW()
+WHERE @credit_menu_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'restaurant:credit: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, '挂账新增', @credit_menu_id, 3, 2, 'restaurant:credit:add', 0, 0, 1, 1, '新增挂账客户', NOW(), NOW()
+WHERE @credit_menu_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'restaurant:credit:add');
+
+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, '挂账编辑', @credit_menu_id, 3, 3, 'restaurant:credit:edit', 0, 0, 1, 1, '编辑挂账客户', NOW(), NOW()
+WHERE @credit_menu_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'restaurant:credit:edit');
+
+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, '挂账删除', @credit_menu_id, 3, 4, 'restaurant:credit:delete', 0, 0, 1, 1, '删除挂账客户', NOW(), NOW()
+WHERE @credit_menu_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'restaurant:credit:delete');

+ 215 - 0
forge-server/db/migration/V1.0.130__change_restaurant_price_from_cents_to_yuan.sql

@@ -0,0 +1,215 @@
+-- =====================================================================
+-- V1.0.130: 餐厅价格字段单位从"分"改为"元"
+-- 1. 列类型从 bigint 改为 decimal(18,2)
+-- 2. 已有数据除以 100(分 → 元)
+-- 3. 更新列注释
+-- =====================================================================
+
+-- ------------------- restaurant_dish_library -------------------
+
+-- sale_price: bigint → decimal(18,2),数据 /100
+SET @col_exists = (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'restaurant_dish_library' AND COLUMN_NAME = 'sale_price');
+SET @is_bigint = (SELECT DATA_TYPE FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'restaurant_dish_library' AND COLUMN_NAME = 'sale_price');
+
+SET @sql = IF(@col_exists > 0 AND @is_bigint = 'bigint',
+    'UPDATE restaurant_dish_library SET sale_price = sale_price / 100 WHERE sale_price IS NOT NULL AND sale_price != 0',
+    'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql = IF(@col_exists > 0 AND @is_bigint = 'bigint',
+    'ALTER TABLE restaurant_dish_library MODIFY COLUMN sale_price decimal(18,2) DEFAULT NULL COMMENT ''销售价(单位:元)''',
+    'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- cost_price: bigint → decimal(18,2),数据 /100
+SET @col_exists = (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'restaurant_dish_library' AND COLUMN_NAME = 'cost_price');
+SET @is_bigint = (SELECT DATA_TYPE FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'restaurant_dish_library' AND COLUMN_NAME = 'cost_price');
+
+SET @sql = IF(@col_exists > 0 AND @is_bigint = 'bigint',
+    'UPDATE restaurant_dish_library SET cost_price = cost_price / 100 WHERE cost_price IS NOT NULL AND cost_price != 0',
+    'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql = IF(@col_exists > 0 AND @is_bigint = 'bigint',
+    'ALTER TABLE restaurant_dish_library MODIFY COLUMN cost_price decimal(18,2) DEFAULT NULL COMMENT ''参考成本价(单位:元)''',
+    'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- ------------------- restaurant_dish_menu_dish -------------------
+
+SET @col_exists = (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'restaurant_dish_menu_dish' AND COLUMN_NAME = 'sale_price');
+SET @is_bigint = (SELECT DATA_TYPE FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'restaurant_dish_menu_dish' AND COLUMN_NAME = 'sale_price');
+
+SET @sql = IF(@col_exists > 0 AND @is_bigint = 'bigint',
+    'UPDATE restaurant_dish_menu_dish SET sale_price = sale_price / 100 WHERE sale_price IS NOT NULL AND sale_price != 0',
+    'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql = IF(@col_exists > 0 AND @is_bigint = 'bigint',
+    'ALTER TABLE restaurant_dish_menu_dish MODIFY COLUMN sale_price decimal(18,2) DEFAULT NULL COMMENT ''菜单销售价(单位:元,覆盖菜品库价格)''',
+    'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- ------------------- restaurant_dish_order -------------------
+
+-- total_amount
+SET @col_exists = (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'restaurant_dish_order' AND COLUMN_NAME = 'total_amount');
+SET @is_bigint = (SELECT DATA_TYPE FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'restaurant_dish_order' AND COLUMN_NAME = 'total_amount');
+
+SET @sql = IF(@col_exists > 0 AND @is_bigint = 'bigint',
+    'UPDATE restaurant_dish_order SET total_amount = total_amount / 100 WHERE total_amount IS NOT NULL AND total_amount != 0',
+    'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql = IF(@col_exists > 0 AND @is_bigint = 'bigint',
+    'ALTER TABLE restaurant_dish_order MODIFY COLUMN total_amount decimal(18,2) DEFAULT 0 COMMENT ''总金额(单位:元)''',
+    'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- discount_amount
+SET @col_exists = (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'restaurant_dish_order' AND COLUMN_NAME = 'discount_amount');
+SET @is_bigint = (SELECT DATA_TYPE FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'restaurant_dish_order' AND COLUMN_NAME = 'discount_amount');
+
+SET @sql = IF(@col_exists > 0 AND @is_bigint = 'bigint',
+    'UPDATE restaurant_dish_order SET discount_amount = discount_amount / 100 WHERE discount_amount IS NOT NULL AND discount_amount != 0',
+    'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql = IF(@col_exists > 0 AND @is_bigint = 'bigint',
+    'ALTER TABLE restaurant_dish_order MODIFY COLUMN discount_amount decimal(18,2) DEFAULT 0 COMMENT ''优惠金额(单位:元)''',
+    'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- payable_amount
+SET @col_exists = (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'restaurant_dish_order' AND COLUMN_NAME = 'payable_amount');
+SET @is_bigint = (SELECT DATA_TYPE FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'restaurant_dish_order' AND COLUMN_NAME = 'payable_amount');
+
+SET @sql = IF(@col_exists > 0 AND @is_bigint = 'bigint',
+    'UPDATE restaurant_dish_order SET payable_amount = payable_amount / 100 WHERE payable_amount IS NOT NULL AND payable_amount != 0',
+    'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql = IF(@col_exists > 0 AND @is_bigint = 'bigint',
+    'ALTER TABLE restaurant_dish_order MODIFY COLUMN payable_amount decimal(18,2) DEFAULT 0 COMMENT ''实收金额(单位:元)''',
+    'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- ------------------- restaurant_dish_order_detail -------------------
+
+-- sale_price
+SET @col_exists = (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'restaurant_dish_order_detail' AND COLUMN_NAME = 'sale_price');
+SET @is_bigint = (SELECT DATA_TYPE FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'restaurant_dish_order_detail' AND COLUMN_NAME = 'sale_price');
+
+SET @sql = IF(@col_exists > 0 AND @is_bigint = 'bigint',
+    'UPDATE restaurant_dish_order_detail SET sale_price = sale_price / 100 WHERE sale_price IS NOT NULL AND sale_price != 0',
+    'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql = IF(@col_exists > 0 AND @is_bigint = 'bigint',
+    'ALTER TABLE restaurant_dish_order_detail MODIFY COLUMN sale_price decimal(18,2) DEFAULT NULL COMMENT ''单价(单位:元,来自菜单价)''',
+    'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- amount
+SET @col_exists = (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'restaurant_dish_order_detail' AND COLUMN_NAME = 'amount');
+SET @is_bigint = (SELECT DATA_TYPE FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'restaurant_dish_order_detail' AND COLUMN_NAME = 'amount');
+
+SET @sql = IF(@col_exists > 0 AND @is_bigint = 'bigint',
+    'UPDATE restaurant_dish_order_detail SET amount = amount / 100 WHERE amount IS NOT NULL AND amount != 0',
+    'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql = IF(@col_exists > 0 AND @is_bigint = 'bigint',
+    'ALTER TABLE restaurant_dish_order_detail MODIFY COLUMN amount decimal(18,2) DEFAULT NULL COMMENT ''金额(单位:元)''',
+    'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- ------------------- restaurant_dish_room -------------------
+
+SET @col_exists = (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'restaurant_dish_room' AND COLUMN_NAME = 'min_consumption');
+SET @is_bigint = (SELECT DATA_TYPE FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'restaurant_dish_room' AND COLUMN_NAME = 'min_consumption');
+
+SET @sql = IF(@col_exists > 0 AND @is_bigint = 'bigint',
+    'UPDATE restaurant_dish_room SET min_consumption = min_consumption / 100 WHERE min_consumption IS NOT NULL AND min_consumption != 0',
+    'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql = IF(@col_exists > 0 AND @is_bigint = 'bigint',
+    'ALTER TABLE restaurant_dish_room MODIFY COLUMN min_consumption decimal(18,2) DEFAULT NULL COMMENT ''最低消费(单位:元)''',
+    'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- ------------------- restaurant_credit_customer -------------------
+
+SET @col_exists = (SELECT COUNT(*) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'restaurant_credit_customer' AND COLUMN_NAME = 'total_arrears_amount');
+SET @is_bigint = (SELECT DATA_TYPE FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'restaurant_credit_customer' AND COLUMN_NAME = 'total_arrears_amount');
+
+SET @sql = IF(@col_exists > 0 AND @is_bigint = 'bigint',
+    'UPDATE restaurant_credit_customer SET total_arrears_amount = total_arrears_amount / 100 WHERE total_arrears_amount IS NOT NULL AND total_arrears_amount != 0',
+    'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+SET @sql = IF(@col_exists > 0 AND @is_bigint = 'bigint',
+    'ALTER TABLE restaurant_credit_customer MODIFY COLUMN total_arrears_amount decimal(18,2) DEFAULT 0 COMMENT ''累计挂账金额(单位:元)''',
+    'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;

+ 26 - 0
forge-server/db/migration/V1.0.131__add_special_requirement_dict.sql

@@ -0,0 +1,26 @@
+-- ==================== 特殊要求字典 ====================
+
+-- 字典类型
+INSERT INTO sys_dict_type (tenant_id, dict_name, dict_type, dict_status, remark, create_time, update_time)
+SELECT 1, '餐厅特殊要求', 'special_requirement', 1, '点菜订单特殊要求选项', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'special_requirement' AND tenant_id = 1);
+
+-- 不要香菜
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 1, '不要香菜', 'NO_CILANTRO', 'special_requirement', NULL, 'default', 'N', 1, '不要香菜', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'special_requirement' AND dict_value = 'NO_CILANTRO' AND tenant_id = 1);
+
+-- 不要辣
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 2, '不要辣', 'NO_SPICY', 'special_requirement', NULL, 'default', 'N', 1, '不要辣', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'special_requirement' AND dict_value = 'NO_SPICY' AND tenant_id = 1);
+
+-- 需要宝宝椅
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 3, '需要宝宝椅', 'NEED_BABY_CHAIR', 'special_requirement', NULL, 'default', 'N', 1, '需要宝宝椅', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'special_requirement' AND dict_value = 'NEED_BABY_CHAIR' AND tenant_id = 1);
+
+-- 需要围裙
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 4, '需要围裙', 'NEED_APRON', 'special_requirement', NULL, 'default', 'N', 1, '需要围裙', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'special_requirement' AND dict_value = 'NEED_APRON' AND tenant_id = 1);

+ 81 - 0
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/controller/CreditCustomerController.java

@@ -0,0 +1,81 @@
+package com.mdframe.forge.business.core.restaurant.controller;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.mdframe.forge.business.core.restaurant.dto.CreditCustomerDTO;
+import com.mdframe.forge.business.core.restaurant.service.CreditCustomerService;
+import com.mdframe.forge.business.core.restaurant.vo.CreditCustomerVO;
+import com.mdframe.forge.starter.core.annotation.log.OperationLog;
+import com.mdframe.forge.starter.core.domain.OperationType;
+import com.mdframe.forge.starter.core.domain.PageQuery;
+import com.mdframe.forge.starter.core.domain.RespInfo;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 挂账客户接口。
+ * <p>
+ * 提供挂账客户的分页列表、详情、新增、编辑、删除、状态切换等操作。
+ */
+@RestController
+@RequestMapping("/restaurant/creditCustomer")
+public class CreditCustomerController {
+
+    @Autowired
+    private CreditCustomerService creditCustomerService;
+
+    /**
+     * 分页查询挂账客户列表。
+     */
+    @GetMapping("/page")
+    @OperationLog(module = "挂账客户", type = OperationType.QUERY, desc = "查询挂账客户列表")
+    public RespInfo<IPage<CreditCustomerVO>> creditPage(
+            PageQuery pageQuery,
+            @RequestParam(required = false) String customerName,
+            @RequestParam(required = false) String mobile,
+            @RequestParam(required = false) String creditStatus) {
+        return RespInfo.success(creditCustomerService.creditPage(pageQuery, customerName, mobile, creditStatus));
+    }
+
+    /**
+     * 查询挂账客户详情。
+     */
+    @GetMapping("/detail")
+    @OperationLog(module = "挂账客户", type = OperationType.QUERY, desc = "查询挂账客户详情")
+    public RespInfo<CreditCustomerVO> creditDetail(@RequestParam Long id) {
+        return RespInfo.success(creditCustomerService.creditDetail(id));
+    }
+
+    /**
+     * 新增/修改挂账客户。
+     */
+    @PostMapping("/save")
+    @OperationLog(module = "挂账客户", type = OperationType.ADD, desc = "保存挂账客户")
+    public RespInfo<Long> creditSave(@RequestBody CreditCustomerDTO dto) {
+        return RespInfo.success(creditCustomerService.creditSave(dto));
+    }
+
+    /**
+     * 删除挂账客户(逻辑删除)。
+     */
+    @PostMapping("/remove")
+    @OperationLog(module = "挂账客户", type = OperationType.DELETE, desc = "删除挂账客户")
+    public RespInfo<Void> creditDelete(@RequestParam Long id) {
+        creditCustomerService.creditDelete(id);
+        return RespInfo.success();
+    }
+
+    /**
+     * 切换挂账状态(正常→暂停→黑名单 循环切换)。
+     */
+    @PostMapping("/toggleStatus")
+    @OperationLog(module = "挂账客户", type = OperationType.UPDATE, desc = "切换挂账状态")
+    public RespInfo<Void> creditToggleStatus(@RequestParam Long id) {
+        creditCustomerService.creditToggleStatus(id);
+        return RespInfo.success();
+    }
+}

+ 84 - 0
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/controller/DishOrderController.java

@@ -0,0 +1,84 @@
+package com.mdframe.forge.business.core.restaurant.controller;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.mdframe.forge.business.core.restaurant.dto.DishOrderSubmitDTO;
+import com.mdframe.forge.business.core.restaurant.service.DishOrderService;
+import com.mdframe.forge.business.core.restaurant.vo.DishOrderDetailVO;
+import com.mdframe.forge.business.core.restaurant.vo.DishOrderVO;
+import com.mdframe.forge.starter.core.annotation.log.OperationLog;
+import com.mdframe.forge.starter.core.domain.OperationType;
+import com.mdframe.forge.starter.core.domain.PageQuery;
+import com.mdframe.forge.starter.core.domain.RespInfo;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 点菜订单接口。
+ * <p>
+ * 提供订单分页列表、详情、当前包房订单、提交订单(下单/加菜/退菜/结账)、取消订单等操作。
+ * 核心方法 submit 通过 settleFlag 区分操作类型。
+ */
+@RestController
+@RequestMapping("/restaurant/dishOrder")
+public class DishOrderController {
+
+    @Autowired
+    private DishOrderService dishOrderService;
+
+    /**
+     * 分页查询订单列表。
+     */
+    @GetMapping("/page")
+    @OperationLog(module = "点菜订单", type = OperationType.QUERY, desc = "查询订单列表")
+    public RespInfo<IPage<DishOrderVO>> orderPage(
+            PageQuery pageQuery,
+            @RequestParam(required = false) Long roomId,
+            @RequestParam(required = false) String orderStatus,
+            @RequestParam(required = false) String settleType) {
+        return RespInfo.success(dishOrderService.orderPage(pageQuery, roomId, orderStatus, settleType));
+    }
+
+    /**
+     * 查询订单详情(含三类明细列表)。
+     */
+    @GetMapping("/detail")
+    @OperationLog(module = "点菜订单", type = OperationType.QUERY, desc = "查询订单详情")
+    public RespInfo<DishOrderDetailVO> orderDetail(@RequestParam Long id) {
+        return RespInfo.success(dishOrderService.orderDetail(id));
+    }
+
+    /**
+     * 查询包房当前用餐中的订单。
+     */
+    @GetMapping("/current")
+    @OperationLog(module = "点菜订单", type = OperationType.QUERY, desc = "查询包房当前订单")
+    public RespInfo<DishOrderVO> currentOrder(@RequestParam Long roomId) {
+        return RespInfo.success(dishOrderService.currentOrder(roomId));
+    }
+
+    /**
+     * 提交订单(下单/加菜/退菜/结账/刷新金额)。
+     * <p>
+     * settleFlag 取值:ORDER-下单/加菜/退菜, CHECKOUT-结账, REFRESH-仅刷新金额。
+     */
+    @PostMapping("/submit")
+    @OperationLog(module = "点菜订单", type = OperationType.ADD, desc = "提交订单")
+    public RespInfo<String> submitOrder(@RequestBody DishOrderSubmitDTO dto) {
+        return RespInfo.success(dishOrderService.submitOrder(dto));
+    }
+
+    /**
+     * 取消订单(释放包房)。
+     */
+    @PostMapping("/cancel")
+    @OperationLog(module = "点菜订单", type = OperationType.UPDATE, desc = "取消订单")
+    public RespInfo<Void> cancelOrder(@RequestParam Long id) {
+        dishOrderService.cancelOrder(id);
+        return RespInfo.success();
+    }
+}

+ 97 - 0
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/domain/CreditCustomer.java

@@ -0,0 +1,97 @@
+package com.mdframe.forge.business.core.restaurant.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.mdframe.forge.starter.tenant.core.TenantEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.io.Serial;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+
+/**
+ * 挂账客户实体。
+ * <p>
+ * 手机号(mobile)全局唯一校验。
+ * 挂账状态:NORMAL-正常挂账, SUSPENDED-暂停挂账, BLACKLIST-黑名单。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("restaurant_credit_customer")
+public class CreditCustomer extends TenantEntity {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /** 主键ID(雪花算法) */
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 客户姓名 */
+    private String customerName;
+
+    /** 手机号(唯一校验) */
+    private String mobile;
+
+    /** 生日 */
+    private LocalDate birthday;
+
+    /** 身份证 */
+    private String idCard;
+
+    /** 职业 */
+    private String occupation;
+
+    /** 公司名称 */
+    private String companyName;
+
+    /** 地址 */
+    private String address;
+
+    /** 紧急联系人 */
+    private String emergencyContact;
+
+    /** 紧急联系电话 */
+    private String emergencyContactPhone;
+
+    /** 发票抬头 */
+    private String invoiceTitle;
+
+    /** 税号 */
+    private String taxNo;
+
+    /** 银行名称 */
+    private String bankName;
+
+    /** 银行账号 */
+    private String bankAccount;
+
+    /** 发票地址电话 */
+    private String invoiceAddressPhone;
+
+    /** 挂账状态:NORMAL-正常挂账, SUSPENDED-暂停挂账, BLACKLIST-黑名单 */
+    private String creditStatus;
+
+    /** 累计挂账金额(单位:元) */
+    private BigDecimal totalArrearsAmount;
+
+    /** 未结账单数 */
+    private Integer unsettledBillCount;
+
+    /** 备注 */
+    private String remark;
+
+    /** 删除标记(0=未删除,删除时写入主键ID) */
+    @TableLogic(value = "0", delval = "id")
+    private Long delFlag;
+
+    // ==================== 虚拟字段(不映射数据库列) ====================
+
+    /** 创建人姓名 */
+    @TableField(exist = false)
+    private String createName;
+}

+ 6 - 5
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/domain/DishLibrary.java

@@ -9,13 +9,14 @@ import lombok.Data;
 import lombok.EqualsAndHashCode;
 import lombok.EqualsAndHashCode;
 
 
 import java.io.Serial;
 import java.io.Serial;
+import java.math.BigDecimal;
 
 
 /**
 /**
  * 菜品库实体。
  * 菜品库实体。
  * <p>
  * <p>
  * 存储所有菜品的基础信息,每个菜品必须归属一个最子级分类。
  * 存储所有菜品的基础信息,每个菜品必须归属一个最子级分类。
  * 菜品编码(dishCode)自动生成,格式为 CP+时间戳,全局唯一。
  * 菜品编码(dishCode)自动生成,格式为 CP+时间戳,全局唯一。
- * 价格字段统一使用分为单位(bigint),前端展示时除以 100 转换为元。
+ * 价格字段统一使用元为单位(decimal)。
  */
  */
 @Data
 @Data
 @EqualsAndHashCode(callSuper = true)
 @EqualsAndHashCode(callSuper = true)
@@ -47,11 +48,11 @@ public class DishLibrary extends TenantEntity {
     /** 规格(如:大份、小份) */
     /** 规格(如:大份、小份) */
     private String spec;
     private String spec;
 
 
-    /** 销售价(单位:分) */
-    private Long salePrice;
+    /** 销售价(单位:元) */
+    private BigDecimal salePrice;
 
 
-    /** 参考成本价(单位:分) */
-    private Long costPrice;
+    /** 参考成本价(单位:元) */
+    private BigDecimal costPrice;
 
 
     /** 口味(如:微辣、酸甜) */
     /** 口味(如:微辣、酸甜) */
     private String taste;
     private String taste;

+ 5 - 4
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/domain/DishMenuDish.java

@@ -10,6 +10,7 @@ import lombok.Data;
 import lombok.EqualsAndHashCode;
 import lombok.EqualsAndHashCode;
 
 
 import java.io.Serial;
 import java.io.Serial;
+import java.math.BigDecimal;
 import java.time.LocalDateTime;
 import java.time.LocalDateTime;
 
 
 /**
 /**
@@ -36,8 +37,8 @@ public class DishMenuDish extends TenantEntity {
     /** 菜品ID */
     /** 菜品ID */
     private Long dishId;
     private Long dishId;
 
 
-    /** 菜单销售价(单位:分,覆盖菜品库价格) */
-    private Long salePrice;
+    /** 菜单销售价(单位:元,覆盖菜品库价格) */
+    private BigDecimal salePrice;
 
 
     /** 排序号 */
     /** 排序号 */
     private Integer sort;
     private Integer sort;
@@ -93,9 +94,9 @@ public class DishMenuDish extends TenantEntity {
     @TableField(exist = false)
     @TableField(exist = false)
     private String status;
     private String status;
 
 
-    /** 菜品库销售价(单位:分) */
+    /** 菜品库销售价(单位:元) */
     @TableField(exist = false)
     @TableField(exist = false)
-    private Long librarySalePrice;
+    private BigDecimal librarySalePrice;
 
 
     /** 创建人姓名 */
     /** 创建人姓名 */
     @TableField(exist = false)
     @TableField(exist = false)

+ 96 - 0
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/domain/DishOrder.java

@@ -0,0 +1,96 @@
+package com.mdframe.forge.business.core.restaurant.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.mdframe.forge.starter.tenant.core.TenantEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.io.Serial;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 点菜订单实体。
+ * <p>
+ * 订单号(orderNo)自动生成,格式为 DC+时间戳。
+ * 冗余包房信息(roomNo/roomName),保证源数据变更后订单仍可读。
+ * 订单状态:ORDERING-用餐中, CHECKED_OUT-已结账, CANCELLED-已取消。
+ * 结账方式:NORMAL-普通, DISCOUNT-折扣, PROMOTION-优惠。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("restaurant_dish_order")
+public class DishOrder extends TenantEntity {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /** 主键ID(雪花算法) */
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 订单号(DC+yyyyMMddHHmmss) */
+    private String orderNo;
+
+    /** 包房ID */
+    private Long roomId;
+
+    /** 包房编号(冗余) */
+    private String roomNo;
+
+    /** 包房名称(冗余) */
+    private String roomName;
+
+    /** 订单状态:ORDERING-用餐中, CHECKED_OUT-已结账, CANCELLED-已取消 */
+    private String orderStatus;
+
+    /** 总金额(单位:元) */
+    private BigDecimal totalAmount;
+
+    /** 结账方式:NORMAL-普通, DISCOUNT-折扣, PROMOTION-优惠 */
+    private String settleType;
+
+    /** 折扣比例(如 80 表示 8 折) */
+    private Integer discountRate;
+
+    /** 优惠金额(单位:元) */
+    private BigDecimal discountAmount;
+
+    /** 实收金额(单位:元) */
+    private BigDecimal payableAmount;
+
+    /** 结账时间 */
+    private LocalDateTime settleTime;
+
+    /** 口味备注 */
+    private String tasteRemark;
+
+    /** 特殊要求 */
+    private String specialRequirements;
+
+    /** 备注 */
+    private String remark;
+
+    /** 删除标记(0=未删除,删除时写入主键ID) */
+    @TableLogic(value = "0", delval = "id")
+    private Long delFlag;
+
+    // ==================== 虚拟字段(不映射数据库列) ====================
+
+    /** 操作标志:ORDER-下单/加菜/退菜, CHECKOUT-结账, REFRESH-仅刷新金额 */
+    @TableField(exist = false)
+    private String settleFlag;
+
+    /** 提交的菜品明细列表 */
+    @TableField(exist = false)
+    private List<DishOrderDetail> detailList;
+
+    /** 创建人姓名 */
+    @TableField(exist = false)
+    private String createName;
+}

+ 91 - 0
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/domain/DishOrderDetail.java

@@ -0,0 +1,91 @@
+package com.mdframe.forge.business.core.restaurant.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.mdframe.forge.starter.tenant.core.TenantEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.io.Serial;
+import java.math.BigDecimal;
+
+/**
+ * 订单明细实体。
+ * <p>
+ * 退菜时 quantity 为负数,汇总通过 SUM(quantity) HAVING > 0 过滤已退完的菜品。
+ * 冗余菜品信息(dishCode/dishName/typeName 等),保证源数据变更后订单仍可读。
+ * salePrice 来自菜单价(覆盖菜品库价格),保证菜单调价不影响已有订单。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("restaurant_dish_order_detail")
+public class DishOrderDetail extends TenantEntity {
+
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    /** 主键ID(雪花算法) */
+    @TableId(value = "id", type = IdType.ASSIGN_ID)
+    private Long id;
+
+    /** 订单ID */
+    private Long orderId;
+
+    /** 包房ID */
+    private Long roomId;
+
+    /** 菜品ID */
+    private Long dishId;
+
+    /** 菜品编码(冗余) */
+    private String dishCode;
+
+    /** 菜品名称(冗余) */
+    private String dishName;
+
+    /** 分类ID(冗余) */
+    private Long typeId;
+
+    /** 分类名称(冗余) */
+    private String typeName;
+
+    /** 单位(冗余) */
+    private String unit;
+
+    /** 规格(冗余) */
+    private String spec;
+
+    /** 单价(单位:元,来自菜单价) */
+    private BigDecimal salePrice;
+
+    /** 数量(退菜时为负数) */
+    private Integer quantity;
+
+    /** 金额 = salePrice × quantity(单位:元) */
+    private BigDecimal amount;
+
+    /** 排序号 */
+    private Integer dishSort;
+
+    /** 是否加菜:0-否, 1-是 */
+    private String isAddDish;
+
+    /** 退菜原因 */
+    private String reason;
+
+    /** 备注 */
+    private String remark;
+
+    /** 删除标记(0=未删除,删除时写入主键ID) */
+    @TableLogic(value = "0", delval = "id")
+    private Long delFlag;
+
+    // ==================== 虚拟字段(不映射数据库列) ====================
+
+    /** 当前已汇总数量(由汇总查询计算) */
+    @TableField(exist = false)
+    private Integer currentQuantity;
+}

+ 3 - 2
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/domain/DishRoom.java

@@ -9,6 +9,7 @@ import lombok.Data;
 import lombok.EqualsAndHashCode;
 import lombok.EqualsAndHashCode;
 
 
 import java.io.Serial;
 import java.io.Serial;
+import java.math.BigDecimal;
 
 
 /**
 /**
  * 包房实体。
  * 包房实体。
@@ -38,8 +39,8 @@ public class DishRoom extends TenantEntity {
     /** 容纳人数 */
     /** 容纳人数 */
     private Integer capacity;
     private Integer capacity;
 
 
-    /** 最低消费(单位:分) */
-    private Long minConsumption;
+    /** 最低消费(单位:元) */
+    private BigDecimal minConsumption;
 
 
     /** 状态:ENABLED-启用, DISABLED-停用 */
     /** 状态:ENABLED-启用, DISABLED-停用 */
     private String status;
     private String status;

+ 58 - 0
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/dto/CreditCustomerDTO.java

@@ -0,0 +1,58 @@
+package com.mdframe.forge.business.core.restaurant.dto;
+
+import lombok.Data;
+
+/**
+ * 挂账客户请求 DTO。
+ */
+@Data
+public class CreditCustomerDTO {
+
+    /** 主键ID(修改时传入) */
+    private Long id;
+
+    /** 客户姓名 */
+    private String customerName;
+
+    /** 手机号 */
+    private String mobile;
+
+    /** 生日(yyyy-MM-dd) */
+    private String birthday;
+
+    /** 身份证 */
+    private String idCard;
+
+    /** 职业 */
+    private String occupation;
+
+    /** 公司名称 */
+    private String companyName;
+
+    /** 地址 */
+    private String address;
+
+    /** 紧急联系人 */
+    private String emergencyContact;
+
+    /** 紧急联系电话 */
+    private String emergencyContactPhone;
+
+    /** 发票抬头 */
+    private String invoiceTitle;
+
+    /** 税号 */
+    private String taxNo;
+
+    /** 银行名称 */
+    private String bankName;
+
+    /** 银行账号 */
+    private String bankAccount;
+
+    /** 发票地址电话 */
+    private String invoiceAddressPhone;
+
+    /** 备注 */
+    private String remark;
+}

+ 6 - 5
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/dto/DishLibraryDTO.java

@@ -4,13 +4,14 @@ import lombok.Data;
 
 
 import java.io.Serial;
 import java.io.Serial;
 import java.io.Serializable;
 import java.io.Serializable;
+import java.math.BigDecimal;
 
 
 /**
 /**
  * 菜品库请求DTO。
  * 菜品库请求DTO。
  * <p>
  * <p>
  * 用于新增和编辑菜品时传递参数。
  * 用于新增和编辑菜品时传递参数。
  * 新增时 id 留空,编辑时 id 必填。
  * 新增时 id 留空,编辑时 id 必填。
- * 价格字段统一使用分为单位。
+ * 价格字段统一使用元为单位。
  */
  */
 @Data
 @Data
 public class DishLibraryDTO implements Serializable {
 public class DishLibraryDTO implements Serializable {
@@ -39,11 +40,11 @@ public class DishLibraryDTO implements Serializable {
     /** 规格(如:大份、小份) */
     /** 规格(如:大份、小份) */
     private String spec;
     private String spec;
 
 
-    /** 销售价(单位:分) */
-    private Long salePrice;
+    /** 销售价(单位:元) */
+    private BigDecimal salePrice;
 
 
-    /** 参考成本价(单位:分) */
-    private Long costPrice;
+    /** 参考成本价(单位:元) */
+    private BigDecimal costPrice;
 
 
     /** 口味(如:微辣、酸甜) */
     /** 口味(如:微辣、酸甜) */
     private String taste;
     private String taste;

+ 3 - 2
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/dto/DishMenuAddDishDTO.java

@@ -6,6 +6,7 @@ import lombok.Data;
 
 
 import java.io.Serial;
 import java.io.Serial;
 import java.io.Serializable;
 import java.io.Serializable;
+import java.math.BigDecimal;
 
 
 /**
 /**
  * 添加菜品到菜单请求DTO。
  * 添加菜品到菜单请求DTO。
@@ -20,8 +21,8 @@ public class DishMenuAddDishDTO implements Serializable {
     @JsonSerialize(using = ToStringSerializer.class)
     @JsonSerialize(using = ToStringSerializer.class)
     private Long dishId;
     private Long dishId;
 
 
-    /** 菜单销售价(单位:分,不传则默认使用菜品库价格) */
-    private Long salePrice;
+    /** 菜单销售价(单位:元,不传则默认使用菜品库价格) */
+    private BigDecimal salePrice;
 
 
     /** 排序号 */
     /** 排序号 */
     private Integer sort;
     private Integer sort;

+ 3 - 2
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/dto/DishMenuPriceDTO.java

@@ -6,6 +6,7 @@ import lombok.Data;
 
 
 import java.io.Serial;
 import java.io.Serial;
 import java.io.Serializable;
 import java.io.Serializable;
+import java.math.BigDecimal;
 
 
 /**
 /**
  * 修改菜单菜品价格请求DTO。
  * 修改菜单菜品价格请求DTO。
@@ -20,6 +21,6 @@ public class DishMenuPriceDTO implements Serializable {
     @JsonSerialize(using = ToStringSerializer.class)
     @JsonSerialize(using = ToStringSerializer.class)
     private Long id;
     private Long id;
 
 
-    /** 新的菜单销售价(单位:分) */
-    private Long salePrice;
+    /** 新的菜单销售价(单位:元) */
+    private BigDecimal salePrice;
 }
 }

+ 51 - 0
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/dto/DishOrderSubmitDTO.java

@@ -0,0 +1,51 @@
+package com.mdframe.forge.business.core.restaurant.dto;
+
+import com.mdframe.forge.business.core.restaurant.domain.DishOrderDetail;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 订单提交 DTO。
+ * <p>
+ * 通过 settleFlag 区分操作类型:
+ * <ul>
+ *   <li>ORDER — 下单/加菜/退菜(detailList 中 quantity 正数为加菜,负数为退菜)</li>
+ *   <li>CHECKOUT — 结账(需填 settleType + discountRate/discountAmount)</li>
+ *   <li>REFRESH — 仅刷新金额</li>
+ * </ul>
+ */
+@Data
+public class DishOrderSubmitDTO {
+
+    /** 订单ID(已有订单时传入,新建订单时为空) */
+    private Long id;
+
+    /** 包房ID */
+    private Long roomId;
+
+    /** 操作标志:ORDER-下单/加菜/退菜, CHECKOUT-结账, REFRESH-仅刷新金额 */
+    private String settleFlag;
+
+    /** 结账方式:NORMAL-普通, DISCOUNT-折扣, PROMOTION-优惠 */
+    private String settleType;
+
+    /** 折扣比例(如 80 表示 8 折,settleType=DISCOUNT 时有效) */
+    private Integer discountRate;
+
+    /** 优惠金额(单位:元,settleType=PROMOTION 时有效) */
+    private BigDecimal discountAmount;
+
+    /** 口味备注 */
+    private String tasteRemark;
+
+    /** 特殊要求 */
+    private String specialRequirements;
+
+    /** 提交的菜品明细列表 */
+    private List<DishOrderDetail> detailList;
+
+    /** 排序数据(dishId → dishSort 映射) */
+    private List<DishOrderDetail> sortList;
+}

+ 3 - 1
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/dto/DishRoomDTO.java

@@ -2,6 +2,8 @@ package com.mdframe.forge.business.core.restaurant.dto;
 
 
 import lombok.Data;
 import lombok.Data;
 
 
+import java.math.BigDecimal;
+
 /**
 /**
  * 包房保存 DTO。
  * 包房保存 DTO。
  */
  */
@@ -21,7 +23,7 @@ public class DishRoomDTO {
     private Integer capacity;
     private Integer capacity;
 
 
     /** 最低消费(单位:元,前端传入) */
     /** 最低消费(单位:元,前端传入) */
-    private Long minConsumption;
+    private BigDecimal minConsumption;
 
 
     /** 状态:ENABLED-启用, DISABLED-停用 */
     /** 状态:ENABLED-启用, DISABLED-停用 */
     private String status;
     private String status;

+ 37 - 0
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/mapper/CreditCustomerMapper.java

@@ -0,0 +1,37 @@
+package com.mdframe.forge.business.core.restaurant.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.mdframe.forge.business.core.restaurant.domain.CreditCustomer;
+import com.mdframe.forge.business.core.restaurant.vo.CreditCustomerVO;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * 挂账客户 Mapper。
+ */
+@Mapper
+public interface CreditCustomerMapper extends BaseMapper<CreditCustomer> {
+
+    /**
+     * 分页查询挂账客户列表。
+     */
+    IPage<CreditCustomerVO> selectPage(Page<CreditCustomerVO> page,
+                                        @Param("tenantId") Long tenantId,
+                                        @Param("customerName") String customerName,
+                                        @Param("mobile") String mobile,
+                                        @Param("creditStatus") String creditStatus);
+
+    /**
+     * 查询挂账客户详情。
+     */
+    CreditCustomerVO selectDetail(@Param("tenantId") Long tenantId, @Param("id") Long id);
+
+    /**
+     * 检查手机号是否重复(排除自身)。
+     */
+    int checkMobileExists(@Param("tenantId") Long tenantId,
+                          @Param("mobile") String mobile,
+                          @Param("excludeId") Long excludeId);
+}

+ 47 - 0
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/mapper/DishOrderDetailMapper.java

@@ -0,0 +1,47 @@
+package com.mdframe.forge.business.core.restaurant.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.mdframe.forge.business.core.restaurant.domain.DishOrderDetail;
+import com.mdframe.forge.business.core.restaurant.vo.DishOrderItemVO;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 订单明细 Mapper。
+ * <p>
+ * 核心设计:退菜时 quantity 为负数,汇总通过 SUM(quantity) HAVING > 0 过滤已退完的菜品。
+ */
+@Mapper
+public interface DishOrderDetailMapper extends BaseMapper<DishOrderDetail> {
+
+    /**
+     * 按订单汇总查询(GROUP BY dish_id,SUM(quantity) HAVING > 0)。
+     */
+    List<DishOrderItemVO> findSummaryByOrderId(@Param("orderId") Long orderId);
+
+    /**
+     * 加菜列表(isAddDish='1' AND quantity > 0)。
+     */
+    List<DishOrderItemVO> findAddDishList(@Param("orderId") Long orderId);
+
+    /**
+     * 退菜列表(quantity < 0)。
+     */
+    List<DishOrderItemVO> findReduceDishList(@Param("orderId") Long orderId);
+
+    /**
+     * 按订单+菜品查询当前汇总数量(SUM(quantity))。
+     * 返回 List,每个元素包含 dish_id 和 current_quantity。
+     */
+    List<Map<String, Object>> getCurrentQuantityMap(@Param("orderId") Long orderId);
+
+    /**
+     * 批量更新排序号。
+     */
+    void updateDishSort(@Param("orderId") Long orderId,
+                        @Param("dishId") Long dishId,
+                        @Param("dishSort") Integer dishSort);
+}

+ 35 - 0
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/mapper/DishOrderMapper.java

@@ -0,0 +1,35 @@
+package com.mdframe.forge.business.core.restaurant.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.mdframe.forge.business.core.restaurant.domain.DishOrder;
+import com.mdframe.forge.business.core.restaurant.vo.DishOrderVO;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * 点菜订单 Mapper。
+ */
+@Mapper
+public interface DishOrderMapper extends BaseMapper<DishOrder> {
+
+    /**
+     * 分页查询订单列表。
+     */
+    IPage<DishOrderVO> selectPage(Page<DishOrderVO> page,
+                                   @Param("tenantId") Long tenantId,
+                                   @Param("roomId") Long roomId,
+                                   @Param("orderStatus") String orderStatus,
+                                   @Param("settleType") String settleType);
+
+    /**
+     * 查询订单详情(含创建人姓名)。
+     */
+    DishOrderVO selectDetail(@Param("tenantId") Long tenantId, @Param("id") Long id);
+
+    /**
+     * 查询包房当前用餐中的订单。
+     */
+    DishOrder findCurrentByRoomId(@Param("tenantId") Long tenantId, @Param("roomId") Long roomId);
+}

+ 37 - 0
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/service/CreditCustomerService.java

@@ -0,0 +1,37 @@
+package com.mdframe.forge.business.core.restaurant.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.mdframe.forge.business.core.restaurant.dto.CreditCustomerDTO;
+import com.mdframe.forge.business.core.restaurant.vo.CreditCustomerVO;
+import com.mdframe.forge.starter.core.domain.PageQuery;
+
+/**
+ * 挂账客户服务接口。
+ */
+public interface CreditCustomerService {
+
+    /**
+     * 分页查询挂账客户列表。
+     */
+    IPage<CreditCustomerVO> creditPage(PageQuery pageQuery, String customerName, String mobile, String creditStatus);
+
+    /**
+     * 查询挂账客户详情。
+     */
+    CreditCustomerVO creditDetail(Long id);
+
+    /**
+     * 新增/修改挂账客户。
+     */
+    Long creditSave(CreditCustomerDTO dto);
+
+    /**
+     * 删除挂账客户(逻辑删除)。
+     */
+    void creditDelete(Long id);
+
+    /**
+     * 切换挂账状态(正常/暂停/黑名单 循环切换)。
+     */
+    void creditToggleStatus(Long id);
+}

+ 47 - 0
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/service/DishOrderService.java

@@ -0,0 +1,47 @@
+package com.mdframe.forge.business.core.restaurant.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.mdframe.forge.business.core.restaurant.dto.DishOrderSubmitDTO;
+import com.mdframe.forge.business.core.restaurant.vo.DishOrderDetailVO;
+import com.mdframe.forge.business.core.restaurant.vo.DishOrderVO;
+import com.mdframe.forge.starter.core.domain.PageQuery;
+
+/**
+ * 点菜订单服务接口。
+ * <p>
+ * 核心方法 submitOrder 通过 settleFlag 区分操作类型:
+ * <ul>
+ *   <li>ORDER — 新建订单/加菜/退菜</li>
+ *   <li>CHECKOUT — 结账(3 种方式:普通/折扣/优惠)</li>
+ *   <li>REFRESH — 仅刷新金额</li>
+ * </ul>
+ */
+public interface DishOrderService {
+
+    /**
+     * 分页查询订单列表。
+     */
+    IPage<DishOrderVO> orderPage(PageQuery pageQuery, Long roomId, String orderStatus, String settleType);
+
+    /**
+     * 查询订单详情(含三类明细列表)。
+     */
+    DishOrderDetailVO orderDetail(Long id);
+
+    /**
+     * 查询包房当前用餐中的订单。
+     */
+    DishOrderVO currentOrder(Long roomId);
+
+    /**
+     * 提交订单(下单/加菜/退菜/结账/刷新金额)。
+     *
+     * @return 操作结果消息
+     */
+    String submitOrder(DishOrderSubmitDTO dto);
+
+    /**
+     * 取消订单(释放包房)。
+     */
+    void cancelOrder(Long id);
+}

+ 165 - 0
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/service/impl/CreditCustomerServiceImpl.java

@@ -0,0 +1,165 @@
+package com.mdframe.forge.business.core.restaurant.service.impl;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.mdframe.forge.business.core.restaurant.domain.CreditCustomer;
+import com.mdframe.forge.business.core.restaurant.dto.CreditCustomerDTO;
+import com.mdframe.forge.business.core.restaurant.mapper.CreditCustomerMapper;
+import com.mdframe.forge.business.core.restaurant.service.CreditCustomerService;
+import com.mdframe.forge.business.core.restaurant.vo.CreditCustomerVO;
+import com.mdframe.forge.starter.core.domain.PageQuery;
+import com.mdframe.forge.starter.core.exception.BusinessException;
+import com.mdframe.forge.starter.core.session.SessionHelper;
+import com.mdframe.forge.starter.tenant.context.TenantContextHolder;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.Objects;
+
+/**
+ * 挂账客户服务实现。
+ */
+@Slf4j
+@Service
+public class CreditCustomerServiceImpl implements CreditCustomerService {
+
+    @Autowired
+    private CreditCustomerMapper creditCustomerMapper;
+
+    @Override
+    public IPage<CreditCustomerVO> creditPage(PageQuery pageQuery, String customerName, String mobile, String creditStatus) {
+        Long tenantId = resolveTenantId();
+        Page<CreditCustomerVO> page = new Page<>(pageQuery.getPageNum(), pageQuery.getPageSize());
+        return creditCustomerMapper.selectPage(page, tenantId, customerName, mobile, creditStatus);
+    }
+
+    @Override
+    public CreditCustomerVO creditDetail(Long id) {
+        if (id == null) {
+            throw new BusinessException("客户ID不能为空");
+        }
+        Long tenantId = resolveTenantId();
+        CreditCustomerVO vo = creditCustomerMapper.selectDetail(tenantId, id);
+        if (vo == null) {
+            throw new BusinessException("挂账客户不存在");
+        }
+        return vo;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public Long creditSave(CreditCustomerDTO dto) {
+        if (dto == null) {
+            throw new BusinessException("客户参数不能为空");
+        }
+        if (!StringUtils.hasText(dto.getCustomerName())) {
+            throw new BusinessException("客户姓名不能为空");
+        }
+        Long tenantId = resolveTenantId();
+
+        // 校验手机号唯一性
+        if (StringUtils.hasText(dto.getMobile())) {
+            int mobileCount = creditCustomerMapper.checkMobileExists(tenantId, dto.getMobile(), dto.getId());
+            if (mobileCount > 0) {
+                throw new BusinessException("手机号已存在,请检查");
+            }
+        }
+
+        if (dto.getId() != null) {
+            // 修改
+            CreditCustomer entity = creditCustomerMapper.selectById(dto.getId());
+            if (entity == null || !Objects.equals(entity.getTenantId(), tenantId)) {
+                throw new BusinessException("挂账客户不存在");
+            }
+            BeanUtils.copyProperties(dto, entity, "id", "birthday");
+            // 处理生日字段(String → LocalDate)
+            if (StringUtils.hasText(dto.getBirthday())) {
+                entity.setBirthday(LocalDate.parse(dto.getBirthday()));
+            }
+            creditCustomerMapper.updateById(entity);
+            log.info("修改挂账客户: {} ({})", entity.getCustomerName(), entity.getMobile());
+        } else {
+            // 新增
+            CreditCustomer entity = new CreditCustomer();
+            BeanUtils.copyProperties(dto, entity, "birthday");
+            if (StringUtils.hasText(dto.getBirthday())) {
+                entity.setBirthday(LocalDate.parse(dto.getBirthday()));
+            }
+            entity.setTenantId(tenantId);
+            if (!StringUtils.hasText(entity.getCreditStatus())) {
+                entity.setCreditStatus("NORMAL");
+            }
+            if (entity.getTotalArrearsAmount() == null) {
+                entity.setTotalArrearsAmount(BigDecimal.valueOf(0L));
+            }
+            if (entity.getUnsettledBillCount() == null) {
+                entity.setUnsettledBillCount(0);
+            }
+            creditCustomerMapper.insert(entity);
+            log.info("新增挂账客户: {} ({})", entity.getCustomerName(), entity.getMobile());
+            return entity.getId();
+        }
+        return dto.getId();
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void creditDelete(Long id) {
+        if (id == null) {
+            throw new BusinessException("客户ID不能为空");
+        }
+        Long tenantId = resolveTenantId();
+        CreditCustomer entity = creditCustomerMapper.selectById(id);
+        if (entity == null || !Objects.equals(entity.getTenantId(), tenantId)) {
+            throw new BusinessException("挂账客户不存在");
+        }
+        creditCustomerMapper.deleteById(id);
+        log.info("删除挂账客户: {} ({})", entity.getCustomerName(), entity.getMobile());
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void creditToggleStatus(Long id) {
+        if (id == null) {
+            throw new BusinessException("客户ID不能为空");
+        }
+        Long tenantId = resolveTenantId();
+        CreditCustomer entity = creditCustomerMapper.selectById(id);
+        if (entity == null || !Objects.equals(entity.getTenantId(), tenantId)) {
+            throw new BusinessException("挂账客户不存在");
+        }
+
+        // 循环切换:NORMAL → SUSPENDED → BLACKLIST → NORMAL
+        String current = entity.getCreditStatus();
+        String next;
+        switch (current != null ? current : "NORMAL") {
+            case "NORMAL":
+                next = "SUSPENDED";
+                break;
+            case "SUSPENDED":
+                next = "BLACKLIST";
+                break;
+            default:
+                next = "NORMAL";
+                break;
+        }
+        entity.setCreditStatus(next);
+        creditCustomerMapper.updateById(entity);
+        log.info("切换挂账状态: {} -> {}", entity.getCustomerName(), next);
+    }
+
+    private Long resolveTenantId() {
+        Long tenantId = TenantContextHolder.getTenantId();
+        if (tenantId != null) {
+            return tenantId;
+        }
+        tenantId = SessionHelper.getTenantId();
+        return tenantId == null ? 1L : tenantId;
+    }
+}

+ 1 - 1
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/service/impl/DishLibraryServiceImpl.java

@@ -35,7 +35,7 @@ import java.util.Objects;
  * - 菜品编码自动生成(CP+时间戳),全局唯一
  * - 菜品编码自动生成(CP+时间戳),全局唯一
  * - 保存时校验分类必须启用且为最子级
  * - 保存时校验分类必须启用且为最子级
  * - 同分类下菜品名称不能重复
  * - 同分类下菜品名称不能重复
- * - 价格字段统一使用分为单位
+ * - 价格字段统一使用元为单位
  */
  */
 @Slf4j
 @Slf4j
 @Service
 @Service

+ 4 - 3
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/service/impl/DishMenuServiceImpl.java

@@ -23,6 +23,7 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.annotation.Transactional;
 
 
+import java.math.BigDecimal;
 import java.time.LocalDateTime;
 import java.time.LocalDateTime;
 import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeFormatter;
 import java.util.List;
 import java.util.List;
@@ -146,7 +147,7 @@ public class DishMenuServiceImpl implements DishMenuService {
             }
             }
 
 
             // 如果未指定价格,使用菜品库价格
             // 如果未指定价格,使用菜品库价格
-            Long salePrice = dto.getSalePrice();
+            BigDecimal salePrice = dto.getSalePrice();
             if (salePrice == null) {
             if (salePrice == null) {
                 DishLibrary dish = dishLibraryMapper.selectById(dto.getDishId());
                 DishLibrary dish = dishLibraryMapper.selectById(dto.getDishId());
                 if (dish != null) {
                 if (dish != null) {
@@ -204,14 +205,14 @@ public class DishMenuServiceImpl implements DishMenuService {
                 DishMenuDish existing = dishMenuDishMapper.findCurrentByDishId(tenantId, menuId, dto.getDishId());
                 DishMenuDish existing = dishMenuDishMapper.findCurrentByDishId(tenantId, menuId, dto.getDishId());
                 if (existing != null) {
                 if (existing != null) {
                     // 更新已有记录
                     // 更新已有记录
-                    Long salePrice = dto.getSalePrice() != null ? dto.getSalePrice() : existing.getSalePrice();
+                    BigDecimal salePrice = dto.getSalePrice() != null ? dto.getSalePrice() : existing.getSalePrice();
                     existing.setSalePrice(salePrice);
                     existing.setSalePrice(salePrice);
                     existing.setSort(dto.getSort() != null ? dto.getSort() : sort);
                     existing.setSort(dto.getSort() != null ? dto.getSort() : sort);
                     existing.setRemark(dto.getRemark());
                     existing.setRemark(dto.getRemark());
                     dishMenuDishMapper.updateById(existing);
                     dishMenuDishMapper.updateById(existing);
                 } else {
                 } else {
                     // 新增记录
                     // 新增记录
-                    Long salePrice = dto.getSalePrice();
+                    BigDecimal salePrice = dto.getSalePrice();
                     if (salePrice == null) {
                     if (salePrice == null) {
                         DishLibrary dish = dishLibraryMapper.selectById(dto.getDishId());
                         DishLibrary dish = dishLibraryMapper.selectById(dto.getDishId());
                         if (dish != null) {
                         if (dish != null) {

+ 524 - 0
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/service/impl/DishOrderServiceImpl.java

@@ -0,0 +1,524 @@
+package com.mdframe.forge.business.core.restaurant.service.impl;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.mdframe.forge.business.core.restaurant.domain.DishLibrary;
+import com.mdframe.forge.business.core.restaurant.domain.DishMenu;
+import com.mdframe.forge.business.core.restaurant.domain.DishMenuDish;
+import com.mdframe.forge.business.core.restaurant.domain.DishOrder;
+import com.mdframe.forge.business.core.restaurant.domain.DishOrderDetail;
+import com.mdframe.forge.business.core.restaurant.domain.DishRoom;
+import com.mdframe.forge.business.core.restaurant.dto.DishOrderSubmitDTO;
+import com.mdframe.forge.business.core.restaurant.mapper.DishLibraryMapper;
+import com.mdframe.forge.business.core.restaurant.mapper.DishMenuDishMapper;
+import com.mdframe.forge.business.core.restaurant.mapper.DishMenuMapper;
+import com.mdframe.forge.business.core.restaurant.mapper.DishOrderDetailMapper;
+import com.mdframe.forge.business.core.restaurant.mapper.DishOrderMapper;
+import com.mdframe.forge.business.core.restaurant.mapper.DishRoomMapper;
+import com.mdframe.forge.business.core.restaurant.mapper.DishTypeMapper;
+import com.mdframe.forge.business.core.restaurant.domain.DishType;
+import com.mdframe.forge.business.core.restaurant.service.DishOrderService;
+import com.mdframe.forge.business.core.restaurant.vo.DishOrderDetailVO;
+import com.mdframe.forge.business.core.restaurant.vo.DishOrderItemVO;
+import com.mdframe.forge.business.core.restaurant.vo.DishOrderVO;
+import com.mdframe.forge.starter.core.domain.PageQuery;
+import com.mdframe.forge.starter.core.exception.BusinessException;
+import com.mdframe.forge.starter.core.session.SessionHelper;
+import com.mdframe.forge.starter.tenant.context.TenantContextHolder;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.text.SimpleDateFormat;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * 点菜订单服务实现。
+ * <p>
+ * 核心状态机 — submitOrder 方法通过 settleFlag 区分操作类型:
+ * <ul>
+ *   <li>ORDER(settleFlag="ORDER"):新建订单/加菜/退菜
+ *     <ul>
+ *       <li>无现有订单 → 新建订单 + 占用包房</li>
+ *       <li>有现有订单 + quantity 正数 → 加菜</li>
+ *       <li>有现有订单 + quantity 负数 → 退菜</li>
+ *     </ul>
+ *   </li>
+ *   <li>CHECKOUT(settleFlag="CHECKOUT"):结账
+ *     <ul>
+ *       <li>NORMAL — 普通结账:payableAmount = totalAmount</li>
+ *       <li>DISCOUNT — 折扣结账:payableAmount = totalAmount × discountRate / 100</li>
+ *       <li>PROMOTION — 优惠结账:payableAmount = totalAmount - discountAmount</li>
+ *     </ul>
+ *   </li>
+ *   <li>REFRESH(settleFlag="REFRESH"):仅刷新金额</li>
+ * </ul>
+ * <p>
+ * 退菜设计:插入 quantity 为负数的明细记录,汇总通过 SUM(quantity) HAVING > 0 过滤。
+ */
+@Slf4j
+@Service
+public class DishOrderServiceImpl implements DishOrderService {
+
+    @Autowired
+    private DishOrderMapper dishOrderMapper;
+
+    @Autowired
+    private DishOrderDetailMapper dishOrderDetailMapper;
+
+    @Autowired
+    private DishRoomMapper dishRoomMapper;
+
+    @Autowired
+    private DishMenuMapper dishMenuMapper;
+
+    @Autowired
+    private DishMenuDishMapper dishMenuDishMapper;
+
+    @Autowired
+    private DishLibraryMapper dishLibraryMapper;
+
+    @Autowired
+    private DishTypeMapper dishTypeMapper;
+
+    @Override
+    public IPage<DishOrderVO> orderPage(PageQuery pageQuery, Long roomId, String orderStatus, String settleType) {
+        Long tenantId = resolveTenantId();
+        Page<DishOrderVO> page = new Page<>(pageQuery.getPageNum(), pageQuery.getPageSize());
+        return dishOrderMapper.selectPage(page, tenantId, roomId, orderStatus, settleType);
+    }
+
+    @Override
+    public DishOrderDetailVO orderDetail(Long id) {
+        if (id == null) {
+            throw new BusinessException("订单ID不能为空");
+        }
+        Long tenantId = resolveTenantId();
+        DishOrderVO orderVO = dishOrderMapper.selectDetail(tenantId, id);
+        if (orderVO == null) {
+            throw new BusinessException("订单不存在");
+        }
+
+        // 构建详情 VO(含三类明细列表)
+        DishOrderDetailVO detailVO = new DishOrderDetailVO();
+        detailVO.setId(orderVO.getId());
+        detailVO.setOrderNo(orderVO.getOrderNo());
+        detailVO.setRoomId(orderVO.getRoomId());
+        detailVO.setRoomNo(orderVO.getRoomNo());
+        detailVO.setRoomName(orderVO.getRoomName());
+        detailVO.setOrderStatus(orderVO.getOrderStatus());
+        detailVO.setTotalAmount(orderVO.getTotalAmount());
+        detailVO.setSettleType(orderVO.getSettleType());
+        detailVO.setDiscountRate(orderVO.getDiscountRate());
+        detailVO.setDiscountAmount(orderVO.getDiscountAmount());
+        detailVO.setPayableAmount(orderVO.getPayableAmount());
+        detailVO.setSettleTime(orderVO.getSettleTime());
+        detailVO.setTasteRemark(orderVO.getTasteRemark());
+        detailVO.setSpecialRequirements(orderVO.getSpecialRequirements());
+        detailVO.setRemark(orderVO.getRemark());
+        detailVO.setCreateTime(orderVO.getCreateTime());
+        detailVO.setCreateName(orderVO.getCreateName());
+
+        // 三类明细列表
+        detailVO.setDetailList(dishOrderDetailMapper.findSummaryByOrderId(id));
+        detailVO.setAddDishList(dishOrderDetailMapper.findAddDishList(id));
+        detailVO.setReduceDishList(dishOrderDetailMapper.findReduceDishList(id));
+
+        return detailVO;
+    }
+
+    @Override
+    public DishOrderVO currentOrder(Long roomId) {
+        if (roomId == null) {
+            throw new BusinessException("包房ID不能为空");
+        }
+        Long tenantId = resolveTenantId();
+        DishOrder order = dishOrderMapper.findCurrentByRoomId(tenantId, roomId);
+        if (order == null) {
+            return null;
+        }
+        DishOrderVO orderVO = dishOrderMapper.selectDetail(tenantId, order.getId());
+        // 填充订单明细列表(汇总后的菜品)
+        orderVO.setDetailList(dishOrderDetailMapper.findSummaryByOrderId(order.getId()));
+        return orderVO;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public String submitOrder(DishOrderSubmitDTO dto) {
+        if (dto == null) {
+            throw new BusinessException("订单参数不能为空");
+        }
+        if (dto.getRoomId() == null) {
+            throw new BusinessException("包房ID不能为空");
+        }
+
+        Long tenantId = resolveTenantId();
+        String settleFlag = dto.getSettleFlag();
+        if (settleFlag == null) {
+            settleFlag = "ORDER";
+        }
+
+        // 校验包房
+        DishRoom room = dishRoomMapper.selectById(dto.getRoomId());
+        if (room == null || !Objects.equals(room.getTenantId(), tenantId)) {
+            throw new BusinessException("包房不存在");
+        }
+        if (!"ENABLED".equals(room.getStatus())) {
+            throw new BusinessException("包房已停用");
+        }
+
+        // 获取当前菜单(点菜必须基于当前菜单)
+        DishMenu currentMenu = dishMenuMapper.findCurrentMenu(tenantId);
+        Long menuId = currentMenu != null ? currentMenu.getId() : null;
+
+        // 查找包房当前订单
+        DishOrder order = dishOrderMapper.findCurrentByRoomId(tenantId, dto.getRoomId());
+
+        // 结账时如果找不到当前订单,尝试按 ID 查找(可能已被其他操作处理)
+        if (order == null && "CHECKOUT".equals(settleFlag) && dto.getId() != null) {
+            order = dishOrderMapper.selectById(dto.getId());
+        }
+
+        // 收集有效的明细数据
+        List<DishOrderDetail> detailList = dto.getDetailList();
+        if (detailList == null) {
+            detailList = new ArrayList<>();
+        }
+
+        // 过滤有效明细(dishId 不为空且 quantity 不为 0)
+        List<DishOrderDetail> validDetails = new ArrayList<>();
+        for (DishOrderDetail d : detailList) {
+            if (d.getDishId() != null && d.getQuantity() != null && d.getQuantity() != 0) {
+                validDetails.add(d);
+            }
+        }
+
+        // 检查是否全部无效
+        if (validDetails.isEmpty()) {
+            // 有排序数据 → 仅保存排序
+            List<DishOrderDetail> sortList = dto.getSortList();
+            if (sortList != null && !sortList.isEmpty() && order != null) {
+                saveDishSort(order.getId(), sortList);
+                return "排序保存成功";
+            }
+            // 结账模式 → 仅刷新金额
+            if ("CHECKOUT".equals(settleFlag) && order != null) {
+                refreshOrderAmount(order.getId(), dto, tenantId);
+                return "结账成功";
+            }
+            throw new BusinessException("请选择菜品");
+        }
+
+        // 判断新建还是加菜
+        boolean addDish = false;
+        if (order == null) {
+            // 新建订单
+            order = createOrder(dto, room, tenantId);
+        } else {
+            // 已有订单 → 加菜模式
+            addDish = true;
+        }
+
+        // 获取当前各菜品的汇总数量(用于校验退菜不能超已点)
+        Map<Long, Integer> currentQtyMap = getCurrentQuantityMap(order.getId());
+
+        // 遍历有效明细,逐条插入
+        BigDecimal changeAmount = BigDecimal.ZERO;
+        for (DishOrderDetail detail : validDetails) {
+            Long dishId = detail.getDishId();
+            int quantity = detail.getQuantity();
+
+            // 校验退菜不能超出已点数量
+            Integer currentQty = currentQtyMap.get(dishId);
+            if (currentQty == null) {
+                currentQty = 0;
+            }
+            if (currentQty + quantity < 0) {
+                DishLibrary dish = dishLibraryMapper.selectById(dishId);
+                String dishName = dish != null ? dish.getDishName() : String.valueOf(dishId);
+                throw new BusinessException("退菜数量超出已点数量:" + dishName + "(当前" + currentQty + "份)");
+            }
+
+            // 从当前菜单获取菜品信息和菜单价
+            DishMenuDish menuDish = null;
+            if (menuId != null) {
+                menuDish = dishMenuDishMapper.findCurrentByDishId(tenantId, menuId, dishId);
+            }
+            if (menuDish == null) {
+                DishLibrary dish = dishLibraryMapper.selectById(dishId);
+                String dishName = dish != null ? dish.getDishName() : String.valueOf(dishId);
+                throw new BusinessException("菜品不在当前菜单中:" + dishName);
+            }
+
+            // 检查菜品是否启用
+            DishLibrary dish = dishLibraryMapper.selectById(dishId);
+            if (dish == null || !"ENABLED".equals(dish.getStatus())) {
+                throw new BusinessException("菜品已停用:" + (dish != null ? dish.getDishName() : String.valueOf(dishId)));
+            }
+
+            // 构建明细记录
+            DishOrderDetail newDetail = new DishOrderDetail();
+            newDetail.setOrderId(order.getId());
+            newDetail.setRoomId(room.getId());
+            newDetail.setDishId(dishId);
+            newDetail.setDishCode(dish.getDishCode());
+            newDetail.setDishName(dish.getDishName());
+            newDetail.setTypeId(dish.getTypeId());
+            // 查分类名称
+            newDetail.setTypeName(getTypeName(tenantId, dish.getTypeId()));
+            newDetail.setUnit(dish.getUnit());
+            newDetail.setSpec(dish.getSpec());
+            newDetail.setSalePrice(menuDish.getSalePrice());
+            newDetail.setQuantity(quantity);
+            newDetail.setAmount(menuDish.getSalePrice().multiply(BigDecimal.valueOf(quantity)));
+            newDetail.setDishSort(detail.getDishSort() != null ? detail.getDishSort() : 0);
+            newDetail.setIsAddDish(addDish ? "1" : "0");
+            newDetail.setReason(detail.getReason());
+            newDetail.setRemark(detail.getRemark());
+            newDetail.setTenantId(tenantId);
+            dishOrderDetailMapper.insert(newDetail);
+
+            changeAmount = changeAmount.add(newDetail.getAmount());
+        }
+
+        // 保存排序
+        List<DishOrderDetail> sortList = dto.getSortList();
+        if (sortList != null && !sortList.isEmpty()) {
+            saveDishSort(order.getId(), sortList);
+        }
+
+        // 刷新订单金额(结账时同时处理结账逻辑)
+        refreshOrderAmount(order.getId(), dto, tenantId);
+
+        // 返回消息
+        if ("CHECKOUT".equals(settleFlag)) {
+            return "结账成功";
+        } else if (addDish) {
+            // 判断是加菜还是退菜
+            boolean hasPositive = false;
+            boolean hasNegative = false;
+            for (DishOrderDetail d : validDetails) {
+                if (d.getQuantity() > 0) {
+                    hasPositive = true;
+                }
+                if (d.getQuantity() < 0) {
+                    hasNegative = true;
+                }
+            }
+            if (hasNegative && !hasPositive) {
+                return "退菜成功";
+            }
+            return "加菜成功";
+        }
+        return "下单成功";
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void cancelOrder(Long id) {
+        if (id == null) {
+            throw new BusinessException("订单ID不能为空");
+        }
+        Long tenantId = resolveTenantId();
+        DishOrder order = dishOrderMapper.selectById(id);
+        if (order == null || !Objects.equals(order.getTenantId(), tenantId)) {
+            throw new BusinessException("订单不存在");
+        }
+        if (!"ORDERING".equals(order.getOrderStatus())) {
+            throw new BusinessException("只有用餐中的订单才能取消");
+        }
+
+        // 更新订单状态为已取消
+        order.setOrderStatus("CANCELLED");
+        dishOrderMapper.updateById(order);
+
+        // 释放包房
+        DishRoom room = dishRoomMapper.selectById(order.getRoomId());
+        if (room != null && "IN_USE".equals(room.getUseStatus())) {
+            room.setUseStatus("IDLE");
+            dishRoomMapper.updateById(room);
+        }
+
+        log.info("取消订单: orderId={}, orderNo={}, roomId={}", id, order.getOrderNo(), order.getRoomId());
+    }
+
+    // ==================== 私有方法 ====================
+
+    /**
+     * 创建新订单。
+     */
+    private DishOrder createOrder(DishOrderSubmitDTO dto, DishRoom room, Long tenantId) {
+        DishOrder order = new DishOrder();
+        order.setOrderNo(genOrderNo());
+        order.setRoomId(room.getId());
+        order.setRoomNo(room.getRoomNo());
+        order.setRoomName(room.getRoomName());
+        order.setOrderStatus("ORDERING");
+        order.setTotalAmount(BigDecimal.ZERO);
+        order.setPayableAmount(BigDecimal.ZERO);
+        order.setDiscountRate(100);
+        order.setDiscountAmount(BigDecimal.ZERO);
+        order.setTasteRemark(dto.getTasteRemark());
+        order.setSpecialRequirements(dto.getSpecialRequirements());
+        order.setTenantId(tenantId);
+        dishOrderMapper.insert(order);
+
+        // 占用包房
+        room.setUseStatus("IN_USE");
+        dishRoomMapper.updateById(room);
+
+        log.info("新建订单: orderNo={}, roomId={}, roomName={}", order.getOrderNo(), room.getId(), room.getRoomName());
+        return order;
+    }
+
+    /**
+     * 刷新订单金额。
+     * <p>
+     * 汇总所有明细(SUM(amount) where quantity > 0),更新订单总额。
+     * 如果是结账操作,同时计算实收金额、更新状态、释放包房。
+     */
+    private void refreshOrderAmount(Long orderId, DishOrderSubmitDTO dto, Long tenantId) {
+        // 汇总明细
+        List<DishOrderItemVO> summaryList = dishOrderDetailMapper.findSummaryByOrderId(orderId);
+        BigDecimal totalAmount = BigDecimal.ZERO;
+        for (DishOrderItemVO item : summaryList) {
+            if (item.getAmount() != null) {
+                totalAmount = totalAmount.add(item.getAmount());
+            }
+        }
+
+        DishOrder update = dishOrderMapper.selectById(orderId);
+        update.setTotalAmount(totalAmount);
+
+        // 结账处理
+        if ("CHECKOUT".equals(dto.getSettleFlag())) {
+            fillSettleAmount(update, totalAmount, dto);
+            update.setOrderStatus("CHECKED_OUT");
+            update.setSettleTime(LocalDateTime.now());
+
+            // 释放包房
+            DishRoom room = dishRoomMapper.selectById(update.getRoomId());
+            if (room != null && "IN_USE".equals(room.getUseStatus())) {
+                room.setUseStatus("IDLE");
+                dishRoomMapper.updateById(room);
+            }
+        }
+
+        dishOrderMapper.updateById(update);
+    }
+
+    /**
+     * 计算结账金额。
+     * <ul>
+     *   <li>NORMAL — payableAmount = totalAmount</li>
+     *   <li>DISCOUNT — payableAmount = totalAmount × discountRate / 100</li>
+     *   <li>PROMOTION — payableAmount = totalAmount - discountAmount</li>
+     * </ul>
+     */
+    private void fillSettleAmount(DishOrder order, BigDecimal totalAmount, DishOrderSubmitDTO dto) {
+        String settleType = dto.getSettleType();
+        if (settleType == null) {
+            settleType = "NORMAL";
+        }
+
+        switch (settleType) {
+            case "DISCOUNT":
+                int discountRate = dto.getDiscountRate() != null ? dto.getDiscountRate() : 100;
+                if (discountRate < 0) {
+                    discountRate = 0;
+                }
+                if (discountRate > 100) {
+                    discountRate = 100;
+                }
+                order.setSettleType("DISCOUNT");
+                order.setDiscountRate(discountRate);
+                BigDecimal discountPayable = totalAmount.multiply(BigDecimal.valueOf(discountRate)).divide(BigDecimal.valueOf(100), 2, java.math.RoundingMode.HALF_UP);
+                order.setPayableAmount(discountPayable);
+                order.setDiscountAmount(totalAmount.subtract(discountPayable));
+                break;
+            case "PROMOTION":
+                BigDecimal discountAmount = dto.getDiscountAmount() != null ? dto.getDiscountAmount() : BigDecimal.ZERO;
+                if (discountAmount.compareTo(BigDecimal.ZERO) < 0) {
+                    discountAmount = BigDecimal.ZERO;
+                }
+                if (discountAmount.compareTo(totalAmount) > 0) {
+                    discountAmount = totalAmount;
+                }
+                order.setSettleType("PROMOTION");
+                order.setDiscountRate(100);
+                order.setPayableAmount(totalAmount.subtract(discountAmount));
+                order.setDiscountAmount(discountAmount);
+                break;
+            default:
+                order.setSettleType("NORMAL");
+                order.setDiscountRate(100);
+                order.setDiscountAmount(BigDecimal.ZERO);
+                order.setPayableAmount(totalAmount);
+                break;
+        }
+    }
+
+    /**
+     * 获取订单各菜品当前汇总数量。
+     */
+    private Map<Long, Integer> getCurrentQuantityMap(Long orderId) {
+        Map<Long, Integer> map = new HashMap<>();
+        List<Map<String, Object>> list = dishOrderDetailMapper.getCurrentQuantityMap(orderId);
+        for (Map<String, Object> row : list) {
+            Long dishId = ((Number) row.get("dish_id")).longValue();
+            int currentQty = ((Number) row.get("current_quantity")).intValue();
+            map.put(dishId, currentQty);
+        }
+        return map;
+    }
+
+    /**
+     * 保存排序。
+     */
+    private void saveDishSort(Long orderId, List<DishOrderDetail> sortList) {
+        for (DishOrderDetail s : sortList) {
+            if (s.getDishId() != null && s.getDishSort() != null) {
+                dishOrderDetailMapper.updateDishSort(orderId, s.getDishId(), s.getDishSort());
+            }
+        }
+    }
+
+    /**
+     * 获取分类名称。
+     */
+    private String getTypeName(Long tenantId, Long typeId) {
+        if (typeId == null) {
+            return null;
+        }
+        DishType dishType = dishTypeMapper.selectById(typeId);
+        if (dishType != null && Objects.equals(dishType.getTenantId(), tenantId)) {
+            return dishType.getName();
+        }
+        return null;
+    }
+
+    /**
+     * 生成订单号:DC + yyyyMMddHHmmss。
+     */
+    private String genOrderNo() {
+        return "DC" + new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
+    }
+
+    private Long resolveTenantId() {
+        Long tenantId = TenantContextHolder.getTenantId();
+        if (tenantId != null) {
+            return tenantId;
+        }
+        tenantId = SessionHelper.getTenantId();
+        return tenantId == null ? 1L : tenantId;
+    }
+}

+ 64 - 0
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/vo/CreditCustomerVO.java

@@ -0,0 +1,64 @@
+package com.mdframe.forge.business.core.restaurant.vo;
+
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 挂账客户列表/详情 VO。
+ */
+@Data
+public class CreditCustomerVO {
+
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long id;
+
+    private String customerName;
+
+    private String mobile;
+
+    private String birthday;
+
+    private String idCard;
+
+    private String occupation;
+
+    private String companyName;
+
+    private String address;
+
+    private String emergencyContact;
+
+    private String emergencyContactPhone;
+
+    private String invoiceTitle;
+
+    private String taxNo;
+
+    private String bankName;
+
+    private String bankAccount;
+
+    private String invoiceAddressPhone;
+
+    /** 挂账状态:NORMAL-正常挂账, SUSPENDED-暂停挂账, BLACKLIST-黑名单 */
+    private String creditStatus;
+
+    /** 累计挂账金额(单位:元) */
+    private BigDecimal totalArrearsAmount;
+
+    /** 未结账单数 */
+    private Integer unsettledBillCount;
+
+    private String remark;
+
+    private LocalDateTime createTime;
+
+    private LocalDateTime updateTime;
+
+    /** 创建人姓名 */
+    private String createName;
+}

+ 6 - 5
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/vo/DishLibraryVO.java

@@ -4,13 +4,14 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize;
 import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
 import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
 import lombok.Data;
 import lombok.Data;
 
 
+import java.math.BigDecimal;
 import java.util.Date;
 import java.util.Date;
 
 
 /**
 /**
  * 菜品库展示对象。
  * 菜品库展示对象。
  * <p>
  * <p>
  * 包含菜品基本信息及关联的分类名称,用于列表和详情展示。
  * 包含菜品基本信息及关联的分类名称,用于列表和详情展示。
- * 价格字段为分,前端需除以 100 转换为元显示。
+ * 价格字段为元,前端直接显示。
  */
  */
 @Data
 @Data
 public class DishLibraryVO {
 public class DishLibraryVO {
@@ -41,11 +42,11 @@ public class DishLibraryVO {
     /** 规格 */
     /** 规格 */
     private String spec;
     private String spec;
 
 
-    /** 销售价(单位:分) */
-    private Long salePrice;
+    /** 销售价(单位:元) */
+    private BigDecimal salePrice;
 
 
-    /** 参考成本价(单位:分) */
-    private Long costPrice;
+    /** 参考成本价(单位:元) */
+    private BigDecimal costPrice;
 
 
     /** 口味 */
     /** 口味 */
     private String taste;
     private String taste;

+ 4 - 2
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/vo/DishMenuAvailableDishVO.java

@@ -4,6 +4,8 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize;
 import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
 import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
 import lombok.Data;
 import lombok.Data;
 
 
+import java.math.BigDecimal;
+
 /**
 /**
  * 可添加菜品展示对象。
  * 可添加菜品展示对象。
  * <p>
  * <p>
@@ -41,6 +43,6 @@ public class DishMenuAvailableDishVO {
     /** 口味 */
     /** 口味 */
     private String taste;
     private String taste;
 
 
-    /** 菜品库销售价(单位:分) */
-    private Long salePrice;
+    /** 菜品库销售价(单位:元) */
+    private BigDecimal salePrice;
 }
 }

+ 6 - 5
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/vo/DishMenuDishVO.java

@@ -4,13 +4,14 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize;
 import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
 import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
 import lombok.Data;
 import lombok.Data;
 
 
+import java.math.BigDecimal;
 import java.util.Date;
 import java.util.Date;
 
 
 /**
 /**
  * 当前菜单菜品展示对象。
  * 当前菜单菜品展示对象。
  * <p>
  * <p>
  * 4 表 JOIN 查询结果:菜单菜品关联 + 菜品库 + 菜品分类 + 操作人。
  * 4 表 JOIN 查询结果:菜单菜品关联 + 菜品库 + 菜品分类 + 操作人。
- * 价格字段为分(菜单价 salePrice 覆盖菜品库价 librarySalePrice)。
+ * 价格字段为元(菜单价 salePrice 覆盖菜品库价 librarySalePrice)。
  */
  */
 @Data
 @Data
 public class DishMenuDishVO {
 public class DishMenuDishVO {
@@ -52,11 +53,11 @@ public class DishMenuDishVO {
     /** 口味 */
     /** 口味 */
     private String taste;
     private String taste;
 
 
-    /** 菜单销售价(单位:分,覆盖菜品库价格) */
-    private Long salePrice;
+    /** 菜单销售价(单位:元,覆盖菜品库价格) */
+    private BigDecimal salePrice;
 
 
-    /** 菜品库销售价(单位:分) */
-    private Long librarySalePrice;
+    /** 菜品库销售价(单位:元) */
+    private BigDecimal librarySalePrice;
 
 
     /** 菜品状态(ENABLED/DISABLED,来自菜品库) */
     /** 菜品状态(ENABLED/DISABLED,来自菜品库) */
     private String status;
     private String status;

+ 65 - 0
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/vo/DishOrderDetailVO.java

@@ -0,0 +1,65 @@
+package com.mdframe.forge.business.core.restaurant.vo;
+
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 订单详情 VO(含三类明细列表)。
+ * <p>
+ * detailList — 实际菜单汇总(SUM(quantity) HAVING > 0)
+ * addDishList — 加菜明细(isAddDish='1' AND quantity > 0)
+ * reduceDishList — 退菜明细(quantity < 0)
+ */
+@Data
+public class DishOrderDetailVO {
+
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long id;
+
+    private String orderNo;
+
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long roomId;
+
+    private String roomNo;
+
+    private String roomName;
+
+    private String orderStatus;
+
+    private BigDecimal totalAmount;
+
+    private String settleType;
+
+    private Integer discountRate;
+
+    private BigDecimal discountAmount;
+
+    private BigDecimal payableAmount;
+
+    private LocalDateTime settleTime;
+
+    private String tasteRemark;
+
+    private String specialRequirements;
+
+    private String remark;
+
+    private LocalDateTime createTime;
+
+    private String createName;
+
+    /** 实际菜单汇总 */
+    private List<DishOrderItemVO> detailList;
+
+    /** 加菜明细 */
+    private List<DishOrderItemVO> addDishList;
+
+    /** 退菜明细 */
+    private List<DishOrderItemVO> reduceDishList;
+}

+ 51 - 0
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/vo/DishOrderItemVO.java

@@ -0,0 +1,51 @@
+package com.mdframe.forge.business.core.restaurant.vo;
+
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 订单明细项 VO(用于汇总列表、加菜列表、退菜列表)。
+ */
+@Data
+public class DishOrderItemVO {
+
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long id;
+
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long dishId;
+
+    private String dishCode;
+
+    private String dishName;
+
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long typeId;
+
+    private String typeName;
+
+    private String unit;
+
+    private String spec;
+
+    /** 单价(单位:元) */
+    private BigDecimal salePrice;
+
+    /** 数量 */
+    private Integer quantity;
+
+    /** 金额(单位:元) */
+    private BigDecimal amount;
+
+    /** 排序号 */
+    private Integer dishSort;
+
+    /** 是否加菜:0-否, 1-是 */
+    private String isAddDish;
+
+    /** 退菜原因 */
+    private String reason;
+}

+ 67 - 0
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/vo/DishOrderVO.java

@@ -0,0 +1,67 @@
+package com.mdframe.forge.business.core.restaurant.vo;
+
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 订单列表/详情 VO。
+ */
+@Data
+public class DishOrderVO {
+
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long id;
+
+    private String orderNo;
+
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long roomId;
+
+    private String roomNo;
+
+    private String roomName;
+
+    /** 订单状态:ORDERING-用餐中, CHECKED_OUT-已结账, CANCELLED-已取消 */
+    private String orderStatus;
+
+    /** 总金额(单位:元) */
+    private BigDecimal totalAmount;
+
+    /** 结账方式 */
+    private String settleType;
+
+    /** 折扣比例 */
+    private Integer discountRate;
+
+    /** 优惠金额(单位:元) */
+    private BigDecimal discountAmount;
+
+    /** 实收金额(单位:元) */
+    private BigDecimal payableAmount;
+
+    /** 结账时间 */
+    private LocalDateTime settleTime;
+
+    /** 口味备注 */
+    private String tasteRemark;
+
+    /** 特殊要求 */
+    private String specialRequirements;
+
+    private String remark;
+
+    private LocalDateTime createTime;
+
+    private LocalDateTime updateTime;
+
+    /** 创建人姓名 */
+    private String createName;
+
+    /** 订单明细列表(当前订单汇总) */
+    private List<DishOrderItemVO> detailList;
+}

+ 3 - 2
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/vo/DishRoomVO.java

@@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize;
 import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
 import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
 import lombok.Data;
 import lombok.Data;
 
 
+import java.math.BigDecimal;
 import java.time.LocalDateTime;
 import java.time.LocalDateTime;
 
 
 /**
 /**
@@ -21,8 +22,8 @@ public class DishRoomVO {
 
 
     private Integer capacity;
     private Integer capacity;
 
 
-    /** 最低消费(单位:分) */
-    private Long minConsumption;
+    /** 最低消费(单位:元) */
+    private BigDecimal minConsumption;
 
 
     private String status;
     private String status;
 
 

+ 56 - 0
forge-server/forge-business/forge-restaurant/src/main/resources/mapper/business/restaurant/CreditCustomerMapper.xml

@@ -0,0 +1,56 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.mdframe.forge.business.core.restaurant.mapper.CreditCustomerMapper">
+
+    <!-- 分页查询挂账客户列表 -->
+    <select id="selectPage" resultType="com.mdframe.forge.business.core.restaurant.vo.CreditCustomerVO">
+        SELECT c.id, c.customer_name, c.mobile, c.birthday, c.id_card, c.occupation,
+               c.company_name, c.address, c.emergency_contact, c.emergency_contact_phone,
+               c.invoice_title, c.tax_no, c.bank_name, c.bank_account,
+               c.invoice_address_phone, c.credit_status, c.total_arrears_amount,
+               c.unsettled_bill_count, c.remark, c.create_time, c.update_time,
+               su.username AS create_name
+        FROM restaurant_credit_customer c
+        LEFT JOIN sys_user su ON su.id = c.create_by
+        WHERE c.tenant_id = #{tenantId}
+          AND c.del_flag = 0
+        <if test="customerName != null and customerName != ''">
+            AND c.customer_name LIKE CONCAT('%', #{customerName}, '%')
+        </if>
+        <if test="mobile != null and mobile != ''">
+            AND c.mobile LIKE CONCAT('%', #{mobile}, '%')
+        </if>
+        <if test="creditStatus != null and creditStatus != ''">
+            AND c.credit_status = #{creditStatus}
+        </if>
+        ORDER BY c.create_time DESC
+    </select>
+
+    <!-- 查询挂账客户详情 -->
+    <select id="selectDetail" resultType="com.mdframe.forge.business.core.restaurant.vo.CreditCustomerVO">
+        SELECT c.id, c.customer_name, c.mobile, c.birthday, c.id_card, c.occupation,
+               c.company_name, c.address, c.emergency_contact, c.emergency_contact_phone,
+               c.invoice_title, c.tax_no, c.bank_name, c.bank_account,
+               c.invoice_address_phone, c.credit_status, c.total_arrears_amount,
+               c.unsettled_bill_count, c.remark, c.create_time, c.update_time,
+               su.username AS create_name
+        FROM restaurant_credit_customer c
+        LEFT JOIN sys_user su ON su.id = c.create_by
+        WHERE c.tenant_id = #{tenantId}
+          AND c.id = #{id}
+          AND c.del_flag = 0
+    </select>
+
+    <!-- 检查手机号重复(排除自身) -->
+    <select id="checkMobileExists" resultType="int">
+        SELECT COUNT(1)
+        FROM restaurant_credit_customer
+        WHERE tenant_id = #{tenantId}
+          AND del_flag = 0
+          AND mobile = #{mobile}
+        <if test="excludeId != null">
+            AND id != #{excludeId}
+        </if>
+    </select>
+
+</mapper>

+ 65 - 0
forge-server/forge-business/forge-restaurant/src/main/resources/mapper/business/restaurant/DishOrderDetailMapper.xml

@@ -0,0 +1,65 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.mdframe.forge.business.core.restaurant.mapper.DishOrderDetailMapper">
+
+    <!-- 按订单汇总查询(GROUP BY dish_id,SUM(quantity) HAVING > 0) -->
+    <select id="findSummaryByOrderId" resultType="com.mdframe.forge.business.core.restaurant.vo.DishOrderItemVO">
+        SELECT d.id, d.dish_id, d.dish_code, d.dish_name, d.type_id, d.type_name,
+               d.unit, d.spec, d.sale_price,
+               SUM(d.quantity) AS quantity,
+               SUM(d.amount) AS amount,
+               MIN(d.dish_sort) AS dish_sort,
+               d.is_add_dish
+        FROM restaurant_dish_order_detail d
+        WHERE d.order_id = #{orderId}
+          AND d.del_flag = 0
+        GROUP BY d.dish_id, d.dish_code, d.dish_name, d.type_id, d.type_name,
+                 d.unit, d.spec, d.sale_price, d.is_add_dish
+        HAVING SUM(d.quantity) > 0
+        ORDER BY MIN(d.dish_sort) ASC
+    </select>
+
+    <!-- 加菜列表(isAddDish='1' AND quantity > 0) -->
+    <select id="findAddDishList" resultType="com.mdframe.forge.business.core.restaurant.vo.DishOrderItemVO">
+        SELECT id, dish_id, dish_code, dish_name, type_id, type_name,
+               unit, spec, sale_price, quantity, amount, dish_sort,
+               is_add_dish, reason
+        FROM restaurant_dish_order_detail
+        WHERE order_id = #{orderId}
+          AND del_flag = 0
+          AND is_add_dish = '1'
+          AND quantity > 0
+        ORDER BY dish_sort ASC, create_time ASC
+    </select>
+
+    <!-- 退菜列表(quantity < 0) -->
+    <select id="findReduceDishList" resultType="com.mdframe.forge.business.core.restaurant.vo.DishOrderItemVO">
+        SELECT id, dish_id, dish_code, dish_name, type_id, type_name,
+               unit, spec, sale_price, quantity, amount, dish_sort,
+               is_add_dish, reason
+        FROM restaurant_dish_order_detail
+        WHERE order_id = #{orderId}
+          AND del_flag = 0
+          AND quantity &lt; 0
+        ORDER BY create_time ASC
+    </select>
+
+    <!-- 按订单查询各菜品当前汇总数量 -->
+    <select id="getCurrentQuantityMap" resultType="java.util.Map">
+        SELECT dish_id, SUM(quantity) AS current_quantity
+        FROM restaurant_dish_order_detail
+        WHERE order_id = #{orderId}
+          AND del_flag = 0
+        GROUP BY dish_id
+    </select>
+
+    <!-- 更新排序号 -->
+    <update id="updateDishSort">
+        UPDATE restaurant_dish_order_detail
+        SET dish_sort = #{dishSort}, update_time = NOW()
+        WHERE order_id = #{orderId}
+          AND dish_id = #{dishId}
+          AND del_flag = 0
+    </update>
+
+</mapper>

+ 56 - 0
forge-server/forge-business/forge-restaurant/src/main/resources/mapper/business/restaurant/DishOrderMapper.xml

@@ -0,0 +1,56 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.mdframe.forge.business.core.restaurant.mapper.DishOrderMapper">
+
+    <!-- 分页查询订单列表 -->
+    <select id="selectPage" resultType="com.mdframe.forge.business.core.restaurant.vo.DishOrderVO">
+        SELECT o.id, o.order_no, o.room_id, o.room_no, o.room_name, o.order_status,
+               o.total_amount, o.settle_type, o.discount_rate, o.discount_amount,
+               o.payable_amount, o.settle_time, o.taste_remark, o.special_requirements,
+               o.remark, o.create_time, o.update_time,
+               su.username AS create_name
+        FROM restaurant_dish_order o
+        LEFT JOIN sys_user su ON su.id = o.create_by
+        WHERE o.tenant_id = #{tenantId}
+          AND o.del_flag = 0
+        <if test="roomId != null">
+            AND o.room_id = #{roomId}
+        </if>
+        <if test="orderStatus != null and orderStatus != ''">
+            AND o.order_status = #{orderStatus}
+        </if>
+        <if test="settleType != null and settleType != ''">
+            AND o.settle_type = #{settleType}
+        </if>
+        ORDER BY o.create_time DESC
+    </select>
+
+    <!-- 查询订单详情(含创建人姓名) -->
+    <select id="selectDetail" resultType="com.mdframe.forge.business.core.restaurant.vo.DishOrderVO">
+        SELECT o.id, o.order_no, o.room_id, o.room_no, o.room_name, o.order_status,
+               o.total_amount, o.settle_type, o.discount_rate, o.discount_amount,
+               o.payable_amount, o.settle_time, o.taste_remark, o.special_requirements,
+               o.remark, o.create_time, o.update_time,
+               su.username AS create_name
+        FROM restaurant_dish_order o
+        LEFT JOIN sys_user su ON su.id = o.create_by
+        WHERE o.tenant_id = #{tenantId}
+          AND o.id = #{id}
+          AND o.del_flag = 0
+    </select>
+
+    <!-- 查询包房当前用餐中的订单 -->
+    <select id="findCurrentByRoomId" resultType="com.mdframe.forge.business.core.restaurant.domain.DishOrder">
+        SELECT id, tenant_id, order_no, room_id, room_no, room_name, order_status,
+               total_amount, settle_type, discount_rate, discount_amount, payable_amount,
+               settle_time, taste_remark, special_requirements, remark, del_flag,
+               create_by, create_time, create_dept, update_by, update_time
+        FROM restaurant_dish_order
+        WHERE tenant_id = #{tenantId}
+          AND room_id = #{roomId}
+          AND order_status = 'ORDERING'
+          AND del_flag = 0
+        LIMIT 1
+    </select>
+
+</mapper>