Selaa lähdekoodia

餐饮模块部分功能开发(折扣异步通知功能完成)

徐滕 1 viikko sitten
vanhempi
commit
cf23cda3da
35 muutettua tiedostoa jossa 8534 lisäystä ja 292 poistoa
  1. 24 0
      forge-admin-ui/src/api/flow.js
  2. 3 3
      forge-admin-ui/src/api/restaurant.js
  3. 19 0
      forge-admin-ui/src/components/ai-form/AiFormItem.vue
  4. 40 54
      forge-admin-ui/src/views/flow/done.vue
  5. 623 0
      forge-admin-ui/src/views/flow/notification.vue
  6. 1 1
      forge-admin-ui/src/views/flow/todo.vue
  7. 2 2
      forge-admin-ui/src/views/flow/utils/processDisplay.js
  8. 48 17
      forge-admin-ui/src/views/restaurant/creditCustomer.vue
  9. 54 0
      forge-server/db/flow-definitions/discount_checkout_notify.bpmn.xml
  10. 6372 154
      forge-server/db/forge_admin.sql
  11. 134 0
      forge-server/db/migration/V1.0.140__add_discount_checkout_flow.sql
  12. 73 0
      forge-server/db/migration/V1.0.141__add_business_flow_permissions.sql
  13. 145 0
      forge-server/db/migration/V1.0.142__add_notification_todo_separation.sql
  14. 75 3
      forge-server/forge-business/forge-hotel/编码习惯与规范.md
  15. 5 5
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/controller/CreditCustomerController.java
  16. 6 0
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/domain/DishOrder.java
  17. 2 2
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/service/CreditCustomerService.java
  18. 15 20
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/service/impl/CreditCustomerServiceImpl.java
  19. 151 0
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/service/impl/DishOrderServiceImpl.java
  20. 61 0
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/support/DiscountCheckoutFlowBpmn.java
  21. 221 0
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/support/DiscountCheckoutFlowDefinition.java
  22. 11 9
      forge-server/forge-business/forge-restaurant/餐饮模块迁移分析文档.md
  23. 63 0
      forge-server/forge-flow/forge-flow-server/src/main/java/com/mdframe/forge/flow/controller/FlowTaskController.java
  24. 11 0
      forge-server/forge-framework/forge-plugin-parent/forge-plugin-flow/src/main/java/com/mdframe/forge/starter/flow/entity/FlowTask.java
  25. 43 0
      forge-server/forge-framework/forge-plugin-parent/forge-plugin-flow/src/main/java/com/mdframe/forge/starter/flow/entity/FlowTaskRead.java
  26. 46 2
      forge-server/forge-framework/forge-plugin-parent/forge-plugin-flow/src/main/java/com/mdframe/forge/starter/flow/listener/FlowTaskEventListener.java
  27. 11 0
      forge-server/forge-framework/forge-plugin-parent/forge-plugin-flow/src/main/java/com/mdframe/forge/starter/flow/mapper/FlowTaskMapper.java
  28. 23 0
      forge-server/forge-framework/forge-plugin-parent/forge-plugin-flow/src/main/java/com/mdframe/forge/starter/flow/mapper/FlowTaskReadMapper.java
  29. 20 0
      forge-server/forge-framework/forge-plugin-parent/forge-plugin-flow/src/main/java/com/mdframe/forge/starter/flow/service/FlowTaskService.java
  30. 26 1
      forge-server/forge-framework/forge-plugin-parent/forge-plugin-flow/src/main/java/com/mdframe/forge/starter/flow/service/impl/FlowInstanceServiceImpl.java
  31. 35 0
      forge-server/forge-framework/forge-plugin-parent/forge-plugin-flow/src/main/java/com/mdframe/forge/starter/flow/service/impl/FlowTaskServiceImpl.java
  32. 91 19
      forge-server/forge-framework/forge-plugin-parent/forge-plugin-flow/src/main/resources/mapper/FlowTaskMapper.xml
  33. 31 0
      forge-server/forge-framework/forge-plugin-parent/forge-plugin-flow/src/main/resources/mapper/FlowTaskReadMapper.xml
  34. 47 0
      forge-server/forge-framework/forge-plugin-parent/forge-plugin-generator/src/main/java/com/mdframe/forge/plugin/generator/service/businessapp/BusinessFlowService.java
  35. 2 0
      forge-server/forge-framework/forge-starter-parent/forge-starter-tenant/src/main/java/com/mdframe/forge/starter/tenant/config/TenantProperties.java

+ 24 - 0
forge-admin-ui/src/api/flow.js

@@ -111,6 +111,30 @@ export default {
   remindTask: taskId =>
     request.post('/api/flow/task/remind', null, { params: { taskId } }),
 
+  /**
+   * 我的通知任务(task_type=1,带已读状态)
+   */
+  getNotificationTasks: (params, config = {}) =>
+    request.get('/api/flow/task/notifications', { ...config, params }),
+
+  /**
+   * 获取未读通知数量
+   */
+  getUnreadNotificationCount: userId =>
+    request.get('/api/flow/task/unread-count', { params: { userId } }),
+
+  /**
+   * 标记通知任务已读
+   */
+  markNotificationAsRead: data =>
+    request.post('/api/flow/task/read', data),
+
+  /**
+   * 全部标记已读
+   */
+  markAllNotificationsAsRead: data =>
+    request.post('/api/flow/task/read-all', data),
+
   // ========== 流程实例 ==========
 
   /**

+ 3 - 3
forge-admin-ui/src/api/restaurant.js

@@ -219,9 +219,9 @@ 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 } })
+/** 变更挂账状态(显式指定目标状态:0-正常挂账、1-暂停挂账、2-黑名单) */
+export function updateCreditStatus(id, creditStatus) {
+  return request.post('/restaurant/creditCustomer/updateStatus', null, { params: { id, creditStatus } })
 }
 
 /** 提交挂账客户审批(审批人按流程节点配置自适应,仅指定人模式需传 approverId) */

+ 19 - 0
forge-admin-ui/src/components/ai-form/AiFormItem.vue

@@ -63,6 +63,14 @@
           {{ resolveReadonlySelectionText(field) }}
         </div>
 
+        <!-- 只读模式下空值字段显示纯文本 -->
+        <div
+          v-else-if="isReadonlyEmptyField"
+          class="ai-form-readonly-text"
+        >
+          -
+        </div>
+
         <!-- 低代码页面展示组件 -->
         <PageWidgetRenderer
           v-else-if="isRuntimePageWidgetField"
@@ -926,6 +934,17 @@ const isFieldRequired = computed(() => {
     return false
   return (Array.isArray(rules) ? rules : [rules]).some(rule => rule?.required === true)
 })
+/** 只读模式下空值字段:显示纯文本而非占位符 */
+const isReadonlyEmptyField = computed(() => {
+  const field = props.field || {}
+  const isReadonly = field.readonly === true
+    || field.disabled === true
+    || field.props?.readonly === true
+    || field.props?.disabled === true
+    || disabledHandler(field)
+  const isEmpty = !isFilledValue(props.value)
+  return isReadonly && isEmpty
+})
 const fieldDictType = computed(() => props.field?.dictType || props.field?.props?.dictType || '')
 const cascadeConfig = computed(() => resolveCascadeConfig(props.field))
 const dictCascadeConfig = computed(() => {

+ 40 - 54
forge-admin-ui/src/views/flow/done.vue

@@ -149,39 +149,52 @@
             </div>
 
             <div v-else-if="useBusinessManagedForm" class="business-task-form-section readonly">
-              <div class="approval-form-title">
-                <span>{{ businessFormTitle }}</span>
-              </div>
-              <AiForm
-                v-model:value="businessFormData"
-                :schema="readonlyBusinessFormFields"
-                :field-permissions="readonlyBusinessFormFieldPermissions"
-                :show-actions="false"
-                :show-feedback="false"
-                :grid-cols="businessFormGridCols"
-                :label-placement="businessFormLabelPlacement"
-                :label-width="businessFormLabelWidth"
-                :context="businessFormRenderContext"
-                :form-assets="businessFormContext.formAssets || []"
-              />
-              <ChildTableEditor
-                v-if="businessFormChildrenConfig.length"
-                v-model:value="businessChildFormData"
-                :children-config="businessFormChildrenConfig"
-                readonly
-                :parent-form-data="businessFormData"
-                :context="businessFormRenderContext"
+              <!-- 有 formUrl 时使用 FlowBusinessForm 加载业务组件,与待办页面保持一致的表格布局 -->
+              <FlowBusinessForm
+                v-if="businessFormContext?.formUrl"
+                :form-url="businessFormContext.formUrl"
+                :task-id="taskFormInfo?.taskId"
+                :business-key="taskFormInfo?.businessKey"
+                :process-instance-id="taskFormInfo?.processInstanceId"
+                :task-def-key="taskFormInfo?.taskDefKey"
+                :process-def-key="taskFormInfo?.processDefKey"
+                :variables="taskFormInfo?.variables || {}"
+                :approval-policy="readonlyApprovalPolicy"
+                :initial-task-context="businessFormContext"
+                read-only
+                @submit="noop"
               />
+              <!-- 无 formUrl 时降级使用 AiForm 渲染 -->
+              <template v-else>
+                <div class="approval-form-title">
+                  <span>{{ businessFormTitle }}</span>
+                </div>
+                <AiForm
+                  v-model:value="businessFormData"
+                  :schema="readonlyBusinessFormFields"
+                  :field-permissions="readonlyBusinessFormFieldPermissions"
+                  :show-actions="false"
+                  :show-feedback="false"
+                  :grid-cols="businessFormGridCols"
+                  :label-placement="businessFormLabelPlacement"
+                  :label-width="businessFormLabelWidth"
+                  :context="businessFormRenderContext"
+                  :form-assets="businessFormContext.formAssets || []"
+                />
+                <ChildTableEditor
+                  v-if="businessFormChildrenConfig.length"
+                  v-model:value="businessChildFormData"
+                  :children-config="businessFormChildrenConfig"
+                  readonly
+                  :parent-form-data="businessFormData"
+                  :context="businessFormRenderContext"
+                />
+              </template>
               <div v-if="businessFormWarnings.length" class="business-form-warnings">
                 <n-alert v-for="warning in businessFormWarnings" :key="warning" type="warning" :show-icon="false">
                   {{ warning }}
                 </n-alert>
               </div>
-              <div v-if="businessCodeFormUrl" class="business-form-actions">
-                <NButton type="primary" secondary @click="openBusinessCodeForm">
-                  打开完整业务页
-                </NButton>
-              </div>
             </div>
 
             <div v-if="useDynamicForm" class="dynamic-form-section readonly">
@@ -221,7 +234,6 @@
 <script setup>
 import { NButton, NTreeSelect } from 'naive-ui'
 import { computed, onMounted, reactive, ref } from 'vue'
-import { useRouter } from 'vue-router'
 import { businessTaskFormReadonlyContext } from '@/api/business-app'
 import flowApi from '@/api/flow'
 import { AiForm } from '@/components/ai-form'
@@ -240,7 +252,6 @@ import { buildFlowCategoryTreeOptions, resolveFlowCategoryLabel } from './utils/
 import { getBusinessFormDisplayTitle, getRowDisplayTitle, getTaskDisplayName } from './utils/processDisplay'
 
 const userStore = useUserStore()
-const router = useRouter()
 const { dict, getLabel } = useDict('flow_done_status')
 const loading = ref(false)
 const dataSource = ref([])
@@ -297,7 +308,6 @@ const businessFormChildrenConfig = computed(() => {
   const children = Array.isArray(businessFormContext.value?.childrenConfig) ? businessFormContext.value.childrenConfig : []
   return children.filter(child => child?.showInDetail !== false && Array.isArray(child.fields) && child.fields.length)
 })
-const businessCodeFormUrl = computed(() => businessFormContext.value?.formUrl || businessFormContext.value?.formRef?.formUrl || '')
 const businessFormGridCols = computed(() => Math.max(1, Number(businessFormContext.value?.gridCols || 1)))
 const businessFormLabelPlacement = computed(() => ['left', 'top'].includes(businessFormContext.value?.labelPlacement)
   ? businessFormContext.value.labelPlacement
@@ -554,30 +564,6 @@ async function loadReadonlyFormInfo(row) {
   }
 }
 
-function openBusinessCodeForm() {
-  const url = businessCodeFormUrl.value
-  if (!url)
-    return
-  if (/^https?:\/\//i.test(url)) {
-    window.open(url, '_blank', 'noopener,noreferrer')
-    return
-  }
-  router.push({
-    path: url,
-    query: compactParams({
-      taskId: businessFormContext.value?.taskId || taskFormInfo.value?.taskId || currentTask.value?.taskId,
-      businessKey: businessFormContext.value?.businessKey,
-      processInstanceId: businessFormContext.value?.processInstanceId,
-      taskDefKey: businessFormContext.value?.taskDefKey,
-      processDefKey: businessFormContext.value?.processDefKey,
-      objectCode: businessFormContext.value?.objectCode,
-      recordId: businessFormContext.value?.recordId,
-      source: 'flowDone',
-      readOnly: 'true',
-    }),
-  })
-}
-
 function noop() {}
 
 async function openDrawer(row) {

+ 623 - 0
forge-admin-ui/src/views/flow/notification.vue

@@ -0,0 +1,623 @@
+<template>
+  <div class="flow-page">
+    <!-- 通知列表 -->
+    <FlowTaskCardList
+      v-model:selected-keys="selectedTaskKeys"
+      v-model:search-value="queryParams.title"
+      title="我的通知"
+      :items="dataSource"
+      :loading="loading"
+      :pagination="pagination"
+      row-key="id"
+      search-placeholder="通过名称搜索"
+      empty-text="暂无通知"
+      @search="handleSearch"
+      @refresh="loadData"
+      @row-click="openDrawer"
+      @update:page="pagination.onChange"
+      @update:page-size="pagination.onUpdatePageSize"
+    >
+      <template #filters>
+        <n-select
+          v-model:value="queryParams.isRead"
+          placeholder="已读状态"
+          clearable
+          class="category-select"
+          :options="readStatusOptions"
+          @update:value="handleSearch"
+        />
+        <NButton secondary @click="handleReset">
+          重置
+        </NButton>
+      </template>
+      <template #batch-actions>
+        <NButton
+          v-if="selectedTaskKeys.length > 0"
+          size="small"
+          type="primary"
+          @click="handleMarkSelectedAsRead"
+        >
+          标记已读
+        </NButton>
+        <NButton
+          size="small"
+          type="info"
+          @click="handleMarkAllAsRead"
+        >
+          全部已读
+        </NButton>
+        <span v-if="unreadCount > 0" class="task-list-hint unread">
+          <i class="i-material-symbols:notifications-active" />
+          {{ unreadCount }} 条未读
+        </span>
+      </template>
+      <template #status="{ row }">
+        <span class="task-status-pill" :class="row.isRead === 1 ? 'notification-read' : 'notification-unread'">
+          {{ row.isRead === 1 ? '已读' : '未读' }}
+        </span>
+      </template>
+      <template #title="{ row }">
+        <div class="notification-title-row">
+          <span v-if="row.isRead !== 1" class="unread-dot" />
+          {{ getRowDisplayTitle(row) }}
+        </div>
+      </template>
+      <template #meta="{ row }">
+        <span><span class="task-meta-label">申请人</span> <span class="task-meta-value">{{ row.startUserName || '-' }}</span></span>
+        <span><span class="task-meta-label">提交时间</span> <span class="task-meta-value">{{ row.createTime || '-' }}</span></span>
+        <span><span class="task-meta-label">当前节点</span> <span class="task-meta-value">{{ getTaskDisplayName(row) }}</span></span>
+        <span><span class="task-meta-label">流程分类</span> <span class="task-meta-value">{{ getCategoryDisplayName(row) }}</span></span>
+      </template>
+      <template #actions="{ row }">
+        <button
+          v-if="row.isRead !== 1"
+          type="button"
+          class="task-row-link-action info"
+          aria-label="标记已读"
+          @click.stop="handleMarkAsRead(row)"
+        >
+          标记已读
+        </button>
+        <button type="button" class="task-row-link-action primary" aria-label="查看详情" @click="openDrawer(row)">
+          <span>查看</span>
+          <i class="i-material-symbols:chevron-right" />
+        </button>
+      </template>
+    </FlowTaskCardList>
+
+    <!-- 通知详情弹窗 -->
+    <FlowTaskDetailShell
+      v-model:show="showDrawer"
+      :busy="loading"
+      :title="currentTask ? getRowDisplayTitle(currentTask) : '通知详情'"
+      :subtitle="getTaskDisplayName(currentTask, '') ? `当前节点:${getTaskDisplayName(currentTask)}` : ''"
+      :status-text="currentTask?.isRead === 1 ? '已读' : '未读'"
+      :status-class="currentTask?.isRead === 1 ? 'notification-read' : 'notification-unread'"
+      :status-icon="currentTask?.isRead === 1 ? 'i-material-symbols:check-circle' : 'i-material-symbols:notifications-active'"
+      :records="approvalHistory"
+      record-title="流转记录"
+      fullscreen
+    >
+      <template v-if="currentTask">
+        <section class="approval-detail-section">
+          <div class="approval-section-header">
+            <i class="i-material-symbols:info-outline" />
+            基本信息
+          </div>
+          <div class="approval-field-grid">
+            <div class="approval-field">
+              <span class="approval-label">当前节点</span>
+              <span class="approval-value">{{ getTaskDisplayName(currentTask) }}</span>
+            </div>
+            <div class="approval-field">
+              <span class="approval-label">流程名称</span>
+              <span class="approval-value">{{ getProcessDisplayName(currentTask) }}</span>
+            </div>
+            <div class="approval-field">
+              <span class="approval-label">流程分类</span>
+              <span class="approval-value">{{ getCategoryDisplayName(currentTask) }}</span>
+            </div>
+            <div class="approval-field">
+              <span class="approval-label">发起人</span>
+              <span class="approval-value approval-user-inline">
+                <UserAvatar :name="currentTask.startUserName || '未知'" :size="24" />
+                {{ currentTask.startUserName || '-' }}
+              </span>
+            </div>
+            <div class="approval-field">
+              <span class="approval-label">发起部门</span>
+              <span class="approval-value">{{ currentTask.startDeptName || '-' }}</span>
+            </div>
+            <div class="approval-field">
+              <span class="approval-label">发起时间</span>
+              <span class="approval-value">{{ currentTask.createTime || '-' }}</span>
+            </div>
+            <div class="approval-field">
+              <span class="approval-label">已读状态</span>
+              <span class="approval-value">
+                <span class="task-status-pill" :class="currentTask.isRead === 1 ? 'notification-read' : 'notification-unread'">
+                  {{ currentTask.isRead === 1 ? '已读' : '未读' }}
+                </span>
+              </span>
+            </div>
+          </div>
+        </section>
+
+        <section class="approval-detail-section">
+          <n-collapse arrow-placement="right">
+            <n-collapse-item title="查看流程图" name="diagram">
+              <div class="approval-diagram">
+                <DingFlowViewer v-if="currentTask.processInstanceId" :process-instance-id="currentTask.processInstanceId" :compact="true" />
+                <NEmpty v-else description="暂无流程图" size="small" />
+              </div>
+            </n-collapse-item>
+          </n-collapse>
+        </section>
+
+        <section v-if="orderDetail" class="approval-detail-section">
+          <div class="approval-section-header">
+            <i class="i-material-symbols:receipt-long" />
+            订单明细
+          </div>
+          <div class="approval-field-grid">
+            <div class="approval-field">
+              <span class="approval-label">订单号</span>
+              <span class="approval-value">{{ orderDetail.orderNo || '-' }}</span>
+            </div>
+            <div class="approval-field">
+              <span class="approval-label">包房</span>
+              <span class="approval-value">{{ orderDetail.roomName || '-' }}</span>
+            </div>
+            <div class="approval-field">
+              <span class="approval-label">结账方式</span>
+              <span class="approval-value">{{ getSettleTypeLabel(orderDetail.settleType) }}</span>
+            </div>
+            <div class="approval-field">
+              <span class="approval-label">下单人</span>
+              <span class="approval-value">{{ orderDetail.createName || '-' }}</span>
+            </div>
+          </div>
+
+          <!-- 菜品明细表 -->
+          <div v-if="orderDetail.detailList && orderDetail.detailList.length" class="dish-table-wrap">
+            <table class="dish-table">
+              <thead>
+                <tr>
+                  <th>菜品名称</th>
+                  <th>分类</th>
+                  <th>单价</th>
+                  <th>数量</th>
+                  <th>单位</th>
+                  <th>小计</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr v-for="item in orderDetail.detailList" :key="item.id">
+                  <td>{{ item.dishName || '-' }}</td>
+                  <td>{{ item.typeName || '-' }}</td>
+                  <td>{{ formatMoney(item.salePrice) }}</td>
+                  <td>{{ item.quantity }}</td>
+                  <td>{{ item.unit || '-' }}</td>
+                  <td>{{ formatMoney(item.amount) }}</td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+          <NEmpty v-else description="暂无菜品明细" size="small" />
+
+          <!-- 金额汇总 -->
+          <div class="amount-summary">
+            <div class="amount-row">
+              <span class="amount-label">订单总额</span>
+              <span class="amount-value">{{ formatMoney(orderDetail.totalAmount) }}</span>
+            </div>
+            <div v-if="orderDetail.settleType === 'DISCOUNT'" class="amount-row discount">
+              <span class="amount-label">折扣比例</span>
+              <span class="amount-value">{{ orderDetail.discountRate ? `${orderDetail.discountRate / 10} 折` : '-' }}</span>
+            </div>
+            <div v-if="orderDetail.discountAmount && orderDetail.discountAmount > 0" class="amount-row discount">
+              <span class="amount-label">优惠金额</span>
+              <span class="amount-value text-error">-{{ formatMoney(orderDetail.discountAmount) }}</span>
+            </div>
+            <div class="amount-row total">
+              <span class="amount-label">实收金额</span>
+              <span class="amount-value">{{ formatMoney(orderDetail.payableAmount) }}</span>
+            </div>
+          </div>
+        </section>
+
+        <section v-if="currentTask.isRead !== 1" class="approval-detail-section">
+          <div class="notification-action-bar">
+            <NButton type="primary" @click="handleMarkAsRead(currentTask)">
+              <i class="i-material-symbols:check-circle mr-2" />
+              标记为已读
+            </NButton>
+          </div>
+        </section>
+      </template>
+    </FlowTaskDetailShell>
+  </div>
+</template>
+
+<script setup>
+import { NButton, NEmpty } from 'naive-ui'
+import { computed, onMounted, reactive, ref } from 'vue'
+import flowApi from '@/api/flow'
+import UserAvatar from '@/components/common/UserAvatar.vue'
+import DingFlowViewer from '@/components/flow-designer/viewer/DingFlowViewer.vue'
+import FlowTaskCardList from '@/components/flow/FlowTaskCardList.vue'
+import FlowTaskDetailShell from '@/components/flow/FlowTaskDetailShell.vue'
+import { useUserStore } from '@/store'
+import { request } from '@/utils'
+import { buildFlowCategoryTreeOptions, resolveFlowCategoryLabel } from './utils/categoryOptions'
+import { getRowDisplayTitle, getTaskDisplayName } from './utils/processDisplay'
+
+const userStore = useUserStore()
+const loading = ref(false)
+const dataSource = ref([])
+const pagination = reactive({
+  page: 1,
+  pageSize: 10,
+  itemCount: 0,
+  showSizePicker: true,
+  pageSizes: [10, 20, 50],
+  onChange: (page) => {
+    pagination.page = page
+    loadData()
+  },
+  onUpdatePageSize: (size) => {
+    pagination.pageSize = size
+    pagination.page = 1
+    loadData()
+  },
+})
+
+const queryParams = reactive({ title: '', isRead: null })
+const categoryTreeOptions = ref([])
+const unreadCount = ref(0)
+const selectedTaskKeys = ref([])
+
+// 抽屉状态
+const showDrawer = ref(false)
+const currentTask = ref(null)
+const approvalHistory = ref([])
+const orderDetail = ref(null)
+const orderLoading = ref(false)
+
+const readStatusOptions = computed(() => [
+  { label: '未读', value: 0 },
+  { label: '已读', value: 1 },
+])
+
+function getCategoryDisplayName(row) {
+  return row?.categoryName || resolveFlowCategoryLabel(row?.category, categoryTreeOptions.value, '-') || '-'
+}
+
+function getProcessDisplayName(task) {
+  return task?.processName || task?.processDefinitionName || task?.processTitle || task?.modelName || '-'
+}
+
+function getSettleTypeLabel(settleType) {
+  const map = { NORMAL: '普通', DISCOUNT: '折扣', PROMOTION: '优惠' }
+  return map[settleType] || settleType || '-'
+}
+
+function formatMoney(value) {
+  if (value === null || value === undefined)
+    return '-'
+  return `¥${Number(value).toFixed(2)}`
+}
+
+/** 从 businessKey 中提取订单ID(格式:discount_checkout:{orderId}) */
+function extractOrderId(businessKey) {
+  if (!businessKey)
+    return null
+  const parts = String(businessKey).split(':')
+  if (parts.length >= 2 && parts[1]) {
+    // 保持字符串格式,避免大数字精度丢失(雪花ID超过Number.MAX_SAFE_INTEGER)
+    return parts[1]
+  }
+  return null
+}
+
+async function loadOrderDetail(row) {
+  const orderId = extractOrderId(row?.businessKey)
+  if (!orderId) {
+    orderDetail.value = null
+    return
+  }
+  orderLoading.value = true
+  try {
+    const res = await request.get('/restaurant/dishOrder/detail', { params: { id: orderId } })
+    if (res.code === 200) {
+      orderDetail.value = res.data || null
+    }
+  }
+  catch (error) {
+    console.error('加载订单明细失败', error)
+    orderDetail.value = null
+  }
+  finally {
+    orderLoading.value = false
+  }
+}
+
+async function loadData() {
+  loading.value = true
+  try {
+    const res = await flowApi.getNotificationTasks({
+      pageNum: pagination.page,
+      pageSize: pagination.pageSize,
+      userId: String(userStore.userId),
+      title: queryParams.title || undefined,
+      isRead: queryParams.isRead,
+    })
+    if (res.code === 200) {
+      dataSource.value = res.data?.records || []
+      pagination.itemCount = res.data?.total || 0
+    }
+  }
+  catch (error) {
+    console.error('加载通知列表失败', error)
+  }
+  finally {
+    loading.value = false
+  }
+}
+
+async function loadUnreadCount() {
+  try {
+    const res = await flowApi.getUnreadNotificationCount(String(userStore.userId))
+    if (res.code === 200) {
+      unreadCount.value = res.data || 0
+    }
+  }
+  catch (error) {
+    console.error('加载未读数量失败', error)
+  }
+}
+
+function handleSearch() {
+  pagination.page = 1
+  loadData()
+}
+
+function handleReset() {
+  queryParams.title = ''
+  queryParams.isRead = null
+  handleSearch()
+}
+
+async function handleMarkAsRead(row) {
+  try {
+    const res = await flowApi.markNotificationAsRead({
+      taskId: row.taskId || row.id,
+      userId: String(userStore.userId),
+      tenantId: userStore.tenantId,
+    })
+    if (res.code === 200) {
+      window.$message?.success('已标记为已读')
+      row.isRead = 1
+      await loadUnreadCount()
+    }
+  }
+  catch (error) {
+    console.error('标记已读失败', error)
+    window.$message?.error('标记已读失败')
+  }
+}
+
+async function handleMarkSelectedAsRead() {
+  if (selectedTaskKeys.value.length === 0) {
+    return
+  }
+  try {
+    for (const key of selectedTaskKeys.value) {
+      const task = dataSource.value.find(t => t.id === key || t.taskId === key)
+      if (task && task.isRead !== 1) {
+        await flowApi.markNotificationAsRead({
+          taskId: task.taskId || task.id,
+          userId: String(userStore.userId),
+          tenantId: userStore.tenantId,
+        })
+        task.isRead = 1
+      }
+    }
+    window.$message?.success(`已标记 ${selectedTaskKeys.value.length} 条为已读`)
+    selectedTaskKeys.value = []
+    await loadUnreadCount()
+    loadData()
+  }
+  catch (error) {
+    console.error('批量标记已读失败', error)
+    window.$message?.error('批量标记已读失败')
+  }
+}
+
+async function handleMarkAllAsRead() {
+  try {
+    const res = await flowApi.markAllNotificationsAsRead({
+      userId: String(userStore.userId),
+      tenantId: userStore.tenantId,
+    })
+    if (res.code === 200) {
+      window.$message?.success('全部已标记为已读')
+      dataSource.value.forEach((task) => {
+        task.isRead = 1
+      })
+      unreadCount.value = 0
+    }
+  }
+  catch (error) {
+    console.error('全部标记已读失败', error)
+    window.$message?.error('全部标记已读失败')
+  }
+}
+
+async function openDrawer(row) {
+  currentTask.value = row
+  approvalHistory.value = []
+  orderDetail.value = null
+  showDrawer.value = true
+
+  // 自动标记为已读
+  if (row.isRead !== 1) {
+    await handleMarkAsRead(row)
+  }
+
+  // 加载流转记录
+  if (row.processInstanceId) {
+    try {
+      const res = await flowApi.getProcessHistory(row.processInstanceId)
+      if (res.code === 200) {
+        approvalHistory.value = res.data || []
+      }
+    }
+    catch (error) {
+      console.error('加载流转记录失败', error)
+    }
+  }
+
+  // 加载订单明细(折扣/优惠结账通知)
+  const orderId = extractOrderId(row?.businessKey)
+  if (orderId) {
+    await loadOrderDetail(row)
+  }
+}
+
+onMounted(() => {
+  categoryTreeOptions.value = buildFlowCategoryTreeOptions([])
+  loadData()
+  loadUnreadCount()
+})
+</script>
+
+<style scoped>
+.notification-title-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.unread-dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  background-color: #18a058;
+  flex-shrink: 0;
+}
+
+.notification-unread {
+  background-color: #fffbe6;
+  color: #d48806;
+  border: 1px solid #ffe58f;
+}
+
+.notification-read {
+  background-color: #f5f5f5;
+  color: #8c8c8c;
+  border: 1px solid #d9d9d9;
+}
+
+.task-list-hint.unread {
+  color: #18a058;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+
+.notification-action-bar {
+  display: flex;
+  justify-content: flex-end;
+  padding: 16px 0;
+}
+
+.dish-table-wrap {
+  margin: 16px 0;
+  overflow-x: auto;
+}
+
+.dish-table {
+  width: 100%;
+  border-collapse: collapse;
+  font-size: 13px;
+}
+
+.dish-table th {
+  padding: 8px 12px;
+  text-align: left;
+  background: #f8fafc;
+  color: #64748b;
+  font-weight: 600;
+  border-bottom: 2px solid #e2e8f0;
+  white-space: nowrap;
+}
+
+.dish-table td {
+  padding: 8px 12px;
+  border-bottom: 1px solid #f1f5f9;
+  color: #334155;
+}
+
+.dish-table tbody tr:hover {
+  background: #f8fafc;
+}
+
+.amount-summary {
+  margin-top: 16px;
+  padding: 16px;
+  border: 1px solid #e2e8f0;
+  border-radius: 8px;
+  background: #fafbfc;
+}
+
+.amount-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 6px 0;
+}
+
+.amount-row:not(:last-child) {
+  border-bottom: 1px dashed #e2e8f0;
+}
+
+.amount-label {
+  color: #64748b;
+  font-size: 14px;
+}
+
+.amount-value {
+  color: #1e293b;
+  font-size: 14px;
+  font-weight: 600;
+}
+
+.amount-row.discount .amount-label {
+  color: #f59e0b;
+}
+
+.amount-row.discount .amount-value {
+  color: #f59e0b;
+}
+
+.amount-row.total {
+  padding-top: 10px;
+  margin-top: 4px;
+  border-top: 2px solid #e2e8f0;
+  border-bottom: none;
+}
+
+.amount-row.total .amount-label {
+  color: #1e293b;
+  font-weight: 700;
+  font-size: 15px;
+}
+
+.amount-row.total .amount-value {
+  color: #dc2626;
+  font-size: 18px;
+  font-weight: 700;
+}
+</style>

+ 1 - 1
forge-admin-ui/src/views/flow/todo.vue

@@ -636,7 +636,7 @@ function getPriorityText(p) {
 }
 
 function getProcessDisplayName(task) {
-  return task?.processName || task?.processTitle || task?.modelName || task?.businessType || '-'
+  return task?.processName || task?.processDefinitionName || task?.processTitle || task?.modelName || '-'
 }
 
 function getCategoryDisplayName(row) {

+ 2 - 2
forge-admin-ui/src/views/flow/utils/processDisplay.js

@@ -1,11 +1,11 @@
 export function getRowDisplayTitle(row = {}) {
   return firstText(
     row.businessSummary,
-    row.businessObjectName,
     row.processName,
+    row.processDefinitionName,
     row.processTitle,
     row.modelName,
-    row.processDefinitionName,
+    row.businessObjectName,
     row.taskName,
     row.title,
     row.processDefinitionKey,

+ 48 - 17
forge-admin-ui/src/views/restaurant/creditCustomer.vue

@@ -78,10 +78,10 @@
 </template>
 
 <script setup>
-import { NButton, NForm, NFormItem, NModal, NSelect, useDialog, useMessage } from 'naive-ui'
+import { NButton, NDropdown, NForm, NFormItem, NModal, NSelect, useDialog, useMessage } from 'naive-ui'
 import { computed, h, ref } from 'vue'
 import flowApi from '@/api/flow'
-import { checkCreditApproverSelection, listApproverUsers, submitCreditApproval } from '@/api/restaurant'
+import { checkCreditApproverSelection, listApproverUsers, submitCreditApproval, updateCreditStatus } from '@/api/restaurant'
 import { AiCrudPage } from '@/components/ai-form'
 import DictTag from '@/components/DictTag.vue'
 import FlowProcessDetailModal from '@/components/flow/FlowProcessDetailModal.vue'
@@ -163,7 +163,6 @@ const tableColumns = computed(() => [
     width: 110,
     render: row => h(DictTag, { dictType: 'restaurant_credit_status', value: row.creditStatus }),
   },
-  { prop: 'approverName', label: '审批人', width: 110, render: row => row.approverName || '-' },
   { prop: 'createTime', label: '创建时间', width: 170, render: row => row.createTime || '-' },
   {
     prop: 'approvalStatus',
@@ -205,11 +204,20 @@ const tableColumns = computed(() => [
         }, '撤回'))
       }
       if (canToggle(row)) {
-        btns.push(h('a', {
-          class: 'text-warning cursor-pointer hover:opacity-80',
-          style: btns.length ? 'margin-left: 12px' : '',
-          onClick: () => handleToggleStatus(row),
-        }, row.creditStatus === '0' ? '暂停挂账' : '恢复正常'))
+        // 下拉菜单显式选择目标挂账状态(正常/暂停/黑名单),排除当前状态,避免单按钮盲循环导致误操作
+        const statusOptions = creditStatusOptions.value
+          .filter(opt => String(opt.value) !== String(row.creditStatus))
+          .map(opt => ({ label: opt.label, key: String(opt.value) }))
+        btns.push(h(NDropdown, {
+          trigger: 'click',
+          options: statusOptions,
+          onSelect: key => handleSetCreditStatus(row, key),
+        }, {
+          default: () => h('a', {
+            class: 'text-warning cursor-pointer hover:opacity-80',
+            style: btns.length ? 'margin-left: 12px' : '',
+          }, '变更状态 ▾'),
+        }))
       }
       if (canDelete(row)) {
         btns.push(h('a', {
@@ -248,6 +256,21 @@ function handleDelete(row) {
   crudRef.value?.handleDelete(row)
 }
 
+// 撤回后状态由流程事件异步回调落库(Redis Pub/Sub → @FlowCallback),接口返回时业务状态可能尚未更新,
+// 需要轮询刷新直到该行离开"审批中",避免用户手动刷新才能看到最新状态
+function refreshUntilStatusChanged(rowId, expectedOldStatus, attempt = 0) {
+  crudRef.value?.refresh()
+  if (attempt >= 5)
+    return
+  setTimeout(() => {
+    const latest = (crudRef.value?.getTableData() || []).find(item => String(item.id) === String(rowId))
+    // 行已被删除或状态已流转则停止轮询
+    if (!latest || latest.approvalStatus !== expectedOldStatus)
+      return
+    refreshUntilStatusChanged(rowId, expectedOldStatus, attempt + 1)
+  }, 800)
+}
+
 // 撤回:发起人撤回审批中的流程;后端删除流程实例,回调把业务状态置为撤回
 function handleWithdraw(row) {
   dialog.warning({
@@ -263,21 +286,29 @@ function handleWithdraw(row) {
       }).then((res) => {
         if (res?.code === 200) {
           message.success('已撤回')
-          crudRef.value?.refresh()
+          refreshUntilStatusChanged(row.id, '2')
         }
       })
     },
   })
 }
 
-function handleToggleStatus(row) {
-  import('@/api/restaurant').then(({ toggleCreditStatus }) => {
-    toggleCreditStatus(row.id).then((res) => {
-      if (res.code === 200) {
-        message.success('操作成功')
-        crudRef.value?.refresh()
-      }
-    })
+// 变更挂账状态:下拉菜单显式选择目标状态(正常/暂停/黑名单),二次确认后直接落库
+function handleSetCreditStatus(row, targetStatus) {
+  const label = creditStatusOptions.value.find(opt => String(opt.value) === String(targetStatus))?.label || '目标状态'
+  dialog.warning({
+    title: '确认变更挂账状态',
+    content: `确定将「${row.customerName}」的挂账状态变更为「${label}」吗?`,
+    positiveText: '确定',
+    negativeText: '取消',
+    onPositiveClick: () => {
+      return updateCreditStatus(row.id, targetStatus).then((res) => {
+        if (res?.code === 200) {
+          message.success('操作成功')
+          crudRef.value?.refresh()
+        }
+      })
+    },
   })
 }
 

+ 54 - 0
forge-server/db/flow-definitions/discount_checkout_notify.bpmn.xml

@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  折扣/优惠结账通知流程 BPMN 定义(备份文件)
+  
+  此文件为流程设计参考备份,实际部署由代码 DiscountCheckoutFlowBpmn.java 在运行时
+  通过 FlowClient.createModel() + FlowClient.deployModel() 自动完成。
+  
+  如需在流程设计器中导入,可复制此文件内容粘贴到 BPMN 编辑器。
+  
+  流程结构:开始 → 老板通知(只读查看) → 结束
+  纯通知型,无需老板审核操作,记录永久保存。
+-->
+<bpmn:definitions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+                  xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
+                  xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
+                  xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
+                  xmlns:di="http://www.omg.org/spec/DD/20100524/DI"
+                  xmlns:flowable="http://flowable.org/bpmn"
+                  id="Definitions_discount_checkout_notify"
+                  targetNamespace="http://forge.mdframe.com/bpmn">
+  <bpmn:process id="discount_checkout_notify" name="折扣/优惠结账通知流程" isExecutable="true">
+    <bpmn:startEvent id="StartEvent_1" name="折扣/优惠结账" flowable:initiator="initiator">
+      <bpmn:outgoing>Flow_001</bpmn:outgoing>
+    </bpmn:startEvent>
+    <bpmn:userTask id="boss_notify" name="老板通知"
+                   flowable:candidateGroups="jjt-zhgly"
+                   flowable:candidateGroupNames="景聚庭-租户管理员"
+                   flowable:formKey="discount_checkout_notify_form"
+                   flowable:formFieldPermissions='[{"field":"bossRemark","label":"老板查看备注","readable":true,"writable":true,"required":false}]'
+                   flowable:allowApprove="false"
+                   flowable:allowReject="false"
+                   flowable:allowDelegate="false"
+                   flowable:allowReturn="false"
+                   flowable:allowTerminate="false"
+                   flowable:requireComment="false">
+      <bpmn:incoming>Flow_001</bpmn:incoming>
+      <bpmn:outgoing>Flow_002</bpmn:outgoing>
+    </bpmn:userTask>
+    <bpmn:endEvent id="EndEvent_notified" name="通知完成">
+      <bpmn:incoming>Flow_002</bpmn:incoming>
+    </bpmn:endEvent>
+    <bpmn:sequenceFlow id="Flow_001" sourceRef="StartEvent_1" targetRef="boss_notify" />
+    <bpmn:sequenceFlow id="Flow_002" sourceRef="boss_notify" targetRef="EndEvent_notified" />
+  </bpmn:process>
+  <bpmndi:BPMNDiagram id="BPMNDiagram_discount_checkout_notify">
+    <bpmndi:BPMNPlane id="BPMNPlane_discount_checkout_notify" bpmnElement="discount_checkout_notify">
+      <bpmndi:BPMNShape id="StartEvent_1_di" bpmnElement="StartEvent_1"><dc:Bounds x="80" y="192" width="36" height="36" /></bpmndi:BPMNShape>
+      <bpmndi:BPMNShape id="boss_notify_di" bpmnElement="boss_notify"><dc:Bounds x="220" y="170" width="140" height="80" /></bpmndi:BPMNShape>
+      <bpmndi:BPMNShape id="EndEvent_notified_di" bpmnElement="EndEvent_notified"><dc:Bounds x="460" y="192" width="36" height="36" /></bpmndi:BPMNShape>
+      <bpmndi:BPMNEdge id="Flow_001_di" bpmnElement="Flow_001"><di:waypoint x="116" y="210" /><di:waypoint x="220" y="210" /></bpmndi:BPMNEdge>
+      <bpmndi:BPMNEdge id="Flow_002_di" bpmnElement="Flow_002"><di:waypoint x="360" y="210" /><di:waypoint x="460" y="210" /></bpmndi:BPMNEdge>
+    </bpmndi:BPMNPlane>
+  </bpmndi:BPMNDiagram>
+</bpmn:definitions>

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 6372 - 154
forge-server/db/forge_admin.sql


+ 134 - 0
forge-server/db/migration/V1.0.140__add_discount_checkout_flow.sql

@@ -0,0 +1,134 @@
+-- =====================================================================
+-- V1.0.140  折扣/优惠结账通知流程(Flowable 纯通知,无需审核)
+-- 1) restaurant_dish_order 增加流程关联字段(business_key, process_instance_id)
+-- 2) 应用中心 object/app/binding 种子数据
+-- 说明:折扣/优惠结账后自动发起 Flowable 流程通知老板,
+--       老板在待办列表中随时可查看通知记录,记录永久保存。
+--       回滚方式:删除新增列和应用中心种子数据。
+-- =====================================================================
+
+-- ==================== 1. 表结构:流程关联字段 ====================
+
+SET @col_exists = (
+  SELECT COUNT(1) FROM information_schema.COLUMNS
+  WHERE TABLE_SCHEMA = DATABASE()
+    AND TABLE_NAME = 'restaurant_dish_order'
+    AND COLUMN_NAME = 'business_key'
+);
+
+SET @ddl = IF(@col_exists = 0,
+  'ALTER TABLE `restaurant_dish_order`
+    ADD COLUMN `business_key` varchar(128) DEFAULT NULL COMMENT ''流程业务Key(discount_checkout:{orderId})'' AFTER `remark`,
+    ADD COLUMN `process_instance_id` varchar(128) DEFAULT NULL COMMENT ''流程实例ID'' AFTER `business_key`',
+  'SELECT 1');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 索引:流程实例ID
+SET @idx_exists = (
+  SELECT COUNT(1) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE()
+    AND TABLE_NAME = 'restaurant_dish_order'
+    AND INDEX_NAME = 'idx_dish_order_process'
+);
+SET @ddl = IF(@idx_exists = 0,
+  'ALTER TABLE `restaurant_dish_order`
+    ADD KEY `idx_dish_order_process` (`tenant_id`, `process_instance_id`)',
+  'SELECT 1');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- ==================== 2. 应用中心种子数据 ====================
+
+-- 复用餐饮套件(V1.0.133 已创建)
+SET @restaurant_suite_id = (
+  SELECT id FROM ai_business_suite
+  WHERE tenant_id = 1 AND suite_code = 'RESTAURANT_SUITE'
+  LIMIT 1
+);
+
+-- 业务对象:折扣/优惠结账
+INSERT INTO ai_business_object (id, tenant_id, suite_code, object_code, object_name, object_type, model_id,
+                                model_code, display_field, icon, description, status, sort_order, options,
+                                design_status, config_key, last_publish_time, last_publish_version,
+                                designer_options, create_by, create_time, create_dept, update_by, update_time)
+SELECT 1910000000000140001, 1, 'RESTAURANT_SUITE', 'discount_checkout', '折扣/优惠结账', 'TRANSACTION', NULL,
+       NULL, 'orderNo', 'mdi:cash-minus',
+       '代码实现的折扣/优惠结账通知业务,服务员折扣结账后自动发起Flowable流程通知老板,记录永久保存', 1, 20,
+       '{"codeApp":true,"businessType":"discount_checkout","flowModelKey":"discount_checkout_notify"}',
+       'PUBLISHED', NULL, NOW(), 1,
+       '{"codeApp":true,"documentManaged":false,"defaultPanel":"flow-app"}',
+       1, NOW(), 1, 1, NOW()
+WHERE @restaurant_suite_id IS NOT NULL
+  AND NOT EXISTS (
+    SELECT 1 FROM ai_business_object
+    WHERE tenant_id = 1 AND suite_code = 'RESTAURANT_SUITE' AND object_code = 'discount_checkout'
+  );
+
+SET @discount_object_id = (
+  SELECT id FROM ai_business_object
+  WHERE tenant_id = 1 AND suite_code = 'RESTAURANT_SUITE' AND object_code = 'discount_checkout'
+  LIMIT 1
+);
+
+-- 业务应用:折扣结账审核
+INSERT INTO ai_business_app (id, tenant_id, app_code, app_name, app_type, suite_code, object_code, entry_mode,
+                             entry_url, config_key, icon, description, status, sort_order, options, create_by,
+                             create_time, create_dept, update_by, update_time)
+SELECT 1910000000000140002, 1, 'DISCOUNT_CHECKOUT_NOTIFY', '折扣结账通知', 'BUSINESS',
+       'RESTAURANT_SUITE', 'discount_checkout', 'ROUTE',
+       '/restaurant/dishOrder', NULL, 'mdi:cash-minus',
+       '打开订单记录列表查看折扣/优惠结账通知记录', 1, 20,
+       '{"codeApp":true,"flowConfigUrl":"/app-center/object/discount_checkout/designer?panel=flow-app&codeApp=1&name=折扣优惠结账"}',
+       1, NOW(), 1, 1, NOW()
+WHERE @discount_object_id IS NOT NULL
+  AND NOT EXISTS (
+    SELECT 1 FROM ai_business_app WHERE tenant_id = 1 AND app_code = 'DISCOUNT_CHECKOUT_REVIEW'
+  );
+
+-- 流程绑定:对象 → 流程模型
+INSERT INTO ai_business_binding (id, tenant_id, target_type, target_id, target_code, binding_type, binding_key,
+                                 binding_name, binding_config, description, status, sort_order, create_by,
+                                 create_time, create_dept, update_by, update_time)
+SELECT 1910000000000140003, 1, 'OBJECT', @discount_object_id, 'discount_checkout', 'FLOW', 'discount_checkout_notify',
+       '折扣结账通知流程',
+       '{
+         "flowModelKey":"discount_checkout_notify",
+         "flowModelName":"折扣/优惠结账通知流程",
+         "titleTemplate":"折扣结账通知-{roomName}-{orderNo}",
+         "startMode":"AUTO",
+         "businessBinding":{
+           "mode":"ADAPTER",
+           "primaryKeyField":"id",
+           "tenantField":"tenant_id",
+           "titleField":"orderNo",
+           "statusField":"orderStatus",
+           "ownerField":"createBy"
+         },
+         "variableMapping":[
+           {"formField":"businessKey","flowVariable":"businessKey","label":"业务Key"},
+           {"formField":"orderId","flowVariable":"orderId","label":"订单ID"},
+           {"formField":"orderNo","flowVariable":"orderNo","label":"订单号"},
+           {"formField":"roomName","flowVariable":"roomName","label":"包房名称"},
+           {"formField":"settleType","flowVariable":"settleType","label":"结账方式"},
+           {"formField":"totalAmount","flowVariable":"totalAmount","label":"总金额"},
+           {"formField":"payableAmount","flowVariable":"payableAmount","label":"实收金额"},
+           {"formField":"title","flowVariable":"title","label":"流程标题"}
+         ],
+         "nodeForms":[],
+         "conditionFlows":[],
+         "options":{"codeApp":true,"businessKeyPattern":"discount_checkout:{recordId}"}
+       }',
+       '折扣/优惠结账代码业务默认流程绑定。纯通知型,无需审核操作。节点表单与字段权限以BPMN节点配置为准。',
+       1, 1, 1, NOW(), 1, 1, NOW()
+WHERE @discount_object_id IS NOT NULL
+  AND NOT EXISTS (
+    SELECT 1 FROM ai_business_binding
+    WHERE tenant_id = 1
+      AND target_type = 'OBJECT'
+      AND target_code = 'discount_checkout'
+      AND binding_type = 'FLOW'
+      AND binding_key = 'discount_checkout_notify'
+  );

+ 73 - 0
forge-server/db/migration/V1.0.141__add_business_flow_permissions.sql

@@ -0,0 +1,73 @@
+-- =============================================
+-- V1.0.141: 添加 ai:businessFlow 系列权限资源
+-- 解决 BusinessFlowController 端点 403 权限不足问题
+-- =============================================
+
+-- 1. 添加 ai:businessFlow 父级目录(应用中心 -> 业务流程配置)
+-- 注意:这里假设应用中心已经存在,parent_id 需要动态获取
+-- 如果应用中心不存在,需要先创建应用中心菜单
+
+-- 查找或创建"应用中心"父级目录
+SET @app_center_parent_id = (
+    SELECT id FROM sys_resource 
+    WHERE tenant_id = 0 AND resource_name = '应用中心' AND resource_type = 1 
+    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 0, '应用中心', 0, 1, 60, '/app-center', NULL, 0, 0, 1, 1, 'mdi:apps', 0, 0, '应用中心目录', NOW(), NOW()
+WHERE NOT EXISTS (
+    SELECT 1 FROM sys_resource WHERE tenant_id = 0 AND resource_name = '应用中心' AND resource_type = 1
+);
+
+SET @app_center_parent_id = (
+    SELECT id FROM sys_resource 
+    WHERE tenant_id = 0 AND resource_name = '应用中心' AND resource_type = 1 
+    LIMIT 1
+);
+
+-- 2. 添加"业务流程配置"二级菜单
+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 0, '业务流程配置', @app_center_parent_id, 1, 10, '/app-center/business-flow', NULL, 0, 0, 1, 1, 'mdi:workflow', 0, 0, '低代码业务流程配置', NOW(), NOW()
+WHERE NOT EXISTS (
+    SELECT 1 FROM sys_resource WHERE tenant_id = 0 AND resource_name = '业务流程配置' AND parent_id = @app_center_parent_id
+);
+
+SET @business_flow_menu_id = LAST_INSERT_ID();
+
+-- 3. 添加按钮权限:ai:businessFlow:config(业务流程配置权限)
+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 0, '业务流程配置', @business_flow_menu_id, 3, 1, 'ai:businessFlow:config', 0, 0, 1, 1, '配置低代码业务流程', NOW(), NOW()
+WHERE NOT EXISTS (
+    SELECT 1 FROM sys_resource WHERE tenant_id = 0 AND perms = 'ai:businessFlow:config'
+);
+
+-- 4. 添加按钮权限:ai:businessFlow:start(发起业务流程权限)
+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 0, '发起业务流程', @business_flow_menu_id, 3, 2, 'ai:businessFlow:start', 0, 0, 1, 1, '发起低代码业务流程', NOW(), NOW()
+WHERE NOT EXISTS (
+    SELECT 1 FROM sys_resource WHERE tenant_id = 0 AND perms = 'ai:businessFlow:start'
+);
+
+-- 5. 添加按钮权限:ai:businessFlow:view(查看业务流程权限)
+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 0, '查看业务流程', @business_flow_menu_id, 3, 3, 'ai:businessFlow:view', 0, 0, 1, 1, '查看低代码业务流程', NOW(), NOW()
+WHERE NOT EXISTS (
+    SELECT 1 FROM sys_resource WHERE tenant_id = 0 AND perms = 'ai:businessFlow:view'
+);
+
+-- 6. 添加按钮权限:ai:businessFlow:callback(处理业务流程回调权限)
+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 0, '业务流程回调', @business_flow_menu_id, 3, 4, 'ai:businessFlow:callback', 0, 0, 1, 1, '处理低代码业务流程回调', NOW(), NOW()
+WHERE NOT EXISTS (
+    SELECT 1 FROM sys_resource WHERE tenant_id = 0 AND perms = 'ai:businessFlow:callback'
+);
+
+-- 7. 给超级管理员角色(role_id=1)分配这些权限
+INSERT INTO sys_role_resource (role_id, resource_id)
+SELECT 1, id FROM sys_resource 
+WHERE tenant_id = 0 AND perms IN ('ai:businessFlow:config', 'ai:businessFlow:start', 'ai:businessFlow:view', 'ai:businessFlow:callback')
+AND NOT EXISTS (
+    SELECT 1 FROM sys_role_resource srr WHERE srr.role_id = 1 AND srr.resource_id = sys_resource.id
+);

+ 145 - 0
forge-server/db/migration/V1.0.142__add_notification_todo_separation.sql

@@ -0,0 +1,145 @@
+-- =====================================================================
+-- V1.0.142  通知/待办分离 + 已读未读功能
+-- 1) sys_flow_task 增加 task_type 字段(0-审批任务/1-通知任务)
+-- 2) 新建 sys_flow_task_read 表(通知已读记录)
+-- 3) 更新现有折扣结账通知任务的 task_type
+-- 4) 添加"我的通知"菜单资源
+-- =====================================================================
+
+-- ==================== 1. sys_flow_task 增加 task_type 字段 ====================
+
+SET @col_exists = (
+  SELECT COUNT(1) FROM information_schema.COLUMNS
+  WHERE TABLE_SCHEMA = DATABASE()
+    AND TABLE_NAME = 'sys_flow_task'
+    AND COLUMN_NAME = 'task_type'
+);
+
+SET @ddl = IF(@col_exists = 0,
+  'ALTER TABLE `sys_flow_task`
+    ADD COLUMN `task_type` TINYINT NOT NULL DEFAULT 0 COMMENT ''任务类型(0-审批任务/1-通知任务)'' AFTER `business_type`',
+  'SELECT 1');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 索引:按任务类型筛选
+SET @idx_exists = (
+  SELECT COUNT(1) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE()
+    AND TABLE_NAME = 'sys_flow_task'
+    AND INDEX_NAME = 'idx_task_type'
+);
+SET @ddl = IF(@idx_exists = 0,
+  'ALTER TABLE `sys_flow_task`
+    ADD KEY `idx_task_type` (`task_type`, `status`, `assignee`)',
+  'SELECT 1');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- ==================== 2. 更新现有折扣结账通知任务 ====================
+
+-- 将 business_type = 'discount_checkout' 的任务标记为通知类型
+UPDATE sys_flow_task
+SET task_type = 1
+WHERE business_type = 'discount_checkout'
+  AND task_type = 0;
+
+-- ==================== 3. 新建 sys_flow_task_read 表 ====================
+
+CREATE TABLE IF NOT EXISTS `sys_flow_task_read` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID',
+  `task_id` VARCHAR(64) NOT NULL COMMENT '任务ID',
+  `user_id` VARCHAR(64) NOT NULL COMMENT '用户ID',
+  `read_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '已读时间',
+  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_task_user` (`task_id`, `user_id`),
+  KEY `idx_user_id` (`user_id`, `read_time`),
+  KEY `idx_task_id` (`task_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='流程任务已读记录表';
+
+-- ==================== 4. 添加"我的通知"菜单资源 ====================
+
+-- 获取流程管理根目录ID
+SET @flow_root_id = (
+  SELECT id FROM sys_resource
+  WHERE tenant_id = 0 AND resource_name = '流程管理' AND parent_id = 0 AND resource_type = 1
+  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 0, '我的通知', @flow_root_id, 2, 5, '/flow/notification', '/flow/notification', 0, 0, 1, 1, 'mdi:bell-outline', 0, 0, '我的流程通知', NOW(), NOW()
+WHERE @flow_root_id IS NOT NULL
+  AND NOT EXISTS (
+    SELECT 1 FROM sys_resource WHERE tenant_id = 0 AND resource_name = '我的通知' AND parent_id = @flow_root_id
+  );
+
+-- 始终通过名称回查获取菜单ID(禁止依赖 LAST_INSERT_ID,它会残留同连接中上一个脚本的自增值)
+SET @flow_notification_id = (
+  SELECT id FROM sys_resource
+  WHERE tenant_id = 0 AND resource_name = '我的通知' AND parent_id = @flow_root_id
+  LIMIT 1
+);
+
+-- 修复历史孤儿数据:之前因 LAST_INSERT_ID 残留导致按钮挂到了不存在的父节点,统一修正
+-- 注意:MySQL 不允许 UPDATE 子查询直接引用同一张表,需嵌套一层派生表
+UPDATE sys_resource
+SET parent_id = @flow_notification_id
+WHERE tenant_id = 0
+  AND parent_id NOT IN (SELECT id FROM (SELECT id FROM sys_resource WHERE tenant_id = 0) AS _tmp)
+  AND resource_name IN ('通知任务查询', '标记已读', '全部已读', '未读数量')
+  AND @flow_notification_id IS NOT NULL;
+
+-- 添加通知相关按钮权限
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, api_method, api_url, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 0, '通知任务查询', @flow_notification_id, 4, 10, 'flow:task:notification', 'GET', '/api/flow/task/notifications', 0, 0, 1, 1, '查询通知任务', NOW(), NOW()
+WHERE @flow_notification_id > 0
+  AND NOT EXISTS (
+    SELECT 1 FROM sys_resource WHERE tenant_id = 0 AND perms = 'flow:task:notification'
+  );
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, api_method, api_url, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 0, '标记已读', @flow_notification_id, 4, 11, 'flow:task:read', 'POST', '/api/flow/task/read', 0, 0, 1, 1, '标记任务已读', NOW(), NOW()
+WHERE @flow_notification_id > 0
+  AND NOT EXISTS (
+    SELECT 1 FROM sys_resource WHERE tenant_id = 0 AND perms = 'flow:task:read'
+  );
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, api_method, api_url, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 0, '全部已读', @flow_notification_id, 4, 12, 'flow:task:readAll', 'POST', '/api/flow/task/read-all', 0, 0, 1, 1, '全部标记已读', NOW(), NOW()
+WHERE @flow_notification_id > 0
+  AND NOT EXISTS (
+    SELECT 1 FROM sys_resource WHERE tenant_id = 0 AND perms = 'flow:task:readAll'
+  );
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, api_method, api_url, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 0, '未读数量', @flow_notification_id, 4, 13, 'flow:task:unreadCount', 'GET', '/api/flow/task/unread-count', 0, 0, 1, 1, '获取未读数量', NOW(), NOW()
+WHERE @flow_notification_id > 0
+  AND NOT EXISTS (
+    SELECT 1 FROM sys_resource WHERE tenant_id = 0 AND perms = 'flow:task:unreadCount'
+  );
+
+-- 给超级管理员角色分配新菜单权限(NOT EXISTS 防重复)
+INSERT INTO sys_role_resource (role_id, resource_id)
+SELECT 1, r.id
+FROM sys_resource r
+WHERE r.tenant_id = 0
+  AND r.parent_id = @flow_notification_id
+  AND @flow_notification_id > 0
+  AND NOT EXISTS (
+    SELECT 1 FROM sys_role_resource rr
+    WHERE rr.role_id = 1 AND rr.resource_id = r.id
+  );
+
+-- 菜单本身也分配给超级管理员
+INSERT INTO sys_role_resource (role_id, resource_id)
+SELECT 1, @flow_notification_id
+WHERE @flow_notification_id > 0
+  AND NOT EXISTS (
+    SELECT 1 FROM sys_role_resource rr
+    WHERE rr.role_id = 1 AND rr.resource_id = @flow_notification_id
+  );

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

@@ -3,7 +3,7 @@
 > 本文档记录个人在 Forge 项目中的编码习惯和偏好,所有代码编写(含 AI 辅助生成)必须遵循以下规范。
 > 
 > **适用范围**: forge-hotel 模块及后续所有业务模块
-> **最后更新**: 2026-09-24
+> **最后更新**: 2026-09-28
 
 ---
 
@@ -277,8 +277,10 @@ constant/       # 常量
 - **注意**:detail 和 delete 路径中**不包含 `:id`**,ID 由组件自动作为查询参数传递
 - `tableColumns` 和 `editSchema` 必须定义为 `computed`(确保字典异步加载后响应式更新)
 - 操作列使用 `h('a', { class: 'text-primary cursor-pointer hover:opacity-80', onClick: ... })` 模式
-- 刷新方法:`crudRef.value?.refresh()`(不是 reload)
+- 刷新方法:`crudRef.value?.refresh()`(不是 reload;等价于 `loadList`)
 - 编辑方法:`crudRef.value?.handleEdit(row)`
+- 删除方法:`crudRef.value?.handleDelete(row)`(组件自带二次确认弹窗与 `:id` 占位符替换;**不存在 `openDelete`**,误调会点击即抛 TypeError)
+- 读取当前表格数据:`crudRef.value?.getTableData()`(返回 dataSource,可用于轮询判断某行状态是否已变更,见 9.8)
 
 ### 2.3 字段命名
 
@@ -697,7 +699,7 @@ function handleFormSaved(payload) {
 
 - 业务表必须含 `business_key`、`process_instance_id` 字段;businessKey 格式固定 `<objectCode>:<recordId>`
 - 审批状态用**数字字典值**(如 1-暂存 2-审批中 3-已驳回 4-已退回 5-已完成),字典类型按业务命名(如 `restaurant_credit_approval_status`);前端一律 `useDict` + `DictTag` 字典驱动,禁止硬编码选项/标签
-- 状态门禁用函数集中定义:`canSubmit / canEdit / canDelete / canToggle`,操作列按门禁渲染按钮
+- 状态门禁用函数集中定义:`canSubmit / canEdit / canWithdraw / canDelete / canToggle`,操作列按门禁渲染按钮;审批状态数字值 ↔ 操作矩阵(以挂账客户为例,撤回见 9.6):`1暂存`→提交审批+编辑+删除;`2审批中`→撤回;`3撤回 / 4驳回`→编辑+删除;`5已完成`→变更挂账状态
 - 流程模型懒创建:首次提交审批时 `ensureFlowModel()` 自动创建并发布;`getModelByKey` 查询**必须过滤 `del_flag = 0`**,否则已删除模型会导致重复创建
 - 业务状态维护依赖双回调:task-created / task-completed 回调 + `PROCESS_COMPLETED` 监听都要维护状态,不能只靠其一;状态写入必须幂等、可从活动任务节点修复
 - BPMN 节点表单、字段权限、审批人策略在**流程设计器**中维护;代码初始化逻辑不得覆盖设计器已编辑的 BPMN XML
@@ -740,6 +742,15 @@ function handleFormSaved(payload) {
 - formUrl 运行时优先级:`sys_flow_node_config` 节点配置 > `sys_flow_model.form_json`(顶层与 formRef 两处)> Provider 常量;**改 Java 常量不影响存量模型**,必须 Flyway 同步修复 form_json(参考 `V1.0.139__fix_credit_customer_flow_form_url.sql`,REPLACE 两处 + LIKE 防重复)
 - formType=business-code 且 formUrl 为空时降级为平台内置渲染(AiForm 字段目录 + 审批意见 + 按钮);有自定义组件时优先组件模式
 
+#### 已办/通知页面渲染模式(与待办共用 FlowBusinessForm)
+
+- 已办(`views/flow/done.vue`)和通知(`views/flow/notification.vue`)页面与待办页面**共用同一套组件架构**,通过 `readOnly` 属性切换可写/只读态
+- **已办页面渲染优先级**(`useBusinessManagedForm` 分支):
+  1. **优先 `FlowBusinessForm`**(`businessFormContext.formUrl` 存在时):加载同一个业务组件(如 `CreditCustomerApproveForm.vue`),传入 `read-only=true`,展示表格布局(与待办一致)
+  2. **降级 `AiForm`**(无 formUrl 时):用 `readonlyBusinessFormFields`(`toReadonlyField` 转换后的 schema)渲染,表单布局
+- **通知页面**:独立逻辑,通常展示业务摘要 + 自定义详情区(如订单明细),不强制走 FlowBusinessForm
+- **关键原则**:有自定义业务组件(formUrl)的流程,待办/已办必须用同一个组件渲染,保证样式一致;不能待办用组件、已办用 AiForm
+
 #### 业务表单组件契约(照 CreditCustomerApproveForm.vue 仿写)
 
 - props 固定一套:`taskId / businessKey / processInstanceId / taskDefKey / processDefKey / variables / approvalPolicy / initialTaskContext / readOnly / submitting / submittingAction`;emits:`submit`、`cancel`
@@ -790,6 +801,67 @@ function handleFormSaved(payload) {
 - **三处同步红线**:改节点审批人方式(设计器改或代码改)时同步检查 ① 前端提交弹窗 ② 后端提交校验 ③ Java BPMN 种子模板(`XxxFlowBpmn`),缺一处新环境就会按种子模板走回旧路;同时保持 `ensureFlowModel` 不覆盖设计器已编辑的 BPMN(见 9.2)
 - **生效提示**:flow 侧 Mapper XML / Java 改动需**重启 flow 服务**才生效(重启 admin 无效);业务侧 Java 改重启 admin;前端 HMR 即时生效
 
+### 9.6 发起人撤回审批中流程(撤回链路与接入)
+
+> 参考实现:`creditCustomer.vue handleWithdraw` + `flowApi.withdrawProcess` + `FlowTaskServiceImpl.withdraw` + `CreditCustomerServiceImpl.markCanceled`
+
+- **平台已内置撤回链路,业务方零新增后端接口**,前端一个确认弹窗 + 一次 API 调用即可接入
+- 撤回入口:`flowApi.withdrawProcess({ processInstanceId, userId, reason })` → `POST /api/flow/task/withdraw`(`flow.js` 是**默认导出对象**,非命名导出)
+  - `userId` 取 `useUserStore().userId`(`import { useUserStore } from '@/store'`)
+  - payload 三字段固定:`processInstanceId` + `userId` + `reason`
+- 后端链路(全部平台侧,闭环自动落业务状态):
+
+| 步骤 | 位置 | 动作 |
+|------|------|------|
+| 1 校验 | `FlowTaskServiceImpl.assertSubmitterWithdrawAllowed` | 校验 `allowSubmitterWithdraw` 属性非 FALSE + `isProcessSubmitter` 是提交人 |
+| 2 删实例 | `runtimeService.deleteProcessInstance(id, "用户撤回")` | 物理删除运行时实例,FlowTask 状态置 6 |
+| 3 事件 | `FlowTaskEventListener` case `PROCESS_CANCELLED` | FlowBusiness 置 canceled + 发布 `FlowEventMessage.PROCESS_CANCELED` |
+| 4 回调 | 业务 `@FlowCallback(ON_CANCELED)` | `markCanceled` 把业务 `approvalStatus` 置为撤回态(如 '3') |
+
+- 撤回门禁:仅「审批中(2) 且有 processInstanceId」的行显示撤回按钮:`canWithdraw = row => row.approvalStatus === '2' && !!row.processInstanceId`;按钮用 `text-warning` 黄色 + 二次确认
+- **撤回态(3)/驳回态(4) 编辑保存后回到暂存(1)**,形成「撤回/驳回 → 编辑 → 暂存 → 重新送审」闭环(后端 `creditSave` 门禁放行 {1,3,4},且 3/4 保存后 reset 为 DRAFT)
+
+### 9.7 撤回/驳回后重新发起 = 新流程实例(含平台发起幂等修复)
+
+- **撤回不是「暂停后恢复」,而是「作废旧实例 → 修改资料 → 发起新实例」**:
+  - 旧实例被 `deleteProcessInstance` 物理删除(`ACT_RU_*` 消失,痕迹留在 `ACT_HI_*` 与 `sys_flow_task`)
+  - `submitApproval` 再次 `startProcess` 生成**新 processInstanceId**,直接覆盖业务表 `process_instance_id`
+  - `businessKey`(`<objectCode>:<recordId>`)与业务记录 ID **不变**;页面流程详情展示新实例时间轴,旧实例痕迹需走历史查询
+- **平台发起幂等校验坑(已修)**:`FlowInstanceServiceImpl.startProcess` 原逻辑「`sys_flow_business` 存在非运行态记录 → 一律抛『业务流程已存在且不可重复发起』」,与前端 `useFlow.canStart`(canceled 后允许重发)自相矛盾,堵死了「发起→撤回→重发」闭环
+  - 修复:新增 `isRestartableEndedBusiness`,旧流程未成功走完(撤回/驳回/终止)且运行实例已不存在时,`flowBusinessMapper.deleteById` 清除旧关联再重发;该表只保留 businessKey 当前一轮关联
+
+### 9.8 流程异步回调导致的前端刷新时序(轮询刷新)
+
+> 参考实现:`creditCustomer.vue refreshUntilStatusChanged`
+
+- **撤回/审批完成后接口立即返回,但业务状态由跨服务异步回调落库**,前端刷新早于回调会查到旧状态:
+  - 链路:flow 服务 `FlowEventPublisher.publish`(`@Async` + Redis Pub/Sub `flow:event:all`)→ admin 服务 `FlowClientHelperAutoConfiguration` 订阅 → `@FlowCallback` → 改业务状态
+  - 现象:撤回后列表仍显示「审批中」,手动刷新才变「撤回」
+- **修复模式:轮询刷新直到该行离开旧状态**(带上限兜底,避免回调异常时无限轮询):
+```javascript
+function refreshUntilStatusChanged(rowId, expectedOldStatus, attempt = 0) {
+  crudRef.value?.refresh()
+  if (attempt >= 5) return
+  setTimeout(() => {
+    const latest = (crudRef.value?.getTableData() || []).find(i => String(i.id) === String(rowId))
+    if (!latest || latest.approvalStatus !== expectedOldStatus) return  // 已流转或已删除则停
+    refreshUntilStatusChanged(rowId, expectedOldStatus, attempt + 1)
+  }, 800)
+}
+```
+- 适用于所有「操作接口同步返回、业务状态靠异步事件回调落库」的场景(撤回、审批通过/驳回等)
+
+### 9.9 多状态字段操作:下拉菜单显式选目标状态,禁用单按钮盲循环
+
+> 参考实现:`creditCustomer.vue` 挂账状态列(`restaurant_credit_status` 三态)+ `CreditCustomerServiceImpl.creditUpdateStatus`
+
+- **踩坑**:挂账状态三态(0正常/1暂停/2黑名单),后端 `creditToggleStatus` 做 `0→1→2→0` 盲循环,前端按钮却按两态显示文案(`status==='0' ? '暂停挂账' : '恢复正常'`)→ 从「暂停」点一下文案写「恢复正常」实际却跳「黑名单」,文案与行为不符 + 易误操作
+- **规则**:≥ 3 个状态、或状态语义不对等的字段,操作用 **NDropdown 下拉菜单显式选目标状态**,不用单按钮循环:
+  - 选项来自字典(`useDict`),**自动排除当前状态**,点哪个变哪个
+  - 后端接口接收目标状态入参(`creditUpdateStatus(id, creditStatus)`),校验目标值必须是字典合法值,与当前相同则跳过;不再做循环推导
+  - 选中后二次确认弹窗再落库
+- 两态且语义对等(启用/停用)才可用单按钮 toggle
+
 ---
 
 ## 十、AI 执行纪律(防止跑偏规则)

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

@@ -75,12 +75,12 @@ public class CreditCustomerController {
     }
 
     /**
-     * 切换挂账状态(正常→暂停→黑名单 循环切换,仅审批通过后允许)。
+     * 变更挂账状态(显式指定目标状态:0-正常挂账、1-暂停挂账、2-黑名单,仅审批通过后允许)。
      */
-    @PostMapping("/toggleStatus")
-    @OperationLog(module = "挂账客户", type = OperationType.UPDATE, desc = "切换挂账状态")
-    public RespInfo<Void> creditToggleStatus(@RequestParam Long id) {
-        creditCustomerService.creditToggleStatus(id);
+    @PostMapping("/updateStatus")
+    @OperationLog(module = "挂账客户", type = OperationType.UPDATE, desc = "变更挂账状态")
+    public RespInfo<Void> creditUpdateStatus(@RequestParam Long id, @RequestParam String creditStatus) {
+        creditCustomerService.creditUpdateStatus(id, creditStatus);
         return RespInfo.success();
     }
 

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

@@ -76,6 +76,12 @@ public class DishOrder extends TenantEntity {
     /** 备注 */
     private String remark;
 
+    /** 流程业务Key(discount_checkout:{orderId}) */
+    private String businessKey;
+
+    /** 流程实例ID */
+    private String processInstanceId;
+
     /** 删除标记(0=未删除,删除时写入主键ID) */
     @TableLogic(value = "0", delval = "id")
     private Long delFlag;

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

@@ -65,9 +65,9 @@ public interface CreditCustomerService {
     void creditDelete(Long id);
 
     /**
-     * 切换挂账状态(正常/暂停/黑名单 循环切换)。
+     * 变更挂账状态(显式指定目标状态:0-正常挂账、1-暂停挂账、2-黑名单,仅审批通过后允许)。
      */
-    void creditToggleStatus(Long id);
+    void creditUpdateStatus(Long id, String creditStatus);
 
     /**
      * 提交审批(发起老板审核流程),返回流程实例ID。

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

@@ -214,31 +214,26 @@ public class CreditCustomerServiceImpl implements CreditCustomerService {
         log.info("删除挂账客户: {} ({})", entity.getCustomerName(), entity.getMobile());
     }
 
+    /** 合法挂账状态值(对应字典 restaurant_credit_status):0-正常挂账、1-暂停挂账、2-黑名单 */
+    private static final Set<String> CREDIT_STATUS_VALUES = Set.of("0", "1", "2");
+
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public void creditToggleStatus(Long id) {
+    public void creditUpdateStatus(Long id, String creditStatus) {
         CreditCustomer entity = requireEntity(id);
         if (!STATUS_APPROVED.equals(entity.getApprovalStatus())) {
-            throw new BusinessException("挂账客户审批通过后才能切换挂账状态");
-        }
-
-        // 循环切换:0(正常) → 1(暂停) → 2(黑名单) → 0(正常)
-        String current = entity.getCreditStatus();
-        String next;
-        switch (current != null ? current : "0") {
-            case "0":
-                next = "1";
-                break;
-            case "1":
-                next = "2";
-                break;
-            default:
-                next = "0";
-                break;
-        }
-        entity.setCreditStatus(next);
+            throw new BusinessException("挂账客户审批通过后才能变更挂账状态");
+        }
+        // 目标状态由前端下拉菜单显式指定,必须是字典定义的合法值,不再盲循环
+        if (creditStatus == null || !CREDIT_STATUS_VALUES.contains(creditStatus)) {
+            throw new BusinessException("非法的挂账状态: " + creditStatus);
+        }
+        if (creditStatus.equals(entity.getCreditStatus())) {
+            return;
+        }
+        entity.setCreditStatus(creditStatus);
         creditCustomerMapper.updateById(entity);
-        log.info("切换挂账状态: {} -> {}", entity.getCustomerName(), next);
+        log.info("变更挂账状态: {} -> {}", entity.getCustomerName(), creditStatus);
     }
 
     // ==================== 审批流程 ====================

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

@@ -17,10 +17,14 @@ 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.support.DiscountCheckoutFlowBpmn;
+import com.mdframe.forge.business.core.restaurant.support.DiscountCheckoutFlowDefinition;
 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.flow.client.FlowClient;
+import com.mdframe.forge.flow.client.FlowResult;
 import com.mdframe.forge.starter.core.domain.PageQuery;
 import com.mdframe.forge.starter.core.exception.BusinessException;
 import com.mdframe.forge.starter.core.session.SessionHelper;
@@ -29,6 +33,7 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
 
 import java.math.BigDecimal;
 import java.text.SimpleDateFormat;
@@ -89,6 +94,9 @@ public class DishOrderServiceImpl implements DishOrderService {
     @Autowired
     private DishTypeMapper dishTypeMapper;
 
+    @Autowired
+    private FlowClient flowClient;
+
     @Override
     public IPage<DishOrderVO> orderPage(PageQuery pageQuery, Long roomId, String orderStatus, String settleType,
                                         String beginTime, String endTime) {
@@ -497,6 +505,149 @@ public class DishOrderServiceImpl implements DishOrderService {
         }
 
         dishOrderMapper.updateById(update);
+
+        // 折扣/优惠结账 → 发起 Flowable 流程通知老板
+        if ("CHECKOUT".equals(dto.getSettleFlag())) {
+            String settleType = update.getSettleType();
+            if ("DISCOUNT".equals(settleType) || "PROMOTION".equals(settleType)) {
+                startDiscountCheckoutFlow(update);
+            }
+        }
+    }
+
+    /**
+     * 折扣/优惠结账后,发起 Flowable 流程通知老板。
+     * <p>
+     * 流程会在老板的待办列表中创建一条通知记录,老板可随时查看。
+     * 流程记录永久保存在数据库中,不会因页面关闭而丢失。
+     * 纯通知,无需老板审核操作。
+     * <p>
+     * 此方法内部 try-catch 兜底,流程发起失败不影响结账主流程。
+     */
+    private void startDiscountCheckoutFlow(DishOrder order) {
+        try {
+            ensureDiscountCheckoutFlowModel();
+
+            String businessKey = DiscountCheckoutFlowDefinition.businessKey(order.getId());
+            String userId = SessionHelper.getUserId() == null ? null : String.valueOf(SessionHelper.getUserId());
+            String userName = StringUtils.hasText(SessionHelper.getUsername()) ? SessionHelper.getUsername() : "system";
+
+            String settleLabel = "DISCOUNT".equals(order.getSettleType()) ? "折扣" : "优惠";
+            String totalAmount = order.getTotalAmount() != null ? order.getTotalAmount().toPlainString() : "0";
+            String payableAmount = order.getPayableAmount() != null ? order.getPayableAmount().toPlainString() : "0";
+            String title = settleLabel + "结账通知-" + order.getRoomName() + "-" + order.getOrderNo();
+
+            Map<String, Object> variables = new HashMap<>();
+            variables.put(DiscountCheckoutFlowDefinition.VAR_BUSINESS_KEY, businessKey);
+            variables.put(DiscountCheckoutFlowDefinition.VAR_OBJECT_CODE, DiscountCheckoutFlowDefinition.BUSINESS_TYPE);
+            variables.put(DiscountCheckoutFlowDefinition.VAR_RECORD_ID, String.valueOf(order.getId()));
+            variables.put(DiscountCheckoutFlowDefinition.VAR_ORDER_ID, String.valueOf(order.getId()));
+            variables.put(DiscountCheckoutFlowDefinition.VAR_ORDER_NO, order.getOrderNo());
+            variables.put(DiscountCheckoutFlowDefinition.VAR_ROOM_NAME, order.getRoomName());
+            variables.put(DiscountCheckoutFlowDefinition.VAR_SETTLE_TYPE, order.getSettleType());
+            variables.put(DiscountCheckoutFlowDefinition.VAR_TOTAL_AMOUNT, totalAmount);
+            variables.put(DiscountCheckoutFlowDefinition.VAR_PAYABLE_AMOUNT, payableAmount);
+            variables.put(DiscountCheckoutFlowDefinition.VAR_TITLE, title);
+            variables.put(DiscountCheckoutFlowDefinition.VAR_INITIATOR, userId);
+
+            String deptId = SessionHelper.getMainOrgId() == null ? null : String.valueOf(SessionHelper.getMainOrgId());
+            FlowResult<String> result = flowClient.startProcess(
+                    DiscountCheckoutFlowDefinition.MODEL_KEY,
+                    businessKey,
+                    DiscountCheckoutFlowDefinition.BUSINESS_TYPE,
+                    title,
+                    variables,
+                    userId,
+                    userName,
+                    deptId,
+                    null);
+
+            if (result != null && result.isSuccess() && StringUtils.hasText(result.getData())) {
+                // 回写流程信息到订单
+                DishOrder flowUpdate = new DishOrder();
+                flowUpdate.setId(order.getId());
+                flowUpdate.setBusinessKey(businessKey);
+                flowUpdate.setProcessInstanceId(result.getData());
+                dishOrderMapper.updateById(flowUpdate);
+                log.info("折扣/优惠结账 Flowable 通知流程发起成功: orderId={}, orderNo={}, businessKey={}, processInstanceId={}",
+                        order.getId(), order.getOrderNo(), businessKey, result.getData());
+            } else {
+                log.warn("折扣/优惠结账 Flowable 通知流程发起失败(不影响结账): orderId={}, result={}",
+                        order.getId(), result == null ? "null" : result.getMsg());
+            }
+        } catch (Exception e) {
+            // 流程发起失败不影响结账主流程,仅记录日志
+            log.warn("折扣/优惠结账 Flowable 通知流程发起异常(不影响结账): orderId={}, error={}",
+                    order.getId(), e.getMessage());
+        }
+    }
+
+    /**
+     * 确保折扣/优惠结账通知流程模型已创建并发布。
+     * <p>
+     * 首次调用时创建模型并部署 BPMN,后续调用检测到已部署则跳过。
+     * 如果管理员在流程设计器中修改了 BPMN,会保留设计器的配置。
+     */
+    private void ensureDiscountCheckoutFlowModel() {
+        String bpmnXml = DiscountCheckoutFlowBpmn.build();
+        FlowResult<java.util.Map<String, Object>> current = flowClient.getModelByKey(DiscountCheckoutFlowDefinition.MODEL_KEY);
+        java.util.Map<String, Object> model = current == null ? null : current.getData();
+        boolean created = model == null || model.get("id") == null;
+        boolean shouldDeploy = created;
+
+        if (created) {
+            FlowResult<java.util.Map<String, Object>> createdResult = flowClient.createModel(
+                    DiscountCheckoutFlowDefinition.flowModelPayload(null, resolveTenantId(), bpmnXml));
+            if (createdResult == null || !createdResult.isSuccess() || createdResult.getData() == null) {
+                log.warn("创建折扣结账通知流程模型失败: {}",
+                        createdResult == null ? "无返回结果" : createdResult.getMsg());
+                return;
+            }
+            model = createdResult.getData();
+        } else {
+            String existingBpmnXml = org.springframework.util.StringUtils.hasText(
+                    java.util.Objects.toString(model.get("bpmnXml"), ""))
+                    ? java.util.Objects.toString(model.get("bpmnXml"), "") : "";
+            Integer status = toInteger(model.get("status"));
+            shouldDeploy = status == null || status != 1
+                    || !StringUtils.hasText(java.util.Objects.toString(model.get("deploymentId"), ""));
+            if (!StringUtils.hasText(existingBpmnXml)) {
+                FlowResult<java.util.Map<String, Object>> updatedResult = flowClient.updateModel(
+                        DiscountCheckoutFlowDefinition.flowModelPayload(model.get("id"), resolveTenantId(), bpmnXml));
+                if (updatedResult == null || !updatedResult.isSuccess() || updatedResult.getData() == null) {
+                    log.warn("更新折扣结账通知流程模型失败: {}",
+                            updatedResult == null ? "无返回结果" : updatedResult.getMsg());
+                    return;
+                }
+                model = updatedResult.getData();
+                shouldDeploy = true;
+            }
+        }
+
+        if (shouldDeploy) {
+            FlowResult<String> deployedResult = flowClient.deployModel(
+                    java.util.Objects.toString(model.get("id"), ""));
+            if (deployedResult == null || !deployedResult.isSuccess()) {
+                log.warn("发布折扣结账通知流程失败: {}",
+                        deployedResult == null ? "无返回结果" : deployedResult.getMsg());
+            } else {
+                log.info("折扣/优惠结账通知流程已发布: modelId={}", model.get("id"));
+            }
+        }
+    }
+
+    private Integer toInteger(Object value) {
+        if (value instanceof Number) {
+            return ((Number) value).intValue();
+        }
+        if (value == null) {
+            return null;
+        }
+        try {
+            return Integer.parseInt(String.valueOf(value));
+        } catch (NumberFormatException e) {
+            return null;
+        }
     }
 
     /**

+ 61 - 0
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/support/DiscountCheckoutFlowBpmn.java

@@ -0,0 +1,61 @@
+package com.mdframe.forge.business.core.restaurant.support;
+
+/**
+ * 折扣/优惠结账通知流程 BPMN XML 种子模板。
+ * <p>
+ * 最简结构:提交 → 老板通知(只读查看) → 结束。
+ * 纯通知型流程,无需老板审核操作,记录永久保存。
+ */
+public final class DiscountCheckoutFlowBpmn {
+
+    private DiscountCheckoutFlowBpmn() {
+    }
+
+    public static String build() {
+        String xml = """
+                <?xml version="1.0" encoding="UTF-8"?>
+                <bpmn:definitions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+                                  xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
+                                  xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
+                                  xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
+                                  xmlns:di="http://www.omg.org/spec/DD/20100524/DI"
+                                  xmlns:flowable="http://flowable.org/bpmn"
+                                  id="Definitions_@MODEL_KEY@"
+                                  targetNamespace="http://forge.mdframe.com/bpmn">
+                  <bpmn:process id="@MODEL_KEY@" name="@MODEL_NAME@" isExecutable="true">
+                    <bpmn:startEvent id="StartEvent_1" name="折扣/优惠结账" flowable:initiator="@VAR_INITIATOR@">
+                      <bpmn:outgoing>Flow_001</bpmn:outgoing>
+                    </bpmn:startEvent>
+                    <bpmn:userTask id="@NODE_BOSS_NOTIFY@" name="老板通知" flowable:candidateGroups="@BOSS_CANDIDATE_ROLE@" flowable:candidateGroupNames="@BOSS_CANDIDATE_ROLE_NAME@" flowable:formKey="@FORM_KEY@" flowable:formFieldPermissions='@PERM_BOSS@' flowable:allowApprove="false" flowable:allowReject="false" flowable:allowDelegate="false" flowable:allowReturn="false" flowable:allowTerminate="false" flowable:requireComment="false">
+                      <bpmn:incoming>Flow_001</bpmn:incoming>
+                      <bpmn:outgoing>Flow_002</bpmn:outgoing>
+                    </bpmn:userTask>
+                    <bpmn:endEvent id="EndEvent_notified" name="通知完成">
+                      <bpmn:incoming>Flow_002</bpmn:incoming>
+                    </bpmn:endEvent>
+                    <bpmn:sequenceFlow id="Flow_001" sourceRef="StartEvent_1" targetRef="@NODE_BOSS_NOTIFY@" />
+                    <bpmn:sequenceFlow id="Flow_002" sourceRef="@NODE_BOSS_NOTIFY@" targetRef="EndEvent_notified" />
+                  </bpmn:process>
+                  <bpmndi:BPMNDiagram id="BPMNDiagram_@MODEL_KEY@">
+                    <bpmndi:BPMNPlane id="BPMNPlane_@MODEL_KEY@" bpmnElement="@MODEL_KEY@">
+                      <bpmndi:BPMNShape id="StartEvent_1_di" bpmnElement="StartEvent_1"><dc:Bounds x="80" y="192" width="36" height="36" /></bpmndi:BPMNShape>
+                      <bpmndi:BPMNShape id="@NODE_BOSS_NOTIFY@_di" bpmnElement="@NODE_BOSS_NOTIFY@"><dc:Bounds x="220" y="170" width="140" height="80" /></bpmndi:BPMNShape>
+                      <bpmndi:BPMNShape id="EndEvent_notified_di" bpmnElement="EndEvent_notified"><dc:Bounds x="460" y="192" width="36" height="36" /></bpmndi:BPMNShape>
+                      <bpmndi:BPMNEdge id="Flow_001_di" bpmnElement="Flow_001"><di:waypoint x="116" y="210" /><di:waypoint x="220" y="210" /></bpmndi:BPMNEdge>
+                      <bpmndi:BPMNEdge id="Flow_002_di" bpmnElement="Flow_002"><di:waypoint x="360" y="210" /><di:waypoint x="460" y="210" /></bpmndi:BPMNEdge>
+                    </bpmndi:BPMNPlane>
+                  </bpmndi:BPMNDiagram>
+                </bpmn:definitions>
+                """;
+        return xml
+                .replace("@MODEL_KEY@", DiscountCheckoutFlowDefinition.MODEL_KEY)
+                .replace("@MODEL_NAME@", DiscountCheckoutFlowDefinition.MODEL_NAME)
+                .replace("@VAR_INITIATOR@", DiscountCheckoutFlowDefinition.VAR_INITIATOR)
+                .replace("@FORM_KEY@", DiscountCheckoutFlowDefinition.FORM_KEY)
+                .replace("@NODE_BOSS_NOTIFY@", DiscountCheckoutFlowDefinition.NODE_BOSS_NOTIFY)
+                .replace("@BOSS_CANDIDATE_ROLE@", DiscountCheckoutFlowDefinition.BOSS_CANDIDATE_ROLE)
+                .replace("@BOSS_CANDIDATE_ROLE_NAME@", DiscountCheckoutFlowDefinition.BOSS_CANDIDATE_ROLE_NAME)
+                .replace("@PERM_BOSS@",
+                        DiscountCheckoutFlowDefinition.nodeFieldPermissionsJson(DiscountCheckoutFlowDefinition.NODE_BOSS_NOTIFY));
+    }
+}

+ 221 - 0
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/support/DiscountCheckoutFlowDefinition.java

@@ -0,0 +1,221 @@
+package com.mdframe.forge.business.core.restaurant.support;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 折扣/优惠结账通知流程默认定义。
+ * <p>
+ * 服务员以折扣或优惠方式结账后,自动发起 Flowable 流程通知老板。
+ * 老板在待办列表中随时可查看通知记录(订单号、包房、结账方式、总额、实收),
+ * 记录永久保存,无需审核操作。
+ */
+public final class DiscountCheckoutFlowDefinition {
+
+    private DiscountCheckoutFlowDefinition() {
+    }
+
+    public static final String BUSINESS_TYPE = "discount_checkout";
+    public static final String BUSINESS_OBJECT_NAME = "折扣/优惠结账";
+    public static final String BUSINESS_APP_NAME = "折扣优惠结账通知";
+
+    public static final String MODEL_KEY = "discount_checkout_notify";
+    public static final String MODEL_NAME = "折扣/优惠结账通知流程";
+    public static final String MODEL_DESCRIPTION = "餐饮折扣/优惠结账后通知老板,记录永久保存,无需审核操作。";
+    public static final String MODEL_CATEGORY = "restaurant";
+    public static final String FLOW_TYPE = "restaurant";
+    public static final String DESIGNER_TYPE = "approval";
+    public static final String FORM_TYPE_BUSINESS = "business";
+    public static final String NOTIFY_TYPE_REDIS = "redis";
+
+    public static final String PROVIDER_KEY = "discountCheckout";
+    public static final String PROVIDER_NAME = "折扣结账通知代码表单";
+    public static final String FORM_KEY = "discount_checkout_notify_form";
+    public static final String FORM_NAME = "折扣结账通知表单";
+    /** 通知详情内嵌的任务表单组件路径 */
+    public static final String FORM_URL = "/restaurant/DiscountCheckoutNotifyForm";
+    public static final String FORM_MODE_BUSINESS_CODE = "BUSINESS_CODE_FORM";
+    public static final String TASK_FORM_TYPE_BUSINESS_CODE = "business-code";
+
+    public static final String NODE_BOSS_NOTIFY = "boss_notify";
+
+    public static final String VAR_BUSINESS_KEY = "businessKey";
+    public static final String VAR_OBJECT_CODE = "objectCode";
+    public static final String VAR_RECORD_ID = "recordId";
+    public static final String VAR_ORDER_ID = "orderId";
+    public static final String VAR_ORDER_NO = "orderNo";
+    public static final String VAR_ROOM_NAME = "roomName";
+    public static final String VAR_SETTLE_TYPE = "settleType";
+    public static final String VAR_TOTAL_AMOUNT = "totalAmount";
+    public static final String VAR_PAYABLE_AMOUNT = "payableAmount";
+    public static final String VAR_TITLE = "title";
+    public static final String VAR_INITIATOR = "initiator";
+
+    /** 老板通知节点默认候选角色编码(与挂账客户审批共用同一角色) */
+    public static final String BOSS_CANDIDATE_ROLE = "jjt-zhgly";
+    /** 老板通知节点默认候选角色展示名 */
+    public static final String BOSS_CANDIDATE_ROLE_NAME = "景聚庭-租户管理员";
+
+    public static final String FIELD_ID = "id";
+    public static final String FIELD_ORDER_NO = "orderNo";
+    public static final String FIELD_ROOM_NAME = "roomName";
+    public static final String FIELD_SETTLE_TYPE = "settleType";
+    public static final String FIELD_TOTAL_AMOUNT = "totalAmount";
+    public static final String FIELD_PAYABLE_AMOUNT = "payableAmount";
+    public static final String FIELD_DISCOUNT_RATE = "discountRate";
+    public static final String FIELD_DISCOUNT_AMOUNT = "discountAmount";
+    public static final String FIELD_SETTLE_TIME = "settleTime";
+    public static final String FIELD_CREATE_NAME = "createName";
+    public static final String FIELD_BOSS_REMARK = "bossRemark";  // 老板查看备注
+    public static final String FIELD_BUSINESS_KEY = "businessKey";
+    public static final String FIELD_PROCESS_INSTANCE_ID = "processInstanceId";
+    public static final String FIELD_CREATE_TIME = "createTime";
+
+    /** 字段目录:{fieldCode, label, componentType, readonly, internal} */
+    private static final String[][] FIELD_DEFINITIONS = {
+            {FIELD_ID, "订单ID", "number", "1", "1"},
+            {FIELD_ORDER_NO, "订单号", "input", "1", "0"},
+            {FIELD_ROOM_NAME, "包房名称", "input", "1", "0"},
+            {FIELD_SETTLE_TYPE, "结账方式", "select", "1", "0"},
+            {FIELD_TOTAL_AMOUNT, "总金额(元)", "number", "1", "0"},
+            {FIELD_PAYABLE_AMOUNT, "实收金额(元)", "number", "1", "0"},
+            {FIELD_DISCOUNT_RATE, "折扣比例", "number", "1", "0"},
+            {FIELD_DISCOUNT_AMOUNT, "优惠金额(元)", "number", "1", "0"},
+            {FIELD_SETTLE_TIME, "结账时间", "datetime", "1", "0"},
+            {FIELD_CREATE_NAME, "下单人", "input", "1", "0"},
+            {FIELD_BOSS_REMARK, "老板审核意见", "textarea", "0", "0"},
+            {FIELD_BUSINESS_KEY, "业务Key", "input", "1", "1"},
+            {FIELD_PROCESS_INSTANCE_ID, "流程实例ID", "input", "1", "1"},
+            {FIELD_CREATE_TIME, "创建时间", "datetime", "1", "1"}
+    };
+
+    public static boolean supportsObject(String objectCode) {
+        return !org.springframework.util.StringUtils.hasText(objectCode)
+                || BUSINESS_TYPE.equalsIgnoreCase(objectCode);
+    }
+
+    public static List<Map<String, Object>> formAssets(String objectCode) {
+        List<Map<String, Object>> assets = new ArrayList<>();
+        if (!supportsObject(objectCode)) {
+            return assets;
+        }
+        Map<String, Object> asset = new LinkedHashMap<>();
+        asset.put("appName", BUSINESS_APP_NAME);
+        asset.put("objectName", BUSINESS_OBJECT_NAME);
+        asset.put("businessName", BUSINESS_OBJECT_NAME);
+        asset.put("formKey", FORM_KEY);
+        asset.put("formName", FORM_NAME);
+        asset.put("formMode", FORM_MODE_BUSINESS_CODE);
+        asset.put("providerKey", PROVIDER_KEY);
+        asset.put("formUrl", FORM_URL);
+        asset.put("fields", fields());
+        asset.put("fieldCatalog", fields());
+        asset.put("supportsSave", true);
+        asset.put("description", "代码实现的折扣/优惠结账通知表单,老板可随时查看通知记录。");
+        assets.add(asset);
+        return assets;
+    }
+
+    public static List<Map<String, Object>> fields() {
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (String[] definition : FIELD_DEFINITIONS) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            boolean readonly = "1".equals(definition[3]);
+            boolean internal = "1".equals(definition[4]);
+            item.put("field", definition[0]);
+            item.put("fieldCode", definition[0]);
+            item.put("label", definition[1]);
+            item.put("type", definition[2]);
+            item.put("componentType", definition[2]);
+            item.put("visible", !internal);
+            item.put("writable", !readonly);
+            item.put("readonly", readonly);
+            item.put("internal", internal);
+            item.put("systemField", internal);
+            result.add(item);
+        }
+        return result;
+    }
+
+    public static String businessKey(Long orderId) {
+        return orderId == null ? null : BUSINESS_TYPE + ":" + orderId;
+    }
+
+    public static Map<String, Object> flowModelPayload(Object id, Long tenantId, String bpmnXml) {
+        Map<String, Object> payload = new LinkedHashMap<>();
+        if (id != null) {
+            payload.put("id", id);
+        }
+        payload.put("tenantId", tenantId);
+        payload.put("modelKey", MODEL_KEY);
+        payload.put("modelName", MODEL_NAME);
+        payload.put("description", MODEL_DESCRIPTION);
+        payload.put("category", MODEL_CATEGORY);
+        payload.put("flowType", FLOW_TYPE);
+        payload.put("designerType", DESIGNER_TYPE);
+        payload.put("formType", FORM_TYPE_BUSINESS);
+        payload.put("formId", FORM_KEY);
+        payload.put("formJson", defaultBusinessFormRefJson());
+        payload.put("notifyType", NOTIFY_TYPE_REDIS);
+        payload.put("bpmnXml", bpmnXml);
+        return payload;
+    }
+
+    public static String defaultBusinessFormRefJson() {
+        return "{"
+                + "\"type\":\"" + FORM_MODE_BUSINESS_CODE + "\","
+                + "\"formMode\":\"" + FORM_MODE_BUSINESS_CODE + "\","
+                + "\"objectCode\":\"" + BUSINESS_TYPE + "\","
+                + "\"objectName\":\"" + BUSINESS_OBJECT_NAME + "\","
+                + "\"formKey\":\"" + FORM_KEY + "\","
+                + "\"formName\":\"" + FORM_NAME + "\","
+                + "\"providerKey\":\"" + PROVIDER_KEY + "\","
+                + "\"formUrl\":\"" + FORM_URL + "\","
+                + "\"formRef\":{"
+                + "\"type\":\"" + FORM_MODE_BUSINESS_CODE + "\","
+                + "\"formMode\":\"" + FORM_MODE_BUSINESS_CODE + "\","
+                + "\"objectCode\":\"" + BUSINESS_TYPE + "\","
+                + "\"providerKey\":\"" + PROVIDER_KEY + "\","
+                + "\"formKey\":\"" + FORM_KEY + "\","
+                + "\"formUrl\":\"" + FORM_URL + "\""
+                + "}}";
+    }
+
+    /**
+     * 老板审核节点字段权限默认值(仅作种子/兜底,部署后以 BPMN 节点配置为准)。
+     */
+    public static String nodeFieldPermissionsJson(String nodeKey) {
+        List<String[]> permissions;
+        if (NODE_BOSS_NOTIFY.equals(nodeKey)) {
+            permissions = new ArrayList<>();
+            permissions.add(new String[]{FIELD_BOSS_REMARK, "false"});  // 老板可查看备注
+        } else {
+            permissions = new ArrayList<>();
+        }
+        StringBuilder builder = new StringBuilder("[");
+        boolean first = true;
+        for (String[] permission : permissions) {
+            if (!first) {
+                builder.append(',');
+            }
+            first = false;
+            builder.append("{\"field\":\"").append(permission[0])
+                    .append("\",\"label\":\"").append(labelOf(permission[0]))
+                    .append("\",\"readable\":true,\"writable\":true,\"required\":")
+                    .append(permission[1])
+                    .append('}');
+        }
+        return builder.append(']').toString();
+    }
+
+    private static String labelOf(String fieldCode) {
+        for (String[] definition : FIELD_DEFINITIONS) {
+            if (definition[0].equals(fieldCode)) {
+                return definition[1];
+            }
+        }
+        return fieldCode;
+    }
+}

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 11 - 9
forge-server/forge-business/forge-restaurant/餐饮模块迁移分析文档.md


+ 63 - 0
forge-server/forge-flow/forge-flow-server/src/main/java/com/mdframe/forge/flow/controller/FlowTaskController.java

@@ -372,4 +372,67 @@ public class FlowTaskController {
                 processInstanceId, businessKey, processDefKey, taskId, taskDefKey);
         return RespInfo.success(formInfo);
     }
+
+    // ==================== 通知任务相关接口 ====================
+
+    /**
+     * 我的通知任务(task_type=1,带已读状态)
+     */
+    @GetMapping("/notifications")
+    public RespInfo<IPage<FlowTask>> notifications(
+            @RequestParam(defaultValue = "1") Integer pageNum,
+            @RequestParam(defaultValue = "10") Integer pageSize,
+            @RequestParam String userId,
+            @RequestParam(required = false) String title,
+            @RequestParam(required = false) Integer isRead) {
+        
+        Page<FlowTask> page = new Page<>(pageNum, pageSize);
+        IPage<FlowTask> result = flowTaskService.notificationTasks(page, userId, title, isRead);
+        return RespInfo.success(result);
+    }
+
+    /**
+     * 统计未读通知数量
+     */
+    @GetMapping("/unread-count")
+    public RespInfo<Long> unreadCount(@RequestParam String userId) {
+        Long count = flowTaskService.countUnreadNotifications(userId);
+        return RespInfo.success(count);
+    }
+
+    /**
+     * 标记任务已读
+     */
+    @PostMapping("/read")
+    public RespInfo<Void> markAsRead(@RequestBody Map<String, Object> params) {
+        String taskId = String.valueOf(params.get("taskId"));
+        String userId = String.valueOf(params.get("userId"));
+        Long tenantId = params.get("tenantId") != null ? Long.valueOf(String.valueOf(params.get("tenantId"))) : null;
+        
+        if (taskId == null || taskId.isBlank() || "null".equals(taskId)) {
+            return RespInfo.error("任务ID不能为空");
+        }
+        if (userId == null || userId.isBlank() || "null".equals(userId)) {
+            return RespInfo.error("用户ID不能为空");
+        }
+        
+        flowTaskService.markTaskAsRead(taskId, userId, tenantId);
+        return RespInfo.success("已标记为已读", null);
+    }
+
+    /**
+     * 全部标记已读
+     */
+    @PostMapping("/read-all")
+    public RespInfo<Void> markAllAsRead(@RequestBody Map<String, Object> params) {
+        String userId = String.valueOf(params.get("userId"));
+        Long tenantId = params.get("tenantId") != null ? Long.valueOf(String.valueOf(params.get("tenantId"))) : null;
+        
+        if (userId == null || userId.isBlank() || "null".equals(userId)) {
+            return RespInfo.error("用户ID不能为空");
+        }
+        
+        flowTaskService.markAllTasksAsRead(userId, tenantId);
+        return RespInfo.success("全部已标记为已读", null);
+    }
 }

+ 11 - 0
forge-server/forge-framework/forge-plugin-parent/forge-plugin-flow/src/main/java/com/mdframe/forge/starter/flow/entity/FlowTask.java

@@ -66,6 +66,11 @@ public class FlowTask {
     private String businessType;
 
     /**
+     * 任务类型(0-审批任务/1-通知任务)
+     */
+    private Integer taskType;
+
+    /**
      * 流程分类ID或编码(来自流程模型,仅用于展示)
      */
     @TableField(exist = false)
@@ -223,4 +228,10 @@ public class FlowTask {
      * 最终执行的受控动作类型(APPROVE/REJECT)。
      */
     private String actionType;
+
+    /**
+     * 是否已读(仅用于通知任务列表展示,非数据库字段)
+     */
+    @TableField(exist = false)
+    private Integer isRead;
 }

+ 43 - 0
forge-server/forge-framework/forge-plugin-parent/forge-plugin-flow/src/main/java/com/mdframe/forge/starter/flow/entity/FlowTaskRead.java

@@ -0,0 +1,43 @@
+package com.mdframe.forge.starter.flow.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 流程任务已读记录实体
+ */
+@Data
+@TableName("sys_flow_task_read")
+public class FlowTaskRead {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /**
+     * 租户ID
+     */
+    private Long tenantId;
+
+    /**
+     * 任务ID
+     */
+    private String taskId;
+
+    /**
+     * 用户ID
+     */
+    private String userId;
+
+    /**
+     * 已读时间
+     */
+    private LocalDateTime readTime;
+
+    /**
+     * 创建时间
+     */
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+}

+ 46 - 2
forge-server/forge-framework/forge-plugin-parent/forge-plugin-flow/src/main/java/com/mdframe/forge/starter/flow/listener/FlowTaskEventListener.java

@@ -31,10 +31,13 @@ import org.springframework.context.annotation.Lazy;
 import org.springframework.stereotype.Component;
 
 import java.time.LocalDateTime;
+import java.util.ArrayList;
 import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Set;
 import java.util.stream.Collectors;
 
 /**
@@ -193,8 +196,10 @@ public class FlowTaskEventListener implements FlowableEventListener {
                 flowTask.setStartUserName(business.getApplyUserName());
                 flowTask.setStartDeptId(business.getApplyDeptId());
                 flowTask.setStartDeptName(business.getApplyDeptName());
-                log.debug("业务信息: title={}, businessKey={}, applyUserId={}, applyUserName={}",
-                        business.getTitle(), business.getBusinessKey(), business.getApplyUserId(), business.getApplyUserName());
+                // 根据业务类型设置任务类型(0-审批任务/1-通知任务)
+                flowTask.setTaskType(resolveTaskType(business.getBusinessType(), flowTask.getProcessDefKey()));
+                log.debug("业务信息:title={}, businessKey={}, applyUserId={}, applyUserName={}, taskType={}",
+                        business.getTitle(), business.getBusinessKey(), business.getApplyUserId(), business.getApplyUserName(), flowTask.getTaskType());
             } else {
                 // 没有业务信息时设置默认値
                 flowTask.setTitle(task.getName() + " - " + task.getProcessDefinitionId().split(":")[0]);
@@ -265,6 +270,7 @@ public class FlowTaskEventListener implements FlowableEventListener {
                     flowTask.setBusinessType(completedBusiness.getBusinessType());
                     flowTask.setStartUserId(completedBusiness.getApplyUserId());
                     flowTask.setStartUserName(completedBusiness.getApplyUserName());
+                    flowTask.setTaskType(resolveTaskType(completedBusiness.getBusinessType(), flowTask.getProcessDefKey()));
                 }
                 
                 flowTaskMapper.insert(flowTask);
@@ -678,6 +684,28 @@ public class FlowTaskEventListener implements FlowableEventListener {
                 if (!candidateGroups.isEmpty()) {
                     flowTask.setCandidateGroups(String.join(",", candidateGroups));
                     log.debug("任务候选组:taskId={}, candidateGroups={}", task.getId(), candidateGroups);
+
+                    // 将候选组(角色编码)解析为具体用户ID,合并到 candidate_users
+                    // SQL 查询只检查 candidate_users,不检查 candidate_groups
+                    if (flowOrgIntegrationService != null) {
+                        Set<String> resolvedUserIds = new LinkedHashSet<>(candidateUsers);
+                        for (String groupCode : candidateGroups) {
+                            try {
+                                List<String> groupUserIds = flowOrgIntegrationService.getUserIdsByRoleCode(groupCode);
+                                if (groupUserIds != null && !groupUserIds.isEmpty()) {
+                                    resolvedUserIds.addAll(groupUserIds);
+                                    log.debug("候选组解析为用户:groupCode={}, userIds={}", groupCode, groupUserIds);
+                                }
+                            } catch (Exception ex) {
+                                log.warn("解析候选组用户失败:groupCode={}", groupCode, ex);
+                            }
+                        }
+                        if (!resolvedUserIds.isEmpty()) {
+                            flowTask.setCandidateUsers(String.join(",", resolvedUserIds));
+                            log.info("任务候选组解析完成:taskId={}, resolvedCandidateUsers={}",
+                                    task.getId(), resolvedUserIds);
+                        }
+                    }
                 }
             }
         } catch (Exception e) {
@@ -850,4 +878,20 @@ public class FlowTaskEventListener implements FlowableEventListener {
     public String getOnTransaction() {
         return null;
     }
+
+    /**
+     * 根据业务类型或流程定义Key解析任务类型
+     * 0-审批任务(默认),1-通知任务
+     */
+    private Integer resolveTaskType(String businessType, String processDefKey) {
+        // 通知型流程的业务类型列表(可扩展)
+        if ("discount_checkout".equals(businessType)) {
+            return 1;
+        }
+        // 也可以通过流程定义Key判断(如以 _notify 结尾)
+        if (processDefKey != null && processDefKey.endsWith("_notify")) {
+            return 1;
+        }
+        return 0;
+    }
 }

+ 11 - 0
forge-server/forge-framework/forge-plugin-parent/forge-plugin-flow/src/main/java/com/mdframe/forge/starter/flow/mapper/FlowTaskMapper.java

@@ -73,4 +73,15 @@ public interface FlowTaskMapper extends BaseMapper<FlowTask> {
 
     int deleteByProcessInstanceIdPhysically(@Param("processInstanceId") String processInstanceId,
                                             @Param("tenantId") Long tenantId);
+
+    /**
+     * 分页查询通知任务(task_type=1,带已读状态)
+     */
+    IPage<FlowTask> selectNotificationTasks(Page<FlowTask> page, @Param("userId") String userId,
+                                             @Param("title") String title, @Param("isRead") Integer isRead);
+
+    /**
+     * 统计未读通知数量
+     */
+    Long countUnreadNotifications(@Param("userId") String userId);
 }

+ 23 - 0
forge-server/forge-framework/forge-plugin-parent/forge-plugin-flow/src/main/java/com/mdframe/forge/starter/flow/mapper/FlowTaskReadMapper.java

@@ -0,0 +1,23 @@
+package com.mdframe.forge.starter.flow.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.mdframe.forge.starter.flow.entity.FlowTaskRead;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * 流程任务已读记录 Mapper
+ */
+@Mapper
+public interface FlowTaskReadMapper extends BaseMapper<FlowTaskRead> {
+
+    /**
+     * 标记任务已读(插入或更新已读记录)
+     */
+    int markAsRead(@Param("taskId") String taskId, @Param("userId") String userId, @Param("tenantId") Long tenantId);
+
+    /**
+     * 批量标记已读
+     */
+    int markAllAsRead(@Param("userId") String userId, @Param("tenantId") Long tenantId);
+}

+ 20 - 0
forge-server/forge-framework/forge-plugin-parent/forge-plugin-flow/src/main/java/com/mdframe/forge/starter/flow/service/FlowTaskService.java

@@ -181,4 +181,24 @@ public interface FlowTaskService {
      */
     TaskFormInfo getProcessFormInfo(String processInstanceId, String businessKey, String processDefKey,
                                     String taskId, String taskDefKey);
+
+    /**
+     * 我的通知任务(task_type=1,带已读状态)
+     */
+    IPage<FlowTask> notificationTasks(Page<FlowTask> page, String userId, String title, Integer isRead);
+
+    /**
+     * 统计未读通知数量
+     */
+    Long countUnreadNotifications(String userId);
+
+    /**
+     * 标记任务已读
+     */
+    void markTaskAsRead(String taskId, String userId, Long tenantId);
+
+    /**
+     * 全部标记已读
+     */
+    void markAllTasksAsRead(String userId, Long tenantId);
 }

+ 26 - 1
forge-server/forge-framework/forge-plugin-parent/forge-plugin-flow/src/main/java/com/mdframe/forge/starter/flow/service/impl/FlowInstanceServiceImpl.java

@@ -121,7 +121,14 @@ public class FlowInstanceServiceImpl implements FlowInstanceService {
                 return existingBusiness.getProcessInstanceId();
             }
             if (existingBusiness != null) {
-                throw new RuntimeException("业务流程已存在且不可重复发起:" + businessKey);
+                if (!isRestartableEndedBusiness(existingBusiness)) {
+                    throw new RuntimeException("业务流程已存在且不可重复发起:" + businessKey);
+                }
+                // 旧流程未成功走完(撤回/驳回/终止)且运行实例已不存在:清除旧关联允许重新发起;
+                // 历史痕迹保留在引擎历史表与 sys_flow_task,sys_flow_business 只保留 businessKey 当前一轮关联
+                log.info("[流程启动幂等] 旧流程未成功走完,清除旧关联允许重新发起: tenantId={}, businessKey={}, status={}, oldProcessInstanceId={}",
+                        tenantId, businessKey, existingBusiness.getStatus(), existingBusiness.getProcessInstanceId());
+                flowBusinessMapper.deleteById(existingBusiness.getId());
             }
 
         // 1. 获取流程定义
@@ -378,6 +385,24 @@ public class FlowInstanceServiceImpl implements FlowInstanceService {
                 || "completed".equals(normalized);
     }
 
+    /**
+     * 是否允许重新发起:旧流程未成功走完(撤回/驳回/终止)且运行实例已不存在。
+     * <p>
+     * 支撑业务「发起 → 撤回/驳回 → 修改 → 重新发起」闭环(与前端 useFlow.canStart 语义一致);
+     * 已通过/已完成的旧流程仍禁止同 businessKey 重发,由业务侧自行决定是否新键重报。
+     */
+    private boolean isRestartableEndedBusiness(FlowBusiness business) {
+        if (business == null) {
+            return false;
+        }
+        String status = normalizeStatus(business.getStatus());
+        boolean unfinishedEnd = "canceled".equals(status)
+                || "rejected".equals(status)
+                || "terminated".equals(status);
+        return unfinishedEnd
+                && (isBlank(business.getProcessInstanceId()) || !isRuntimeProcessActive(business.getProcessInstanceId()));
+    }
+
     private Long resolveTenantId() {
         Long tenantId = TenantContextHolder.getTenantId();
         if (tenantId == null) {

+ 35 - 0
forge-server/forge-framework/forge-plugin-parent/forge-plugin-flow/src/main/java/com/mdframe/forge/starter/flow/service/impl/FlowTaskServiceImpl.java

@@ -23,6 +23,7 @@ import com.mdframe.forge.starter.flow.entity.FlowTask;
 import com.mdframe.forge.starter.flow.mapper.FlowBusinessMapper;
 import com.mdframe.forge.starter.flow.mapper.FlowFormInstanceMapper;
 import com.mdframe.forge.starter.flow.mapper.FlowTaskMapper;
+import com.mdframe.forge.starter.flow.mapper.FlowTaskReadMapper;
 import com.mdframe.forge.starter.flow.service.FlowErrorLogService;
 import com.mdframe.forge.starter.flow.service.FlowFormService;
 import com.mdframe.forge.starter.flow.service.FlowModelService;
@@ -142,6 +143,9 @@ public class FlowTaskServiceImpl extends ServiceImpl<FlowTaskMapper, FlowTask> i
     @Autowired(required = false)
     private FlowBusinessListDisplayAdapter flowBusinessListDisplayAdapter;
 
+    @Autowired
+    private FlowTaskReadMapper flowTaskReadMapper;
+
     @Override
     public IPage<FlowTask> todoTasks(Page<FlowTask> page, String userId, String title, String category, Integer status) {
         return enrichTaskPage(this.getBaseMapper().selectTodoTasks(page, userId, title, category, status));
@@ -2366,4 +2370,35 @@ public class FlowTaskServiceImpl extends ServiceImpl<FlowTaskMapper, FlowTask> i
             }
         }
     }
+
+    // ==================== 通知任务相关方法 ====================
+
+    @Override
+    public IPage<FlowTask> notificationTasks(Page<FlowTask> page, String userId, String title, Integer isRead) {
+        IPage<FlowTask> result = this.getBaseMapper().selectNotificationTasks(page, userId, title, isRead);
+        return enrichTaskPage(result);
+    }
+
+    @Override
+    public Long countUnreadNotifications(String userId) {
+        return this.getBaseMapper().countUnreadNotifications(userId);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void markTaskAsRead(String taskId, String userId, Long tenantId) {
+        if (taskId == null || userId == null) {
+            return;
+        }
+        flowTaskReadMapper.markAsRead(taskId, userId, tenantId);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void markAllTasksAsRead(String userId, Long tenantId) {
+        if (userId == null) {
+            return;
+        }
+        flowTaskReadMapper.markAllAsRead(userId, tenantId);
+    }
 }

+ 91 - 19
forge-server/forge-framework/forge-plugin-parent/forge-plugin-flow/src/main/resources/mapper/FlowTaskMapper.xml

@@ -5,10 +5,13 @@
     <!-- 根据任务ID查询任务(带流程名称) -->
     <select id="selectByTaskId" resultType="com.mdframe.forge.starter.flow.entity.FlowTask">
         SELECT t.*, m.category AS category, c.category_name AS category_name,
-               m.model_name AS process_name, COALESCE(NULLIF(m.model_name, ''), t.process_def_key) AS process_definition_name
+               COALESCE(NULLIF(m.model_name, ''), bm.model_name) AS process_name,
+               COALESCE(NULLIF(m.model_name, ''), bm.model_name, t.process_def_key) AS process_definition_name
         FROM sys_flow_task t
         LEFT JOIN sys_flow_model m ON t.process_def_key = m.model_key AND m.del_flag = 0
-        LEFT JOIN sys_flow_category c ON c.id = m.category OR c.category_code = m.category
+        LEFT JOIN sys_flow_business b ON b.process_instance_id = t.process_instance_id
+        LEFT JOIN sys_flow_model bm ON b.process_def_key = bm.model_key AND bm.del_flag = 0
+        LEFT JOIN sys_flow_category c ON c.id = COALESCE(m.category, bm.category) OR c.category_code = COALESCE(m.category, bm.category)
         WHERE t.task_id = #{taskId}
     </select>
 
@@ -23,30 +26,36 @@
     <!-- 根据 Flowable 任务ID或流程任务记录ID查询任务(带流程名称) -->
     <select id="selectByIdOrTaskId" resultType="com.mdframe.forge.starter.flow.entity.FlowTask">
         SELECT t.*, m.category AS category, c.category_name AS category_name,
-               m.model_name AS process_name, COALESCE(NULLIF(m.model_name, ''), t.process_def_key) AS process_definition_name
+               COALESCE(NULLIF(m.model_name, ''), bm.model_name) AS process_name,
+               COALESCE(NULLIF(m.model_name, ''), bm.model_name, t.process_def_key) AS process_definition_name
         FROM sys_flow_task t
         LEFT JOIN sys_flow_model m ON t.process_def_key = m.model_key AND m.del_flag = 0
-        LEFT JOIN sys_flow_category c ON c.id = m.category OR c.category_code = m.category
+        LEFT JOIN sys_flow_business b ON b.process_instance_id = t.process_instance_id
+        LEFT JOIN sys_flow_model bm ON b.process_def_key = bm.model_key AND bm.del_flag = 0
+        LEFT JOIN sys_flow_category c ON c.id = COALESCE(m.category, bm.category) OR c.category_code = COALESCE(m.category, bm.category)
         WHERE t.task_id = #{taskId} OR t.id = #{taskId}
         ORDER BY CASE WHEN t.task_id = #{taskId} THEN 0 ELSE 1 END
         LIMIT 1
     </select>
 
-    <!-- 待办任务列表(带分类关联查询) -->
+    <!-- 待办任务列表(带分类关联查询,仅审批任务) -->
     <select id="selectTodoTasks" resultType="com.mdframe.forge.starter.flow.entity.FlowTask">
         SELECT t.*, m.category AS category, c.category_name AS category_name,
-               m.model_name AS process_name, COALESCE(NULLIF(m.model_name, ''), t.process_def_key) AS process_definition_name
+               COALESCE(NULLIF(m.model_name, ''), bm.model_name) AS process_name,
+               COALESCE(NULLIF(m.model_name, ''), bm.model_name, t.process_def_key) AS process_definition_name
         FROM sys_flow_task t
         LEFT JOIN sys_flow_model m ON t.process_def_key = m.model_key AND m.del_flag = 0
-        LEFT JOIN sys_flow_category c ON c.id = m.category OR c.category_code = m.category
+        LEFT JOIN sys_flow_business b ON b.process_instance_id = t.process_instance_id
+        LEFT JOIN sys_flow_model bm ON b.process_def_key = bm.model_key AND bm.del_flag = 0
+        LEFT JOIN sys_flow_category c ON c.id = COALESCE(m.category, bm.category) OR c.category_code = COALESCE(m.category, bm.category)
         <where>
+            AND t.task_type = 0
             AND (t.assignee = #{userId}
                 OR (t.candidate_users IS NOT NULL AND t.candidate_users != ''
                     AND (t.candidate_users = #{userId}
                         OR t.candidate_users LIKE CONCAT(#{userId}, ',%')
                         OR t.candidate_users LIKE CONCAT('%,', #{userId})
-                        OR t.candidate_users LIKE CONCAT('%,', #{userId}, ',%')))
-                OR (t.candidate_groups IS NOT NULL AND t.candidate_groups != ''))
+                        OR t.candidate_users LIKE CONCAT('%,', #{userId}, ',%'))))
             AND (t.status = 0 OR (t.assignee = #{userId} AND t.status = 1))
             <if test="title != null and title != ''">
                 AND t.title LIKE CONCAT('%', #{title}, '%')
@@ -73,10 +82,13 @@
     <!-- 已办任务列表(带分类关联查询) -->
     <select id="selectDoneTasks" resultType="com.mdframe.forge.starter.flow.entity.FlowTask">
         SELECT t.*, m.category AS category, c.category_name AS category_name,
-               m.model_name AS process_name, COALESCE(NULLIF(m.model_name, ''), t.process_def_key) AS process_definition_name
+               COALESCE(NULLIF(m.model_name, ''), bm.model_name) AS process_name,
+               COALESCE(NULLIF(m.model_name, ''), bm.model_name, t.process_def_key) AS process_definition_name
         FROM sys_flow_task t
         LEFT JOIN sys_flow_model m ON t.process_def_key = m.model_key AND m.del_flag = 0
-        LEFT JOIN sys_flow_category c ON c.id = m.category OR c.category_code = m.category
+        LEFT JOIN sys_flow_business b ON b.process_instance_id = t.process_instance_id
+        LEFT JOIN sys_flow_model bm ON b.process_def_key = bm.model_key AND bm.del_flag = 0
+        LEFT JOIN sys_flow_category c ON c.id = COALESCE(m.category, bm.category) OR c.category_code = COALESCE(m.category, bm.category)
         <where>
             AND t.assignee = #{userId}
             AND t.status IN (2, 3, 4, 5, 7, 8)
@@ -105,10 +117,13 @@
     <!-- 我发起的任务列表(带分类关联查询) -->
     <select id="selectStartedTasks" resultType="com.mdframe.forge.starter.flow.entity.FlowTask">
         SELECT t.*, m.category AS category, c.category_name AS category_name,
-               m.model_name AS process_name, COALESCE(NULLIF(m.model_name, ''), t.process_def_key) AS process_definition_name
+               COALESCE(NULLIF(m.model_name, ''), bm.model_name) AS process_name,
+               COALESCE(NULLIF(m.model_name, ''), bm.model_name, t.process_def_key) AS process_definition_name
         FROM sys_flow_task t
         LEFT JOIN sys_flow_model m ON t.process_def_key = m.model_key AND m.del_flag = 0
-        LEFT JOIN sys_flow_category c ON c.id = m.category OR c.category_code = m.category
+        LEFT JOIN sys_flow_business b ON b.process_instance_id = t.process_instance_id
+        LEFT JOIN sys_flow_model bm ON b.process_def_key = bm.model_key AND bm.del_flag = 0
+        LEFT JOIN sys_flow_category c ON c.id = COALESCE(m.category, bm.category) OR c.category_code = COALESCE(m.category, bm.category)
         <where>
             AND t.start_user_id = #{userId}
             <if test="title != null and title != ''">
@@ -136,27 +151,30 @@
     <!-- 已逾期且仍待办理的任务 -->
     <select id="selectOverduePendingTasks" resultType="com.mdframe.forge.starter.flow.entity.FlowTask">
         SELECT t.*, m.category AS category, c.category_name AS category_name,
-               m.model_name AS process_name, COALESCE(NULLIF(m.model_name, ''), t.process_def_key) AS process_definition_name
+               COALESCE(NULLIF(m.model_name, ''), bm.model_name) AS process_name,
+               COALESCE(NULLIF(m.model_name, ''), bm.model_name, t.process_def_key) AS process_definition_name
         FROM sys_flow_task t
         LEFT JOIN sys_flow_model m ON t.process_def_key = m.model_key AND m.del_flag = 0
-        LEFT JOIN sys_flow_category c ON c.id = m.category OR c.category_code = m.category
+        LEFT JOIN sys_flow_business b ON b.process_instance_id = t.process_instance_id
+        LEFT JOIN sys_flow_model bm ON b.process_def_key = bm.model_key AND bm.del_flag = 0
+        LEFT JOIN sys_flow_category c ON c.id = COALESCE(m.category, bm.category) OR c.category_code = COALESCE(m.category, bm.category)
         WHERE t.due_date IS NOT NULL
           AND t.due_date &lt;= #{now}
           AND t.status IN (0, 1)
         ORDER BY t.due_date ASC, t.create_time ASC
     </select>
 
-    <!-- 工作台待办数,条件与 selectTodoTasks 保持一致。 -->
+    <!-- 工作台待办数,条件与 selectTodoTasks 保持一致(仅审批任务)。 -->
     <select id="countWorkspaceTodo" resultType="java.lang.Long">
         SELECT COUNT(1)
         FROM sys_flow_task t
-        WHERE (t.assignee = #{userId}
+        WHERE t.task_type = 0
+          AND (t.assignee = #{userId}
             OR (t.candidate_users IS NOT NULL AND t.candidate_users != ''
                 AND (t.candidate_users = #{userId}
                     OR t.candidate_users LIKE CONCAT(#{userId}, ',%')
                     OR t.candidate_users LIKE CONCAT('%,', #{userId})
-                    OR t.candidate_users LIKE CONCAT('%,', #{userId}, ',%')))
-            OR (t.candidate_groups IS NOT NULL AND t.candidate_groups != ''))
+                    OR t.candidate_users LIKE CONCAT('%,', #{userId}, ',%'))))
           AND (t.status = 0 OR (t.assignee = #{userId} AND t.status = 1))
     </select>
 
@@ -183,4 +201,58 @@
           AND tenant_id = #{tenantId}
     </delete>
 
+    <!-- 通知任务列表(task_type=1,带已读状态) -->
+    <select id="selectNotificationTasks" resultType="com.mdframe.forge.starter.flow.entity.FlowTask">
+        SELECT t.*, m.category AS category, c.category_name AS category_name,
+               COALESCE(NULLIF(m.model_name, ''), bm.model_name) AS process_name,
+               COALESCE(NULLIF(m.model_name, ''), bm.model_name, t.process_def_key) AS process_definition_name,
+               CASE WHEN r.id IS NOT NULL THEN 1 ELSE 0 END AS is_read
+        FROM sys_flow_task t
+        LEFT JOIN sys_flow_model m ON t.process_def_key = m.model_key AND m.del_flag = 0
+        LEFT JOIN sys_flow_business b ON b.process_instance_id = t.process_instance_id
+        LEFT JOIN sys_flow_model bm ON b.process_def_key = bm.model_key AND bm.del_flag = 0
+        LEFT JOIN sys_flow_category c ON c.id = COALESCE(m.category, bm.category) OR c.category_code = COALESCE(m.category, bm.category)
+        LEFT JOIN sys_flow_task_read r ON r.task_id = t.task_id AND r.user_id = #{userId}
+        <where>
+            AND t.task_type = 1
+            AND (t.assignee = #{userId}
+                OR (t.candidate_users IS NOT NULL AND t.candidate_users != ''
+                    AND (t.candidate_users = #{userId}
+                        OR t.candidate_users LIKE CONCAT(#{userId}, ',%')
+                        OR t.candidate_users LIKE CONCAT('%,', #{userId})
+                        OR t.candidate_users LIKE CONCAT('%,', #{userId}, ',%'))))
+            AND (t.status = 0 OR (t.assignee = #{userId} AND t.status = 1))
+            <if test="title != null and title != ''">
+                AND t.title LIKE CONCAT('%', #{title}, '%')
+            </if>
+            <if test="isRead != null">
+                <choose>
+                    <when test="isRead == 1">
+                        AND r.id IS NOT NULL
+                    </when>
+                    <otherwise>
+                        AND r.id IS NULL
+                    </otherwise>
+                </choose>
+            </if>
+        </where>
+        ORDER BY r.id IS NULL DESC, t.create_time DESC
+    </select>
+
+    <!-- 统计未读通知数量 -->
+    <select id="countUnreadNotifications" resultType="java.lang.Long">
+        SELECT COUNT(1)
+        FROM sys_flow_task t
+        LEFT JOIN sys_flow_task_read r ON r.task_id = t.task_id AND r.user_id = #{userId}
+        WHERE t.task_type = 1
+          AND r.id IS NULL
+          AND (t.assignee = #{userId}
+              OR (t.candidate_users IS NOT NULL AND t.candidate_users != ''
+                  AND (t.candidate_users = #{userId}
+                      OR t.candidate_users LIKE CONCAT(#{userId}, ',%')
+                      OR t.candidate_users LIKE CONCAT('%,', #{userId})
+                      OR t.candidate_users LIKE CONCAT('%,', #{userId}, ',%'))))
+          AND (t.status = 0 OR (t.assignee = #{userId} AND t.status = 1))
+    </select>
+
 </mapper>

+ 31 - 0
forge-server/forge-framework/forge-plugin-parent/forge-plugin-flow/src/main/resources/mapper/FlowTaskReadMapper.xml

@@ -0,0 +1,31 @@
+<?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.starter.flow.mapper.FlowTaskReadMapper">
+
+    <!-- 标记任务已读(使用 INSERT ... ON DUPLICATE KEY UPDATE 实现幂等) -->
+    <insert id="markAsRead">
+        INSERT INTO sys_flow_task_read (tenant_id, task_id, user_id, read_time, create_time)
+        VALUES (#{tenantId}, #{taskId}, #{userId}, NOW(), NOW())
+        ON DUPLICATE KEY UPDATE read_time = NOW()
+    </insert>
+
+    <!-- 批量标记已读:将用户所有未读通知任务标记为已读 -->
+    <insert id="markAllAsRead">
+        INSERT INTO sys_flow_task_read (tenant_id, task_id, user_id, read_time, create_time)
+        SELECT #{tenantId}, t.task_id, #{userId}, NOW(), NOW()
+        FROM sys_flow_task t
+        LEFT JOIN sys_flow_task_read r ON r.task_id = t.task_id AND r.user_id = #{userId}
+        WHERE t.task_type = 1
+          AND r.id IS NULL
+          AND (t.assignee = #{userId}
+              OR (t.candidate_users IS NOT NULL AND t.candidate_users != ''
+                  AND (t.candidate_users = #{userId}
+                      OR t.candidate_users LIKE CONCAT(#{userId}, ',%')
+                      OR t.candidate_users LIKE CONCAT('%,', #{userId})
+                      OR t.candidate_users LIKE CONCAT('%,', #{userId}, ',%')))
+              OR (t.candidate_groups IS NOT NULL AND t.candidate_groups != ''))
+          AND (t.status = 0 OR (t.assignee = #{userId} AND t.status = 1))
+        ON DUPLICATE KEY UPDATE read_time = NOW()
+    </insert>
+
+</mapper>

+ 47 - 0
forge-server/forge-framework/forge-plugin-parent/forge-plugin-generator/src/main/java/com/mdframe/forge/plugin/generator/service/businessapp/BusinessFlowService.java

@@ -734,6 +734,17 @@ public class BusinessFlowService {
         if (!claimedByCurrentUser && !unclaimedCandidate) {
             throw new BusinessException("无权访问当前任务业务表单");
         }
+        // 写操作时,如果用户是合法候选人但未签收,自动签收
+        if (writeRequired && !claimedByCurrentUser && unclaimedCandidate) {
+            try {
+                flowClient.claimTask(taskId, currentUser);
+                claimedByCurrentUser = true;
+                log.info("自动签收任务:taskId={}, userId={}", taskId, currentUser);
+            } catch (Exception e) {
+                log.warn("自动签收任务失败:taskId={}, userId={}, error={}", taskId, currentUser, e.getMessage());
+                throw new BusinessException("请先签收任务后再保存业务字段");
+            }
+        }
         if (writeRequired && !claimedByCurrentUser) {
             throw new BusinessException("请先签收任务后再保存业务字段");
         }
@@ -932,6 +943,8 @@ public class BusinessFlowService {
             vo.setEditMode(normalizeNodeEditMode(nodeForm.getString("editMode")));
             vo.setFormRef(readNestedObject(nodeForm.get("formRef")));
             applyApprovalPolicy(vo, nodeForm);
+            // 兜底:如果节点表单没有审批策略(Flow 服务调用失败),应用默认策略
+            applyDefaultApprovalPolicyIfMissing(vo);
             vo.getWarnings().add("当前节点表单类型暂不由低代码业务表单渲染: " + formMode);
             return vo;
         }
@@ -977,6 +990,8 @@ public class BusinessFlowService {
         vo.setChildrenConfig(childrenConfig);
         vo.setRecordData(visibleRecordData);
         applyApprovalPolicy(vo, nodeForm);
+        // 兜底:如果节点表单没有审批策略(Flow 服务调用失败),应用默认策略
+        applyDefaultApprovalPolicyIfMissing(vo);
         if (fields.isEmpty()) {
             vo.getWarnings().add("当前业务表单没有可展示字段");
         }
@@ -1492,6 +1507,38 @@ public class BusinessFlowService {
         vo.setRequireComment(readNullableBooleanValue(source.get("requireComment")));
     }
 
+    /**
+     * 兜底:当节点表单未配置审批策略(通常因 Flow 服务调用失败导致)时,
+     * 应用默认策略,避免前端因字段为 null 而默认显示所有操作按钮。
+     * 默认策略:允许同意/委托,禁止驳回/退回/终止,不强制签名/意见。
+     */
+    private void applyDefaultApprovalPolicyIfMissing(BusinessTaskFormContextVO vo) {
+        if (vo == null) {
+            return;
+        }
+        if (vo.getAllowApprove() == null) {
+            vo.setAllowApprove(true);
+        }
+        if (vo.getAllowReject() == null) {
+            vo.setAllowReject(false);
+        }
+        if (vo.getAllowDelegate() == null) {
+            vo.setAllowDelegate(true);
+        }
+        if (vo.getAllowReturn() == null) {
+            vo.setAllowReturn(false);
+        }
+        if (vo.getAllowTerminate() == null) {
+            vo.setAllowTerminate(false);
+        }
+        if (vo.getRequireSignature() == null) {
+            vo.setRequireSignature(false);
+        }
+        if (vo.getRequireComment() == null) {
+            vo.setRequireComment(false);
+        }
+    }
+
     private BusinessTaskFormContextQueryDTO enrichTaskFormQuery(BusinessTaskFormContextQueryDTO query,
                                                                TaskFormRuntimeContext runtime,
                                                                JSONObject nodeForm,

+ 2 - 0
forge-server/forge-framework/forge-starter-parent/forge-starter-tenant/src/main/java/com/mdframe/forge/starter/tenant/config/TenantProperties.java

@@ -72,5 +72,7 @@ public class TenantProperties {
         ignoreTables.add("ai_page_template");
         ignoreTables.add("sys_dict_type");
         ignoreTables.add("sys_dict_data");
+        // 流程模型是全局共享的流程模板定义,不按租户隔离(不同租户可共用同一流程模型)
+        ignoreTables.add("sys_flow_model");
     }
 }