Parcourir la source

餐饮模块部分功能开发

徐滕 il y a 2 semaines
Parent
commit
3d74f291cd
44 fichiers modifiés avec 4711 ajouts et 238 suppressions
  1. 32 0
      forge-admin-ui/src/api/restaurant.js
  2. 4 0
      forge-admin-ui/src/components/ai-form/AiCrudPage.vue
  3. 306 0
      forge-admin-ui/src/components/flow/FlowProcessDetailModal.vue
  4. 76 0
      forge-admin-ui/src/utils/date-range.js
  5. 28 8
      forge-admin-ui/src/views/flow/todo.vue
  6. 600 0
      forge-admin-ui/src/views/restaurant/CreditCustomerApproveForm.vue
  7. 296 0
      forge-admin-ui/src/views/restaurant/components/CreditCustomerFormModal.vue
  8. 277 148
      forge-admin-ui/src/views/restaurant/creditCustomer.vue
  9. 345 0
      forge-admin-ui/src/views/restaurant/dishOrderList.vue
  10. 49 0
      forge-server/db/migration/V1.0.132__add_restaurant_order_export_config_and_menu.sql
  11. 224 0
      forge-server/db/migration/V1.0.133__add_credit_customer_approval_flow.sql
  12. 56 0
      forge-server/db/migration/V1.0.134__update_credit_customer_approval_status_to_numeric.sql
  13. 36 0
      forge-server/db/migration/V1.0.135__update_credit_status_to_numeric.sql
  14. 12 0
      forge-server/db/migration/V1.0.136__add_credit_customer_form_hidden_menu.sql
  15. 7 0
      forge-server/db/migration/V1.0.137__remove_credit_customer_form_hidden_menu.sql
  16. 25 0
      forge-server/db/migration/V1.0.138__add_credit_customer_age_and_credit_time_columns.sql
  17. 10 0
      forge-server/db/migration/V1.0.139__fix_credit_customer_flow_form_url.sql
  18. 8 0
      forge-server/forge-admin-server/src/main/resources/application-dev.yml
  19. 219 5
      forge-server/forge-business/forge-hotel/编码习惯与规范.md
  20. 5 0
      forge-server/forge-business/forge-restaurant/pom.xml
  21. 46 3
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/controller/CreditCustomerController.java
  22. 24 2
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/controller/DishOrderController.java
  23. 41 2
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/domain/CreditCustomer.java
  24. 9 0
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/dto/CreditCustomerDTO.java
  25. 16 0
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/dto/CreditCustomerSubmitDTO.java
  26. 76 0
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/dto/CreditCustomerTaskSaveDTO.java
  27. 22 1
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/mapper/CreditCustomerMapper.java
  28. 15 1
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/mapper/DishOrderMapper.java
  29. 110 0
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/provider/CreditCustomerCodeFormProvider.java
  30. 57 1
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/service/CreditCustomerService.java
  31. 18 1
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/service/DishOrderService.java
  32. 732 31
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/service/impl/CreditCustomerServiceImpl.java
  33. 85 2
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/service/impl/DishOrderServiceImpl.java
  34. 103 0
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/support/CreditCustomerFlowBpmn.java
  35. 459 0
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/support/CreditCustomerFlowDefinition.java
  36. 20 0
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/vo/CreditCustomerFlowInitVO.java
  37. 44 1
      forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/vo/CreditCustomerVO.java
  38. 84 0
      forge-server/forge-business/forge-restaurant/src/main/resources/flow/credit_customer_approval.bpmn20.xml
  39. 77 14
      forge-server/forge-business/forge-restaurant/src/main/resources/mapper/business/restaurant/CreditCustomerMapper.xml
  40. 35 0
      forge-server/forge-business/forge-restaurant/src/main/resources/mapper/business/restaurant/DishOrderMapper.xml
  41. 10 8
      forge-server/forge-business/forge-restaurant/餐饮模块迁移分析文档.md
  42. 2 1
      forge-server/forge-framework/forge-plugin-parent/forge-plugin-capability-parent/forge-plugin-capability-high-risk-approval/src/main/resources/mapper/CapabilityApprovalMapper.xml
  43. 4 2
      forge-server/forge-framework/forge-plugin-parent/forge-plugin-flow/src/main/java/com/mdframe/forge/starter/flow/service/impl/FlowModelServiceImpl.java
  44. 7 7
      forge-server/forge-framework/forge-plugin-parent/forge-plugin-flow/src/main/resources/mapper/FlowTaskMapper.xml

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

@@ -185,6 +185,18 @@ export function cancelOrder(id) {
   return request.post('/restaurant/dishOrder/cancel', null, { params: { id } })
 }
 
+/** 导出订单列表(配置驱动,返回文件流) */
+export function exportOrder(data) {
+  return request({
+    method: 'post',
+    url: '/restaurant/dishOrder/export',
+    data,
+    responseType: 'blob',
+    rawResponse: true,
+    encrypt: false,
+  })
+}
+
 // ==================== 挂账客户 ====================
 
 /** 分页查询挂账客户 */
@@ -211,3 +223,23 @@ export function deleteCreditCustomer(id) {
 export function toggleCreditStatus(id) {
   return request.post('/restaurant/creditCustomer/toggleStatus', null, { params: { id } })
 }
+
+/** 提交挂账客户审批(审批人按流程节点配置自适应,仅指定人模式需传 approverId) */
+export function submitCreditApproval(id, data) {
+  return request.post('/restaurant/creditCustomer/submit', data, { params: { id } })
+}
+
+/** 查询提交端是否需要选择审批人(老板节点仍用 bossId 变量表达式时为 true) */
+export function checkCreditApproverSelection() {
+  return request.get('/restaurant/creditCustomer/requiresApproverSelection')
+}
+
+/** 加载审批人(老板)候选用户列表,仅在指定人模式下使用 */
+export function listApproverUsers(params = {}) {
+  return request.get('/system/user/page', { params: { pageNum: 1, pageSize: 50, ...params } })
+}
+
+/** 初始化挂账客户审批流程模型(部署 BPMN) */
+export function initCreditApprovalFlow() {
+  return request.post('/restaurant/creditCustomer/initFlow')
+}

+ 4 - 0
forge-admin-ui/src/components/ai-form/AiCrudPage.vue

@@ -269,6 +269,10 @@
               </slot>
             </div>
           </template>
+          <!-- 透传表格工具栏右侧插槽(与刷新/密度/列设置等图标同一排) -->
+          <template #toolbar-right>
+            <slot name="table-toolbar-right" />
+          </template>
           <!-- 透传表格插槽 -->
           <template v-for="slotName in tableSlots" #[slotName]="slotProps">
             <slot :name="`table-${slotName}`" v-bind="slotProps" />

+ 306 - 0
forge-admin-ui/src/components/flow/FlowProcessDetailModal.vue

@@ -0,0 +1,306 @@
+<template>
+  <NModal
+    :show="show"
+    preset="card"
+    :title="title"
+    style="width: 92%; max-width: 1600px"
+    content-style="min-height: 72vh; max-height: 88vh; overflow: auto"
+    :mask-closable="true"
+    @update:show="emit('update:show', $event)"
+  >
+    <NSpin :show="loading">
+      <!-- 业务信息区:优先使用 business 插槽,否则按 businessItems 渲染 -->
+      <template v-if="$slots.business || businessItems.length">
+        <NDivider title-placement="left" style="margin: 0 0 12px">
+          <i class="i-material-symbols:info-outline mr-1" /> {{ businessTitle }}
+        </NDivider>
+        <slot name="business">
+          <NDescriptions :column="column" label-placement="left" bordered size="small">
+            <NDescriptionsItem v-for="(item, idx) in businessItems" :key="idx" :label="item.label">
+              <DictTag v-if="item.dictType" :dict-type="item.dictType" :value="item.value" />
+              <span v-else>{{ formatValue(item.value) }}</span>
+            </NDescriptionsItem>
+          </NDescriptions>
+        </slot>
+      </template>
+
+      <!-- 流转记录 + 流程图 -->
+      <NDivider title-placement="left" style="margin: 16px 0 12px">
+        <i class="i-material-symbols:timeline mr-1" /> 流程信息
+      </NDivider>
+      <NTabs v-if="processInstanceId" type="line" size="small" animated>
+        <NTabPane name="record" tab="流转记录">
+          <!-- 节点步骤条 -->
+          <div v-if="flowSteps.length" class="flow-steps">
+            <div
+              v-for="(step, idx) in flowSteps"
+              :key="idx"
+              class="flow-step"
+              :class="step.status"
+            >
+              <div class="flow-step-rail">
+                <div class="flow-step-dot">
+                  {{ idx + 1 }}
+                </div>
+                <div v-if="idx < flowSteps.length - 1" class="flow-step-line" />
+              </div>
+              <div class="flow-step-body">
+                <div class="flow-step-name">
+                  {{ step.name }}
+                </div>
+                <div class="flow-step-meta">
+                  {{ step.meta }}
+                </div>
+              </div>
+            </div>
+          </div>
+          <!-- 流转记录表格 -->
+          <NDataTable
+            class="mt-4"
+            :columns="historyColumns"
+            :data="approvalHistory"
+            :bordered="true"
+            :single-line="false"
+            size="small"
+          />
+        </NTabPane>
+        <NTabPane name="diagram" tab="流程图" display-directive="show:lazy">
+          <DingFlowViewer :process-instance-id="processInstanceId" :compact="true" />
+        </NTabPane>
+      </NTabs>
+      <NEmpty v-else description="该记录尚未发起审批流程" size="small" />
+    </NSpin>
+  </NModal>
+</template>
+
+<script setup>
+import { NDataTable, NDescriptions, NDescriptionsItem, NDivider, NEmpty, NModal, NSpin, NTabPane, NTabs, NTag } from 'naive-ui'
+import { computed, h, ref, watch } from 'vue'
+import flowApi from '@/api/flow'
+import DictTag from '@/components/DictTag.vue'
+import DingFlowViewer from '@/components/flow-designer/viewer/DingFlowViewer.vue'
+
+/**
+ * 流程详情公共弹窗:业务信息 + 流转记录(步骤条/表格) + 流程图。
+ * 任意业务流程只需传入 processInstanceId 即可复用;
+ * 业务信息区通过 businessItems({ label, value, dictType? })或 #business 插槽定制。
+ */
+defineOptions({ name: 'FlowProcessDetailModal' })
+
+const props = defineProps({
+  show: { type: Boolean, default: false },
+  /** 流程实例ID,为空时展示空态 */
+  processInstanceId: { type: String, default: null },
+  title: { type: String, default: '审批流程详情' },
+  /** 业务信息区标题 */
+  businessTitle: { type: String, default: '业务信息' },
+  /** 业务信息项:[{ label, value, dictType? }],dictType 存在时渲染字典标签 */
+  businessItems: { type: Array, default: () => [] },
+  /** 业务信息每行展示条数 */
+  column: { type: Number, default: 2 },
+  /** 流程是否已结束(点亮步骤条「结束」节点) */
+  finished: { type: Boolean, default: false },
+})
+
+const emit = defineEmits(['update:show'])
+
+const loading = ref(false)
+const approvalHistory = ref([])
+
+// 弹窗打开且流程实例变化时加载审批历史
+watch(
+  () => [props.show, props.processInstanceId],
+  ([show, processInstanceId]) => {
+    if (show && processInstanceId)
+      loadApprovalHistory(processInstanceId)
+    else if (!show)
+      approvalHistory.value = []
+  },
+  { immediate: true },
+)
+
+async function loadApprovalHistory(processInstanceId) {
+  approvalHistory.value = []
+  loading.value = true
+  try {
+    const res = await flowApi.getProcessHistory(processInstanceId)
+    if (res.code === 200)
+      approvalHistory.value = res.data || []
+  }
+  catch (error) {
+    console.warn('加载审批历史失败:', error?.message || error)
+  }
+  finally {
+    loading.value = false
+  }
+}
+
+function formatValue(value) {
+  if (value === null || value === undefined || value === '')
+    return '-'
+  return value
+}
+
+// 时间格式化:2026-09-24T08:59:46 → 2026-09-24 08:59:46
+function formatTime(value) {
+  if (!value)
+    return '-'
+  return String(value).replace('T', ' ').slice(0, 19)
+}
+
+// 任务历时:开始→结束的时间差
+function formatDuration(start, end) {
+  if (!start || !end)
+    return '处理中'
+  const ms = new Date(String(end).replace(' ', 'T')).getTime() - new Date(String(start).replace(' ', 'T')).getTime()
+  if (Number.isNaN(ms) || ms < 0)
+    return '-'
+  const seconds = Math.floor(ms / 1000)
+  if (seconds < 60)
+    return `${seconds}秒`
+  const minutes = Math.floor(seconds / 60)
+  const restSec = seconds % 60
+  if (minutes < 60)
+    return restSec ? `${minutes}分${restSec}秒` : `${minutes}分钟`
+  const hours = Math.floor(minutes / 60)
+  const restMin = minutes % 60
+  if (hours < 24)
+    return restMin ? `${hours}小时${restMin}分` : `${hours}小时`
+  const days = Math.floor(hours / 24)
+  return `${days}天${hours % 24}小时`
+}
+
+function getTimelineType(action) {
+  const map = { approve: 'success', reject: 'error', return: 'warning', terminate: 'error', start: 'info', claim: 'default', delegate: 'default', withdraw: 'warning', pending: 'default' }
+  return map[action] || 'default'
+}
+
+function getActionLabel(action) {
+  const map = { approve: '同意', reject: '驳回', return: '退回', terminate: '终结', start: '发起', claim: '签收', delegate: '转办', withdraw: '撤回', pending: '待处理' }
+  return map[action] || action || '处理中'
+}
+
+// 节点步骤条:历史记录 + 虚拟结束节点
+const flowSteps = computed(() => {
+  if (!approvalHistory.value.length)
+    return []
+  const steps = approvalHistory.value.map((item) => {
+    const done = item.action !== 'pending'
+    return {
+      name: item.taskName || '流程节点',
+      meta: [item.assigneeName, formatTime(item.createTime)].filter(v => v && v !== '-').join(', '),
+      status: done ? 'finish' : 'process',
+    }
+  })
+  steps.push({
+    name: '结束',
+    meta: props.finished ? formatTime(approvalHistory.value.at(-1)?.completeTime) : '',
+    status: props.finished ? 'finish' : 'wait',
+  })
+  return steps
+})
+
+// 流转记录表格列
+const historyColumns = [
+  { title: '执行环节', key: 'taskName', width: 130, render: row => row.taskName || '-' },
+  { title: '执行人', key: 'assigneeName', width: 110, render: row => row.assigneeName || '-' },
+  { title: '开始时间', key: 'createTime', width: 170, render: row => formatTime(row.createTime) },
+  { title: '结束时间', key: 'completeTime', width: 170, render: row => formatTime(row.completeTime) },
+  {
+    title: '办理状态',
+    key: 'action',
+    width: 100,
+    render: row => h(NTag, { size: 'small', type: getTimelineType(row.action), bordered: false }, { default: () => getActionLabel(row.action) }),
+  },
+  { title: '审批意见', key: 'comment', minWidth: 180, render: row => row.comment || '-' },
+  { title: '任务历时', key: 'duration', width: 110, render: row => formatDuration(row.createTime, row.completeTime) },
+]
+</script>
+
+<style scoped>
+/* 流程节点步骤条 */
+.flow-steps {
+  display: flex;
+  padding: 8px 0 4px;
+}
+
+.flow-step {
+  flex: 1;
+  min-width: 0;
+}
+
+.flow-step:last-child {
+  flex: 0 0 auto;
+}
+
+.flow-step-rail {
+  display: flex;
+  align-items: center;
+}
+
+.flow-step-dot {
+  flex: 0 0 auto;
+  width: 24px;
+  height: 24px;
+  border: 1px solid #c2c8d4;
+  border-radius: 50%;
+  background: #fff;
+  color: #909399;
+  font-size: 12px;
+  font-weight: 600;
+  line-height: 22px;
+  text-align: center;
+}
+
+.flow-step-line {
+  flex: 1;
+  height: 2px;
+  margin: 0 6px;
+  background: #e0e3ea;
+}
+
+.flow-step-body {
+  margin-top: 6px;
+  padding-right: 12px;
+}
+
+.flow-step-name {
+  color: #909399;
+  font-size: 13px;
+  font-weight: 600;
+}
+
+.flow-step-meta {
+  margin-top: 2px;
+  color: #a8adb8;
+  font-size: 12px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.flow-step.finish .flow-step-dot {
+  border-color: #2080f0;
+  background: #2080f0;
+  color: #fff;
+}
+
+.flow-step.finish .flow-step-line {
+  background: #2080f0;
+}
+
+.flow-step.finish .flow-step-name,
+.flow-step.finish .flow-step-meta {
+  color: #2080f0;
+}
+
+.flow-step.process .flow-step-dot {
+  border-color: #2080f0;
+  color: #2080f0;
+  box-shadow: 0 0 0 3px rgba(32, 128, 240, 0.15);
+}
+
+.flow-step.process .flow-step-name {
+  color: #2080f0;
+}
+</style>

+ 76 - 0
forge-admin-ui/src/utils/date-range.js

@@ -0,0 +1,76 @@
+import dayjs from 'dayjs'
+
+/**
+ * 日期范围筛选参数转换工具。
+ *
+ * 背景:daterange / datetimerange 组件产出的值形态不一(毫秒时间戳数组、
+ * 'YYYY-MM-DD' 字符串数组、Date 数组等,取决于组件与 value-format 配置)。
+ * 各页面若自行取下标直传后端,极易出现「后端收到时间戳、SQL 字符串比较
+ * 永远为空」这类转换事故,且已在多个页面反复发生。本工具将任意形态的
+ * 范围值统一归一化为后端可直接消费的字符串。
+ *
+ * 与后端的时间过滤约定:
+ * - 仅日期(daterange):传 'YYYY-MM-DD',由后端归一化方法起始补 00:00:00、结束补 23:59:59
+ * - 带时间(datetimerange):传 'YYYY-MM-DD HH:mm:ss',后端原样使用
+ */
+
+const DATE_FORMAT = 'YYYY-MM-DD'
+const DATETIME_FORMAT = 'YYYY-MM-DD HH:mm:ss'
+
+/**
+ * 归一化范围单端值。
+ * 兼容:毫秒时间戳、Date 对象、'YYYY-MM-DD'、'YYYY-MM-DD HH:mm:ss'、带 T 的 ISO 字符串。
+ * @param {*} value 范围单端原始值
+ * @param {boolean} withTime 是否保留时间部分(datetimerange 传 true)
+ * @returns {string|null} 归一化后的字符串;空值或无法解析时返回 null
+ */
+function normalizeRangeBound(value, withTime) {
+  if (value === null || value === undefined || value === '')
+    return null
+  const parsed = dayjs(value)
+  if (!parsed.isValid())
+    return null
+  return parsed.format(withTime ? DATETIME_FORMAT : DATE_FORMAT)
+}
+
+/**
+ * 将日期范围值解析为 { begin, end }。
+ * @param {*} range 范围值(数组形态,元素形态不限)
+ * @param {boolean} withTime 是否保留时间部分(datetimerange 传 true)
+ * @returns {{begin: string, end: string}|null} 解析结果;非法范围返回 null
+ */
+export function parseDateRange(range, withTime = false) {
+  if (!Array.isArray(range) || range.length !== 2)
+    return null
+  const begin = normalizeRangeBound(range[0], withTime)
+  const end = normalizeRangeBound(range[1], withTime)
+  if (begin === null || end === null)
+    return null
+  return { begin, end }
+}
+
+/**
+ * before-search 钩子内使用:把搜索参数中的范围字段拆分为 begin/end 两个参数,并删除原字段。
+ * 范围为空(用户清空)时同时移除 begin/end,避免残留旧过滤条件。
+ * 示例:applyDateRange(params, 'createTime', 'beginTime', 'endTime')
+ * @param {object} params before-search 收到的原始搜索参数
+ * @param {string} field 范围字段名(如 'createTime')
+ * @param {string} beginKey 拆分后的起始参数名(如 'beginTime')
+ * @param {string} endKey 拆分后的结束参数名(如 'endTime')
+ * @param {boolean} withTime 是否保留时间部分(datetimerange 传 true)
+ * @returns {object} 转换后的搜索参数
+ */
+export function applyDateRange(params, field, beginKey, endKey, withTime = false) {
+  const query = { ...params }
+  const range = parseDateRange(query[field], withTime)
+  if (range) {
+    query[beginKey] = range.begin
+    query[endKey] = range.end
+  }
+  else {
+    delete query[beginKey]
+    delete query[endKey]
+  }
+  delete query[field]
+  return query
+}

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

@@ -750,6 +750,13 @@ function hasWritableBusinessFormFields(context) {
   )
 }
 
+// 批量同意仅在节点存在「必填可写字段」时才拦截;无必填字段的业务表单节点(如老板审核)允许批量处理
+function hasRequiredWritableBusinessFormFields(context) {
+  return Array.isArray(context?.fields) && context.fields.some(field =>
+    field?.writable === true && field?.required === true && field?.readonly !== true && field?.disabled !== true,
+  )
+}
+
 async function loadBusinessTaskFormContext(row, formInfo) {
   businessFormContext.value = null
   businessFormData.value = {}
@@ -970,13 +977,14 @@ async function handleExternalFormSubmit({ action, comment, signature, variables
   const approvalSignature = signature || variables?.signature
   if (!canRunAction(action))
     return
-  if (!validateApprovalInput(comment, approvalSignature))
+  const resolvedComment = resolveActionComment(action, comment)
+  if (!validateApprovalInput(resolvedComment, approvalSignature))
     return
 
   approveForm.action = action
   approveLoading.value = true
   try {
-    const res = await submitTaskAction(action, comment, approvalSignature, variables)
+    const res = await submitTaskAction(action, resolvedComment, approvalSignature, variables)
     if (res.code === 200) {
       window.$message.success(getActionSuccessText(action))
       showDrawer.value = false
@@ -1014,6 +1022,19 @@ function hasSignatureValue(signature, signatureRef) {
   return Boolean(signature?.trim()) || Boolean(signatureRef?.hasSignature?.())
 }
 
+// 审批意见非强制:留空时按动作补默认意见,避免前后端 requireComment 拦截
+const DEFAULT_ACTION_COMMENTS = {
+  approve: '同意',
+  reject: '驳回',
+  return: '退回',
+}
+function resolveActionComment(action, comment) {
+  const trimmed = comment?.trim()
+  if (trimmed)
+    return trimmed
+  return DEFAULT_ACTION_COMMENTS[action] || ''
+}
+
 async function resolveSignature(signatureRef, signature) {
   if (!requireSignature.value)
     return signature || ''
@@ -1063,7 +1084,8 @@ function getActionSuccessText(action) {
 async function submitApprove(action) {
   if (!canRunAction(action))
     return
-  if (!validateApprovalInput(approveForm.comment, approveForm.signature, approveSignatureRef.value))
+  const resolvedComment = resolveActionComment(action, approveForm.comment)
+  if (!validateApprovalInput(resolvedComment, approveForm.signature, approveSignatureRef.value))
     return
   approveForm.action = action
   approveLoading.value = true
@@ -1072,7 +1094,7 @@ async function submitApprove(action) {
     approveForm.signature = signature
     const variables = await collectDynamicFormVariables(action)
     await persistBusinessTaskFormBeforeAction(action)
-    const res = await submitTaskAction(action, approveForm.comment, signature, variables)
+    const res = await submitTaskAction(action, resolvedComment, signature, variables)
     if (res.code === 200) {
       window.$message.success(getActionSuccessText(action))
       showDrawer.value = false
@@ -1142,10 +1164,8 @@ function assertQuickActionAllowed(action, formInfo, businessFormContext = null)
     throw new Error('需要填写节点表单,请进入详情处理')
   if (action === 'approve' && !businessManaged && formInfo?.formType === 'external' && formInfo?.formUrl)
     throw new Error('需要填写业务表单,请进入详情处理')
-  if (action === 'approve' && businessFormContext?.configured === true && businessFormContext?.formType === 'business-code')
-    throw new Error('需要进入业务表单处理')
-  if (action === 'approve' && businessFormContext?.configured === true && hasWritableBusinessFormFields(businessFormContext))
-    throw new Error('需要填写业务表单,请进入详情处理')
+  if (action === 'approve' && businessFormContext?.configured === true && hasRequiredWritableBusinessFormFields(businessFormContext))
+    throw new Error('需要先填写业务表单必填字段,请进入详情处理')
 }
 
 async function executeQuickAction(action, row, comment) {

+ 600 - 0
forge-admin-ui/src/views/restaurant/CreditCustomerApproveForm.vue

@@ -0,0 +1,600 @@
+<template>
+  <div v-if="isTaskFormMode" class="credit-task-form">
+    <n-spin :show="detailLoading">
+      <section class="task-form-section">
+        <div class="section-title">
+          <span>基本信息</span>
+          <DictTag v-if="detail.approvalStatus" dict-type="restaurant_credit_approval_status" :value="detail.approvalStatus" size="small" />
+        </div>
+        <div class="info-grid">
+          <div v-if="canShowField('customerName')" class="info-cell">
+            <span class="info-label">客户姓名</span>
+            <span class="info-value">{{ detail.customerName || '-' }}</span>
+          </div>
+          <div v-if="canShowField('mobile')" class="info-cell">
+            <span class="info-label">手机号</span>
+            <span class="info-value">{{ detail.mobile || '-' }}</span>
+          </div>
+          <div v-if="canShowField('age')" class="info-cell">
+            <span class="info-label">年龄</span>
+            <span class="info-value">{{ detail.age ?? '-' }}</span>
+          </div>
+          <div v-if="canShowField('birthday')" class="info-cell">
+            <span class="info-label">生日</span>
+            <span class="info-value">{{ detail.birthday || '-' }}</span>
+          </div>
+          <div v-if="canShowField('idCard')" class="info-cell">
+            <span class="info-label">身份证</span>
+            <span class="info-value">{{ detail.idCard || '-' }}</span>
+          </div>
+          <div v-if="canShowField('occupation')" class="info-cell">
+            <span class="info-label">职业</span>
+            <span class="info-value">{{ detail.occupation || '-' }}</span>
+          </div>
+          <div v-if="canShowField('companyName')" class="info-cell">
+            <span class="info-label">公司名称</span>
+            <span class="info-value">{{ detail.companyName || '-' }}</span>
+          </div>
+          <div v-if="canShowField('address')" class="info-cell">
+            <span class="info-label">地址</span>
+            <span class="info-value">{{ detail.address || '-' }}</span>
+          </div>
+          <div v-if="canShowField('emergencyContact')" class="info-cell">
+            <span class="info-label">紧急联系人</span>
+            <span class="info-value">{{ detail.emergencyContact || '-' }}</span>
+          </div>
+          <div v-if="canShowField('emergencyContactPhone')" class="info-cell">
+            <span class="info-label">紧急联系电话</span>
+            <span class="info-value">{{ detail.emergencyContactPhone || '-' }}</span>
+          </div>
+          <div v-if="canShowField('applicantName')" class="info-cell">
+            <span class="info-label">申请人</span>
+            <span class="info-value">{{ detail.applicantName || '-' }}</span>
+          </div>
+          <div v-if="canShowField('createTime')" class="info-cell">
+            <span class="info-label">创建时间</span>
+            <span class="info-value">{{ detail.createTime || '-' }}</span>
+          </div>
+        </div>
+      </section>
+
+      <section class="task-form-section">
+        <div class="section-title">
+          <span>开票信息</span>
+        </div>
+        <div class="info-grid">
+          <div v-if="canShowField('invoiceTitle')" class="info-cell">
+            <span class="info-label">发票抬头</span>
+            <span class="info-value">{{ detail.invoiceTitle || '-' }}</span>
+          </div>
+          <div v-if="canShowField('taxNo')" class="info-cell">
+            <span class="info-label">税号</span>
+            <span class="info-value">{{ detail.taxNo || '-' }}</span>
+          </div>
+          <div v-if="canShowField('bankName')" class="info-cell">
+            <span class="info-label">银行名称</span>
+            <span class="info-value">{{ detail.bankName || '-' }}</span>
+          </div>
+          <div v-if="canShowField('bankAccount')" class="info-cell">
+            <span class="info-label">银行账号</span>
+            <span class="info-value">{{ detail.bankAccount || '-' }}</span>
+          </div>
+          <div v-if="canShowField('invoiceAddressPhone')" class="info-cell info-cell--wide">
+            <span class="info-label">发票地址电话</span>
+            <span class="info-value">{{ detail.invoiceAddressPhone || '-' }}</span>
+          </div>
+        </div>
+      </section>
+
+      <section class="task-form-section">
+        <div class="section-title">
+          <span>挂账信息</span>
+        </div>
+        <div class="info-grid">
+          <div v-if="canShowField('creditStatus')" class="info-cell">
+            <span class="info-label">挂账状态</span>
+            <span class="info-value">
+              <DictTag v-if="detail.creditStatus" dict-type="restaurant_credit_status" :value="detail.creditStatus" size="small" />
+              <template v-else>-</template>
+            </span>
+          </div>
+          <div v-if="canShowField('totalArrearsAmount')" class="info-cell">
+            <span class="info-label">累计欠数金额</span>
+            <span class="info-value">{{ formatAmount(detail.totalArrearsAmount) }}</span>
+          </div>
+          <div v-if="canShowField('unsettledBillCount')" class="info-cell">
+            <span class="info-label">未结账单数</span>
+            <span class="info-value">{{ detail.unsettledBillCount ?? '-' }}</span>
+          </div>
+          <div v-if="canShowField('firstCreditTime')" class="info-cell">
+            <span class="info-label">首次挂账时间</span>
+            <span class="info-value">{{ detail.firstCreditTime || '-' }}</span>
+          </div>
+          <div v-if="canShowField('lastConsumeTime')" class="info-cell">
+            <span class="info-label">最后消费时间</span>
+            <span class="info-value">{{ detail.lastConsumeTime || '-' }}</span>
+          </div>
+          <div v-if="canShowField('approverName')" class="info-cell">
+            <span class="info-label">审批人(老板)</span>
+            <span class="info-value">{{ detail.approverName || '-' }}</span>
+          </div>
+          <div v-if="detail.rejectReason && canShowField('rejectReason')" class="info-cell info-cell--wide">
+            <span class="info-label">最近驳回原因</span>
+            <span class="info-value text-error">{{ detail.rejectReason }}</span>
+          </div>
+        </div>
+      </section>
+
+      <section class="task-form-section">
+        <n-form
+          ref="taskFormRef"
+          :model="taskForm"
+          :rules="taskFormRules"
+          label-placement="top"
+          require-mark-placement="right-hanging"
+        >
+          <n-grid :cols="2" :x-gap="16" :y-gap="4" responsive="screen">
+            <n-form-item-gi v-if="canEditNodeField('customerName')" label="客户姓名" path="customerName">
+              <n-input v-model:value="taskForm.customerName" :disabled="isFieldDisabled('customerName')" maxlength="64" show-count />
+            </n-form-item-gi>
+            <n-form-item-gi v-if="canEditNodeField('mobile')" label="手机号" path="mobile">
+              <n-input v-model:value="taskForm.mobile" :disabled="isFieldDisabled('mobile')" maxlength="20" show-count />
+            </n-form-item-gi>
+            <n-form-item-gi v-if="canEditNodeField('companyName')" label="公司名称" path="companyName">
+              <n-input v-model:value="taskForm.companyName" :disabled="isFieldDisabled('companyName')" maxlength="128" show-count />
+            </n-form-item-gi>
+            <n-form-item-gi v-if="canEditNodeField('address')" label="地址" path="address">
+              <n-input v-model:value="taskForm.address" :disabled="isFieldDisabled('address')" maxlength="255" show-count />
+            </n-form-item-gi>
+            <n-form-item-gi v-if="canEditNodeField('invoiceTitle')" label="发票抬头" path="invoiceTitle">
+              <n-input v-model:value="taskForm.invoiceTitle" :disabled="isFieldDisabled('invoiceTitle')" maxlength="128" show-count />
+            </n-form-item-gi>
+            <n-form-item-gi v-if="canEditNodeField('taxNo')" label="税号" path="taxNo">
+              <n-input v-model:value="taskForm.taxNo" :disabled="isFieldDisabled('taxNo')" maxlength="64" show-count />
+            </n-form-item-gi>
+            <n-form-item-gi v-if="canEditNodeField('bankName')" label="银行名称" path="bankName">
+              <n-input v-model:value="taskForm.bankName" :disabled="isFieldDisabled('bankName')" maxlength="128" show-count />
+            </n-form-item-gi>
+            <n-form-item-gi v-if="canEditNodeField('bankAccount')" label="银行账号" path="bankAccount">
+              <n-input v-model:value="taskForm.bankAccount" :disabled="isFieldDisabled('bankAccount')" maxlength="64" show-count />
+            </n-form-item-gi>
+            <n-form-item-gi v-if="canEditNodeField('applicantModifyRemark')" label="修改说明" path="applicantModifyRemark" :span="2">
+              <n-input
+                v-model:value="taskForm.applicantModifyRemark"
+                :disabled="isFieldDisabled('applicantModifyRemark')"
+                type="textarea"
+                :rows="3"
+                maxlength="500"
+                show-count
+              />
+            </n-form-item-gi>
+            <n-form-item-gi v-if="canEditNodeField('remark')" label="备注" path="remark" :span="2">
+              <n-input
+                v-model:value="taskForm.remark"
+                :disabled="isFieldDisabled('remark')"
+                type="textarea"
+                :rows="2"
+                maxlength="500"
+                show-count
+              />
+            </n-form-item-gi>
+          </n-grid>
+
+          <n-form-item v-if="!taskRuntime.readOnly" label="审批意见" path="comment" class="approval-comment">
+            <n-input
+              v-model:value="taskForm.comment"
+              :disabled="taskFormDisabled"
+              type="textarea"
+              :rows="3"
+              placeholder="请输入审批意见(可选)"
+              maxlength="500"
+              show-count
+            />
+          </n-form-item>
+
+          <div v-if="!taskRuntime.readOnly" class="task-actions">
+            <slot name="actions" />
+            <n-button
+              v-if="canApprove"
+              type="primary"
+              size="large"
+              :loading="isActionSubmitting('approve')"
+              :disabled="taskFormDisabled"
+              @click="submitTask('approve')"
+            >
+              {{ isModifyTask ? '保存并重提' : '同意' }}
+            </n-button>
+            <n-button
+              v-if="canReject"
+              type="error"
+              secondary
+              size="large"
+              :loading="isActionSubmitting('reject')"
+              :disabled="taskFormDisabled"
+              @click="submitTask('reject')"
+            >
+              {{ isModifyTask ? '终止申请' : '驳回修改' }}
+            </n-button>
+            <n-button size="large" :disabled="localSubmitting || submitting" @click="emit('cancel')">
+              关闭
+            </n-button>
+          </div>
+        </n-form>
+      </section>
+    </n-spin>
+  </div>
+
+  <n-empty v-else description="缺少任务参数,无法加载挂账客户审批表单" style="padding: 40px 0" />
+</template>
+
+<script setup>
+import { computed, reactive, ref, watch } from 'vue'
+import { useRoute } from 'vue-router'
+import { getCreditDetail } from '@/api/restaurant'
+import DictTag from '@/components/DictTag.vue'
+import { useBusinessTaskFormContext } from '@/composables/useBusinessTaskFormContext'
+
+defineOptions({ name: 'CreditCustomerApproveForm' })
+
+const props = defineProps({
+  taskId: { type: String, default: null },
+  businessKey: { type: String, default: null },
+  processInstanceId: { type: String, default: null },
+  taskDefKey: { type: String, default: null },
+  processDefKey: { type: String, default: null },
+  variables: { type: Object, default: () => ({}) },
+  approvalPolicy: { type: Object, default: () => ({}) },
+  initialTaskContext: { type: Object, default: null },
+  readOnly: { type: Boolean, default: false },
+  submitting: { type: Boolean, default: false },
+  submittingAction: { type: String, default: '' },
+})
+
+const emit = defineEmits(['submit', 'cancel'])
+
+const route = useRoute()
+const taskRuntime = computed(() => ({
+  taskId: props.taskId || route.query.taskId || '',
+  businessKey: props.businessKey || route.query.businessKey || '',
+  processInstanceId: props.processInstanceId || route.query.processInstanceId || '',
+  taskDefKey: props.taskDefKey || route.query.taskDefKey || '',
+  processDefKey: props.processDefKey || route.query.processDefKey || '',
+  readOnly: props.readOnly || route.query.readOnly === 'true',
+}))
+const isTaskFormMode = computed(() => Boolean(taskRuntime.value.taskId || taskRuntime.value.businessKey || taskRuntime.value.processInstanceId))
+const taskRuntimeReadonly = computed(() => taskRuntime.value.readOnly)
+const businessTaskForm = useBusinessTaskFormContext({}, { readonly: taskRuntimeReadonly })
+const currentTaskDefKey = computed(() => taskRuntime.value.taskDefKey || businessTaskForm.context.value?.taskDefKey || '')
+
+const detail = reactive({})
+const detailLoading = ref(false)
+const taskFormRef = ref(null)
+const localSubmitting = ref(false)
+const localSubmittingAction = ref('')
+const taskForm = reactive(defaultTaskForm())
+
+const isModifyTask = computed(() => currentTaskDefKey.value === 'applicant_modify')
+
+/** 各节点允许编辑的业务字段(最终仍受节点字段权限约束);老板节点只保留审批意见,不开放业务字段 */
+const nodeEditableFields = computed(() => {
+  if (isModifyTask.value)
+    return ['customerName', 'mobile', 'companyName', 'address', 'invoiceTitle', 'taxNo', 'bankName', 'bankAccount', 'applicantModifyRemark', 'remark']
+  return []
+})
+const writableNodeFields = computed(() => nodeEditableFields.value.filter(field => canShowField(field) && canEditField(field)))
+
+const canApprove = computed(() => props.approvalPolicy?.allowApprove !== false)
+const canReject = computed(() => props.approvalPolicy?.allowReject !== false)
+const taskFormDisabled = computed(() => taskRuntime.value.readOnly || props.submitting || localSubmitting.value)
+
+const taskFormRules = computed(() => {
+  const rules = {}
+  if (canEditField('customerName') && nodeEditableFields.value.includes('customerName'))
+    rules.customerName = { required: true, message: '请输入客户姓名', trigger: ['blur', 'input'] }
+  return rules
+})
+
+watch(() => [
+  taskRuntime.value.taskId,
+  taskRuntime.value.businessKey,
+  taskRuntime.value.processInstanceId,
+  taskRuntime.value.taskDefKey,
+], () => {
+  if (isTaskFormMode.value)
+    loadTaskDetail()
+}, { immediate: true })
+
+function canShowField(field) {
+  return businessTaskForm.canShowField(field)
+}
+
+function canEditField(field) {
+  return businessTaskForm.canEditField(field)
+}
+
+function canEditNodeField(field) {
+  return nodeEditableFields.value.includes(field) && canShowField(field) && canEditField(field)
+}
+
+function isFieldDisabled(field) {
+  return taskFormDisabled.value || !canEditField(field)
+}
+
+function formatAmount(value) {
+  if (value === null || value === undefined || value === '')
+    return '-'
+  return `${value} 元`
+}
+
+async function loadTaskDetail() {
+  detailLoading.value = true
+  try {
+    const context = await loadBusinessTaskContext()
+    if (applyTaskContextRecord(context))
+      return
+    const recordId = resolveRecordId(context)
+    if (!recordId)
+      return
+    const res = await getCreditDetail(recordId)
+    if (res.code !== 200) {
+      window.$message.error(res.message || '挂账客户详情加载失败')
+      return
+    }
+    Object.keys(detail).forEach(key => delete detail[key])
+    Object.assign(detail, res.data || {})
+    Object.assign(taskForm, detailToTaskForm(detail))
+  }
+  finally {
+    detailLoading.value = false
+  }
+}
+
+async function loadBusinessTaskContext() {
+  const initialContext = resolveInitialTaskContext()
+  if (initialContext) {
+    businessTaskForm.setContext(initialContext)
+    return initialContext
+  }
+  try {
+    return await businessTaskForm.load({
+      taskId: taskRuntime.value.taskId,
+      businessKey: taskRuntime.value.businessKey,
+      processInstanceId: taskRuntime.value.processInstanceId,
+      processDefKey: taskRuntime.value.processDefKey,
+      taskDefKey: taskRuntime.value.taskDefKey,
+    })
+  }
+  catch (error) {
+    window.$message.warning(error?.message || '字段权限加载失败,将使用默认节点规则')
+    return null
+  }
+}
+
+function resolveInitialTaskContext() {
+  const context = props.initialTaskContext
+  if (!context || typeof context !== 'object')
+    return null
+  const runtime = taskRuntime.value
+  const pairs = [
+    ['taskId', runtime.taskId],
+    ['businessKey', runtime.businessKey],
+    ['processInstanceId', runtime.processInstanceId],
+    ['taskDefKey', runtime.taskDefKey],
+  ]
+  for (const [key, expected] of pairs) {
+    if (expected && context[key] && String(context[key]) !== String(expected))
+      return null
+  }
+  if (!(context.configured || context.recordData || context.recordId || context.businessKey))
+    return null
+  return context
+}
+
+function applyTaskContextRecord(context) {
+  const recordData = context?.recordData && typeof context.recordData === 'object' ? context.recordData : null
+  if (!recordData || Object.keys(recordData).length === 0)
+    return false
+  const nextDetail = { ...recordData }
+  if (context.recordId && !nextDetail.id)
+    nextDetail.id = context.recordId
+  if (context.businessKey && !nextDetail.businessKey)
+    nextDetail.businessKey = context.businessKey
+  Object.keys(detail).forEach(key => delete detail[key])
+  Object.assign(detail, nextDetail)
+  Object.assign(taskForm, detailToTaskForm(detail))
+  return true
+}
+
+function resolveRecordId(context = businessTaskForm.context.value) {
+  const fromKey = extractIdFromBusinessKey(taskRuntime.value.businessKey || props.variables?.businessKey || context?.businessKey)
+  return normalizeId(props.variables?.creditCustomerId)
+    || normalizeId(context?.recordId)
+    || normalizeId(route.query.recordId)
+    || fromKey
+}
+
+function extractIdFromBusinessKey(value) {
+  if (value === null || value === undefined)
+    return null
+  const text = String(value).trim()
+  if (!text.includes(':'))
+    return null
+  return normalizeId(text.split(':').pop())
+}
+
+function normalizeId(value) {
+  if (value === null || value === undefined)
+    return null
+  const text = String(value).trim()
+  return /^[1-9]\d*$/.test(text) ? text : null
+}
+
+async function saveTaskFields() {
+  const data = {}
+  writableNodeFields.value.forEach((field) => {
+    data[field] = taskForm[field]
+  })
+  if (!businessTaskForm.context.value?.configured)
+    return null
+  const savedContext = await businessTaskForm.save(data, {
+    taskId: taskRuntime.value.taskId,
+    businessKey: taskRuntime.value.businessKey,
+    processInstanceId: taskRuntime.value.processInstanceId,
+    processDefKey: taskRuntime.value.processDefKey,
+    taskDefKey: taskRuntime.value.taskDefKey,
+    recordId: detail.id,
+  })
+  Object.assign(detail, savedContext?.recordData || {})
+  return savedContext?.recordData
+}
+
+async function submitTask(action) {
+  if (taskFormDisabled.value)
+    return
+  if (action === 'approve' && !canApprove.value)
+    return
+  if (action === 'reject' && !canReject.value)
+    return
+
+  localSubmitting.value = true
+  localSubmittingAction.value = action
+  try {
+    await taskFormRef.value?.validate()
+    if (writableNodeFields.value.length)
+      await saveTaskFields()
+    emit('submit', {
+      action,
+      comment: taskForm.comment,
+      variables: {
+        approvalResult: action === 'approve' ? 'approve' : 'reject',
+        approved: action === 'approve',
+        creditCustomerId: detail.id,
+        customerName: detail.customerName,
+        mobile: detail.mobile,
+        applicantModifyRemark: taskForm.applicantModifyRemark,
+      },
+    })
+  }
+  catch (error) {
+    window.$message.error(error?.message || '提交失败')
+  }
+  finally {
+    localSubmitting.value = false
+    localSubmittingAction.value = ''
+  }
+}
+
+function isActionSubmitting(action) {
+  return (localSubmitting.value && localSubmittingAction.value === action)
+    || (props.submitting && props.submittingAction === action)
+}
+
+function defaultTaskForm() {
+  return {
+    customerName: '',
+    mobile: '',
+    companyName: '',
+    address: '',
+    invoiceTitle: '',
+    taxNo: '',
+    bankName: '',
+    bankAccount: '',
+    applicantModifyRemark: '',
+    remark: '',
+    comment: '',
+  }
+}
+
+function detailToTaskForm(row) {
+  return {
+    customerName: row.customerName || '',
+    mobile: row.mobile || '',
+    companyName: row.companyName || '',
+    address: row.address || '',
+    invoiceTitle: row.invoiceTitle || '',
+    taxNo: row.taxNo || '',
+    bankName: row.bankName || '',
+    bankAccount: row.bankAccount || '',
+    applicantModifyRemark: row.applicantModifyRemark || '',
+    remark: row.remark || '',
+    comment: '',
+  }
+}
+</script>
+
+<style scoped>
+.credit-task-form {
+  padding: 4px 0;
+}
+
+.task-form-section {
+  margin-bottom: 18px;
+}
+
+.section-title {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 10px;
+  font-size: 14px;
+  font-weight: 600;
+}
+
+.info-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 1px;
+  background: #efeff5;
+  border: 1px solid #efeff5;
+  border-radius: 3px;
+  overflow: hidden;
+}
+
+.info-cell {
+  display: flex;
+  align-items: stretch;
+  min-height: 38px;
+  background: #fff;
+}
+
+.info-cell--wide {
+  grid-column: 1 / -1;
+}
+
+.info-label {
+  display: flex;
+  align-items: center;
+  flex: none;
+  width: 120px;
+  padding: 8px 12px;
+  background: #fafafc;
+  border-right: 1px solid #efeff5;
+  color: #666;
+  font-size: 13px;
+}
+
+.info-value {
+  display: flex;
+  align-items: center;
+  flex: 1;
+  padding: 8px 12px;
+  color: #333;
+  font-size: 13px;
+  word-break: break-all;
+}
+
+.approval-comment {
+  margin-top: 8px;
+}
+
+.approval-comment :deep(.n-form-item-label) {
+  font-weight: 700;
+}
+
+.task-actions {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-top: 4px;
+}
+</style>

+ 296 - 0
forge-admin-ui/src/views/restaurant/components/CreditCustomerFormModal.vue

@@ -0,0 +1,296 @@
+<template>
+  <NModal
+    :show="show"
+    preset="card"
+    :title="modalTitle"
+    style="width: 900px"
+    :mask-closable="false"
+    @update:show="emit('update:show', $event)"
+  >
+    <NSpin :show="loading">
+      <NForm
+        ref="formRef"
+        :model="form"
+        :rules="rules"
+        label-placement="left"
+        label-width="110"
+        require-mark-placement="right-hanging"
+      >
+        <!-- 基本信息 -->
+        <div class="text-sm mb-2 font-bold">
+          基本信息
+        </div>
+        <NGrid :cols="2" :x-gap="24">
+          <NFormItemGi label="姓名" path="customerName">
+            <NInput v-model:value="form.customerName" placeholder="请输入姓名" />
+          </NFormItemGi>
+          <NFormItemGi label="手机号" path="mobile">
+            <NInput v-model:value="form.mobile" placeholder="请输入手机号" />
+          </NFormItemGi>
+          <NFormItemGi label="生日" path="birthday">
+            <NDatePicker
+              v-model:formatted-value="form.birthday"
+              value-format="yyyy-MM-dd"
+              placeholder="请选择生日"
+              clearable
+              style="width: 100%"
+            />
+          </NFormItemGi>
+          <NFormItemGi label="身份证号" path="idCard">
+            <NInput v-model:value="form.idCard" placeholder="请输入身份证号" />
+          </NFormItemGi>
+          <NFormItemGi label="挂账状态" path="creditStatus">
+            <DictSelect v-model:value="form.creditStatus" dict-type="restaurant_credit_status" placeholder="请选择挂账状态" />
+          </NFormItemGi>
+          <NFormItemGi label="年龄" path="age">
+            <NInputNumber v-model:value="form.age" :min="0" :max="120" placeholder="请输入年龄" style="width: 100%" />
+          </NFormItemGi>
+          <NFormItemGi label="职业" path="occupation">
+            <NInput v-model:value="form.occupation" placeholder="请输入职业" />
+          </NFormItemGi>
+          <NFormItemGi label="公司名称" path="companyName">
+            <NInput v-model:value="form.companyName" placeholder="请输入公司名称" />
+          </NFormItemGi>
+          <NFormItemGi label="紧急联系人" path="emergencyContact">
+            <NInput v-model:value="form.emergencyContact" placeholder="请输入紧急联系人" />
+          </NFormItemGi>
+          <NFormItemGi label="紧急联系人电话" path="emergencyContactPhone">
+            <NInput v-model:value="form.emergencyContactPhone" placeholder="请输入紧急联系人电话" />
+          </NFormItemGi>
+          <NFormItemGi label="住址" path="address" :span="2">
+            <NInput v-model:value="form.address" placeholder="请输入住址" />
+          </NFormItemGi>
+        </NGrid>
+
+        <!-- 开票信息 -->
+        <div class="text-sm mb-2 mt-4 font-bold">
+          开票信息
+        </div>
+        <NGrid :cols="2" :x-gap="24">
+          <NFormItemGi label="抬头" path="invoiceTitle">
+            <NInput v-model:value="form.invoiceTitle" placeholder="请输入开票抬头" />
+          </NFormItemGi>
+          <NFormItemGi label="税号" path="taxNo">
+            <NInput v-model:value="form.taxNo" placeholder="请输入税号" />
+          </NFormItemGi>
+          <NFormItemGi label="开户行" path="bankName">
+            <NInput v-model:value="form.bankName" placeholder="请输入开户行" />
+          </NFormItemGi>
+          <NFormItemGi label="账号" path="bankAccount">
+            <NInput v-model:value="form.bankAccount" placeholder="请输入账号" />
+          </NFormItemGi>
+          <NFormItemGi label="开票地址电话" path="invoiceAddressPhone" :span="2">
+            <NInput v-model:value="form.invoiceAddressPhone" placeholder="请输入开票地址电话" />
+          </NFormItemGi>
+        </NGrid>
+
+        <!-- 挂账信息 -->
+        <div class="text-sm mb-2 mt-4 font-bold">
+          挂账信息
+        </div>
+        <NGrid :cols="2" :x-gap="24">
+          <NFormItemGi label="累计欠数金额" path="totalArrearsAmount">
+            <NInputNumber
+              v-model:value="form.totalArrearsAmount"
+              :min="0"
+              :precision="2"
+              placeholder="请输入累计欠数金额"
+              style="width: 100%"
+            />
+          </NFormItemGi>
+          <NFormItemGi label="未结账单数" path="unsettledBillCount">
+            <NInputNumber
+              v-model:value="form.unsettledBillCount"
+              :min="0"
+              :precision="0"
+              placeholder="请输入未结账单数"
+              style="width: 100%"
+            />
+          </NFormItemGi>
+          <NFormItemGi label="首次挂账时间" path="firstCreditTime">
+            <NDatePicker
+              v-model:formatted-value="form.firstCreditTime"
+              type="datetime"
+              value-format="yyyy-MM-dd HH:mm:ss"
+              placeholder="请选择首次挂账时间"
+              clearable
+              style="width: 100%"
+            />
+          </NFormItemGi>
+          <NFormItemGi label="最后消费时间" path="lastConsumeTime">
+            <NDatePicker
+              v-model:formatted-value="form.lastConsumeTime"
+              type="datetime"
+              value-format="yyyy-MM-dd HH:mm:ss"
+              placeholder="请选择最后消费时间"
+              clearable
+              style="width: 100%"
+            />
+          </NFormItemGi>
+          <NFormItemGi label="录入人">
+            <NInput :value="createByName" placeholder="保存后自动记录" disabled />
+          </NFormItemGi>
+          <NFormItemGi label="审核人">
+            <NInput :value="approverName" placeholder="暂未审核" disabled />
+          </NFormItemGi>
+          <NFormItemGi label="备注" path="remark" :span="2">
+            <NInput
+              v-model:value="form.remark"
+              type="textarea"
+              placeholder="请输入备注"
+              :maxlength="500"
+              show-count
+              :rows="3"
+            />
+          </NFormItemGi>
+        </NGrid>
+      </NForm>
+    </NSpin>
+    <template #footer>
+      <div class="flex justify-end gap-3">
+        <NButton @click="emit('update:show', false)">
+          取消
+        </NButton>
+        <NButton type="primary" :loading="saving" @click="handleSave">
+          送审
+        </NButton>
+      </div>
+    </template>
+  </NModal>
+</template>
+
+<script setup>
+import { NButton, NDatePicker, NForm, NFormItemGi, NGrid, NInput, NInputNumber, NModal, NSpin } from 'naive-ui'
+import { computed, ref, watch } from 'vue'
+import { getCreditDetail, saveCreditCustomer } from '@/api/restaurant'
+import DictSelect from '@/components/DictSelect.vue'
+
+/**
+ * 挂账客户填报弹窗组件:新增/编辑共用。
+ * 代码独立于列表页维护,交互上仍以弹窗形式打开;
+ * 表单分基本信息/开票信息/挂账信息三段,与业务填报表对齐;
+ * 送审 = 保存表单,保存成功后 emit('saved') 由父页面衔接审批人选择。
+ */
+defineOptions({ name: 'CreditCustomerFormModal' })
+
+const props = defineProps({
+  show: { type: Boolean, default: false },
+  /** 当前编辑行,null 表示新增 */
+  row: { type: Object, default: null },
+})
+
+const emit = defineEmits(['update:show', 'saved'])
+
+const isEdit = computed(() => !!props.row?.id)
+const modalTitle = computed(() => (isEdit.value ? '编辑挂账客户' : '新增挂账客户'))
+
+const formRef = ref(null)
+const loading = ref(false)
+const saving = ref(false)
+
+// 只读展示项:录入人/审核人不参与提交
+const createByName = ref('')
+const approverName = ref('')
+
+function emptyForm() {
+  return {
+    customerName: null,
+    mobile: null,
+    birthday: null,
+    idCard: null,
+    creditStatus: '0',
+    age: null,
+    occupation: null,
+    companyName: null,
+    emergencyContact: null,
+    emergencyContactPhone: null,
+    address: null,
+    invoiceTitle: null,
+    taxNo: null,
+    bankName: null,
+    bankAccount: null,
+    invoiceAddressPhone: null,
+    totalArrearsAmount: null,
+    unsettledBillCount: null,
+    firstCreditTime: null,
+    lastConsumeTime: null,
+    remark: null,
+  }
+}
+
+const form = ref(emptyForm())
+
+const rules = {
+  customerName: { required: true, message: '姓名不能为空', trigger: ['blur', 'input'] },
+  mobile: { required: true, message: '手机号不能为空', trigger: ['blur', 'input'] },
+}
+
+// 弹窗打开时:编辑回填详情,新增重置表单
+watch(() => props.show, (show) => {
+  if (!show)
+    return
+  if (isEdit.value)
+    loadDetail()
+  else
+    resetForm()
+})
+
+function resetForm() {
+  form.value = emptyForm()
+  createByName.value = ''
+  approverName.value = ''
+}
+
+async function loadDetail() {
+  loading.value = true
+  try {
+    const res = await getCreditDetail(props.row.id)
+    if (res.code === 200 && res.data) {
+      const base = emptyForm()
+      Object.keys(base).forEach((key) => {
+        base[key] = res.data[key] ?? (key === 'creditStatus' ? '0' : null)
+      })
+      // 日期/时间只保留到秒,匹配 DatePicker 的格式
+      if (base.birthday)
+        base.birthday = String(base.birthday).slice(0, 10)
+      if (base.firstCreditTime)
+        base.firstCreditTime = String(base.firstCreditTime).slice(0, 19)
+      if (base.lastConsumeTime)
+        base.lastConsumeTime = String(base.lastConsumeTime).slice(0, 19)
+      form.value = base
+      createByName.value = res.data.createName || ''
+      approverName.value = res.data.approverName || ''
+    }
+  }
+  catch (error) {
+    console.warn('加载挂账客户详情失败:', error?.message || error)
+  }
+  finally {
+    loading.value = false
+  }
+}
+
+// 送审:校验 + 保存,成功后交由父页面衔接审批人选择
+async function handleSave() {
+  try {
+    await formRef.value?.validate()
+  }
+  catch {
+    return
+  }
+  saving.value = true
+  try {
+    const payload = { ...form.value }
+    if (isEdit.value)
+      payload.id = props.row.id
+    const res = await saveCreditCustomer(payload)
+    if (res.code === 200) {
+      emit('saved', { id: res.data ?? props.row?.id, customerName: form.value.customerName })
+      emit('update:show', false)
+    }
+  }
+  finally {
+    saving.value = false
+  }
+}
+</script>

+ 277 - 148
forge-admin-ui/src/views/restaurant/creditCustomer.vue

@@ -11,41 +11,103 @@
       }"
       :search-schema="searchSchema"
       :columns="tableColumns"
-      :form-schema="formSchema"
       :page-size="20"
-      add-button-text="新增挂账客户"
-      @before-submit="handleBeforeSubmit"
+      hide-add
+    >
+      <!-- 新增/编辑跳转到独立子页面 -->
+      <template #toolbar-start>
+        <NButton type="primary" size="small" @click="goForm()">
+          <template #icon>
+            <i class="i-material-symbols:add-circle-outline mr-1" />
+          </template>
+          新增挂账客户
+        </NButton>
+      </template>
+    </AiCrudPage>
+
+    <!-- 提交审批选人弹窗:仅当老板节点什么都没配置时才弹出;已配置角色/人员/岗位时直接提交不弹框 -->
+    <NModal
+      v-model:show="submitVisible"
+      preset="card"
+      title="提交挂账客户审批"
+      style="width: 460px"
+      :mask-closable="false"
+    >
+      <NForm label-placement="top">
+        <NFormItem label="客户姓名">
+          <span>{{ submitRow?.customerName || '-' }}</span>
+        </NFormItem>
+        <NFormItem label="审批人(老板)" required>
+          <NSelect
+            v-model:value="selectedApproverId"
+            :options="approverOptions"
+            :loading="approverLoading"
+            filterable
+            placeholder="流程节点未配置审批人,请选择审批的老板"
+          />
+        </NFormItem>
+      </NForm>
+      <template #footer>
+        <div class="flex justify-end gap-3">
+          <NButton @click="submitVisible = false">
+            取消
+          </NButton>
+          <NButton type="primary" :loading="submitLoading" @click="confirmSubmit">
+            提交审批
+          </NButton>
+        </div>
+      </template>
+    </NModal>
+
+    <!-- 流程详情:公共组件,传流程实例ID + 业务信息项即可 -->
+    <FlowProcessDetailModal
+      v-model:show="flowDetailVisible"
+      :process-instance-id="flowDetailRow?.processInstanceId"
+      :finished="flowDetailRow?.approvalStatus === '5'"
+      business-title="客户信息"
+      :business-items="creditBusinessItems"
+    />
+
+    <!-- 填报弹窗:组件代码独立维护,交互上仍是弹窗 -->
+    <CreditCustomerFormModal
+      v-model:show="formVisible"
+      :row="formRow"
+      @saved="handleFormSaved"
     />
   </div>
 </template>
 
 <script setup>
-import { NTag } from 'naive-ui'
+import { NButton, 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 { AiCrudPage } from '@/components/ai-form'
+import DictTag from '@/components/DictTag.vue'
+import FlowProcessDetailModal from '@/components/flow/FlowProcessDetailModal.vue'
+import { useDict } from '@/composables/useDict'
+import { useUserStore } from '@/store'
+import CreditCustomerFormModal from './components/CreditCustomerFormModal.vue'
 
 defineOptions({ name: 'RestaurantCreditCustomer' })
 
 const crudRef = ref(null)
+const message = useMessage()
+const dialog = useDialog()
+const userStore = useUserStore()
 
-// ==================== 字典映射 ====================
-const creditStatusOptions = [
-  { label: '正常挂账', value: 'NORMAL' },
-  { label: '暂停挂账', value: 'SUSPENDED' },
-  { label: '黑名单', value: 'BLACKLIST' },
-]
-
-const creditColorMap = {
-  NORMAL: 'success',
-  SUSPENDED: 'warning',
-  BLACKLIST: 'error',
-}
+// ==================== 字典 ====================
+const { dict } = useDict('restaurant_credit_status', 'restaurant_credit_approval_status')
+const creditStatusOptions = computed(() => dict.value.restaurant_credit_status || [])
+const approvalStatusOptions = computed(() => dict.value.restaurant_credit_approval_status || [])
 
-const creditLabelMap = {
-  NORMAL: '正常挂账',
-  SUSPENDED: '暂停挂账',
-  BLACKLIST: '黑名单',
-}
+// ==================== 审批状态门禁 ====================
+// 1-暂存可提交/编辑/删除;2-审批中仅可撤回;3-撤回/4-驳回可编辑/删除(编辑保存后回暂存可重新送审);5-已完成可切换挂账状态
+const canSubmit = row => row.approvalStatus === '1'
+const canEdit = row => ['1', '3', '4'].includes(row.approvalStatus)
+const canWithdraw = row => row.approvalStatus === '2' && !!row.processInstanceId
+const canDelete = row => ['1', '3', '4'].includes(row.approvalStatus)
+const canToggle = row => row.approvalStatus === '5'
 
 // ==================== 搜索配置 ====================
 const searchSchema = computed(() => [
@@ -65,15 +127,22 @@ const searchSchema = computed(() => [
     field: 'creditStatus',
     label: '挂账状态',
     type: 'select',
-    props: { placeholder: '请选择状态', options: creditStatusOptions, clearable: true },
+    props: { placeholder: '请选择状态', options: creditStatusOptions.value, clearable: true },
+  },
+  {
+    field: 'approvalStatus',
+    label: '审批状态',
+    type: 'select',
+    props: { placeholder: '请选择审批状态', options: approvalStatusOptions.value, clearable: true },
   },
 ])
 
 // ==================== 表格列 ====================
-function formatMoney(cents) {
-  if (cents == null)
+// 金额单位:元(decimal(18,2)),直接保留两位小数展示
+function formatMoney(amount) {
+  if (amount == null)
     return '0.00'
-  return (cents / 100).toFixed(2)
+  return Number(amount).toFixed(2)
 }
 
 const tableColumns = computed(() => [
@@ -92,159 +161,219 @@ const tableColumns = computed(() => [
     prop: 'creditStatus',
     label: '挂账状态',
     width: 110,
-    render: row => h(NTag, {
-      type: creditColorMap[row.creditStatus] || 'default',
-      size: 'small',
-    }, { default: () => creditLabelMap[row.creditStatus] || '-' }),
+    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',
+    label: '审批状态',
+    width: 100,
+    render: (row) => {
+      const hasProcess = !!row.processInstanceId
+      return h('span', {
+        style: hasProcess ? 'cursor: pointer' : '',
+        onClick: hasProcess ? () => openFlowDetail(row) : undefined,
+      }, [h(DictTag, { dictType: 'restaurant_credit_approval_status', value: row.approvalStatus })])
+    },
+  },
+  {
     prop: 'action',
     label: '操作',
-    width: 200,
+    width: 260,
     fixed: 'right',
     render: (row) => {
       const btns = []
-      btns.push(h('a', {
-        class: 'text-primary cursor-pointer hover:opacity-80',
-        onClick: () => handleEdit(row),
-      }, '编辑'))
-      btns.push(h('a', {
-        class: 'text-warning cursor-pointer hover:opacity-80',
-        style: 'margin-left: 12px',
-        onClick: () => handleToggleStatus(row),
-      }, row.creditStatus === 'NORMAL' ? '暂停挂账' : '恢复正常'))
-      btns.push(h('a', {
-        class: 'text-error cursor-pointer hover:opacity-80',
-        style: 'margin-left: 12px',
-        onClick: () => handleDelete(row),
-      }, '删除'))
+      if (canSubmit(row)) {
+        btns.push(h('a', {
+          class: 'text-success cursor-pointer hover:opacity-80',
+          onClick: () => openSubmit(row),
+        }, '提交审批'))
+      }
+      if (canEdit(row)) {
+        btns.push(h('a', {
+          class: 'text-primary cursor-pointer hover:opacity-80',
+          style: btns.length ? 'margin-left: 12px' : '',
+          onClick: () => goForm(row),
+        }, '编辑'))
+      }
+      if (canWithdraw(row)) {
+        btns.push(h('a', {
+          class: 'text-warning cursor-pointer hover:opacity-80',
+          style: btns.length ? 'margin-left: 12px' : '',
+          onClick: () => handleWithdraw(row),
+        }, '撤回'))
+      }
+      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' ? '暂停挂账' : '恢复正常'))
+      }
+      if (canDelete(row)) {
+        btns.push(h('a', {
+          class: 'text-error cursor-pointer hover:opacity-80',
+          style: btns.length ? 'margin-left: 12px' : '',
+          onClick: () => handleDelete(row),
+        }, '删除'))
+      }
       return h('div', null, btns)
     },
   },
 ])
 
-// ==================== 表单配置 ====================
-const formSchema = computed(() => [
-  {
-    field: 'customerName',
-    label: '客户姓名',
-    type: 'input',
-    rules: [{ required: true, message: '客户姓名不能为空' }],
-    props: { placeholder: '请输入客户姓名' },
-  },
-  {
-    field: 'mobile',
-    label: '手机号',
-    type: 'input',
-    props: { placeholder: '请输入手机号' },
-  },
-  {
-    field: 'companyName',
-    label: '公司名称',
-    type: 'input',
-    props: { placeholder: '请输入公司名称' },
-  },
-  {
-    field: 'address',
-    label: '地址',
-    type: 'input',
-    props: { placeholder: '请输入地址' },
-  },
-  {
-    field: 'birthday',
-    label: '生日',
-    type: 'datePicker',
-    props: { placeholder: '请选择生日', valueFormat: 'yyyy-MM-dd' },
-  },
-  {
-    field: 'idCard',
-    label: '身份证',
-    type: 'input',
-    props: { placeholder: '请输入身份证号' },
-  },
-  {
-    field: 'occupation',
-    label: '职业',
-    type: 'input',
-    props: { placeholder: '请输入职业' },
-  },
-  {
-    field: 'emergencyContact',
-    label: '紧急联系人',
-    type: 'input',
-    props: { placeholder: '请输入紧急联系人' },
-  },
-  {
-    field: 'emergencyContactPhone',
-    label: '紧急联系电话',
-    type: 'input',
-    props: { placeholder: '请输入联系电话' },
-  },
-  {
-    field: 'invoiceTitle',
-    label: '发票抬头',
-    type: 'input',
-    span: 2,
-    props: { placeholder: '请输入发票抬头' },
-  },
-  {
-    field: 'taxNo',
-    label: '税号',
-    type: 'input',
-    props: { placeholder: '请输入税号' },
-  },
-  {
-    field: 'bankName',
-    label: '银行名称',
-    type: 'input',
-    props: { placeholder: '请输入银行名称' },
-  },
-  {
-    field: 'bankAccount',
-    label: '银行账号',
-    type: 'input',
-    props: { placeholder: '请输入银行账号' },
-  },
-  {
-    field: 'invoiceAddressPhone',
-    label: '发票地址电话',
-    type: 'input',
-    span: 2,
-    props: { placeholder: '请输入发票地址和电话' },
-  },
-  {
-    field: 'remark',
-    label: '备注',
-    type: 'textarea',
-    span: 2,
-    props: { placeholder: '请输入备注', maxlength: 500, showCount: true, rows: 3 },
-  },
-])
-
 // ==================== 事件处理 ====================
-function handleEdit(row) {
-  crudRef.value?.openEdit(row)
+// 新增/编辑:打开填报弹窗组件(代码独立文件维护,交互仍是弹窗)
+const formVisible = ref(false)
+const formRow = ref(null)
+
+function goForm(row) {
+  formRow.value = row || null
+  formVisible.value = true
+}
+
+// 送审成功后:刷新列表;暂存/撤回/驳回编辑保存后衔接提交审批,审批中编辑仅保存
+function handleFormSaved(payload) {
+  crudRef.value?.refresh()
+  const origin = formRow.value
+  if (!origin || ['1', '3', '4'].includes(origin.approvalStatus))
+    openSubmit({ id: payload.id, customerName: payload.customerName })
+  else
+    message.success('保存成功')
 }
 
+// 删除走 AiCrudPage 暴露的 handleDelete(带确认弹窗与 :id 占位符替换)
 function handleDelete(row) {
-  crudRef.value?.openDelete(row)
+  crudRef.value?.handleDelete(row)
+}
+
+// 撤回:发起人撤回审批中的流程;后端删除流程实例,回调把业务状态置为撤回
+function handleWithdraw(row) {
+  dialog.warning({
+    title: '确认撤回',
+    content: `确定撤回「${row.customerName}」的审批吗?撤回后状态变为撤回,可编辑后重新送审。`,
+    positiveText: '撤回',
+    negativeText: '取消',
+    onPositiveClick: () => {
+      return flowApi.withdrawProcess({
+        processInstanceId: row.processInstanceId,
+        userId: userStore.userId,
+        reason: '发起人在挂账客户列表撤回',
+      }).then((res) => {
+        if (res?.code === 200) {
+          message.success('已撤回')
+          crudRef.value?.refresh()
+        }
+      })
+    },
+  })
 }
 
 function handleToggleStatus(row) {
   import('@/api/restaurant').then(({ toggleCreditStatus }) => {
     toggleCreditStatus(row.id).then((res) => {
       if (res.code === 200) {
+        message.success('操作成功')
         crudRef.value?.refresh()
       }
     })
   })
 }
 
-function handleBeforeSubmit(data) {
-  // 提交前不做额外转换
-  return data
+// ==================== 提交审批 ====================
+const submitVisible = ref(false)
+const submitLoading = ref(false)
+const submitRow = ref(null)
+// 审批人选择自适应:老板节点已配置角色/人员/岗位时直接提交;什么都没配置时才弹框选人
+const approverLoading = ref(false)
+const approverOptions = ref([])
+const selectedApproverId = ref(null)
+
+function openSubmit(row) {
+  submitRow.value = row
+  selectedApproverId.value = null
+  // 先探测老板节点配置:已配置则直接提交,未配置才弹选人框
+  checkCreditApproverSelection().then((res) => {
+    if (res?.code === 200 && res.data === true) {
+      submitVisible.value = true
+      if (approverOptions.value.length === 0)
+        loadApproverOptions()
+    }
+    else {
+      doSubmit({})
+    }
+  }).catch(() => {
+    // 探测失败(如流程服务不可用)直接提交,由后端做最终校验
+    doSubmit({})
+  })
+}
+
+function loadApproverOptions() {
+  approverLoading.value = true
+  listApproverUsers().then((res) => {
+    const rows = res?.data?.records || res?.data?.list || res?.data?.rows || []
+    approverOptions.value = rows
+      .map(user => ({
+        label: user.realName || user.name || user.nickname || user.username || String(user.id || ''),
+        value: user.id,
+      }))
+      .filter(option => option.value !== null && option.value !== undefined)
+  }).finally(() => {
+    approverLoading.value = false
+  })
+}
+
+function confirmSubmit() {
+  if (!selectedApproverId.value) {
+    message.warning('请选择审批人(老板)')
+    return
+  }
+  doSubmit({ approverId: selectedApproverId.value })
 }
+
+function doSubmit(payload) {
+  submitLoading.value = true
+  submitCreditApproval(submitRow.value.id, payload).then((res) => {
+    if (res.code === 200) {
+      message.success('已提交审批')
+      submitVisible.value = false
+      crudRef.value?.refresh()
+    }
+  }).finally(() => {
+    submitLoading.value = false
+  })
+}
+
+// ==================== 流程详情 ====================
+const flowDetailVisible = ref(false)
+const flowDetailRow = ref(null)
+
+function openFlowDetail(row) {
+  if (!row.processInstanceId) {
+    message.warning('该记录尚未发起审批流程')
+    return
+  }
+  flowDetailRow.value = row
+  flowDetailVisible.value = true
+}
+
+// 传给公共流程详情组件的业务信息项
+const creditBusinessItems = computed(() => {
+  const row = flowDetailRow.value
+  if (!row)
+    return []
+  return [
+    { label: '客户姓名', value: row.customerName },
+    { label: '手机号', value: row.mobile },
+    { label: '公司名称', value: row.companyName },
+    { label: '挂账状态', value: row.creditStatus, dictType: 'restaurant_credit_status' },
+    { label: '累计挂账', value: `${formatMoney(row.totalArrearsAmount)} 元` },
+    { label: '未结账单', value: row.unsettledBillCount || 0 },
+    { label: '审批人', value: row.approverName },
+    { label: '审批状态', value: row.approvalStatus, dictType: 'restaurant_credit_approval_status' },
+  ]
+})
 </script>
 
 <style scoped>

+ 345 - 0
forge-admin-ui/src/views/restaurant/dishOrderList.vue

@@ -0,0 +1,345 @@
+<template>
+  <div class="dish-order-list-page">
+    <AiCrudPage
+      ref="crudRef"
+      api="/restaurant/dishOrder"
+      :api-config="{
+        list: 'get@/restaurant/dishOrder/page',
+      }"
+      :search-schema="searchSchema"
+      :columns="tableColumns"
+      :before-search="beforeSearch"
+      row-key="id"
+      :hide-add="true"
+      :hide-batch-delete="true"
+      :hide-selection="true"
+      :scroll-x="1360"
+    >
+      <template #table-toolbar-right>
+        <NButton size="small" quaternary :loading="exportLoading" @click="handleExport">
+          <template #icon>
+            <i class="i-material-symbols:download-rounded" />
+          </template>
+        </NButton>
+      </template>
+    </AiCrudPage>
+
+    <!-- 订单详情弹窗 -->
+    <NModal
+      v-model:show="detailVisible"
+      preset="card"
+      title="订单详情"
+      style="width: 900px; max-width: 94vw"
+      :bordered="false"
+    >
+      <NSpin :show="detailLoading">
+        <template v-if="detail">
+          <NDescriptions :column="3" label-placement="left" bordered size="small">
+            <NDescriptionsItem label="订单号">
+              {{ detail.orderNo }}
+            </NDescriptionsItem>
+            <NDescriptionsItem label="包房">
+              {{ detail.roomName }}
+            </NDescriptionsItem>
+            <NDescriptionsItem label="订单状态">
+              <DictTag :options="orderStatusOptions" :value="detail.orderStatus" />
+            </NDescriptionsItem>
+            <NDescriptionsItem label="总金额(元)">
+              {{ formatAmount(detail.totalAmount) }}
+            </NDescriptionsItem>
+            <NDescriptionsItem label="结账方式">
+              <DictTag v-if="detail.settleType" :options="settleTypeOptions" :value="detail.settleType" />
+            </NDescriptionsItem>
+            <NDescriptionsItem label="折扣比例">
+              {{ detail.discountRate != null ? `${detail.discountRate}%` : '' }}
+            </NDescriptionsItem>
+            <NDescriptionsItem label="优惠金额(元)">
+              {{ formatAmount(detail.discountAmount) }}
+            </NDescriptionsItem>
+            <NDescriptionsItem label="实收金额(元)">
+              {{ formatAmount(detail.payableAmount) }}
+            </NDescriptionsItem>
+            <NDescriptionsItem label="结账时间">
+              {{ formatDateTime(detail.settleTime) }}
+            </NDescriptionsItem>
+            <NDescriptionsItem label="下单人">
+              {{ detail.createName }}
+            </NDescriptionsItem>
+            <NDescriptionsItem label="下单时间">
+              {{ formatDateTime(detail.createTime) }}
+            </NDescriptionsItem>
+            <NDescriptionsItem label="口味备注">
+              {{ detail.tasteRemark }}
+            </NDescriptionsItem>
+            <NDescriptionsItem label="特殊要求" :span="3">
+              {{ detail.specialRequirements }}
+            </NDescriptionsItem>
+          </NDescriptions>
+
+          <div class="detail-section-title">
+            菜品明细
+          </div>
+          <NDataTable
+            :columns="detailColumns"
+            :data="detail.detailList || []"
+            :bordered="false"
+            :single-line="false"
+            size="small"
+            :max-height="320"
+          />
+        </template>
+      </NSpin>
+    </NModal>
+  </div>
+</template>
+
+<script setup>
+import dayjs from 'dayjs'
+import { NButton, NDataTable, NDescriptions, NDescriptionsItem, NModal, NSpin } from 'naive-ui'
+import { computed, h, onMounted, ref } from 'vue'
+import { exportOrder, getDishRoomList, getOrderDetail } from '@/api/restaurant'
+import { AiCrudPage } from '@/components/ai-form'
+import DictTag from '@/components/DictTag.vue'
+import { useDict } from '@/composables/useDict'
+import { applyDateRange } from '@/utils/date-range'
+
+defineOptions({ name: 'RestaurantDishOrderList' })
+
+const crudRef = ref(null)
+const { dict } = useDict('restaurant_order_status', 'restaurant_settle_type')
+
+const orderStatusOptions = computed(() => dict.value.restaurant_order_status || [])
+const settleTypeOptions = computed(() => dict.value.restaurant_settle_type || [])
+
+// ==================== 包房下拉 ====================
+
+const roomOptions = ref([])
+
+async function loadRoomOptions() {
+  try {
+    const res = await getDishRoomList()
+    if (res.code === 200) {
+      roomOptions.value = (res.data || []).map(item => ({
+        label: item.roomName || item.roomNo,
+        value: item.id,
+      }))
+    }
+  }
+  catch {
+    // ignore
+  }
+}
+
+// ==================== 搜索表单配置 ====================
+
+const searchSchema = computed(() => [
+  {
+    field: 'roomId',
+    label: '包房',
+    type: 'select',
+    props: { placeholder: '全部包房', options: roomOptions.value, filterable: true, clearable: true },
+  },
+  {
+    field: 'orderStatus',
+    label: '订单状态',
+    type: 'select',
+    props: { placeholder: '全部状态', options: orderStatusOptions.value, clearable: true },
+  },
+  {
+    field: 'settleType',
+    label: '结账方式',
+    type: 'select',
+    props: { placeholder: '全部结账方式', options: settleTypeOptions.value, clearable: true },
+  },
+  {
+    field: 'createTime',
+    label: '下单时间',
+    type: 'daterange',
+    props: { clearable: true },
+  },
+])
+
+/**
+ * 搜索前钩子:将下单时间范围拆分为 beginTime/endTime。
+ * 统一走 @/utils/date-range 归一化(兼容时间戳/字符串/Date 等组件产出形态),
+ * 转换结果会同时作用于列表查询与导出。
+ */
+function beforeSearch(params) {
+  return applyDateRange(params, 'createTime', 'beginTime', 'endTime')
+}
+
+// ==================== 格式化 ====================
+
+function formatAmount(value) {
+  if (value === null || value === undefined)
+    return ''
+  return Number(value).toFixed(2)
+}
+
+function formatDateTime(value) {
+  return value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : ''
+}
+
+// ==================== 表格列配置 ====================
+
+const tableColumns = computed(() => [
+  {
+    label: '序号',
+    width: 60,
+    render: (row, index) => h('span', {}, index + 1),
+  },
+  { prop: 'orderNo', label: '订单号', minWidth: 190, ellipsis: { tooltip: true } },
+  { prop: 'roomNo', label: '包房编号', width: 110 },
+  { prop: 'roomName', label: '包房名称', minWidth: 130, ellipsis: { tooltip: true } },
+  {
+    prop: 'orderStatus',
+    label: '订单状态',
+    width: 100,
+    render: row => h(DictTag, { options: orderStatusOptions.value, value: row.orderStatus }),
+  },
+  {
+    prop: 'totalAmount',
+    label: '总金额(元)',
+    width: 110,
+    render: row => formatAmount(row.totalAmount),
+  },
+  {
+    prop: 'settleType',
+    label: '结账方式',
+    width: 110,
+    render: row => (row.settleType
+      ? h(DictTag, { options: settleTypeOptions.value, value: row.settleType })
+      : ''),
+  },
+  {
+    prop: 'payableAmount',
+    label: '实收金额(元)',
+    width: 120,
+    render: row => formatAmount(row.payableAmount),
+  },
+  {
+    prop: 'settleTime',
+    label: '结账时间',
+    width: 172,
+    render: row => formatDateTime(row.settleTime),
+  },
+  { prop: 'createName', label: '下单人', width: 110 },
+  {
+    prop: 'createTime',
+    label: '下单时间',
+    width: 172,
+    render: row => formatDateTime(row.createTime),
+  },
+  {
+    label: '操作',
+    width: 80,
+    fixed: 'right',
+    render: row => h('a', {
+      class: 'text-info cursor-pointer hover:opacity-80',
+      onClick: () => handleViewDetail(row),
+    }, '详情'),
+  },
+])
+
+// ==================== 详情弹窗 ====================
+
+const detailVisible = ref(false)
+const detailLoading = ref(false)
+const detail = ref(null)
+
+const detailColumns = [
+  { title: '菜品编码', key: 'dishCode', width: 130 },
+  { title: '菜品名称', key: 'dishName', minWidth: 140 },
+  { title: '分类', key: 'typeName', width: 100 },
+  { title: '规格', key: 'spec', width: 80 },
+  { title: '单位', key: 'unit', width: 70 },
+  {
+    title: '单价(元)',
+    key: 'salePrice',
+    width: 100,
+    render: row => formatAmount(row.salePrice),
+  },
+  { title: '数量', key: 'quantity', width: 80 },
+  {
+    title: '金额(元)',
+    key: 'amount',
+    width: 100,
+    render: row => formatAmount(row.amount),
+  },
+]
+
+async function handleViewDetail(row) {
+  detailVisible.value = true
+  detailLoading.value = true
+  detail.value = null
+  try {
+    const res = await getOrderDetail(row.id)
+    if (res.code === 200)
+      detail.value = res.data
+  }
+  catch (error) {
+    console.error('加载订单详情失败:', error)
+  }
+  finally {
+    detailLoading.value = false
+  }
+}
+
+// ==================== 导出 ====================
+
+const exportLoading = ref(false)
+
+async function handleExport() {
+  exportLoading.value = true
+  try {
+    const params = crudRef.value?.getSearchParams?.() || {}
+    const response = await exportOrder(params)
+    downloadBlobResponse(response, '点菜订单.xlsx')
+    window.$message?.success('订单导出成功')
+  }
+  catch (error) {
+    console.error('导出订单失败:', error)
+    window.$message?.error(error?.message || '导出订单失败')
+  }
+  finally {
+    exportLoading.value = false
+  }
+}
+
+function downloadBlobResponse(response, fallbackName) {
+  const blob = response?.data instanceof Blob ? response.data : response
+  if (!(blob instanceof Blob))
+    throw new TypeError('下载响应不是文件流')
+
+  const disposition = response?.headers?.['content-disposition']
+    || response?.headers?.get?.('content-disposition')
+    || ''
+  const utf8Match = disposition.match(/filename\*=utf-8''([^;]+)/i)
+  const filename = utf8Match?.[1] ? decodeURIComponent(utf8Match[1]) : fallbackName
+  const url = URL.createObjectURL(blob)
+  const link = document.createElement('a')
+  link.href = url
+  link.download = filename
+  document.body.appendChild(link)
+  link.click()
+  document.body.removeChild(link)
+  URL.revokeObjectURL(url)
+}
+
+onMounted(() => {
+  loadRoomOptions()
+})
+</script>
+
+<style scoped>
+.dish-order-list-page {
+  height: 100%;
+}
+
+.detail-section-title {
+  margin: 16px 0 8px;
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--text-color-1, #374151);
+}
+</style>

+ 49 - 0
forge-server/db/migration/V1.0.132__add_restaurant_order_export_config_and_menu.sql

@@ -0,0 +1,49 @@
+-- ============================================================
+-- V1.0.132  餐饮模块:点菜订单导出配置 + 订单记录列表菜单
+-- ============================================================
+
+-- ==================== 1. Excel 导出主配置 ====================
+-- data_source_bean 对应 DishOrderServiceImpl 的 Spring 默认 Bean 名,query_method 反射调用无分页查询。
+INSERT INTO sys_excel_export_config
+(config_key, config_type, export_name, sheet_name, file_name_template, data_source_bean, query_method, auto_trans, pageable, max_rows, status, allow_import, remark)
+SELECT 'restaurant_dish_order_export', 'EXPORT', '点菜订单导出', '订单数据', '点菜订单_{date}_{time}.xlsx', 'dishOrderServiceImpl', 'queryExportList', 1, 0, 100000, 1, 0, '餐饮点菜订单列表导出'
+WHERE NOT EXISTS (SELECT 1 FROM sys_excel_export_config WHERE config_key = 'restaurant_dish_order_export');
+
+-- ==================== 2. Excel 导出列配置 ====================
+-- orderStatus/settleType 通过 dict_type 自动翻译;时间列通过 date_format 格式化。
+INSERT INTO sys_excel_column_config
+(config_key, field_name, column_name, width, order_num, export, date_format, dict_type, importable)
+SELECT * FROM (
+    SELECT 'restaurant_dish_order_export' AS config_key, 'orderNo' AS field_name, '订单号' AS column_name, 22 AS width, 1 AS order_num, 1 AS export, NULL AS date_format, NULL AS dict_type, 0 AS importable
+    UNION ALL SELECT 'restaurant_dish_order_export', 'roomNo', '包房编号', 14, 2, 1, NULL, NULL, 0
+    UNION ALL SELECT 'restaurant_dish_order_export', 'roomName', '包房名称', 16, 3, 1, NULL, NULL, 0
+    UNION ALL SELECT 'restaurant_dish_order_export', 'orderStatus', '订单状态', 12, 4, 1, NULL, 'restaurant_order_status', 0
+    UNION ALL SELECT 'restaurant_dish_order_export', 'totalAmount', '总金额(元)', 14, 5, 1, NULL, NULL, 0
+    UNION ALL SELECT 'restaurant_dish_order_export', 'settleType', '结账方式', 12, 6, 1, NULL, 'restaurant_settle_type', 0
+    UNION ALL SELECT 'restaurant_dish_order_export', 'discountRate', '折扣比例', 10, 7, 1, NULL, NULL, 0
+    UNION ALL SELECT 'restaurant_dish_order_export', 'discountAmount', '优惠金额(元)', 14, 8, 1, NULL, NULL, 0
+    UNION ALL SELECT 'restaurant_dish_order_export', 'payableAmount', '实收金额(元)', 14, 9, 1, NULL, NULL, 0
+    UNION ALL SELECT 'restaurant_dish_order_export', 'settleTime', '结账时间', 22, 10, 1, 'yyyy-MM-dd HH:mm:ss', NULL, 0
+    UNION ALL SELECT 'restaurant_dish_order_export', 'createName', '下单人', 14, 11, 1, NULL, NULL, 0
+    UNION ALL SELECT 'restaurant_dish_order_export', 'createTime', '下单时间', 22, 12, 1, 'yyyy-MM-dd HH:mm:ss', NULL, 0
+) AS cols
+WHERE NOT EXISTS (
+    SELECT 1 FROM sys_excel_column_config c
+    WHERE c.config_key = cols.config_key AND c.field_name = cols.field_name
+);
+
+-- ==================== 3. 订单记录列表菜单 ====================
+-- 「点菜订单」为包房选择入口,本菜单为可查询/导出的订单记录列表页。
+SET @restaurant_parent_id = (SELECT id FROM sys_resource WHERE resource_name = '餐厅管理' AND tenant_id = 1 AND parent_id = 0 LIMIT 1);
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, path, component, is_external, is_public, menu_status, visible, icon, keep_alive, always_show, remark, create_time, update_time)
+SELECT 1, '订单记录', @restaurant_parent_id, 2, 7, '/restaurant/dishOrderList', 'restaurant/dishOrderList', 0, 0, 1, 1, 'mdi:file-table-box', 0, 0, '点菜订单记录列表(查询/导出)', NOW(), NOW()
+WHERE @restaurant_parent_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE path = '/restaurant/dishOrderList' AND tenant_id = 1);
+
+-- 订单记录查询权限(导出权限 restaurant:order:export 已在 V1.0.129 创建,此处仅补查询权限)
+SET @order_list_menu_id = (SELECT id FROM sys_resource WHERE path = '/restaurant/dishOrderList' AND tenant_id = 1 LIMIT 1);
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '订单记录查询', @order_list_menu_id, 3, 1, 'restaurant:order:list', 0, 0, 1, 1, '查询订单记录列表', NOW(), NOW()
+WHERE @order_list_menu_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'restaurant:order:list');

+ 224 - 0
forge-server/db/migration/V1.0.133__add_credit_customer_approval_flow.sql

@@ -0,0 +1,224 @@
+-- =====================================================================
+-- V1.0.133  挂账客户审批流程(单步老板审核)
+-- 1) restaurant_credit_customer 增加审批流程字段(存量数据回填为已通过)
+-- 2) 审批状态字典 restaurant_credit_approval_status
+-- 3) 挂账客户菜单新增按钮权限:提交审批 / 初始化流程
+-- 4) 应用中心 suite/object/app 种子 + ai_business_binding 流程绑定
+-- 说明:涉及既有数据状态回填(存量挂账客户视为已审批通过,保证现有挂账
+--       结账业务不中断);回滚方式为删除新增列/字典/权限/绑定记录。
+-- =====================================================================
+
+-- ==================== 1. 表结构:审批流程字段 ====================
+
+SET @col_exists = (
+  SELECT COUNT(1) FROM information_schema.COLUMNS
+  WHERE TABLE_SCHEMA = DATABASE()
+    AND TABLE_NAME = 'restaurant_credit_customer'
+    AND COLUMN_NAME = 'approval_status'
+);
+
+SET @ddl = IF(@col_exists = 0,
+  'ALTER TABLE `restaurant_credit_customer`
+    ADD COLUMN `approval_status` varchar(32) NOT NULL DEFAULT ''DRAFT'' COMMENT ''审批状态:DRAFT-草稿, IN_PROCESS-审批中, NEED_MODIFY-待修改, APPROVED-已通过, REJECTED-已拒绝, CANCELED-已取消'' AFTER `remark`,
+    ADD COLUMN `business_key` varchar(128) DEFAULT NULL COMMENT ''流程业务Key(credit_customer:{id})'' AFTER `approval_status`,
+    ADD COLUMN `process_instance_id` varchar(128) DEFAULT NULL COMMENT ''流程实例ID'' AFTER `business_key`,
+    ADD COLUMN `applicant_id` bigint DEFAULT NULL COMMENT ''申请人ID(提交审批的人)'' AFTER `process_instance_id`,
+    ADD COLUMN `applicant_name` varchar(64) DEFAULT NULL COMMENT ''申请人姓名'' AFTER `applicant_id`,
+    ADD COLUMN `approver_id` bigint DEFAULT NULL COMMENT ''审批人(老板)用户ID'' AFTER `applicant_name`,
+    ADD COLUMN `boss_remark` varchar(500) DEFAULT NULL COMMENT ''老板审核意见'' AFTER `approver_id`,
+    ADD COLUMN `applicant_modify_remark` varchar(500) DEFAULT NULL COMMENT ''申请人修改说明'' AFTER `boss_remark`,
+    ADD COLUMN `reject_reason` varchar(500) DEFAULT NULL COMMENT ''驳回/取消原因'' AFTER `applicant_modify_remark`',
+  'SELECT 1');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- 存量数据回填:迁移前已存在的挂账客户视为已审批通过(无流程Key即为存量数据)
+UPDATE restaurant_credit_customer c
+LEFT JOIN sys_user u ON u.id = c.create_by
+SET c.approval_status = 'APPROVED',
+    c.applicant_id = c.create_by,
+    c.applicant_name = COALESCE(u.real_name, u.username)
+WHERE c.approval_status = 'DRAFT'
+  AND c.business_key IS NULL;
+
+-- 唯一索引:业务Key + 删除标记(del_flag=0 有效行唯一,删除行写入主键墓碑)
+SET @idx_exists = (
+  SELECT COUNT(1) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE()
+    AND TABLE_NAME = 'restaurant_credit_customer'
+    AND INDEX_NAME = 'uk_credit_customer_business_key'
+);
+SET @ddl = IF(@idx_exists = 0,
+  'ALTER TABLE `restaurant_credit_customer`
+    ADD UNIQUE KEY `uk_credit_customer_business_key` (`tenant_id`, `business_key`, `del_flag`)',
+  '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 = 'restaurant_credit_customer'
+    AND INDEX_NAME = 'idx_credit_customer_approval_status'
+);
+SET @ddl = IF(@idx_exists = 0,
+  'ALTER TABLE `restaurant_credit_customer`
+    ADD KEY `idx_credit_customer_approval_status` (`tenant_id`, `approval_status`, `update_time`)',
+  '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 = 'restaurant_credit_customer'
+    AND INDEX_NAME = 'idx_credit_customer_process'
+);
+SET @ddl = IF(@idx_exists = 0,
+  'ALTER TABLE `restaurant_credit_customer`
+    ADD KEY `idx_credit_customer_process` (`tenant_id`, `process_instance_id`)',
+  'SELECT 1');
+PREPARE stmt FROM @ddl;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;
+
+-- ==================== 2. 审批状态字典 ====================
+
+INSERT INTO sys_dict_type (tenant_id, dict_name, dict_type, dict_status, remark, create_time, update_time)
+SELECT 1, '挂账客户审批状态', 'restaurant_credit_approval_status', 1, '挂账客户审批流程业务状态', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'restaurant_credit_approval_status' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 1, '草稿', 'DRAFT', 'restaurant_credit_approval_status', NULL, 'default', 'N', 1, '已创建未提交审批', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'restaurant_credit_approval_status' AND dict_value = 'DRAFT' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 2, '审批中', 'IN_PROCESS', 'restaurant_credit_approval_status', NULL, 'info', 'N', 1, '老板审核中', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'restaurant_credit_approval_status' AND dict_value = 'IN_PROCESS' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 3, '待修改', 'NEED_MODIFY', 'restaurant_credit_approval_status', NULL, 'warning', 'N', 1, '被驳回后等待申请人修改', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'restaurant_credit_approval_status' AND dict_value = 'NEED_MODIFY' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 4, '已通过', 'APPROVED', 'restaurant_credit_approval_status', NULL, 'success', 'N', 1, '老板审核通过,允许挂账', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'restaurant_credit_approval_status' AND dict_value = 'APPROVED' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 5, '已拒绝', 'REJECTED', 'restaurant_credit_approval_status', NULL, 'error', 'N', 1, '申请人终止或流程拒绝结束', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'restaurant_credit_approval_status' AND dict_value = 'REJECTED' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 6, '已取消', 'CANCELED', 'restaurant_credit_approval_status', NULL, 'default', 'N', 1, '流程取消/撤回', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'restaurant_credit_approval_status' AND dict_value = 'CANCELED' AND tenant_id = 1);
+
+-- ==================== 3. 按钮权限 ====================
+
+SET @credit_menu_id = (
+  SELECT id FROM sys_resource
+  WHERE tenant_id = 1 AND resource_type = 2
+    AND path = '/restaurant/creditCustomer'
+  ORDER BY id LIMIT 1
+);
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '挂账提交审批', @credit_menu_id, 3, 5, 'restaurant:credit:submit', 0, 0, 1, 1, '提交挂账客户审批', NOW(), NOW()
+WHERE @credit_menu_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'restaurant:credit:submit' AND tenant_id = 1);
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
+SELECT 1, '挂账流程初始化', @credit_menu_id, 3, 6, 'restaurant:credit:initFlow', 0, 0, 1, 1, '初始化挂账客户审批流程模型', NOW(), NOW()
+WHERE @credit_menu_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE perms = 'restaurant:credit:initFlow' AND tenant_id = 1);
+
+-- ==================== 4. 应用中心 + 流程绑定 ====================
+
+INSERT INTO ai_business_suite (id, tenant_id, parent_id, suite_code, suite_name, icon, description, status,
+                               sort_order, options, create_by, create_time, create_dept, update_by, update_time)
+SELECT 1910000000000133001, 1, NULL, 'RESTAURANT_SUITE', '餐饮业务', 'ionicons5:RestaurantOutline',
+       '餐饮模块业务域(挂账客户审批等)', 1, 41,
+       '{"codeApp":true,"scenario":"restaurant_business"}',
+       1, NOW(), 1, 1, NOW()
+WHERE NOT EXISTS (
+  SELECT 1 FROM ai_business_suite WHERE tenant_id = 1 AND suite_code = 'RESTAURANT_SUITE'
+);
+
+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 1910000000000133002, 1, 'RESTAURANT_SUITE', 'credit_customer', '挂账客户', 'TRANSACTION', NULL,
+       NULL, 'customerName', 'mdi:account-cash',
+       '代码实现的挂账客户审批业务,流程设计器维护节点表单和字段权限', 1, 10,
+       '{"codeApp":true,"businessType":"credit_customer","flowModelKey":"credit_customer_approval"}',
+       'PUBLISHED', NULL, NOW(), 1,
+       '{"codeApp":true,"documentManaged":false,"defaultPanel":"flow-app"}',
+       1, NOW(), 1, 1, NOW()
+WHERE NOT EXISTS (
+  SELECT 1 FROM ai_business_object
+  WHERE tenant_id = 1 AND suite_code = 'RESTAURANT_SUITE' AND object_code = 'credit_customer'
+);
+
+SET @credit_object_id = (
+  SELECT id FROM ai_business_object
+  WHERE tenant_id = 1 AND suite_code = 'RESTAURANT_SUITE' AND object_code = 'credit_customer'
+  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 1910000000000133003, 1, 'CREDIT_CUSTOMER_APPROVAL', '挂账客户审批', 'BUSINESS',
+       'RESTAURANT_SUITE', 'credit_customer', 'ROUTE',
+       '/restaurant/creditCustomer', NULL, 'mdi:account-cash',
+       '打开挂账客户管理页面', 1, 10,
+       '{"codeApp":true,"flowConfigUrl":"/app-center/object/credit_customer/designer?panel=flow-app&codeApp=1&name=挂账客户"}',
+       1, NOW(), 1, 1, NOW()
+WHERE @credit_object_id IS NOT NULL
+  AND NOT EXISTS (
+    SELECT 1 FROM ai_business_app WHERE tenant_id = 1 AND app_code = 'CREDIT_CUSTOMER_APPROVAL'
+  );
+
+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 1910000000000133004, 1, 'OBJECT', @credit_object_id, 'credit_customer', 'FLOW', 'credit_customer_approval',
+       '挂账客户审批流程',
+       '{
+         "flowModelKey":"credit_customer_approval",
+         "flowModelName":"挂账客户审批流程",
+         "titleTemplate":"挂账客户审批-{customerName}",
+         "startMode":"MANUAL",
+         "businessBinding":{
+           "mode":"ADAPTER",
+           "primaryKeyField":"id",
+           "tenantField":"tenant_id",
+           "titleField":"customerName",
+           "statusField":"approval_status",
+           "ownerField":"applicantId"
+         },
+         "variableMapping":[
+           {"formField":"businessKey","flowVariable":"businessKey","label":"业务Key"},
+           {"formField":"customerName","flowVariable":"customerName","label":"客户姓名"},
+           {"formField":"mobile","flowVariable":"mobile","label":"手机号"},
+           {"formField":"title","flowVariable":"title","label":"流程标题"},
+           {"formField":"approverId","flowVariable":"bossId","label":"审批人(老板)"}
+         ],
+         "nodeForms":[],
+         "conditionFlows":[],
+         "options":{"codeApp":true,"businessKeyPattern":"credit_customer:{recordId}"}
+       }',
+       '挂账客户代码业务默认流程绑定。节点表单与字段权限以BPMN节点配置为准,nodeForms仅作兼容兜底。',
+       1, 1, 1, NOW(), 1, 1, NOW()
+WHERE @credit_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 = 'credit_customer'
+      AND binding_type = 'FLOW'
+      AND binding_key = 'credit_customer_approval'
+  );

+ 56 - 0
forge-server/db/migration/V1.0.134__update_credit_customer_approval_status_to_numeric.sql

@@ -0,0 +1,56 @@
+-- =====================================================================
+-- V1.0.134  挂账客户审批状态改为数字值
+-- 1) 更新 approval_status 字段值:字符串 → 数字
+-- 2) 更新字典数据
+-- 说明:统一流程状态为数字值,便于跨模块标准化
+-- =====================================================================
+
+-- ==================== 1. 更新现有数据的状态值 ====================
+
+-- DRAFT → 1 (暂存)
+UPDATE restaurant_credit_customer SET approval_status = '1' WHERE approval_status = 'DRAFT';
+
+-- IN_PROCESS, NEED_MODIFY → 2 (审批中)
+UPDATE restaurant_credit_customer SET approval_status = '2' WHERE approval_status IN ('IN_PROCESS', 'NEED_MODIFY');
+
+-- CANCELED → 3 (撤回)
+UPDATE restaurant_credit_customer SET approval_status = '3' WHERE approval_status = 'CANCELED';
+
+-- REJECTED → 4 (驳回)
+UPDATE restaurant_credit_customer SET approval_status = '4' WHERE approval_status = 'REJECTED';
+
+-- APPROVED → 5 (已完成)
+UPDATE restaurant_credit_customer SET approval_status = '5' WHERE approval_status = 'APPROVED';
+
+-- ==================== 2. 更新字典数据 ====================
+
+-- 删除旧的字典数据
+DELETE FROM sys_dict_data WHERE dict_type = 'restaurant_credit_approval_status' AND tenant_id = 1;
+
+-- 插入新的字典数据(数字值)
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+VALUES
+(1, 1, '暂存', '1', 'restaurant_credit_approval_status', NULL, 'default', 'N', 1, '草稿/暂存状态', NOW(), NOW()),
+(1, 2, '审批中', '2', 'restaurant_credit_approval_status', NULL, 'info', 'N', 1, '流程审批中', NOW(), NOW()),
+(1, 3, '撤回', '3', 'restaurant_credit_approval_status', NULL, 'warning', 'N', 1, '申请人撤回', NOW(), NOW()),
+(1, 4, '驳回', '4', 'restaurant_credit_approval_status', NULL, 'error', 'N', 1, '审批驳回', NOW(), NOW()),
+(1, 5, '已完成', '5', 'restaurant_credit_approval_status', NULL, 'success', 'N', 1, '审批通过完成', NOW(), NOW());
+
+-- ==================== 3. 修复流程模型重复创建问题 ====================
+
+-- 问题:ensureFlowModel() 在首次模型被删除后重复创建,导致同 model_key 存在多条记录,
+-- 且 create_by 不同导致流程模型列表按 create_by 过滤时看不到。
+-- 修复:保留最新的有效记录,清理已删除的重复记录,统一 create_by。
+
+-- 删除已删除的重复记录(del_flag=1 且 model_key 有有效记录的)
+DELETE m FROM sys_flow_model m
+INNER JOIN (
+  SELECT model_key, MIN(id) AS keep_id
+  FROM sys_flow_model
+  WHERE del_flag = 0
+  GROUP BY model_key
+) valid ON m.model_key = valid.model_key AND m.id != valid.keep_id
+WHERE m.del_flag = 1;
+
+-- 将 credit_customer_approval 有效记录的 create_by 统一为 admin(首次创建者)
+UPDATE sys_flow_model SET create_by = 'admin' WHERE model_key = 'credit_customer_approval' AND del_flag = 0;

+ 36 - 0
forge-server/db/migration/V1.0.135__update_credit_status_to_numeric.sql

@@ -0,0 +1,36 @@
+-- V1.0.135  挂账状态字典值从字符串改为数字(NORMAL→0, SUSPENDED→1, BLACKLIST→2)
+
+-- ==================== 1. 更新现有业务数据 ====================
+
+UPDATE restaurant_credit_customer
+SET credit_status = '0'
+WHERE credit_status = 'NORMAL' AND del_flag = 0;
+
+UPDATE restaurant_credit_customer
+SET credit_status = '1'
+WHERE credit_status = 'SUSPENDED' AND del_flag = 0;
+
+UPDATE restaurant_credit_customer
+SET credit_status = '2'
+WHERE credit_status = 'BLACKLIST' AND del_flag = 0;
+
+-- ==================== 2. 更新字典数据 ====================
+
+-- 删除旧的字典数据(字符串值)
+DELETE FROM sys_dict_data
+WHERE dict_type = 'restaurant_credit_status'
+  AND dict_value IN ('NORMAL', 'SUSPENDED', 'BLACKLIST')
+  AND tenant_id = 1;
+
+-- 插入新的字典数据(数字值)
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 1, '正常挂账', '0', 'restaurant_credit_status', NULL, 'success', 'Y', 1, '正常挂账', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'restaurant_credit_status' AND dict_value = '0' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 2, '暂停挂账', '1', 'restaurant_credit_status', NULL, 'warning', 'N', 1, '暂停挂账', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'restaurant_credit_status' AND dict_value = '1' AND tenant_id = 1);
+
+INSERT INTO sys_dict_data (tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, dict_status, remark, create_time, update_time)
+SELECT 1, 3, '黑名单', '2', 'restaurant_credit_status', NULL, 'error', 'N', 1, '黑名单', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE dict_type = 'restaurant_credit_status' AND dict_value = '2' AND tenant_id = 1);

+ 12 - 0
forge-server/db/migration/V1.0.136__add_credit_customer_form_hidden_menu.sql

@@ -0,0 +1,12 @@
+-- =====================================================================
+-- V1.0.136: 挂账客户新增/编辑子页面(隐藏路由菜单)
+-- 列表页「新增挂账客户 / 编辑」跳转独立子页面 /restaurant/creditCustomerForm,
+-- 菜单 visible=0 不在侧边栏显示,仅用于路由注册与页签标题。
+-- =====================================================================
+
+SET @restaurant_parent_id = (SELECT id FROM sys_resource WHERE resource_name = '餐厅管理' AND tenant_id = 1 AND parent_id = 0 LIMIT 1);
+
+INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, path, component, is_external, is_public, menu_status, visible, icon, keep_alive, always_show, remark, create_time, update_time)
+SELECT 1, '挂账客户填报', @restaurant_parent_id, 2, 97, '/restaurant/creditCustomerForm', 'restaurant/creditCustomerForm', 0, 0, 1, 0, '', 0, 0, '挂账客户新增/编辑子页面(隐藏路由)', NOW(), NOW()
+WHERE @restaurant_parent_id IS NOT NULL
+  AND NOT EXISTS (SELECT 1 FROM sys_resource WHERE path = '/restaurant/creditCustomerForm' AND tenant_id = 1);

+ 7 - 0
forge-server/db/migration/V1.0.137__remove_credit_customer_form_hidden_menu.sql

@@ -0,0 +1,7 @@
+-- =====================================================================
+-- V1.0.137: 清理「挂账客户填报」隐藏菜单
+-- 填报表单已改为弹窗组件(views/restaurant/components/CreditCustomerFormModal.vue),
+-- 不再需要独立路由子页面,删除 V1.0.136 注册的隐藏菜单,避免残留无页面的路由白名单。
+-- =====================================================================
+
+DELETE FROM sys_resource WHERE path = '/restaurant/creditCustomerForm' AND tenant_id = 1;

+ 25 - 0
forge-server/db/migration/V1.0.138__add_credit_customer_age_and_credit_time_columns.sql

@@ -0,0 +1,25 @@
+-- =====================================================================
+-- V1.0.138: 挂账客户表补充 年龄/首次挂账时间/最后消费时间 字段
+-- 填报表单与业务挂账人信息表对齐:基本信息增加年龄,挂账信息增加首次挂账时间、最后消费时间。
+-- =====================================================================
+
+SET @has_age = (SELECT COUNT(1) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'restaurant_credit_customer' AND COLUMN_NAME = 'age');
+SET @sql = IF(@has_age = 0,
+    'ALTER TABLE restaurant_credit_customer ADD COLUMN age int DEFAULT NULL COMMENT ''年龄'' AFTER id_card',
+    'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @has_first_credit_time = (SELECT COUNT(1) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'restaurant_credit_customer' AND COLUMN_NAME = 'first_credit_time');
+SET @sql = IF(@has_first_credit_time = 0,
+    'ALTER TABLE restaurant_credit_customer ADD COLUMN first_credit_time datetime DEFAULT NULL COMMENT ''首次挂账时间'' AFTER unsettled_bill_count',
+    'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @has_last_consume_time = (SELECT COUNT(1) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'restaurant_credit_customer' AND COLUMN_NAME = 'last_consume_time');
+SET @sql = IF(@has_last_consume_time = 0,
+    'ALTER TABLE restaurant_credit_customer ADD COLUMN last_consume_time datetime DEFAULT NULL COMMENT ''最后消费时间'' AFTER first_credit_time',
+    'SELECT 1');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

+ 10 - 0
forge-server/db/migration/V1.0.139__fix_credit_customer_flow_form_url.sql

@@ -0,0 +1,10 @@
+-- 修复挂账客户审批模型的任务表单地址:
+-- 旧值 /restaurant/creditCustomer 指向挂账客户列表页,导致审批详情内嵌整个 CRUD 列表且缺少审批意见区;
+-- 新值指向专用审批组件 CreditCustomerApproveForm(展示业务数据 + 节点字段 + 审批意见 + 同意/驳回)。
+-- form_json 中 formUrl 出现两次(顶层与 formRef),REPLACE 一次性全部替换;LIKE 条件保证脚本可重复执行。
+UPDATE sys_flow_model
+SET form_json = REPLACE(form_json, '"/restaurant/creditCustomer"', '"/restaurant/CreditCustomerApproveForm"'),
+    update_time = NOW()
+WHERE model_key = 'credit_customer_approval'
+  AND del_flag = 0
+  AND form_json LIKE '%"/restaurant/creditCustomer"%';

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

@@ -63,6 +63,14 @@ spring.data:
         threads: 16
         nettyThreads: 32
 
+
+# 流程服务地址
+forge:
+  flow:
+    client:
+      url: http://localhost:8081
+
+
 # MCP Server 默认关闭且只允许 Streamable HTTP。开启后长期 fcp_ 密钥只能换取短期 fdu_ Token。
 # 开发环境的三个 Pepper 会首次自动生成到 ~/.forge/secrets/crypto.properties 并在重启后复用。
 # 生产多实例应通过共享 Secret Manager 显式提供三个互不相同的 Pepper:

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

@@ -3,7 +3,7 @@
 > 本文档记录个人在 Forge 项目中的编码习惯和偏好,所有代码编写(含 AI 辅助生成)必须遵循以下规范。
 > 
 > **适用范围**: forge-hotel 模块及后续所有业务模块
-> **最后更新**: 2026-08-17
+> **最后更新**: 2026-09-24
 
 ---
 
@@ -220,6 +220,9 @@ public class HotelBusinessHours extends TenantEntity {
 - 原因:`DataScopeInterceptor` 按 `mapperMethod` 精确匹配改写 SQL
 - Mapper XML 中的查询必须显式过滤未删除数据:`AND del_flag = 0`
 - 原因:自定义 XML SQL 不会被 MP 自动补全逻辑删除条件
+- **`FOR UPDATE` 语句禁止写 `LIMIT`**(实际踩过的坑):租户拦截器 `TenantLineInnerInterceptor` 会用 JSqlParser 4.9 对所有查询 SQL 重新解析序列化,序列化时把 `LIMIT 1 FOR UPDATE` 重排为 `FOR UPDATE LIMIT 1`,MySQL 直接语法错误(审批同意/驳回时报 `SQLSyntaxErrorException`);纯 `FOR UPDATE`(无 LIMIT)往返不变
+  - 规则:Mapper XML 锁行查询只写 `WHERE 唯一条件 + FOR UPDATE`,不加 LIMIT,并在 XML 中加防回归注释;条件不唯一时先把条件改唯一,而不是靠 LIMIT 兜
+  - 已修复参考:`FlowTaskMapper.selectByTaskIdForUpdate`、`CapabilityApprovalMapper.selectForUpdate`
 
 ### 1.6 操作日志
 
@@ -369,6 +372,55 @@ export function deleteDish(id) {
   - 状态更新:`/xxx/:id/status` → `/xxx/updateStatus` + `{ params: { id, status } }`
   - 其他操作:`/xxx/:id/action` → `/xxx/action` + `{ params: { id } }`
 
+### 2.11 只读列表页与工具栏/导出按钮展示习惯
+
+- **只读列表页(仅查询 + 详情 + 导出)同样使用 `AiCrudPage`**,不要手写筛选栏 + `NDataTable`,保证与菜品规格等页面外观一致:
+  - 隐藏写操作:`:hide-add="true"`、`:hide-batch-delete="true"`、`:hide-selection="true"`
+  - 富详情(描述区 + 子表)不用组件内建 detail,用自定义操作列触发自己的弹窗
+- **导出按钮位置**(二选一,按页面需要):
+  - 内建导出:`:show-export="true"` + `export-api` + `export-file-name`,按钮在工具栏**左侧**;当工具栏溢出操作只有导出 1 项时直接显示为独立按钮,≥2 项才折叠进「更多」下拉
+  - 靠表格工具栏**右侧**(与刷新/密度/列设置等图标同一排):用 `#table-toolbar-right` 插槽放自定义按钮,导出参数用 `crudRef.value?.getSearchParams()` 获取(已经过 `before-search` 转换)
+- **日期范围筛选**:search-schema 用 `type: 'daterange'`(或 `datetimerange`),在 `before-search` 钩子中拆为 `beginTime`/`endTime` 并删除原字段;该钩子结果**同时作用于列表查询与导出**;后端列表接口必须同步支持 `beginTime`/`endTime`,否则筛选是「死」的
+- **日期范围转换必须统一走共享工具 `@/utils/date-range`,禁止各页面手写取下标直传**(此类转换事故已反复出现多次):
+  - `applyDateRange(params, 'createTime', 'beginTime', 'endTime')` 一步完成「范围 → begin/end + 删原字段」,兼容毫秒时间戳 / 字符串 / Date 等组件产出形态;范围为空时同时移除 begin/end
+  - `datetimerange` 传第 5 个参数 `true`,直传 `YYYY-MM-DD HH:mm:ss`
+  - 后端时间归一化方法(如 `normalizeTime`)必须兼容多格式:13 位毫秒时间戳、`yyyyMMdd`、`yyyy-MM-dd`、带 `T` 的 ISO、`yyyy-MM-dd HH:mm:ss`;且结束端为零点 `00:00:00` 时抬升为 `23:59:59`,防止结束日期整天被排除
+- 示例(只读列表 + 右侧导出):
+```vue
+<AiCrudPage
+  ref="crudRef"
+  api="/restaurant/dishOrder"
+  :api-config="{ list: 'get@/restaurant/dishOrder/page' }"
+  :search-schema="searchSchema"
+  :columns="tableColumns"
+  :before-search="beforeSearch"
+  row-key="id"
+  :hide-add="true"
+  :hide-batch-delete="true"
+  :hide-selection="true"
+>
+  <template #table-toolbar-right>
+    <NButton size="small" quaternary :loading="exportLoading" @click="handleExport">导出</NButton>
+  </template>
+</AiCrudPage>
+```
+```javascript
+// 日期范围 → beginTime/endTime(同时作用于列表与导出)
+// 统一走共享工具,禁止手写 range[0]/range[1] 直传
+import { applyDateRange } from '@/utils/date-range'
+
+function beforeSearch(params) {
+  return applyDateRange(params, 'createTime', 'beginTime', 'endTime')
+}
+
+// 右侧自定义导出:取组件已转换的搜索参数
+async function handleExport() {
+  const params = crudRef.value?.getSearchParams?.() || {}
+  const response = await exportOrder(params)
+  downloadBlobResponse(response, '导出.xlsx')
+}
+```
+
 ---
 
 ## 三、数据库编码习惯
@@ -578,25 +630,187 @@ pnpm.cmd build:h5
 
 ---
 
-## 八、AI 执行纪律(防止跑偏规则)
+## 八、列表页 + 表单弹窗组件拆分习惯
+
+> 参考实现:`views/restaurant/creditCustomer.vue`(列表页)+ `views/restaurant/components/CreditCustomerFormModal.vue`(表单弹窗组件)
+
+### 8.1 拆分原则
+
+- 表单字段多时,**表单代码写在独立组件文件**,不与列表页混在同一个 vue;但交互上**仍以弹窗形式打开**,不做独立路由页面
+- 表单弹窗组件统一放 `views/<模块>/components/` 目录:unplugin-vue-router 的 routesFolder 排除 `**/components/**`,不会生成多余路由,也无需注册 sys_resource 菜单
+- 命名 `XxxFormModal.vue`;props 固定 `show`(v-model)+ `row`(null 为新增),送审成功后 `emit('saved', payload)`,刷新与后续衔接交给父页面
+- 仅当确实需要带参数的独立页面(如点菜详情 dishOrder)才用路由子页面 + sys_resource 隐藏菜单(`resource_type=2`、`visible=0`),且 `component` 字段必须与文件路径完全一致,否则 404
+
+### 8.2 列表页(父页面)写法
+
+- AiCrudPage 加 `hide-add` + `#toolbar-start` 插槽自定义新增按钮打开表单弹窗;**不传 `edit-schema`**,操作列编辑同样打开表单弹窗
+- 监听 `saved` 事件刷新并衔接流程:
+```javascript
+function handleFormSaved(payload) {
+  crudRef.value?.refresh()
+  const origin = formRow.value
+  // 新增或暂存:送审后直接衔接审批人选择;审批中编辑仅保存
+  if (!origin || origin.approvalStatus === '1')
+    openSubmit({ id: payload.id, customerName: payload.customerName })
+  else
+    message.success('保存成功')
+}
+```
+
+### 8.3 表单弹窗组件写法
+
+- `NModal preset="card"` + 固定宽度(如 900px)、`:mask-closable="false"`;标题按 row 区分新增/编辑
+- 表单体:`NGrid :cols="2"` + `NFormItemGi`,长文本字段 `:span="2"`;日期用 `NDatePicker` + `v-model:formatted-value` + `value-format="yyyy-MM-dd"`
+- 用 `row` prop 区分新增/编辑:有 row 调 `getXxxDetail(row.id)` 回填;保存 payload 带 id 走同一个 save 接口
+- 填报类表单底部主按钮叫**送审**(不叫保存):送审 = 校验 + 保存,成功后 emit saved 并关闭,是否衔接审批人选择由父页面决定
+- ID 保持字符串直传,禁止转 `Number`
+
+---
+
+## 九、流程(审批流)书写与配置习惯
+
+> 参考实现:挂账客户审批(`restaurant/creditCustomer.vue` + 后端 `CreditCustomerServiceImpl` + `CreditCustomerFlowDefinition`)
+
+### 9.1 流程详情展示:统一用公共组件 FlowProcessDetailModal
+
+- 组件路径:`@/components/flow/FlowProcessDetailModal.vue`,封装「业务信息区 + 流转记录(步骤条 + 表格)+ 流程图」,内部自动加载审批历史、格式化时间/历时、映射状态标签
+- 任何业务流程**传参即用,禁止再写一套**弹窗/时间轴/表格列代码:
+```vue
+<FlowProcessDetailModal
+  v-model:show="flowDetailVisible"
+  :process-instance-id="row.processInstanceId"
+  :finished="row.approvalStatus === '5'"
+  business-title="客户信息"
+  :business-items="businessItems"
+/>
+```
+- 参数约定:
+  - `process-instance-id`:流程实例ID,核心参数,为空展示空态
+  - `finished`:流程是否已结束(点亮步骤条「结束」节点)
+  - `business-items`:`[{ label, value, dictType? }]`,带 `dictType` 自动渲染 `DictTag`;需完全自定义业务区时用 `#business` 插槽
+  - `business-title` / `title` / `column`:业务区标题 / 弹窗标题 / 业务区每行条数(默认 2)
+- 弹窗尺寸已内置百分比规范:`width: 92%; max-width: 1600px`,内容区 `min-height: 72vh; max-height: 88vh; overflow: auto`,业务页面不要自行覆盖
+- 列表页审批状态列可点击查看流程:`DictTag` 不接受 onClick,必须用 `h('span', { style, onClick }, [h(DictTag, {...})])` 包裹
+- 审批历史统一由组件内部 `flowApi.getProcessHistory(processInstanceId)` 获取,业务页面禁止自行调用和维护历史数据
+
+### 9.2 业务表与流程绑定配置
+
+- 业务表必须含 `business_key`、`process_instance_id` 字段;businessKey 格式固定 `<objectCode>:<recordId>`
+- 审批状态用**数字字典值**(如 1-暂存 2-审批中 3-已驳回 4-已退回 5-已完成),字典类型按业务命名(如 `restaurant_credit_approval_status`);前端一律 `useDict` + `DictTag` 字典驱动,禁止硬编码选项/标签
+- 状态门禁用函数集中定义:`canSubmit / canEdit / canDelete / canToggle`,操作列按门禁渲染按钮
+- 流程模型懒创建:首次提交审批时 `ensureFlowModel()` 自动创建并发布;`getModelByKey` 查询**必须过滤 `del_flag = 0`**,否则已删除模型会导致重复创建
+- 业务状态维护依赖双回调:task-created / task-completed 回调 + `PROCESS_COMPLETED` 监听都要维护状态,不能只靠其一;状态写入必须幂等、可从活动任务节点修复
+- BPMN 节点表单、字段权限、审批人策略在**流程设计器**中维护;代码初始化逻辑不得覆盖设计器已编辑的 BPMN XML
+- 前端流程变量与 API payload 中 ID 一律字符串,禁止雪花 `Long` 转 JavaScript `Number`
+
+### 9.3 流程相关 Flyway 脚本习惯
+
+- 流程绑定种子数据(`ai_business_binding` 等)与菜单/字典同脚本维护,全部 `NOT EXISTS` 防重复、`tenant_id = 1`
+- 存量数据回填审批状态时,无流程Key的历史数据视为「已完成」,保证老数据不被门禁锁死
+- 字典值变更(如字符串改数字)必须同脚本更新业务表存量数据 + 删旧字典 + 插新字典,三步齐全
+
+### 9.4 审批详情弹窗:公共骨架 + 业务插件(formUrl 组件契约)
+
+> 参考实现:`views/flow/todo.vue`(公共骨架)+ `views/restaurant/CreditCustomerApproveForm.vue`(挂账业务插件)+ `views/business/purchase-order-test.vue`(采购单业务插件)
+
+#### 架构结论
+
+- 审批详情弹窗(待办 / 已办 / 首页待办点入均为同一套,只读态靠 `readOnly` 切换)是「公共骨架 + 业务插件」结构:**业务方只提供三样东西,其余全部是共用组件,审批弹窗本身零改动**
+- 公共部分(禁止重写、禁止复制一套):
+
+| 区域 | 载体 | 数据来源 |
+|------|------|---------|
+| 弹窗骨架 + 右侧审批记录 | `@/components/flow/FlowTaskDetailShell.vue` | `flowApi.getProcessHistory(processInstanceId)` |
+| 基本信息区(当前节点/流程名称/分类/发起人/部门/时间/状态) | `views/flow/todo.vue` 内置模板 | 任务行数据(sys_flow_task join 模型) |
+| 查看流程图 | `DingFlowViewer` | processInstanceId |
+| 业务表单动态加载器 | `@/components/common/FlowBusinessForm.vue` | 按 formUrl 动态加载 `@/views` 下组件 |
+| 业务表单上下文接口 | `@/api/business-app` 的 `businessTaskFormContext` | 后端 `BusinessFlowService` 按 providerKey 路由到 Provider |
+| 审批提交链路 | `completeBusinessTaskAction` / 统一同意/驳回/转办接口 | 平台统一 |
+
+- 业务差异部分(每个流程一套,仅三样):
+  1. **前端表单组件**:由模型配置 formUrl 指定,FlowBusinessForm 用 `import.meta.glob('@/views/**/*.vue')` 按路径加载
+  2. **后端 Provider**:实现 `BusinessCodeFormProvider`(providerKey 与配置一致),提供字段目录 `fields`、记录数据 `recordData`、节点保存 `saveContext`
+  3. **配置**:`sys_flow_model.form_json`(formUrl / providerKey / formKey)、BPMN 节点、节点字段权限、审批策略(allowApprove / requireComment 等)
+- 数据流:打开弹窗 → 公共代码调上下文接口 → 后端按 providerKey 找 Provider 取业务数据 → 以 `initialTaskContext` 传入业务组件(**组件禁止重复请求**)→ 提交时业务组件 `emit('submit', { action, comment, variables })` → 公共代码走统一审批接口
+
+#### formUrl 红线(实际踩过的坑)
+
+- **formUrl 是 `@/views` 下的「组件路径」,不是路由地址**:如 `/restaurant/CreditCustomerApproveForm` 对应 `src/views/restaurant/CreditCustomerApproveForm.vue`;FlowBusinessForm 按路径匹配(含大小写不敏感兜底、includes 匹配)
+- **严禁把 formUrl 指向列表页或其它业务页面**:曾指向 `creditCustomer.vue` 导致整个 CRUD 列表被嵌进审批区,且审批意见、同意/驳回按钮全部缺失(列表页组件不提供审批契约)
+- 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 字段目录 + 审批意见 + 按钮);有自定义组件时优先组件模式
+
+#### 业务表单组件契约(照 CreditCustomerApproveForm.vue 仿写)
+
+- props 固定一套:`taskId / businessKey / processInstanceId / taskDefKey / processDefKey / variables / approvalPolicy / initialTaskContext / readOnly / submitting / submittingAction`;emits:`submit`、`cancel`
+- 数据加载顺序:`initialTaskContext.recordData` 直接用 → 不匹配则 `useBusinessTaskFormContext().load(...)` → 最后兜底业务详情接口;`readOnly` 模式隐藏操作区
+- 字段显隐/编辑一律走 `useBusinessTaskFormContext` 的 `canShowField / canEditField`;节点可编辑字段 = 组件按节点定义的编辑清单 ∩ 字段权限
+- 提交前先保存本节点可写字段(`businessTaskForm.save`),再 `emit('submit', { action, comment, variables })`;variables 必须带网关变量(如 `approvalResult: 'approve' | 'reject'`)与业务主键
+- `cancel` 事件由公共骨架绑定为关闭弹窗;组件内「关闭」按钮直接 `emit('cancel')`
+
+#### 提交链路共用机制(todo.vue 内建,业务方零成本)
+
+- **审批意见可选 + 默认补全**:意见不做必填;`handleExternalFormSubmit`(业务表单组件)与 `submitApprove`(内置表单)两条提交路径提交前统一走 `resolveActionComment`,留空时按动作补默认意见(`DEFAULT_ACTION_COMMENTS`:approve→同意、reject→驳回、return→退回),绕过前端 `validateApprovalInput` 与后端 `policy.requireComment` 的双拦截;**不要改成放后端/BPMN 校验**,否则存量模型全部要重部署
+- **批量/快捷同意精准拦截**:`assertQuickActionAllowed` 仅在节点存在「必填可写字段」时拦截批量同意(`hasRequiredWritableBusinessFormFields`:writable && required && 非 readonly/disabled);无必填字段的 business-code 节点(如老板审核仅可选备注)允许批量同意/驳回;有必填字段的节点(如申请人修改 customerName 必填)仍引导进详情处理
+- **批量通道与详情审批等价**:批量走 `executeQuickAction` → `completeBusinessTaskAction`,网关变量(approvalResult/approved)由后端 `mergeActionVariables` 在 complete 时补齐,不依赖前端组件传参,不存在网关走错风险
+
+#### 审批区 UI 习惯(用户确认版)
+
+- 业务信息展示**不用 `n-descriptions`**(其表格列宽随内容伸缩,上下长短不一):用自定义 CSS Grid 均分两列(`repeat(2, minmax(0, 1fr))` + 1px gap 做分隔线),label 固定 120px 灰底、值列 `flex: 1` 填满;长文本字段 `grid-column: 1 / -1` 通栏
+- 审批意见**可选**(不做必填校验)、label 加粗;操作按钮固定:转办(公共 slot)+ 同意 + 驳回修改(修改节点为保存并重提/终止申请)+ **关闭**
+- 不加节点标题栏(如「老板审核」tag)与「当前节点只查看」类 alert 提示
+
+#### 新流程接入清单
+
+1. 后端:`XxxFlowDefinition`(常量 + 字段目录 + 默认 formRef JSON)+ `XxxCodeFormProvider` + Service 双回调维护状态
+2. 前端:`@/views/<模块>/XxxApproveForm.vue` 按上述契约新建
+3. 配置:formUrl 指向新组件(form_json 顶层与 formRef 两处)、providerKey 与 Provider 一致
+4. 库中已存在的存量模型:Flyway 脚本同步替换 formUrl
+5. 验证:待办打开审批 → 信息区均分展示、审批意见与按钮齐全;已办只读打开无操作按钮
+
+### 9.5 提交端审批人自适应(有配置直接提交,没配置才选人)
+
+> 参考实现:`CreditCustomerController#/requiresApproverSelection` + `CreditCustomerServiceImpl.requiresApproverSelection` + `creditCustomer.vue openSubmit`
+
+- **交互规则**:点「提交审批」先探测老板节点配置——已配置**直接提交不弹任何框**;仅当什么都没配置(或仍是旧 `${bossId}` 变量表达式)才弹选人框(必选)
+- **判定规则**(解析已部署模型 BPMN 节点开标签,与运行时真相一致):
+
+| 节点配置 | requiresApproverSelection | 提交端表现 |
+|---------|---------------------------|-----------|
+| `flowable:candidateGroups` 非空(角色/岗位/部门候选组) | false | 直接提交 |
+| `flowable:candidateUsers` 非空(候选人员) | false | 直接提交 |
+| `flowable:assignee` 固定值(无 `${}`) | false | 直接提交 |
+| `flowable:assignee` 为发起人/上级/负责人等表达式 | false | 引擎自行解析,直接提交 |
+| 无候选人且无 assignee,或节点不存在 | true | 弹框选老板 |
+| `flowable:assignee="${bossId}"`(旧模板) | true | 弹框选老板,提交传 approverId 写变量 |
+
+- **为什么以 BPMN 为准**:设计器配置角色/人员/岗位最终落到 BPMN 的候选组/候选人/assignee 属性,运行时任务分配由 Flowable 按这些属性原生执行;`sys_flow_node_config` 只服务于设计器/AI 上下文与 `calculateApprovers`,不用于本判定
+- **双保险**:前端探测只决定「弹不弹框」;后端 `submitApproval` 用同一判定再校验(`approverId == null && requiresApproverSelection()` 抛「请选择审批人」),绕过前端直接调接口也拦得住
+- **选人下拉数据源**:复用 `GET /system/user/page`(兼容 records/list/rows 三种返回形态,label 优先 realName),业务侧不另建老板候选接口
+- **三处同步红线**:改节点审批人方式(设计器改或代码改)时同步检查 ① 前端提交弹窗 ② 后端提交校验 ③ Java BPMN 种子模板(`XxxFlowBpmn`),缺一处新环境就会按种子模板走回旧路;同时保持 `ensureFlowModel` 不覆盖设计器已编辑的 BPMN(见 9.2)
+- **生效提示**:flow 侧 Mapper XML / Java 改动需**重启 flow 服务**才生效(重启 admin 无效);业务侧 Java 改重启 admin;前端 HMR 即时生效
+
+---
+
+## 十、AI 执行纪律(防止跑偏规则)
 
 > 本节是 AI 编程助手执行任务时的强制约束,防止在终端命令、代码调查等环节浪费时间。
 
-### 8.1 终端命令纪律
+### 10.1 终端命令纪律
 
 1. **命令失败最多重试 1 次**:第 1 次失败后,必须先查本节「7.3 PowerShell 常见坑」表,禁止盲目尝试不同写法
 2. **禁止探索文件系统找工具路径**:Maven、Node、pnpm 等工具路径已记录在 7.3 节,直接用,不要 `where.exe`、`Get-ChildItem`、`find` 等搜索
 3. **禁止用 `cmd /c` 绕过 PowerShell 限制**:所有命令必须用 PowerShell 原生语法(`;` 分隔 + `.cmd` 后缀)
 4. **构建命令统一用本文档记录的命令**:不要自己猜测命令,先读 7.3 节复制粘贴
 
-### 8.2 任务执行纪律
+### 10.2 任务执行纪律
 
 1. **严格按待办列表顺序执行**:完成当前 Task 后直接进入下一个,不要“顺便看看”其他代码
 2. **不做任务范围外的调查**:如果当前任务是「接入营业时间校验」,不要去核实「权限机制」「数据库表结构」等无关内容
 3. **调查必须有明确目标**:只有在当前 Task 明确要求「确认某文件内容」时才去读代码,不要主动发散
 4. **遇到不确定的环境配置,先查记忆再查代码**:工具路径、数据库连接等已在记忆中的信息,直接检索,不要花时间在文件系统中搜索
 
-### 8.3 时间分配纪律
+### 10.3 时间分配纪律
 
 1. **单个终端命令耗时不超过 30 秒**:如果构建/安装卡住超过 30 秒,检查是否命令写错,不要反复尝试
 2. **单个调查任务不超过 3 次工具调用**:如果需要读超过 3 个文件才能确认一件事,说明调查范围太大,应该缩小范围或直接问用户

+ 5 - 0
forge-server/forge-business/forge-restaurant/pom.xml

@@ -65,6 +65,11 @@
         </dependency>
 
         <dependency>
+            <groupId>com.mdframe.forge</groupId>
+            <artifactId>forge-plugin-generator</artifactId>
+        </dependency>
+
+        <dependency>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-actuator</artifactId>
         </dependency>

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

@@ -2,7 +2,10 @@ package com.mdframe.forge.business.core.restaurant.controller;
 
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.mdframe.forge.business.core.restaurant.dto.CreditCustomerDTO;
+import com.mdframe.forge.business.core.restaurant.dto.CreditCustomerSubmitDTO;
+import com.mdframe.forge.business.core.restaurant.dto.CreditCustomerTaskSaveDTO;
 import com.mdframe.forge.business.core.restaurant.service.CreditCustomerService;
+import com.mdframe.forge.business.core.restaurant.vo.CreditCustomerFlowInitVO;
 import com.mdframe.forge.business.core.restaurant.vo.CreditCustomerVO;
 import com.mdframe.forge.starter.core.annotation.log.OperationLog;
 import com.mdframe.forge.starter.core.domain.OperationType;
@@ -37,8 +40,10 @@ public class CreditCustomerController {
             PageQuery pageQuery,
             @RequestParam(required = false) String customerName,
             @RequestParam(required = false) String mobile,
-            @RequestParam(required = false) String creditStatus) {
-        return RespInfo.success(creditCustomerService.creditPage(pageQuery, customerName, mobile, creditStatus));
+            @RequestParam(required = false) String creditStatus,
+            @RequestParam(required = false) String approvalStatus) {
+        return RespInfo.success(creditCustomerService.creditPage(pageQuery, customerName, mobile,
+                creditStatus, approvalStatus));
     }
 
     /**
@@ -70,7 +75,7 @@ public class CreditCustomerController {
     }
 
     /**
-     * 切换挂账状态(正常→暂停→黑名单 循环切换)。
+     * 切换挂账状态(正常→暂停→黑名单 循环切换,仅审批通过后允许)。
      */
     @PostMapping("/toggleStatus")
     @OperationLog(module = "挂账客户", type = OperationType.UPDATE, desc = "切换挂账状态")
@@ -78,4 +83,42 @@ public class CreditCustomerController {
         creditCustomerService.creditToggleStatus(id);
         return RespInfo.success();
     }
+
+    /**
+     * 提交端是否需要选择审批人:按已部署模型的老板审核节点配置自适应。
+     * <p>
+     * 节点配置了候选角色/候选用户/固定办理人时返回 false(提交直接执行,不弹选人框);
+     * 仅当什么都没配置(或 assignee 仍为 bossId 变量表达式)时返回 true(提交需选老板)。
+     */
+    @GetMapping("/requiresApproverSelection")
+    public RespInfo<Boolean> creditRequiresApproverSelection() {
+        return RespInfo.success(creditCustomerService.requiresApproverSelection());
+    }
+
+    /**
+     * 提交审批(发起老板审核流程)。
+     */
+    @PostMapping("/submit")
+    @OperationLog(module = "挂账客户", type = OperationType.UPDATE, desc = "提交挂账客户审批")
+    public RespInfo<String> creditSubmit(@RequestParam Long id, @RequestBody CreditCustomerSubmitDTO dto) {
+        return RespInfo.success(creditCustomerService.submitApproval(id, dto));
+    }
+
+    /**
+     * 保存待办节点业务字段(老板审核意见 / 申请人修改)。
+     */
+    @PostMapping("/task/save")
+    @OperationLog(module = "挂账客户", type = OperationType.UPDATE, desc = "保存挂账客户待办节点字段")
+    public RespInfo<CreditCustomerVO> creditSaveTaskFields(@RequestBody CreditCustomerTaskSaveDTO dto) {
+        return RespInfo.success(creditCustomerService.saveTaskFields(dto));
+    }
+
+    /**
+     * 初始化并发布挂账客户审批流程模型(幂等,保留设计器已编辑配置)。
+     */
+    @PostMapping("/initFlow")
+    @OperationLog(module = "挂账客户", type = OperationType.UPDATE, desc = "初始化挂账客户审批流程")
+    public RespInfo<CreditCustomerFlowInitVO> creditInitFlow() {
+        return RespInfo.success(creditCustomerService.ensureFlowModel());
+    }
 }

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

@@ -1,6 +1,7 @@
 package com.mdframe.forge.business.core.restaurant.controller;
 
 import com.baomidou.mybatisplus.core.metadata.IPage;
+import cn.dev33.satoken.annotation.SaCheckPermission;
 import com.mdframe.forge.business.core.restaurant.dto.DishOrderSubmitDTO;
 import com.mdframe.forge.business.core.restaurant.service.DishOrderService;
 import com.mdframe.forge.business.core.restaurant.vo.DishOrderDetailVO;
@@ -9,6 +10,8 @@ import com.mdframe.forge.starter.core.annotation.log.OperationLog;
 import com.mdframe.forge.starter.core.domain.OperationType;
 import com.mdframe.forge.starter.core.domain.PageQuery;
 import com.mdframe.forge.starter.core.domain.RespInfo;
+import com.mdframe.forge.starter.excel.core.DynamicExportEngine;
+import jakarta.servlet.http.HttpServletResponse;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PostMapping;
@@ -17,6 +20,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
+import java.util.Map;
+
 /**
  * 点菜订单接口。
  * <p>
@@ -30,6 +35,9 @@ public class DishOrderController {
     @Autowired
     private DishOrderService dishOrderService;
 
+    @Autowired
+    private DynamicExportEngine dynamicExportEngine;
+
     /**
      * 分页查询订单列表。
      */
@@ -39,8 +47,10 @@ public class DishOrderController {
             PageQuery pageQuery,
             @RequestParam(required = false) Long roomId,
             @RequestParam(required = false) String orderStatus,
-            @RequestParam(required = false) String settleType) {
-        return RespInfo.success(dishOrderService.orderPage(pageQuery, roomId, orderStatus, settleType));
+            @RequestParam(required = false) String settleType,
+            @RequestParam(required = false) String beginTime,
+            @RequestParam(required = false) String endTime) {
+        return RespInfo.success(dishOrderService.orderPage(pageQuery, roomId, orderStatus, settleType, beginTime, endTime));
     }
 
     /**
@@ -81,4 +91,16 @@ public class DishOrderController {
         dishOrderService.cancelOrder(id);
         return RespInfo.success();
     }
+
+    /**
+     * 导出订单列表(配置驱动,支持包房/状态/结账方式/下单时间范围过滤)。
+     */
+    @PostMapping("/export")
+    @SaCheckPermission("restaurant:order:export")
+    @OperationLog(module = "点菜订单", type = OperationType.EXPORT, desc = "导出订单",
+            saveRequestParams = false, saveResponseResult = false)
+    public void export(@RequestBody(required = false) Map<String, Object> queryParams,
+                       HttpServletResponse response) {
+        dynamicExportEngine.export(response, "restaurant_dish_order_export", queryParams);
+    }
 }

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

@@ -12,12 +12,13 @@ import lombok.EqualsAndHashCode;
 import java.io.Serial;
 import java.math.BigDecimal;
 import java.time.LocalDate;
+import java.time.LocalDateTime;
 
 /**
  * 挂账客户实体。
  * <p>
  * 手机号(mobile)全局唯一校验。
- * 挂账状态:NORMAL-正常挂账, SUSPENDED-暂停挂账, BLACKLIST-黑名单。
+ * 挂账状态:0-正常挂账, 1-暂停挂账, 2-黑名单。
  */
 @Data
 @EqualsAndHashCode(callSuper = true)
@@ -73,7 +74,7 @@ public class CreditCustomer extends TenantEntity {
     /** 发票地址电话 */
     private String invoiceAddressPhone;
 
-    /** 挂账状态:NORMAL-正常挂账, SUSPENDED-暂停挂账, BLACKLIST-黑名单 */
+    /** 挂账状态:0-正常挂账, 1-暂停挂账, 2-黑名单 */
     private String creditStatus;
 
     /** 累计挂账金额(单位:元) */
@@ -82,9 +83,47 @@ public class CreditCustomer extends TenantEntity {
     /** 未结账单数 */
     private Integer unsettledBillCount;
 
+    /** 年龄 */
+    private Integer age;
+
+    /** 首次挂账时间 */
+    private LocalDateTime firstCreditTime;
+
+    /** 最后消费时间 */
+    private LocalDateTime lastConsumeTime;
+
     /** 备注 */
     private String remark;
 
+    // ==================== 审批流程字段 ====================
+
+    /** 审批状态:DRAFT/IN_PROCESS/NEED_MODIFY/APPROVED/REJECTED/CANCELED */
+    private String approvalStatus;
+
+    /** 流程业务Key(credit_customer:{id}) */
+    private String businessKey;
+
+    /** 流程实例ID */
+    private String processInstanceId;
+
+    /** 申请人ID(提交审批的人) */
+    private Long applicantId;
+
+    /** 申请人姓名 */
+    private String applicantName;
+
+    /** 审批人(老板)用户ID */
+    private Long approverId;
+
+    /** 老板审核意见 */
+    private String bossRemark;
+
+    /** 申请人修改说明 */
+    private String applicantModifyRemark;
+
+    /** 驳回/取消原因 */
+    private String rejectReason;
+
     /** 删除标记(0=未删除,删除时写入主键ID) */
     @TableLogic(value = "0", delval = "id")
     private Long delFlag;

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

@@ -53,6 +53,15 @@ public class CreditCustomerDTO {
     /** 发票地址电话 */
     private String invoiceAddressPhone;
 
+    /** 年龄 */
+    private Integer age;
+
+    /** 首次挂账时间(yyyy-MM-dd HH:mm:ss) */
+    private String firstCreditTime;
+
+    /** 最后消费时间(yyyy-MM-dd HH:mm:ss) */
+    private String lastConsumeTime;
+
     /** 备注 */
     private String remark;
 }

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

@@ -0,0 +1,16 @@
+package com.mdframe.forge.business.core.restaurant.dto;
+
+import lombok.Data;
+
+/**
+ * 挂账客户提交审批参数。
+ */
+@Data
+public class CreditCustomerSubmitDTO {
+
+    /** 审批人(老板)用户ID */
+    private Long approverId;
+
+    /** 审批人(老板)姓名(仅用于展示留痕,可空) */
+    private String approverName;
+}

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

@@ -0,0 +1,76 @@
+package com.mdframe.forge.business.core.restaurant.dto;
+
+import lombok.Data;
+
+/**
+ * 挂账客户待办节点业务字段保存参数。
+ * <p>
+ * boss_approve 节点仅允许保存老板审核意见;
+ * applicant_modify 节点允许修改客户资料并填写修改说明。
+ */
+@Data
+public class CreditCustomerTaskSaveDTO {
+
+    private Long id;
+
+    private String businessKey;
+
+    private String taskId;
+
+    private String taskDefKey;
+
+    // ==================== 申请人修改节点可写字段 ====================
+
+    /** 客户姓名 */
+    private String customerName;
+
+    /** 手机号 */
+    private String mobile;
+
+    /** 生日(yyyy-MM-dd) */
+    private String birthday;
+
+    /** 身份证 */
+    private String idCard;
+
+    /** 职业 */
+    private String occupation;
+
+    /** 公司名称 */
+    private String companyName;
+
+    /** 地址 */
+    private String address;
+
+    /** 紧急联系人 */
+    private String emergencyContact;
+
+    /** 紧急联系电话 */
+    private String emergencyContactPhone;
+
+    /** 发票抬头 */
+    private String invoiceTitle;
+
+    /** 税号 */
+    private String taxNo;
+
+    /** 银行名称 */
+    private String bankName;
+
+    /** 银行账号 */
+    private String bankAccount;
+
+    /** 发票地址电话 */
+    private String invoiceAddressPhone;
+
+    /** 备注 */
+    private String remark;
+
+    /** 申请人修改说明 */
+    private String applicantModifyRemark;
+
+    // ==================== 老板审核节点可写字段 ====================
+
+    /** 老板审核意见 */
+    private String bossRemark;
+}

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

@@ -8,6 +8,10 @@ import com.mdframe.forge.business.core.restaurant.vo.CreditCustomerVO;
 import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
 
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
 /**
  * 挂账客户 Mapper。
  */
@@ -21,7 +25,8 @@ public interface CreditCustomerMapper extends BaseMapper<CreditCustomer> {
                                         @Param("tenantId") Long tenantId,
                                         @Param("customerName") String customerName,
                                         @Param("mobile") String mobile,
-                                        @Param("creditStatus") String creditStatus);
+                                        @Param("creditStatus") String creditStatus,
+                                        @Param("approvalStatus") String approvalStatus);
 
     /**
      * 查询挂账客户详情。
@@ -29,6 +34,22 @@ public interface CreditCustomerMapper extends BaseMapper<CreditCustomer> {
     CreditCustomerVO selectDetail(@Param("tenantId") Long tenantId, @Param("id") Long id);
 
     /**
+     * 批量查询挂账客户详情(待办/已办列表摘要用)。
+     */
+    List<CreditCustomerVO> selectDetailsByIds(@Param("tenantId") Long tenantId, @Param("ids") Collection<Long> ids);
+
+    /**
+     * 按流程业务Key查询实体(流程回调用)。
+     */
+    CreditCustomer selectByBusinessKey(@Param("tenantId") Long tenantId, @Param("businessKey") String businessKey);
+
+    /**
+     * 批量查询活动待办节点(状态对账修复用)。
+     */
+    List<Map<String, Object>> selectActiveTaskDefKeysByBusinessKeys(@Param("tenantId") Long tenantId,
+                                                                     @Param("businessKeys") Collection<String> businessKeys);
+
+    /**
      * 检查手机号是否重复(排除自身)。
      */
     int checkMobileExists(@Param("tenantId") Long tenantId,

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

@@ -8,6 +8,8 @@ import com.mdframe.forge.business.core.restaurant.vo.DishOrderVO;
 import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
 
+import java.util.List;
+
 /**
  * 点菜订单 Mapper。
  */
@@ -21,7 +23,9 @@ public interface DishOrderMapper extends BaseMapper<DishOrder> {
                                    @Param("tenantId") Long tenantId,
                                    @Param("roomId") Long roomId,
                                    @Param("orderStatus") String orderStatus,
-                                   @Param("settleType") String settleType);
+                                   @Param("settleType") String settleType,
+                                   @Param("beginTime") String beginTime,
+                                   @Param("endTime") String endTime);
 
     /**
      * 查询订单详情(含创建人姓名)。
@@ -32,4 +36,14 @@ public interface DishOrderMapper extends BaseMapper<DishOrder> {
      * 查询包房当前用餐中的订单。
      */
     DishOrder findCurrentByRoomId(@Param("tenantId") Long tenantId, @Param("roomId") Long roomId);
+
+    /**
+     * 导出订单列表(无分页,支持包房/状态/结账方式/下单时间范围过滤)。
+     */
+    List<DishOrderVO> selectExportList(@Param("tenantId") Long tenantId,
+                                       @Param("roomId") Long roomId,
+                                       @Param("orderStatus") String orderStatus,
+                                       @Param("settleType") String settleType,
+                                       @Param("beginTime") String beginTime,
+                                       @Param("endTime") String endTime);
 }

+ 110 - 0
forge-server/forge-business/forge-restaurant/src/main/java/com/mdframe/forge/business/core/restaurant/provider/CreditCustomerCodeFormProvider.java

@@ -0,0 +1,110 @@
+package com.mdframe.forge.business.core.restaurant.provider;
+
+import com.mdframe.forge.business.core.restaurant.dto.CreditCustomerTaskSaveDTO;
+import com.mdframe.forge.business.core.restaurant.service.CreditCustomerService;
+import com.mdframe.forge.business.core.restaurant.support.CreditCustomerFlowDefinition;
+import com.mdframe.forge.business.core.restaurant.vo.CreditCustomerVO;
+import com.mdframe.forge.plugin.generator.dto.businessapp.BusinessTaskFormContextQueryDTO;
+import com.mdframe.forge.plugin.generator.dto.businessapp.BusinessTaskFormSaveDTO;
+import com.mdframe.forge.plugin.generator.service.businessapp.BusinessCodeFormProvider;
+import com.mdframe.forge.plugin.generator.vo.businessapp.BusinessTaskFormContextVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 挂账客户审批代码表单 Provider。
+ * <p>
+ * 为流程待办/已办/历史页面提供业务表单上下文(字段目录 + 记录数据 + 节点字段权限),
+ * 并承接待办节点的业务字段保存。
+ */
+@Component
+public class CreditCustomerCodeFormProvider implements BusinessCodeFormProvider {
+
+    @Autowired
+    private CreditCustomerService creditCustomerService;
+
+    @Override
+    public String providerKey() {
+        return CreditCustomerFlowDefinition.PROVIDER_KEY;
+    }
+
+    @Override
+    public String providerName() {
+        return CreditCustomerFlowDefinition.PROVIDER_NAME;
+    }
+
+    @Override
+    public String buildSummary(Map<String, Object> recordData) {
+        return CreditCustomerFlowDefinition.buildSummary(recordData);
+    }
+
+    @Override
+    public Map<Long, String> buildSummaries(String objectCode, Collection<Long> recordIds) {
+        Map<Long, String> result = new LinkedHashMap<>();
+        if (!CreditCustomerFlowDefinition.supportsObject(objectCode)) {
+            return result;
+        }
+        List<CreditCustomerVO> details = creditCustomerService.detailsByIds(recordIds);
+        if (details == null || details.isEmpty()) {
+            return result;
+        }
+        for (CreditCustomerVO detail : details) {
+            if (detail != null && detail.getId() != null) {
+                result.put(detail.getId(), CreditCustomerFlowDefinition.buildSummary(detail));
+            }
+        }
+        return result;
+    }
+
+    @Override
+    public List<Map<String, Object>> formAssets(String objectCode) {
+        return CreditCustomerFlowDefinition.formAssets(objectCode);
+    }
+
+    @Override
+    public BusinessTaskFormContextVO buildContext(BusinessTaskFormContextQueryDTO query,
+                                                  Map<String, Object> formRef,
+                                                  List<Map<String, Object>> fieldPermissions) {
+        Long recordId = CreditCustomerFlowDefinition.resolveRecordId(
+                query == null ? null : query.getRecordId(),
+                query == null ? null : query.getBusinessKey());
+        CreditCustomerVO detail = recordId == null ? null : creditCustomerService.creditDetail(recordId);
+        BusinessTaskFormContextVO vo = CreditCustomerFlowDefinition.buildTaskFormContext(
+                query,
+                formRef,
+                fieldPermissions,
+                recordId,
+                CreditCustomerFlowDefinition.buildSummary(detail),
+                detail != null);
+        if (detail != null) {
+            vo.setRecordData(CreditCustomerFlowDefinition.recordData(detail));
+        } else {
+            vo.getWarnings().add("未找到挂账客户业务记录");
+        }
+        return vo;
+    }
+
+    @Override
+    public BusinessTaskFormContextVO saveContext(BusinessTaskFormSaveDTO dto,
+                                                 Map<String, Object> formRef,
+                                                 List<Map<String, Object>> fieldPermissions) {
+        CreditCustomerTaskSaveDTO saveDTO = CreditCustomerFlowDefinition.toTaskSaveDTO(dto);
+        CreditCustomerVO detail = creditCustomerService.saveTaskFields(saveDTO);
+
+        BusinessTaskFormContextQueryDTO query = new BusinessTaskFormContextQueryDTO();
+        query.setTaskId(dto.getTaskId());
+        query.setBusinessKey(dto.getBusinessKey());
+        query.setProcessInstanceId(dto.getProcessInstanceId());
+        query.setProcessDefKey(dto.getProcessDefKey());
+        query.setTaskDefKey(dto.getTaskDefKey());
+        query.setObjectCode(dto.getObjectCode());
+        query.setRecordId(detail.getId());
+        query.setFormKey(dto.getFormKey());
+        return buildContext(query, formRef, fieldPermissions);
+    }
+}

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

@@ -2,18 +2,42 @@ package com.mdframe.forge.business.core.restaurant.service;
 
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.mdframe.forge.business.core.restaurant.dto.CreditCustomerDTO;
+import com.mdframe.forge.business.core.restaurant.dto.CreditCustomerSubmitDTO;
+import com.mdframe.forge.business.core.restaurant.dto.CreditCustomerTaskSaveDTO;
+import com.mdframe.forge.business.core.restaurant.support.CreditCustomerFlowDefinition;
+import com.mdframe.forge.business.core.restaurant.vo.CreditCustomerFlowInitVO;
 import com.mdframe.forge.business.core.restaurant.vo.CreditCustomerVO;
 import com.mdframe.forge.starter.core.domain.PageQuery;
 
+import java.util.Collection;
+import java.util.List;
+
 /**
  * 挂账客户服务接口。
  */
 public interface CreditCustomerService {
 
+    String MODEL_KEY = CreditCustomerFlowDefinition.MODEL_KEY;
+
+    String BUSINESS_TYPE = CreditCustomerFlowDefinition.BUSINESS_TYPE;
+
+    String STATUS_DRAFT = CreditCustomerFlowDefinition.STATUS_DRAFT;
+
+    String STATUS_IN_PROCESS = CreditCustomerFlowDefinition.STATUS_IN_PROCESS;
+
+    String STATUS_NEED_MODIFY = CreditCustomerFlowDefinition.STATUS_NEED_MODIFY;
+
+    String STATUS_APPROVED = CreditCustomerFlowDefinition.STATUS_APPROVED;
+
+    String STATUS_REJECTED = CreditCustomerFlowDefinition.STATUS_REJECTED;
+
+    String STATUS_CANCELED = CreditCustomerFlowDefinition.STATUS_CANCELED;
+
     /**
      * 分页查询挂账客户列表。
      */
-    IPage<CreditCustomerVO> creditPage(PageQuery pageQuery, String customerName, String mobile, String creditStatus);
+    IPage<CreditCustomerVO> creditPage(PageQuery pageQuery, String customerName, String mobile,
+                                       String creditStatus, String approvalStatus);
 
     /**
      * 查询挂账客户详情。
@@ -21,6 +45,16 @@ public interface CreditCustomerService {
     CreditCustomerVO creditDetail(Long id);
 
     /**
+     * 批量查询挂账客户详情(待办/已办列表摘要用)。
+     */
+    List<CreditCustomerVO> detailsByIds(Collection<Long> ids);
+
+    /**
+     * 按流程业务Key查询详情。
+     */
+    CreditCustomerVO detailByBusinessKey(String businessKey);
+
+    /**
      * 新增/修改挂账客户。
      */
     Long creditSave(CreditCustomerDTO dto);
@@ -34,4 +68,26 @@ public interface CreditCustomerService {
      * 切换挂账状态(正常/暂停/黑名单 循环切换)。
      */
     void creditToggleStatus(Long id);
+
+    /**
+     * 提交审批(发起老板审核流程),返回流程实例ID。
+     */
+    String submitApproval(Long id, CreditCustomerSubmitDTO dto);
+
+    /**
+     * 提交端是否需要选择审批人:按已部署模型老板节点配置自适应——
+     * 节点配置了候选角色/候选用户/固定办理人(含岗位、部门等设计器配置)时返回 false(直接提交);
+     * 仅当什么都没配置(无候选人且无办理人,或 assignee 仍为 bossId 变量表达式)时返回 true。
+     */
+    boolean requiresApproverSelection();
+
+    /**
+     * 保存待办节点业务字段(老板审核意见 / 申请人修改)。
+     */
+    CreditCustomerVO saveTaskFields(CreditCustomerTaskSaveDTO dto);
+
+    /**
+     * 初始化并发布挂账客户审批流程模型(幂等,保留设计器已编辑的节点配置)。
+     */
+    CreditCustomerFlowInitVO ensureFlowModel();
 }

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

@@ -6,6 +6,9 @@ import com.mdframe.forge.business.core.restaurant.vo.DishOrderDetailVO;
 import com.mdframe.forge.business.core.restaurant.vo.DishOrderVO;
 import com.mdframe.forge.starter.core.domain.PageQuery;
 
+import java.util.List;
+import java.util.Map;
+
 /**
  * 点菜订单服务接口。
  * <p>
@@ -20,8 +23,12 @@ public interface DishOrderService {
 
     /**
      * 分页查询订单列表。
+     *
+     * @param beginTime 下单时间起(yyyy-MM-dd 或 yyyy-MM-dd HH:mm:ss,可空)
+     * @param endTime   下单时间止(yyyy-MM-dd 或 yyyy-MM-dd HH:mm:ss,可空)
      */
-    IPage<DishOrderVO> orderPage(PageQuery pageQuery, Long roomId, String orderStatus, String settleType);
+    IPage<DishOrderVO> orderPage(PageQuery pageQuery, Long roomId, String orderStatus, String settleType,
+                                 String beginTime, String endTime);
 
     /**
      * 查询订单详情(含三类明细列表)。
@@ -44,4 +51,14 @@ public interface DishOrderService {
      * 取消订单(释放包房)。
      */
     void cancelOrder(Long id);
+
+    /**
+     * 导出订单列表(无分页,供 Excel 动态导出引擎反射调用)。
+     * <p>
+     * 支持的过滤参数:roomId、orderStatus、settleType、beginTime、endTime(yyyy-MM-dd 或 yyyy-MM-dd HH:mm:ss)。
+     *
+     * @param params 前端传入的查询条件
+     * @return 订单列表
+     */
+    List<DishOrderVO> queryExportList(Map<String, Object> params);
 }

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

@@ -1,12 +1,26 @@
 package com.mdframe.forge.business.core.restaurant.service.impl;
 
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.mdframe.forge.business.core.restaurant.domain.CreditCustomer;
 import com.mdframe.forge.business.core.restaurant.dto.CreditCustomerDTO;
+import com.mdframe.forge.business.core.restaurant.dto.CreditCustomerSubmitDTO;
+import com.mdframe.forge.business.core.restaurant.dto.CreditCustomerTaskSaveDTO;
 import com.mdframe.forge.business.core.restaurant.mapper.CreditCustomerMapper;
 import com.mdframe.forge.business.core.restaurant.service.CreditCustomerService;
+import com.mdframe.forge.business.core.restaurant.support.CreditCustomerFlowBpmn;
+import com.mdframe.forge.business.core.restaurant.support.CreditCustomerFlowDefinition;
+import com.mdframe.forge.business.core.restaurant.vo.CreditCustomerFlowInitVO;
 import com.mdframe.forge.business.core.restaurant.vo.CreditCustomerVO;
+import com.mdframe.forge.flow.client.FlowClient;
+import com.mdframe.forge.flow.client.FlowResult;
+import com.mdframe.forge.flow.client.annotation.FlowBind;
+import com.mdframe.forge.flow.client.annotation.FlowCallback;
+import com.mdframe.forge.flow.client.annotation.FlowEventContext;
+import com.mdframe.forge.plugin.generator.domain.entity.AiBusinessBinding;
+import com.mdframe.forge.plugin.generator.mapper.BusinessBindingMapper;
 import com.mdframe.forge.starter.core.domain.PageQuery;
 import com.mdframe.forge.starter.core.exception.BusinessException;
 import com.mdframe.forge.starter.core.session.SessionHelper;
@@ -20,23 +34,48 @@ import org.springframework.util.StringUtils;
 
 import java.math.BigDecimal;
 import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
 import java.util.Objects;
+import java.util.Set;
 
 /**
  * 挂账客户服务实现。
+ * <p>
+ * 集成 Flowable 审批流程(单步老板审核):
+ * 提交后写入 business_key/process_instance_id 并置为 IN_PROCESS,
+ * 回调维护 NEED_MODIFY/APPROVED/REJECTED/CANCELED 状态,
+ * 分页/详情查询按活动待办节点对账修复状态漂移。
  */
 @Slf4j
 @Service
+@FlowBind(modelKey = CreditCustomerService.MODEL_KEY, businessType = CreditCustomerService.BUSINESS_TYPE)
 public class CreditCustomerServiceImpl implements CreditCustomerService {
 
     @Autowired
     private CreditCustomerMapper creditCustomerMapper;
 
+    @Autowired
+    private FlowClient flowClient;
+
+    @Autowired
+    private BusinessBindingMapper businessBindingMapper;
+
     @Override
-    public IPage<CreditCustomerVO> creditPage(PageQuery pageQuery, String customerName, String mobile, String creditStatus) {
+    public IPage<CreditCustomerVO> creditPage(PageQuery pageQuery, String customerName, String mobile,
+                                              String creditStatus, String approvalStatus) {
         Long tenantId = resolveTenantId();
         Page<CreditCustomerVO> page = new Page<>(pageQuery.getPageNum(), pageQuery.getPageSize());
-        return creditCustomerMapper.selectPage(page, tenantId, customerName, mobile, creditStatus);
+        IPage<CreditCustomerVO> result = creditCustomerMapper.selectPage(page, tenantId, customerName, mobile,
+                creditStatus, approvalStatus);
+        reconcileStatusesWithActiveTasks(result.getRecords());
+        return result;
     }
 
     @Override
@@ -49,10 +88,43 @@ public class CreditCustomerServiceImpl implements CreditCustomerService {
         if (vo == null) {
             throw new BusinessException("挂账客户不存在");
         }
+        List<CreditCustomerVO> single = new ArrayList<>();
+        single.add(vo);
+        reconcileStatusesWithActiveTasks(single);
         return vo;
     }
 
     @Override
+    public List<CreditCustomerVO> detailsByIds(Collection<Long> ids) {
+        List<Long> normalizedIds = new ArrayList<>();
+        if (ids != null) {
+            for (Long id : ids) {
+                if (id != null && !normalizedIds.contains(id)) {
+                    normalizedIds.add(id);
+                }
+            }
+        }
+        if (normalizedIds.isEmpty()) {
+            return new ArrayList<>();
+        }
+        List<CreditCustomerVO> details = creditCustomerMapper.selectDetailsByIds(resolveTenantId(), normalizedIds);
+        reconcileStatusesWithActiveTasks(details);
+        return details == null ? new ArrayList<>() : details;
+    }
+
+    @Override
+    public CreditCustomerVO detailByBusinessKey(String businessKey) {
+        if (!StringUtils.hasText(businessKey)) {
+            throw new BusinessException("挂账客户业务Key不能为空");
+        }
+        CreditCustomer entity = creditCustomerMapper.selectByBusinessKey(resolveTenantId(), businessKey.trim());
+        if (entity == null) {
+            throw new BusinessException("挂账客户不存在");
+        }
+        return creditDetail(entity.getId());
+    }
+
+    @Override
     @Transactional(rollbackFor = Exception.class)
     public Long creditSave(CreditCustomerDTO dto) {
         if (dto == null) {
@@ -72,28 +144,39 @@ public class CreditCustomerServiceImpl implements CreditCustomerService {
         }
 
         if (dto.getId() != null) {
-            // 修改
-            CreditCustomer entity = creditCustomerMapper.selectById(dto.getId());
-            if (entity == null || !Objects.equals(entity.getTenantId(), tenantId)) {
-                throw new BusinessException("挂账客户不存在");
+            // 修改:仅暂存/撤回/驳回状态允许编辑;审批中走待办任务表单,审批通过后资料锁定
+            CreditCustomer entity = requireEntity(dto.getId());
+            String originStatus = entity.getApprovalStatus();
+            if (!STATUS_DRAFT.equals(originStatus)
+                    && !STATUS_CANCELED.equals(originStatus)
+                    && !STATUS_REJECTED.equals(originStatus)) {
+                throw new BusinessException("只有暂存、撤回或驳回状态的挂账客户允许编辑");
+            }
+            BeanUtils.copyProperties(dto, entity, "id", "birthday", "firstCreditTime", "lastConsumeTime");
+            // 撤回/驳回记录编辑保存后回到暂存,允许重新送审
+            if (STATUS_CANCELED.equals(originStatus) || STATUS_REJECTED.equals(originStatus)) {
+                entity.setApprovalStatus(STATUS_DRAFT);
             }
-            BeanUtils.copyProperties(dto, entity, "id", "birthday");
             // 处理生日字段(String → LocalDate)
             if (StringUtils.hasText(dto.getBirthday())) {
                 entity.setBirthday(LocalDate.parse(dto.getBirthday()));
             }
+            entity.setFirstCreditTime(parseDateTime(dto.getFirstCreditTime()));
+            entity.setLastConsumeTime(parseDateTime(dto.getLastConsumeTime()));
             creditCustomerMapper.updateById(entity);
             log.info("修改挂账客户: {} ({})", entity.getCustomerName(), entity.getMobile());
         } else {
-            // 新增
+            // 新增:初始为草稿,等待提交老板审批
             CreditCustomer entity = new CreditCustomer();
-            BeanUtils.copyProperties(dto, entity, "birthday");
+            BeanUtils.copyProperties(dto, entity, "birthday", "firstCreditTime", "lastConsumeTime");
             if (StringUtils.hasText(dto.getBirthday())) {
                 entity.setBirthday(LocalDate.parse(dto.getBirthday()));
             }
+            entity.setFirstCreditTime(parseDateTime(dto.getFirstCreditTime()));
+            entity.setLastConsumeTime(parseDateTime(dto.getLastConsumeTime()));
             entity.setTenantId(tenantId);
             if (!StringUtils.hasText(entity.getCreditStatus())) {
-                entity.setCreditStatus("NORMAL");
+                entity.setCreditStatus("0");
             }
             if (entity.getTotalArrearsAmount() == null) {
                 entity.setTotalArrearsAmount(BigDecimal.valueOf(0L));
@@ -101,6 +184,9 @@ public class CreditCustomerServiceImpl implements CreditCustomerService {
             if (entity.getUnsettledBillCount() == null) {
                 entity.setUnsettledBillCount(0);
             }
+            entity.setApprovalStatus(STATUS_DRAFT);
+            entity.setApplicantId(SessionHelper.getUserId());
+            entity.setApplicantName(resolveUsername());
             creditCustomerMapper.insert(entity);
             log.info("新增挂账客户: {} ({})", entity.getCustomerName(), entity.getMobile());
             return entity.getId();
@@ -108,16 +194,21 @@ public class CreditCustomerServiceImpl implements CreditCustomerService {
         return dto.getId();
     }
 
+    /** 解析时间字段(String yyyy-MM-dd HH:mm:ss → LocalDateTime),空值返回 null */
+    private LocalDateTime parseDateTime(String value) {
+        return StringUtils.hasText(value)
+                ? LocalDateTime.parse(value.trim(), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
+                : null;
+    }
+
     @Override
     @Transactional(rollbackFor = Exception.class)
     public void creditDelete(Long id) {
-        if (id == null) {
-            throw new BusinessException("客户ID不能为空");
-        }
-        Long tenantId = resolveTenantId();
-        CreditCustomer entity = creditCustomerMapper.selectById(id);
-        if (entity == null || !Objects.equals(entity.getTenantId(), tenantId)) {
-            throw new BusinessException("挂账客户不存在");
+        CreditCustomer entity = requireEntity(id);
+        if (!STATUS_DRAFT.equals(entity.getApprovalStatus())
+                && !STATUS_REJECTED.equals(entity.getApprovalStatus())
+                && !STATUS_CANCELED.equals(entity.getApprovalStatus())) {
+            throw new BusinessException("当前审批状态不允许删除,仅草稿/已拒绝/已取消可删除");
         }
         creditCustomerMapper.deleteById(id);
         log.info("删除挂账客户: {} ({})", entity.getCustomerName(), entity.getMobile());
@@ -126,27 +217,23 @@ public class CreditCustomerServiceImpl implements CreditCustomerService {
     @Override
     @Transactional(rollbackFor = Exception.class)
     public void creditToggleStatus(Long id) {
-        if (id == null) {
-            throw new BusinessException("客户ID不能为空");
-        }
-        Long tenantId = resolveTenantId();
-        CreditCustomer entity = creditCustomerMapper.selectById(id);
-        if (entity == null || !Objects.equals(entity.getTenantId(), tenantId)) {
-            throw new BusinessException("挂账客户不存在");
+        CreditCustomer entity = requireEntity(id);
+        if (!STATUS_APPROVED.equals(entity.getApprovalStatus())) {
+            throw new BusinessException("挂账客户审批通过后才能切换挂账状态");
         }
 
-        // 循环切换:NORMAL → SUSPENDED → BLACKLIST → NORMAL
+        // 循环切换:0(正常) → 1(暂停) → 2(黑名单) → 0(正常)
         String current = entity.getCreditStatus();
         String next;
-        switch (current != null ? current : "NORMAL") {
-            case "NORMAL":
-                next = "SUSPENDED";
+        switch (current != null ? current : "0") {
+            case "0":
+                next = "1";
                 break;
-            case "SUSPENDED":
-                next = "BLACKLIST";
+            case "1":
+                next = "2";
                 break;
             default:
-                next = "NORMAL";
+                next = "0";
                 break;
         }
         entity.setCreditStatus(next);
@@ -154,7 +241,595 @@ public class CreditCustomerServiceImpl implements CreditCustomerService {
         log.info("切换挂账状态: {} -> {}", entity.getCustomerName(), next);
     }
 
+    // ==================== 审批流程 ====================
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public String submitApproval(Long id, CreditCustomerSubmitDTO dto) {
+        CreditCustomer entity = requireEntity(id);
+        if (!STATUS_DRAFT.equals(entity.getApprovalStatus())) {
+            throw new BusinessException("只有草稿状态的挂账客户允许提交审批");
+        }
+        // 审批人由流程节点配置(候选角色/指定人)决定;仅当老板节点仍用 bossId 变量表达式时才强制选择
+        Long approverId = dto == null ? null : dto.getApproverId();
+        if (approverId == null && requiresApproverSelection()) {
+            throw new BusinessException("请选择审批人(老板)");
+        }
+        ensureFlowModel();
+
+        String businessKey = CreditCustomerFlowDefinition.businessKey(entity.getId());
+        String userId = SessionHelper.getUserId() == null ? null : String.valueOf(SessionHelper.getUserId());
+        String userName = resolveUsername();
+
+        Map<String, Object> variables = new LinkedHashMap<>();
+        variables.put(CreditCustomerFlowDefinition.VAR_BUSINESS_KEY, businessKey);
+        variables.put(CreditCustomerFlowDefinition.VAR_OBJECT_CODE, BUSINESS_TYPE);
+        variables.put(CreditCustomerFlowDefinition.VAR_RECORD_ID, String.valueOf(entity.getId()));
+        variables.put(CreditCustomerFlowDefinition.VAR_CREDIT_CUSTOMER_ID, String.valueOf(entity.getId()));
+        variables.put(CreditCustomerFlowDefinition.VAR_CUSTOMER_NAME, entity.getCustomerName());
+        variables.put(CreditCustomerFlowDefinition.VAR_MOBILE, entity.getMobile());
+        variables.put(CreditCustomerFlowDefinition.VAR_TITLE, "挂账客户审批-" + entity.getCustomerName());
+        variables.put(CreditCustomerFlowDefinition.VAR_INITIATOR, userId);
+        if (approverId != null) {
+            variables.put(CreditCustomerFlowDefinition.VAR_BOSS_ID, String.valueOf(approverId));
+        }
+
+        String flowTitle = buildConfiguredFlowTitle(entity, businessKey, variables);
+        FlowResult<String> result = flowClient.startProcess(
+                MODEL_KEY,
+                businessKey,
+                BUSINESS_TYPE,
+                flowTitle,
+                variables,
+                userId,
+                userName,
+                SessionHelper.getMainOrgId() == null ? null : String.valueOf(SessionHelper.getMainOrgId()),
+                null);
+        if (result == null || !result.isSuccess() || !StringUtils.hasText(result.getData())) {
+            throw new BusinessException("流程发起失败: " + (result == null ? "无返回结果" : result.getMsg()));
+        }
+
+        entity.setBusinessKey(businessKey);
+        entity.setProcessInstanceId(result.getData());
+        entity.setApplicantId(SessionHelper.getUserId());
+        entity.setApplicantName(userName);
+        if (approverId != null) {
+            entity.setApproverId(approverId);
+        }
+        entity.setApprovalStatus(STATUS_IN_PROCESS);
+        entity.setRejectReason(null);
+        creditCustomerMapper.updateById(entity);
+        log.info("挂账客户提交审批: {} ({}), 审批人ID={}, 流程实例={}",
+                entity.getCustomerName(), entity.getMobile(), approverId, result.getData());
+        return result.getData();
+    }
+
+    @Override
+    public boolean requiresApproverSelection() {
+        FlowResult<Map<String, Object>> current = flowClient.getModelByKey(MODEL_KEY);
+        Map<String, Object> model = current == null ? null : current.getData();
+        String bpmnXml = model == null ? null : Objects.toString(model.get("bpmnXml"), "");
+        if (!StringUtils.hasText(bpmnXml)) {
+            // 模型尚未创建:种子模板为候选角色,无需选人
+            return false;
+        }
+        String taskTag = findUserTaskTag(bpmnXml, CreditCustomerFlowDefinition.NODE_BOSS_APPROVE);
+        if (taskTag == null) {
+            // 节点不存在等同什么都没配置,提交端兜底选人
+            return true;
+        }
+        // 配置了候选角色/候选用户(角色、岗位、部门等在设计器中均落到候选组/候选人)→ 自动分配
+        if (hasNonEmptyAttribute(taskTag, "flowable:candidateGroups")
+                || hasNonEmptyAttribute(taskTag, "flowable:candidateUsers")) {
+            return false;
+        }
+        String assignee = readTagAttribute(taskTag, "flowable:assignee");
+        if (assignee == null || assignee.isBlank()) {
+            // 无任何办理人配置 → 需要提交端选人
+            return true;
+        }
+        if (!assignee.contains("${")) {
+            // 固定办理人 → 自动分配
+            return false;
+        }
+        // 表达式:仅 bossId 变量依赖提交时选人;发起人/上级/负责人等表达式由引擎自行解析
+        return assignee.contains(CreditCustomerFlowDefinition.VAR_BOSS_ID);
+    }
+
+    /** 判断节点开标签上指定属性是否存在且非空 */
+    private boolean hasNonEmptyAttribute(String tag, String name) {
+        String value = readTagAttribute(tag, name);
+        return value != null && !value.isBlank();
+    }
+
+    /** 提取 BPMN XML 中指定 userTask 节点的开标签(找不到返回 null) */
+    private String findUserTaskTag(String xml, String nodeId) {
+        String marker = "<bpmn:userTask";
+        int index = xml.indexOf(marker);
+        while (index >= 0) {
+            int end = xml.indexOf('>', index);
+            if (end < 0) {
+                return null;
+            }
+            String tag = xml.substring(index, end);
+            if (tag.contains("id=\"" + nodeId + "\"")) {
+                return tag;
+            }
+            index = xml.indexOf(marker, end);
+        }
+        return null;
+    }
+
+    private String readTagAttribute(String tag, String name) {
+        String prefix = name + "=\"";
+        int start = tag.indexOf(prefix);
+        if (start < 0) {
+            return null;
+        }
+        int valueStart = start + prefix.length();
+        int valueEnd = tag.indexOf('"', valueStart);
+        return valueEnd < 0 ? null : tag.substring(valueStart, valueEnd);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public CreditCustomerVO saveTaskFields(CreditCustomerTaskSaveDTO dto) {
+        if (dto == null) {
+            throw new BusinessException("保存参数不能为空");
+        }
+        CreditCustomer entity = resolveTaskEntity(dto);
+        String taskDefKey = StringUtils.hasText(dto.getTaskDefKey()) ? dto.getTaskDefKey() : "";
+        if (CreditCustomerFlowDefinition.NODE_BOSS_APPROVE.equals(taskDefKey)) {
+            ensureApprovalState(entity, dto);
+            entity.setBossRemark(dto.getBossRemark());
+        } else if (CreditCustomerFlowDefinition.NODE_APPLICANT_MODIFY.equals(taskDefKey)) {
+            ensureApplicantModifyState(entity, dto);
+            applyModifyFields(entity, dto);
+            entity.setApplicantModifyRemark(dto.getApplicantModifyRemark());
+        } else {
+            throw new BusinessException("当前节点不允许保存挂账客户字段: " + taskDefKey);
+        }
+        creditCustomerMapper.updateById(entity);
+        return creditDetail(entity.getId());
+    }
+
+    @Override
+    public CreditCustomerFlowInitVO ensureFlowModel() {
+        String bpmnXml = CreditCustomerFlowBpmn.build();
+        FlowResult<Map<String, Object>> current = flowClient.getModelByKey(MODEL_KEY);
+        Map<String, Object> model = current == null ? null : current.getData();
+        boolean created = model == null || model.get("id") == null;
+        boolean shouldDeploy = created;
+        boolean preserved = false;
+
+        if (created) {
+            FlowResult<Map<String, Object>> createdResult = flowClient.createModel(buildFlowModelPayload(null, bpmnXml));
+            if (createdResult == null || !createdResult.isSuccess() || createdResult.getData() == null) {
+                throw new BusinessException("创建挂账客户审批流程失败: "
+                        + (createdResult == null ? "无返回结果" : createdResult.getMsg()));
+            }
+            model = createdResult.getData();
+        } else {
+            String existingBpmnXml = Objects.toString(model.get("bpmnXml"), "");
+            Integer status = toInteger(model.get("status"));
+            shouldDeploy = status == null || status != 1
+                    || !StringUtils.hasText(Objects.toString(model.get("deploymentId"), ""));
+            if (!StringUtils.hasText(existingBpmnXml)) {
+                FlowResult<Map<String, Object>> updatedResult = flowClient.updateModel(
+                        buildFlowModelPayload(model.get("id"), bpmnXml));
+                if (updatedResult == null || !updatedResult.isSuccess() || updatedResult.getData() == null) {
+                    throw new BusinessException("更新挂账客户审批流程失败: "
+                            + (updatedResult == null ? "无返回结果" : updatedResult.getMsg()));
+                }
+                model = updatedResult.getData();
+                shouldDeploy = true;
+            } else {
+                preserved = true;
+            }
+        }
+
+        String deploymentId = Objects.toString(model.get("deploymentId"), null);
+        if (shouldDeploy) {
+            FlowResult<String> deployedResult = flowClient.deployModel(Objects.toString(model.get("id"), ""));
+            if (deployedResult == null || !deployedResult.isSuccess()) {
+                throw new BusinessException("发布挂账客户审批流程失败: "
+                        + (deployedResult == null ? "无返回结果" : deployedResult.getMsg()));
+            }
+            deploymentId = deployedResult.getData();
+        }
+
+        CreditCustomerFlowInitVO vo = new CreditCustomerFlowInitVO();
+        vo.setModelKey(MODEL_KEY);
+        vo.setModelId(Objects.toString(model.get("id"), null));
+        vo.setDeploymentId(deploymentId);
+        vo.setStatus(shouldDeploy ? 1 : toInteger(model.get("status")));
+        vo.setMessage(shouldDeploy
+                ? (preserved ? "挂账客户审批流程已发布,已保留流程设计器中的节点配置" : "挂账客户审批流程已初始化并发布")
+                : (preserved ? "挂账客户审批流程已存在,已保留流程设计器中的节点配置" : "挂账客户审批流程已存在,无需重复发布"));
+        return vo;
+    }
+
+    /**
+     * 流程事件回调:维护业务审批状态(幂等)。
+     */
+    @FlowCallback(on = {
+            FlowCallback.ON_TASK_CREATED,
+            FlowCallback.ON_TASK_COMPLETED,
+            FlowCallback.ON_COMPLETED,
+            FlowCallback.ON_REJECTED,
+            FlowCallback.ON_CANCELED
+    })
+    @Transactional(rollbackFor = Exception.class)
+    public void handleFlowEvent(FlowEventContext context) {
+        if (context == null || !StringUtils.hasText(context.getBusinessKey())) {
+            return;
+        }
+        CreditCustomer entity = creditCustomerMapper.selectByBusinessKey(
+                resolveTenantId(context.getTenantId()), context.getBusinessKey());
+        if (entity == null) {
+            log.warn("挂账客户流程回调未找到业务记录: event={}, businessKey={}",
+                    context.getEvent(), context.getBusinessKey());
+            return;
+        }
+        if (FlowCallback.ON_TASK_COMPLETED.equals(context.getEvent())) {
+            handleTaskCompleted(entity, context);
+            return;
+        }
+        if (FlowCallback.ON_TASK_CREATED.equals(context.getEvent())) {
+            handleTaskCreated(entity, context);
+            return;
+        }
+        if (FlowCallback.ON_COMPLETED.equals(context.getEvent())) {
+            markApproved(entity);
+            creditCustomerMapper.updateById(entity);
+            log.info("挂账客户审批通过: businessKey={}", entity.getBusinessKey());
+            return;
+        }
+        if (FlowCallback.ON_REJECTED.equals(context.getEvent())) {
+            markRejected(entity, firstText(context.getLastComment(), context.getComment(), entity.getRejectReason()));
+            creditCustomerMapper.updateById(entity);
+            return;
+        }
+        if (FlowCallback.ON_CANCELED.equals(context.getEvent())) {
+            markCanceled(entity, firstText(context.getLastComment(), context.getComment(), entity.getRejectReason()));
+            creditCustomerMapper.updateById(entity);
+        }
+    }
+
+    private void handleTaskCreated(CreditCustomer entity, FlowEventContext context) {
+        if (CreditCustomerFlowDefinition.isApplicantModifyNode(context.getTaskDefKey())) {
+            if (!STATUS_IN_PROCESS.equals(entity.getApprovalStatus())) {
+                return;
+            }
+            markNeedModify(entity, firstText(context.getComment(), context.getLastComment(), entity.getRejectReason()));
+            creditCustomerMapper.updateById(entity);
+            log.info("挂账客户进入申请人修改节点,状态自动切换为待修改: businessKey={}, taskId={}",
+                    entity.getBusinessKey(), context.getTaskId());
+            return;
+        }
+        if (CreditCustomerFlowDefinition.isApprovalNode(context.getTaskDefKey())
+                && STATUS_NEED_MODIFY.equals(entity.getApprovalStatus())) {
+            markInProcess(entity);
+            creditCustomerMapper.updateById(entity);
+            log.info("挂账客户重新进入老板审核节点,状态自动切换为审批中: businessKey={}, taskId={}",
+                    entity.getBusinessKey(), context.getTaskId());
+        }
+    }
+
+    private void handleTaskCompleted(CreditCustomer entity, FlowEventContext context) {
+        copyFinishVariables(entity, context.getVariables());
+        if (isRejectAction(context)) {
+            if (CreditCustomerFlowDefinition.isApplicantModifyNode(context.getTaskDefKey())) {
+                markRejected(entity, firstText(context.getComment(), context.getLastComment(), entity.getRejectReason()));
+            } else {
+                markNeedModify(entity, firstText(context.getComment(), context.getLastComment(), entity.getRejectReason()));
+            }
+            creditCustomerMapper.updateById(entity);
+            return;
+        }
+        if (CreditCustomerFlowDefinition.isApplicantModifyNode(context.getTaskDefKey())) {
+            markInProcess(entity);
+            creditCustomerMapper.updateById(entity);
+        }
+    }
+
+    private boolean isRejectAction(FlowEventContext context) {
+        if (context == null || context.getVariables() == null || context.getVariables().isEmpty()) {
+            return false;
+        }
+        Map<String, Object> variables = context.getVariables();
+        String approvalResult = Objects.toString(
+                variables.get(CreditCustomerFlowDefinition.VAR_APPROVAL_RESULT), null);
+        if (CreditCustomerFlowDefinition.isRejectAction(approvalResult)) {
+            return true;
+        }
+        Object approved = variables.get("approved");
+        if (approved instanceof Boolean) {
+            return !(Boolean) approved;
+        }
+        if (approved != null) {
+            return "false".equalsIgnoreCase(String.valueOf(approved)) || "0".equals(String.valueOf(approved));
+        }
+        return false;
+    }
+
+    private void copyFinishVariables(CreditCustomer entity, Map<String, Object> variables) {
+        if (variables == null || variables.isEmpty()) {
+            return;
+        }
+        Object bossRemark = variables.get(CreditCustomerFlowDefinition.FIELD_BOSS_REMARK);
+        if (bossRemark != null && StringUtils.hasText(String.valueOf(bossRemark))) {
+            entity.setBossRemark(String.valueOf(bossRemark));
+        }
+    }
+
+    // ==================== 状态机与对账修复 ====================
+
+    private void ensureApprovalState(CreditCustomer entity, CreditCustomerTaskSaveDTO dto) {
+        if (STATUS_NEED_MODIFY.equals(entity.getApprovalStatus())
+                && dto != null
+                && CreditCustomerFlowDefinition.isApprovalNode(dto.getTaskDefKey())) {
+            markInProcess(entity);
+            log.info("挂账客户审批节点保存前修复审批中状态: businessKey={}, taskId={}",
+                    entity.getBusinessKey(), dto.getTaskId());
+            return;
+        }
+        if (!STATUS_IN_PROCESS.equals(entity.getApprovalStatus())) {
+            throw new BusinessException("当前挂账客户不是审批中状态,不能保存审批节点字段");
+        }
+    }
+
+    private void ensureApplicantModifyState(CreditCustomer entity, CreditCustomerTaskSaveDTO dto) {
+        if (STATUS_IN_PROCESS.equals(entity.getApprovalStatus())
+                && dto != null
+                && CreditCustomerFlowDefinition.isApplicantModifyNode(dto.getTaskDefKey())) {
+            markNeedModify(entity, entity.getRejectReason());
+            log.info("挂账客户申请人修改节点保存前修复待修改状态: businessKey={}, taskId={}",
+                    entity.getBusinessKey(), dto.getTaskId());
+            return;
+        }
+        if (!STATUS_NEED_MODIFY.equals(entity.getApprovalStatus())) {
+            throw new BusinessException("当前挂账客户不是待修改状态,不能执行申请人修改节点");
+        }
+    }
+
+    private void reconcileStatusesWithActiveTasks(List<CreditCustomerVO> records) {
+        if (records == null || records.isEmpty()) {
+            return;
+        }
+        Set<String> businessKeys = new LinkedHashSet<>();
+        for (CreditCustomerVO record : records) {
+            if (record == null || !isRunningStatus(record.getApprovalStatus())
+                    || !StringUtils.hasText(record.getBusinessKey())) {
+                continue;
+            }
+            businessKeys.add(record.getBusinessKey());
+        }
+        if (businessKeys.isEmpty()) {
+            return;
+        }
+        List<Map<String, Object>> activeTasks =
+                creditCustomerMapper.selectActiveTaskDefKeysByBusinessKeys(resolveTenantId(), businessKeys);
+        if (activeTasks == null || activeTasks.isEmpty()) {
+            return;
+        }
+        Map<String, String> activeTaskIndex = new LinkedHashMap<>();
+        for (Map<String, Object> task : activeTasks) {
+            String businessKey = Objects.toString(task.get("businessKey"), "");
+            String taskDefKey = Objects.toString(task.get("taskDefKey"), "");
+            if (StringUtils.hasText(businessKey) && StringUtils.hasText(taskDefKey)
+                    && !activeTaskIndex.containsKey(businessKey)) {
+                activeTaskIndex.put(businessKey, taskDefKey);
+            }
+        }
+        for (CreditCustomerVO record : records) {
+            if (record == null || !isRunningStatus(record.getApprovalStatus())) {
+                continue;
+            }
+            String taskDefKey = activeTaskIndex.get(record.getBusinessKey());
+            String expectedStatus = expectedStatusByActiveTask(taskDefKey);
+            if (!StringUtils.hasText(expectedStatus) || Objects.equals(expectedStatus, record.getApprovalStatus())) {
+                continue;
+            }
+            CreditCustomer update = new CreditCustomer();
+            update.setId(record.getId());
+            update.setApprovalStatus(expectedStatus);
+            creditCustomerMapper.updateById(update);
+            record.setApprovalStatus(expectedStatus);
+            log.info("挂账客户状态按当前待办节点对账修复: businessKey={}, taskDefKey={}, status={}",
+                    record.getBusinessKey(), taskDefKey, expectedStatus);
+        }
+    }
+
+    private boolean isRunningStatus(String status) {
+        return STATUS_IN_PROCESS.equals(status) || STATUS_NEED_MODIFY.equals(status);
+    }
+
+    private String expectedStatusByActiveTask(String taskDefKey) {
+        if (CreditCustomerFlowDefinition.isApplicantModifyNode(taskDefKey)) {
+            return STATUS_NEED_MODIFY;
+        }
+        if (CreditCustomerFlowDefinition.isApprovalNode(taskDefKey)) {
+            return STATUS_IN_PROCESS;
+        }
+        return null;
+    }
+
+    private void markInProcess(CreditCustomer entity) {
+        entity.setApprovalStatus(STATUS_IN_PROCESS);
+    }
+
+    private void markNeedModify(CreditCustomer entity, String rejectReason) {
+        entity.setApprovalStatus(STATUS_NEED_MODIFY);
+        entity.setRejectReason(rejectReason);
+    }
+
+    private void markApproved(CreditCustomer entity) {
+        entity.setApprovalStatus(STATUS_APPROVED);
+    }
+
+    private void markRejected(CreditCustomer entity, String rejectReason) {
+        entity.setApprovalStatus(STATUS_REJECTED);
+        entity.setRejectReason(rejectReason);
+    }
+
+    private void markCanceled(CreditCustomer entity, String rejectReason) {
+        entity.setApprovalStatus(STATUS_CANCELED);
+        entity.setRejectReason(rejectReason);
+    }
+
+    // ==================== 私有辅助 ====================
+
+    private void applyModifyFields(CreditCustomer entity, CreditCustomerTaskSaveDTO dto) {
+        if (StringUtils.hasText(dto.getCustomerName())) {
+            entity.setCustomerName(dto.getCustomerName().trim());
+        }
+        if (StringUtils.hasText(dto.getMobile()) && !dto.getMobile().equals(entity.getMobile())) {
+            int mobileCount = creditCustomerMapper.checkMobileExists(entity.getTenantId(), dto.getMobile(), entity.getId());
+            if (mobileCount > 0) {
+                throw new BusinessException("手机号已存在,请检查");
+            }
+            entity.setMobile(dto.getMobile().trim());
+        }
+        if (StringUtils.hasText(dto.getBirthday())) {
+            entity.setBirthday(LocalDate.parse(dto.getBirthday()));
+        }
+        if (dto.getIdCard() != null) {
+            entity.setIdCard(dto.getIdCard());
+        }
+        if (dto.getOccupation() != null) {
+            entity.setOccupation(dto.getOccupation());
+        }
+        if (dto.getCompanyName() != null) {
+            entity.setCompanyName(dto.getCompanyName());
+        }
+        if (dto.getAddress() != null) {
+            entity.setAddress(dto.getAddress());
+        }
+        if (dto.getEmergencyContact() != null) {
+            entity.setEmergencyContact(dto.getEmergencyContact());
+        }
+        if (dto.getEmergencyContactPhone() != null) {
+            entity.setEmergencyContactPhone(dto.getEmergencyContactPhone());
+        }
+        if (dto.getInvoiceTitle() != null) {
+            entity.setInvoiceTitle(dto.getInvoiceTitle());
+        }
+        if (dto.getTaxNo() != null) {
+            entity.setTaxNo(dto.getTaxNo());
+        }
+        if (dto.getBankName() != null) {
+            entity.setBankName(dto.getBankName());
+        }
+        if (dto.getBankAccount() != null) {
+            entity.setBankAccount(dto.getBankAccount());
+        }
+        if (dto.getInvoiceAddressPhone() != null) {
+            entity.setInvoiceAddressPhone(dto.getInvoiceAddressPhone());
+        }
+        if (dto.getRemark() != null) {
+            entity.setRemark(dto.getRemark());
+        }
+    }
+
+    private CreditCustomer resolveTaskEntity(CreditCustomerTaskSaveDTO dto) {
+        if (dto.getId() != null) {
+            return requireEntity(dto.getId());
+        }
+        if (StringUtils.hasText(dto.getBusinessKey())) {
+            CreditCustomer entity = creditCustomerMapper.selectByBusinessKey(resolveTenantId(), dto.getBusinessKey());
+            if (entity != null) {
+                return entity;
+            }
+        }
+        throw new BusinessException("未找到待办关联的挂账客户");
+    }
+
+    private CreditCustomer requireEntity(Long id) {
+        if (id == null) {
+            throw new BusinessException("客户ID不能为空");
+        }
+        CreditCustomer entity = creditCustomerMapper.selectById(id);
+        if (entity == null || !Objects.equals(entity.getTenantId(), resolveTenantId())) {
+            throw new BusinessException("挂账客户不存在");
+        }
+        return entity;
+    }
+
+    private Map<String, Object> buildFlowModelPayload(Object id, String bpmnXml) {
+        return CreditCustomerFlowDefinition.flowModelPayload(id, resolveTenantId(), bpmnXml);
+    }
+
+    private String buildConfiguredFlowTitle(CreditCustomer entity,
+                                            String businessKey,
+                                            Map<String, Object> variables) {
+        String fallback = "挂账客户审批-" + entity.getCustomerName();
+        JSONObject bindingConfig = loadFlowBindingConfig();
+        String template = trimToNull(bindingConfig.getString("titleTemplate"));
+        if (template == null) {
+            return fallback;
+        }
+        Map<String, Object> titleData = new LinkedHashMap<>();
+        if (variables != null) {
+            titleData.putAll(variables);
+        }
+        titleData.put("id", entity.getId());
+        titleData.put("businessKey", businessKey);
+        titleData.put("objectCode", BUSINESS_TYPE);
+        titleData.put("businessType", BUSINESS_TYPE);
+        titleData.put("modelKey", MODEL_KEY);
+        titleData.put(CreditCustomerFlowDefinition.FIELD_CUSTOMER_NAME, entity.getCustomerName());
+        titleData.put(CreditCustomerFlowDefinition.FIELD_MOBILE, entity.getMobile());
+        titleData.put(CreditCustomerFlowDefinition.FIELD_COMPANY_NAME, entity.getCompanyName());
+        titleData.put(CreditCustomerFlowDefinition.FIELD_APPLICANT_NAME, entity.getApplicantName());
+        String title = template;
+        for (Map.Entry<String, Object> entry : titleData.entrySet()) {
+            title = replaceTemplateValue(title, entry.getKey(), entry.getValue());
+        }
+        return StringUtils.hasText(title) ? title : fallback;
+    }
+
+    private JSONObject loadFlowBindingConfig() {
+        AiBusinessBinding binding = businessBindingMapper.selectBindingByTypeAndCode(
+                resolveTenantId(), "OBJECT", BUSINESS_TYPE, "FLOW");
+        if (binding == null || !StringUtils.hasText(binding.getBindingConfig())) {
+            return new JSONObject();
+        }
+        try {
+            return JSON.parseObject(binding.getBindingConfig());
+        } catch (Exception e) {
+            log.warn("挂账客户流程标题模板配置解析失败: bindingId={}", binding.getId(), e);
+            return new JSONObject();
+        }
+    }
+
+    private String replaceTemplateValue(String template, String key, Object value) {
+        if (!StringUtils.hasText(template) || !StringUtils.hasText(key)) {
+            return template;
+        }
+        String text = value == null ? "" : String.valueOf(value);
+        return template.replace("${" + key + "}", text).replace("{" + key + "}", text);
+    }
+
+    private String trimToNull(String value) {
+        if (!StringUtils.hasText(value)) {
+            return null;
+        }
+        return value.trim();
+    }
+
+    private String resolveUsername() {
+        return StringUtils.hasText(SessionHelper.getUsername()) ? SessionHelper.getUsername() : "system";
+    }
+
     private Long resolveTenantId() {
+        return resolveTenantId(null);
+    }
+
+    private Long resolveTenantId(Long eventTenantId) {
+        if (eventTenantId != null) {
+            return eventTenantId;
+        }
         Long tenantId = TenantContextHolder.getTenantId();
         if (tenantId != null) {
             return tenantId;
@@ -162,4 +837,30 @@ public class CreditCustomerServiceImpl implements CreditCustomerService {
         tenantId = SessionHelper.getTenantId();
         return tenantId == null ? 1L : tenantId;
     }
+
+    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;
+        }
+    }
+
+    private String firstText(String... values) {
+        if (values == null) {
+            return null;
+        }
+        for (String value : values) {
+            if (StringUtils.hasText(value)) {
+                return value;
+            }
+        }
+        return null;
+    }
 }

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

@@ -90,10 +90,13 @@ public class DishOrderServiceImpl implements DishOrderService {
     private DishTypeMapper dishTypeMapper;
 
     @Override
-    public IPage<DishOrderVO> orderPage(PageQuery pageQuery, Long roomId, String orderStatus, String settleType) {
+    public IPage<DishOrderVO> orderPage(PageQuery pageQuery, Long roomId, String orderStatus, String settleType,
+                                        String beginTime, String endTime) {
         Long tenantId = resolveTenantId();
         Page<DishOrderVO> page = new Page<>(pageQuery.getPageNum(), pageQuery.getPageSize());
-        return dishOrderMapper.selectPage(page, tenantId, roomId, orderStatus, settleType);
+        String begin = normalizeTime(parseString(beginTime), false);
+        String end = normalizeTime(parseString(endTime), true);
+        return dishOrderMapper.selectPage(page, tenantId, roomId, orderStatus, settleType, begin, end);
     }
 
     @Override
@@ -351,9 +354,89 @@ public class DishOrderServiceImpl implements DishOrderService {
         log.info("取消订单: orderId={}, orderNo={}, roomId={}", id, order.getOrderNo(), order.getRoomId());
     }
 
+    @Override
+    public List<DishOrderVO> queryExportList(Map<String, Object> params) {
+        Long tenantId = resolveTenantId();
+        Map<String, Object> query = params != null ? params : new HashMap<>();
+        Long roomId = parseLong(query.get("roomId"));
+        String orderStatus = parseString(query.get("orderStatus"));
+        String settleType = parseString(query.get("settleType"));
+        String beginTime = normalizeTime(parseString(query.get("beginTime")), false);
+        String endTime = normalizeTime(parseString(query.get("endTime")), true);
+        return dishOrderMapper.selectExportList(tenantId, roomId, orderStatus, settleType, beginTime, endTime);
+    }
+
     // ==================== 私有方法 ====================
 
     /**
+     * 解析字符串参数,空字符串视为 null。
+     */
+    private String parseString(Object value) {
+        if (value == null) {
+            return null;
+        }
+        String str = String.valueOf(value).trim();
+        return str.isEmpty() ? null : str;
+    }
+
+    /**
+     * 解析 Long 参数,非法值返回 null。
+     */
+    private Long parseLong(Object value) {
+        String str = parseString(value);
+        if (str == null) {
+            return null;
+        }
+        try {
+            return Long.valueOf(str);
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    /**
+     * 归一化时间过滤值:兼容前端多种传参形态,统一转为 yyyy-MM-dd HH:mm:ss。
+     * <ul>
+     *   <li>13 位毫秒时间戳:按服务器时区格式化</li>
+     *   <li>8 位纯数字 yyyyMMdd:视为日期</li>
+     *   <li>yyyy-MM-dd:起始补 00:00:00,结束补 23:59:59</li>
+     *   <li>带 T 的 ISO 格式:T 替换为空格并截断到秒</li>
+     *   <li>yyyy-MM-dd HH:mm:ss:原样使用;结束端若为零点 00:00:00 抬升为当天 23:59:59,
+     *       防止前端传零点时间导致结束日期整天被排除</li>
+     * </ul>
+     */
+    private String normalizeTime(String time, boolean endOfDay) {
+        if (time == null) {
+            return null;
+        }
+        String text = time.trim();
+        if (text.isEmpty()) {
+            return null;
+        }
+        if (text.matches("\\d{13}")) {
+            text = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date(Long.parseLong(text)));
+        } else if (text.matches("\\d{8}")) {
+            text = text.substring(0, 4) + "-" + text.substring(4, 6) + "-" + text.substring(6, 8);
+        } else if (text.contains("T")) {
+            text = text.replace('T', ' ');
+            if (text.length() > 19) {
+                text = text.substring(0, 19);
+            }
+        }
+        if (text.length() == 10) {
+            return endOfDay ? text + " 23:59:59" : text + " 00:00:00";
+        }
+        if (text.length() >= 19) {
+            text = text.substring(0, 19);
+            if (endOfDay && text.endsWith(" 00:00:00")) {
+                return text.substring(0, 10) + " 23:59:59";
+            }
+            return text;
+        }
+        return text;
+    }
+
+    /**
      * 创建新订单。
      */
     private DishOrder createOrder(DishOrderSubmitDTO dto, DishRoom room, Long tenantId) {

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

@@ -0,0 +1,103 @@
+package com.mdframe.forge.business.core.restaurant.support;
+
+/**
+ * 挂账客户审批流程 BPMN。
+ * <p>
+ * 单步审批:提交后直接进入「老板审核」;通过则流程结束(APPROVED),
+ * 驳回则进入「申请人修改」,申请人可重新提交(回到老板审核)或终止申请(REJECTED)。
+ */
+public final class CreditCustomerFlowBpmn {
+
+    private CreditCustomerFlowBpmn() {
+    }
+
+    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_APPROVE@" name="老板审核" flowable:candidateGroups="@BOSS_CANDIDATE_ROLE@" flowable:candidateGroupNames="@BOSS_CANDIDATE_ROLE_NAME@" flowable:formKey="@FORM_KEY@" flowable:formFieldPermissions='@PERM_BOSS@' flowable:allowApprove="true" flowable:allowReject="true" flowable:allowDelegate="true" flowable:allowReturn="false" flowable:allowTerminate="false" flowable:requireComment="true">
+                      <bpmn:incoming>Flow_001</bpmn:incoming>
+                      <bpmn:incoming>Flow_006</bpmn:incoming>
+                      <bpmn:outgoing>Flow_002</bpmn:outgoing>
+                    </bpmn:userTask>
+                    <bpmn:exclusiveGateway id="Gateway_boss_result" name="老板审核结果" default="Flow_003">
+                      <bpmn:incoming>Flow_002</bpmn:incoming>
+                      <bpmn:outgoing>Flow_003</bpmn:outgoing>
+                      <bpmn:outgoing>Flow_004</bpmn:outgoing>
+                    </bpmn:exclusiveGateway>
+                    <bpmn:userTask id="@NODE_APPLICANT_MODIFY@" name="申请人修改" flowable:assignee="@EXPR_INITIATOR@" flowable:formKey="@FORM_KEY@" flowable:formFieldPermissions='@PERM_APPLICANT_MODIFY@' flowable:allowApprove="true" flowable:allowReject="true" flowable:allowDelegate="false" flowable:allowReturn="false" flowable:allowTerminate="false" flowable:requireComment="true">
+                      <bpmn:incoming>Flow_004</bpmn:incoming>
+                      <bpmn:outgoing>Flow_005</bpmn:outgoing>
+                    </bpmn:userTask>
+                    <bpmn:exclusiveGateway id="Gateway_modify_result" name="修改处理结果" default="Flow_006">
+                      <bpmn:incoming>Flow_005</bpmn:incoming>
+                      <bpmn:outgoing>Flow_006</bpmn:outgoing>
+                      <bpmn:outgoing>Flow_007</bpmn:outgoing>
+                    </bpmn:exclusiveGateway>
+                    <bpmn:endEvent id="EndEvent_approved" name="审批通过">
+                      <bpmn:incoming>Flow_003</bpmn:incoming>
+                    </bpmn:endEvent>
+                    <bpmn:endEvent id="EndEvent_rejected" name="申请人终止">
+                      <bpmn:incoming>Flow_007</bpmn:incoming>
+                    </bpmn:endEvent>
+                    <bpmn:sequenceFlow id="Flow_001" sourceRef="StartEvent_1" targetRef="@NODE_BOSS_APPROVE@" />
+                    <bpmn:sequenceFlow id="Flow_002" sourceRef="@NODE_BOSS_APPROVE@" targetRef="Gateway_boss_result" />
+                    <bpmn:sequenceFlow id="Flow_003" name="通过" sourceRef="Gateway_boss_result" targetRef="EndEvent_approved" />
+                    <bpmn:sequenceFlow id="Flow_004" name="驳回修改" sourceRef="Gateway_boss_result" targetRef="@NODE_APPLICANT_MODIFY@">
+                      <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression"><![CDATA[@REJECT_CONDITION@]]></bpmn:conditionExpression>
+                    </bpmn:sequenceFlow>
+                    <bpmn:sequenceFlow id="Flow_005" sourceRef="@NODE_APPLICANT_MODIFY@" targetRef="Gateway_modify_result" />
+                    <bpmn:sequenceFlow id="Flow_006" name="重新提交" sourceRef="Gateway_modify_result" targetRef="@NODE_BOSS_APPROVE@" />
+                    <bpmn:sequenceFlow id="Flow_007" name="终止申请" sourceRef="Gateway_modify_result" targetRef="EndEvent_rejected">
+                      <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression"><![CDATA[@REJECT_CONDITION@]]></bpmn:conditionExpression>
+                    </bpmn:sequenceFlow>
+                  </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_APPROVE@_di" bpmnElement="@NODE_BOSS_APPROVE@"><dc:Bounds x="220" y="170" width="140" height="80" /></bpmndi:BPMNShape>
+                      <bpmndi:BPMNShape id="Gateway_boss_result_di" bpmnElement="Gateway_boss_result" isMarkerVisible="true"><dc:Bounds x="420" y="185" width="50" height="50" /></bpmndi:BPMNShape>
+                      <bpmndi:BPMNShape id="EndEvent_approved_di" bpmnElement="EndEvent_approved"><dc:Bounds x="540" y="192" width="36" height="36" /></bpmndi:BPMNShape>
+                      <bpmndi:BPMNShape id="@NODE_APPLICANT_MODIFY@_di" bpmnElement="@NODE_APPLICANT_MODIFY@"><dc:Bounds x="375" y="330" width="140" height="80" /></bpmndi:BPMNShape>
+                      <bpmndi:BPMNShape id="Gateway_modify_result_di" bpmnElement="Gateway_modify_result" isMarkerVisible="true"><dc:Bounds x="575" y="345" width="50" height="50" /></bpmndi:BPMNShape>
+                      <bpmndi:BPMNShape id="EndEvent_rejected_di" bpmnElement="EndEvent_rejected"><dc:Bounds x="700" y="352" 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="420" y="210" /></bpmndi:BPMNEdge>
+                      <bpmndi:BPMNEdge id="Flow_003_di" bpmnElement="Flow_003"><di:waypoint x="470" y="210" /><di:waypoint x="540" y="210" /></bpmndi:BPMNEdge>
+                      <bpmndi:BPMNEdge id="Flow_004_di" bpmnElement="Flow_004"><di:waypoint x="445" y="235" /><di:waypoint x="445" y="330" /></bpmndi:BPMNEdge>
+                      <bpmndi:BPMNEdge id="Flow_005_di" bpmnElement="Flow_005"><di:waypoint x="515" y="370" /><di:waypoint x="575" y="370" /></bpmndi:BPMNEdge>
+                      <bpmndi:BPMNEdge id="Flow_006_di" bpmnElement="Flow_006"><di:waypoint x="600" y="345" /><di:waypoint x="600" y="95" /><di:waypoint x="290" y="95" /><di:waypoint x="290" y="170" /></bpmndi:BPMNEdge>
+                      <bpmndi:BPMNEdge id="Flow_007_di" bpmnElement="Flow_007"><di:waypoint x="625" y="370" /><di:waypoint x="700" y="370" /></bpmndi:BPMNEdge>
+                    </bpmndi:BPMNPlane>
+                  </bpmndi:BPMNDiagram>
+                </bpmn:definitions>
+                """;
+        return xml
+                .replace("@MODEL_KEY@", CreditCustomerFlowDefinition.MODEL_KEY)
+                .replace("@MODEL_NAME@", CreditCustomerFlowDefinition.MODEL_NAME)
+                .replace("@VAR_INITIATOR@", CreditCustomerFlowDefinition.VAR_INITIATOR)
+                .replace("@FORM_KEY@", CreditCustomerFlowDefinition.FORM_KEY)
+                .replace("@NODE_BOSS_APPROVE@", CreditCustomerFlowDefinition.NODE_BOSS_APPROVE)
+                .replace("@NODE_APPLICANT_MODIFY@", CreditCustomerFlowDefinition.NODE_APPLICANT_MODIFY)
+                .replace("@BOSS_CANDIDATE_ROLE@", CreditCustomerFlowDefinition.BOSS_CANDIDATE_ROLE)
+                .replace("@BOSS_CANDIDATE_ROLE_NAME@", CreditCustomerFlowDefinition.BOSS_CANDIDATE_ROLE_NAME)
+                .replace("@EXPR_INITIATOR@",
+                        CreditCustomerFlowDefinition.variableExpression(CreditCustomerFlowDefinition.VAR_INITIATOR))
+                .replace("@REJECT_CONDITION@", CreditCustomerFlowDefinition.rejectConditionExpression())
+                .replace("@PERM_BOSS@",
+                        CreditCustomerFlowDefinition.nodeFieldPermissionsJson(CreditCustomerFlowDefinition.NODE_BOSS_APPROVE))
+                .replace("@PERM_APPLICANT_MODIFY@",
+                        CreditCustomerFlowDefinition.nodeFieldPermissionsJson(CreditCustomerFlowDefinition.NODE_APPLICANT_MODIFY));
+    }
+}

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

@@ -0,0 +1,459 @@
+package com.mdframe.forge.business.core.restaurant.support;
+
+import com.mdframe.forge.business.core.restaurant.dto.CreditCustomerTaskSaveDTO;
+import com.mdframe.forge.business.core.restaurant.vo.CreditCustomerVO;
+import com.mdframe.forge.plugin.generator.dto.businessapp.BusinessTaskFormContextQueryDTO;
+import com.mdframe.forge.plugin.generator.dto.businessapp.BusinessTaskFormSaveDTO;
+import com.mdframe.forge.plugin.generator.vo.businessapp.BusinessTaskFormContextVO;
+import org.springframework.util.StringUtils;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 挂账客户审批流程/表单默认定义。
+ * <p>
+ * 单步审批:老板审核(boss_approve);驳回后进入申请人修改(applicant_modify),
+ * 可重新提交或终止申请。应用管理保存的 codeAppMetadata 会覆盖这些默认资产,
+ * 部署后的 BPMN 节点配置是最终权威。
+ */
+public final class CreditCustomerFlowDefinition {
+
+    private CreditCustomerFlowDefinition() {
+    }
+
+    public static final String BUSINESS_TYPE = "credit_customer";
+    public static final String BUSINESS_OBJECT_NAME = "挂账客户";
+    public static final String BUSINESS_APP_NAME = "挂账客户审批";
+
+    public static final String MODEL_KEY = "credit_customer_approval";
+    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 = "creditCustomer";
+    public static final String PROVIDER_NAME = "挂账客户审批代码表单";
+    public static final String FORM_KEY = "credit_customer_approval_form";
+    public static final String FORM_NAME = "挂账客户审批表单";
+    /** 审批详情内嵌的任务表单组件路径(FlowBusinessForm 按 @/views 下组件路径解析,禁止指向列表页) */
+    public static final String FORM_URL = "/restaurant/CreditCustomerApproveForm";
+    public static final String FORM_MODE_BUSINESS_CODE = "BUSINESS_CODE_FORM";
+    public static final String TASK_FORM_TYPE_BUSINESS_CODE = "business-code";
+
+    /** 审批状态数字值:1-暂存、2-审批中、3-撤回、4-驳回、5-已完成 */
+    public static final String STATUS_DRAFT = "1";
+    public static final String STATUS_IN_PROCESS = "2";
+    /** NEED_MODIFY 与 IN_PROCESS 共用数字值 2(审批中),保留别名以区分业务语义 */
+    public static final String STATUS_NEED_MODIFY = "2";
+    public static final String STATUS_CANCELED = "3";
+    public static final String STATUS_REJECTED = "4";
+    public static final String STATUS_APPROVED = "5";
+
+    public static final String NODE_BOSS_APPROVE = "boss_approve";
+    public static final String NODE_APPLICANT_MODIFY = "applicant_modify";
+
+    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_CREDIT_CUSTOMER_ID = "creditCustomerId";
+    public static final String VAR_CUSTOMER_NAME = "customerName";
+    public static final String VAR_MOBILE = "mobile";
+    public static final String VAR_TITLE = "title";
+    public static final String VAR_INITIATOR = "initiator";
+    public static final String VAR_BOSS_ID = "bossId";
+    public static final String VAR_APPROVAL_RESULT = "approvalResult";
+
+    /** 老板审核节点默认候选角色编码(与流程设计器配置一致;新环境可在设计器改绑其它角色/指定人) */
+    public static final String BOSS_CANDIDATE_ROLE = "jjt-zhgly";
+    /** 老板审核节点默认候选角色展示名 */
+    public static final String BOSS_CANDIDATE_ROLE_NAME = "景聚庭-租户管理员";
+
+    public static final String ACTION_APPROVE = "approve";
+    public static final String ACTION_REJECT = "reject";
+
+    public static final String FIELD_ID = "id";
+    public static final String FIELD_CUSTOMER_NAME = "customerName";
+    public static final String FIELD_MOBILE = "mobile";
+    public static final String FIELD_BIRTHDAY = "birthday";
+    public static final String FIELD_ID_CARD = "idCard";
+    public static final String FIELD_OCCUPATION = "occupation";
+    public static final String FIELD_COMPANY_NAME = "companyName";
+    public static final String FIELD_ADDRESS = "address";
+    public static final String FIELD_EMERGENCY_CONTACT = "emergencyContact";
+    public static final String FIELD_EMERGENCY_CONTACT_PHONE = "emergencyContactPhone";
+    public static final String FIELD_INVOICE_TITLE = "invoiceTitle";
+    public static final String FIELD_TAX_NO = "taxNo";
+    public static final String FIELD_BANK_NAME = "bankName";
+    public static final String FIELD_BANK_ACCOUNT = "bankAccount";
+    public static final String FIELD_INVOICE_ADDRESS_PHONE = "invoiceAddressPhone";
+    public static final String FIELD_CREDIT_STATUS = "creditStatus";
+    public static final String FIELD_TOTAL_ARREARS_AMOUNT = "totalArrearsAmount";
+    public static final String FIELD_UNSETTLED_BILL_COUNT = "unsettledBillCount";
+    public static final String FIELD_APPROVAL_STATUS = "approvalStatus";
+    public static final String FIELD_APPLICANT_ID = "applicantId";
+    public static final String FIELD_APPLICANT_NAME = "applicantName";
+    public static final String FIELD_APPROVER_ID = "approverId";
+    public static final String FIELD_APPROVER_NAME = "approverName";
+    public static final String FIELD_BUSINESS_KEY = "businessKey";
+    public static final String FIELD_PROCESS_INSTANCE_ID = "processInstanceId";
+    public static final String FIELD_BOSS_REMARK = "bossRemark";
+    public static final String FIELD_APPLICANT_MODIFY_REMARK = "applicantModifyRemark";
+    public static final String FIELD_REJECT_REASON = "rejectReason";
+    public static final String FIELD_REMARK = "remark";
+    public static final String FIELD_CREATE_TIME = "createTime";
+    public static final String FIELD_UPDATE_TIME = "updateTime";
+
+    /** 字段目录:{fieldCode, label, componentType, readonly, internal} */
+    private static final String[][] FIELD_DEFINITIONS = {
+            {FIELD_ID, "客户ID", "number", "1", "1"},
+            {FIELD_CUSTOMER_NAME, "客户姓名", "input", "0", "0"},
+            {FIELD_MOBILE, "手机号", "input", "0", "0"},
+            {FIELD_COMPANY_NAME, "公司名称", "input", "0", "0"},
+            {FIELD_ADDRESS, "地址", "input", "0", "0"},
+            {FIELD_BIRTHDAY, "生日", "date", "0", "0"},
+            {FIELD_ID_CARD, "身份证", "input", "0", "0"},
+            {FIELD_OCCUPATION, "职业", "input", "0", "0"},
+            {FIELD_EMERGENCY_CONTACT, "紧急联系人", "input", "0", "0"},
+            {FIELD_EMERGENCY_CONTACT_PHONE, "紧急联系电话", "input", "0", "0"},
+            {FIELD_INVOICE_TITLE, "发票抬头", "input", "0", "0"},
+            {FIELD_TAX_NO, "税号", "input", "0", "0"},
+            {FIELD_BANK_NAME, "银行名称", "input", "0", "0"},
+            {FIELD_BANK_ACCOUNT, "银行账号", "input", "0", "0"},
+            {FIELD_INVOICE_ADDRESS_PHONE, "发票地址电话", "input", "0", "0"},
+            {FIELD_CREDIT_STATUS, "挂账状态", "select", "1", "0"},
+            {FIELD_TOTAL_ARREARS_AMOUNT, "累计挂账金额(元)", "number", "1", "0"},
+            {FIELD_UNSETTLED_BILL_COUNT, "未结账单数", "number", "1", "0"},
+            {FIELD_APPROVAL_STATUS, "审批状态", "select", "1", "0"},
+            {FIELD_APPLICANT_ID, "申请人ID", "number", "1", "1"},
+            {FIELD_APPLICANT_NAME, "申请人", "input", "1", "0"},
+            {FIELD_APPROVER_ID, "审批人ID", "number", "1", "1"},
+            {FIELD_APPROVER_NAME, "审批人(老板)", "input", "1", "0"},
+            {FIELD_BUSINESS_KEY, "业务Key", "input", "1", "1"},
+            {FIELD_PROCESS_INSTANCE_ID, "流程实例ID", "input", "1", "1"},
+            {FIELD_BOSS_REMARK, "老板审核意见", "textarea", "0", "0"},
+            {FIELD_APPLICANT_MODIFY_REMARK, "申请人修改说明", "textarea", "0", "0"},
+            {FIELD_REJECT_REASON, "驳回原因", "textarea", "1", "0"},
+            {FIELD_REMARK, "备注", "textarea", "0", "0"},
+            {FIELD_CREATE_TIME, "创建时间", "datetime", "1", "1"},
+            {FIELD_UPDATE_TIME, "更新时间", "datetime", "1", "1"}
+    };
+
+    public static boolean supportsObject(String objectCode) {
+        return !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 BusinessTaskFormContextVO buildTaskFormContext(BusinessTaskFormContextQueryDTO query,
+                                                                 Map<String, Object> formRef,
+                                                                 List<Map<String, Object>> fieldPermissions,
+                                                                 Long recordId,
+                                                                 String summary,
+                                                                 boolean configured) {
+        BusinessTaskFormContextQueryDTO contextQuery = query == null ? new BusinessTaskFormContextQueryDTO() : query;
+        BusinessTaskFormContextVO vo = new BusinessTaskFormContextVO();
+        vo.setConfigured(configured);
+        vo.setFormType(TASK_FORM_TYPE_BUSINESS_CODE);
+        vo.setTaskId(contextQuery.getTaskId());
+        vo.setBusinessKey(contextQuery.getBusinessKey());
+        vo.setProcessInstanceId(contextQuery.getProcessInstanceId());
+        vo.setProcessDefKey(contextQuery.getProcessDefKey());
+        vo.setTaskDefKey(contextQuery.getTaskDefKey());
+        vo.setObjectCode(BUSINESS_TYPE);
+        vo.setBusinessObjectName(BUSINESS_OBJECT_NAME);
+        vo.setBusinessSummary(summary);
+        vo.setRecordId(recordId);
+        vo.setFormKey(FORM_KEY);
+        vo.setFormName(FORM_NAME);
+        vo.setProviderKey(PROVIDER_KEY);
+        vo.setFormUrl(FORM_URL);
+        vo.setFormRef(formRef == null ? new LinkedHashMap<>() : new LinkedHashMap<>(formRef));
+        vo.setFieldPermissions(fieldPermissions == null ? new ArrayList<>() : fieldPermissions);
+        vo.setFields(fields());
+        return vo;
+    }
+
+    public static CreditCustomerTaskSaveDTO toTaskSaveDTO(BusinessTaskFormSaveDTO dto) {
+        CreditCustomerTaskSaveDTO saveDTO = new CreditCustomerTaskSaveDTO();
+        if (dto == null) {
+            return saveDTO;
+        }
+        saveDTO.setId(dto.getRecordId());
+        saveDTO.setBusinessKey(dto.getBusinessKey());
+        saveDTO.setTaskId(dto.getTaskId());
+        saveDTO.setTaskDefKey(dto.getTaskDefKey());
+        Map<String, Object> data = dto.getData() == null ? new LinkedHashMap<>() : dto.getData();
+        saveDTO.setCustomerName(text(data.get(FIELD_CUSTOMER_NAME)));
+        saveDTO.setMobile(text(data.get(FIELD_MOBILE)));
+        saveDTO.setBirthday(text(data.get(FIELD_BIRTHDAY)));
+        saveDTO.setIdCard(text(data.get(FIELD_ID_CARD)));
+        saveDTO.setOccupation(text(data.get(FIELD_OCCUPATION)));
+        saveDTO.setCompanyName(text(data.get(FIELD_COMPANY_NAME)));
+        saveDTO.setAddress(text(data.get(FIELD_ADDRESS)));
+        saveDTO.setEmergencyContact(text(data.get(FIELD_EMERGENCY_CONTACT)));
+        saveDTO.setEmergencyContactPhone(text(data.get(FIELD_EMERGENCY_CONTACT_PHONE)));
+        saveDTO.setInvoiceTitle(text(data.get(FIELD_INVOICE_TITLE)));
+        saveDTO.setTaxNo(text(data.get(FIELD_TAX_NO)));
+        saveDTO.setBankName(text(data.get(FIELD_BANK_NAME)));
+        saveDTO.setBankAccount(text(data.get(FIELD_BANK_ACCOUNT)));
+        saveDTO.setInvoiceAddressPhone(text(data.get(FIELD_INVOICE_ADDRESS_PHONE)));
+        saveDTO.setRemark(text(data.get(FIELD_REMARK)));
+        saveDTO.setBossRemark(text(data.get(FIELD_BOSS_REMARK)));
+        saveDTO.setApplicantModifyRemark(text(data.get(FIELD_APPLICANT_MODIFY_REMARK)));
+        return saveDTO;
+    }
+
+    public static Map<String, Object> recordData(CreditCustomerVO detail) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        if (detail == null) {
+            return data;
+        }
+        data.put(FIELD_ID, detail.getId());
+        data.put(FIELD_CUSTOMER_NAME, detail.getCustomerName());
+        data.put(FIELD_MOBILE, detail.getMobile());
+        data.put(FIELD_BIRTHDAY, detail.getBirthday());
+        data.put(FIELD_ID_CARD, detail.getIdCard());
+        data.put(FIELD_OCCUPATION, detail.getOccupation());
+        data.put(FIELD_COMPANY_NAME, detail.getCompanyName());
+        data.put(FIELD_ADDRESS, detail.getAddress());
+        data.put(FIELD_EMERGENCY_CONTACT, detail.getEmergencyContact());
+        data.put(FIELD_EMERGENCY_CONTACT_PHONE, detail.getEmergencyContactPhone());
+        data.put(FIELD_INVOICE_TITLE, detail.getInvoiceTitle());
+        data.put(FIELD_TAX_NO, detail.getTaxNo());
+        data.put(FIELD_BANK_NAME, detail.getBankName());
+        data.put(FIELD_BANK_ACCOUNT, detail.getBankAccount());
+        data.put(FIELD_INVOICE_ADDRESS_PHONE, detail.getInvoiceAddressPhone());
+        data.put(FIELD_CREDIT_STATUS, detail.getCreditStatus());
+        data.put(FIELD_TOTAL_ARREARS_AMOUNT, detail.getTotalArrearsAmount());
+        data.put(FIELD_UNSETTLED_BILL_COUNT, detail.getUnsettledBillCount());
+        data.put(FIELD_APPROVAL_STATUS, detail.getApprovalStatus());
+        data.put(FIELD_APPLICANT_ID, detail.getApplicantId());
+        data.put(FIELD_APPLICANT_NAME, detail.getApplicantName());
+        data.put(FIELD_APPROVER_ID, detail.getApproverId());
+        data.put(FIELD_APPROVER_NAME, detail.getApproverName());
+        data.put(FIELD_BUSINESS_KEY, detail.getBusinessKey());
+        data.put(FIELD_PROCESS_INSTANCE_ID, detail.getProcessInstanceId());
+        data.put(FIELD_BOSS_REMARK, detail.getBossRemark());
+        data.put(FIELD_APPLICANT_MODIFY_REMARK, detail.getApplicantModifyRemark());
+        data.put(FIELD_REJECT_REASON, detail.getRejectReason());
+        data.put(FIELD_REMARK, detail.getRemark());
+        data.put(FIELD_CREATE_TIME, detail.getCreateTime());
+        data.put(FIELD_UPDATE_TIME, detail.getUpdateTime());
+        return data;
+    }
+
+    public static String buildSummary(CreditCustomerVO detail) {
+        if (detail == null) {
+            return null;
+        }
+        String customerName = StringUtils.hasText(detail.getCustomerName()) ? detail.getCustomerName() : "未知客户";
+        String mobile = StringUtils.hasText(detail.getMobile()) ? detail.getMobile() : "无手机号";
+        return "挂账客户 · " + customerName + " · " + mobile;
+    }
+
+    public static String buildSummary(Map<String, Object> recordData) {
+        if (recordData == null || recordData.isEmpty()) {
+            return null;
+        }
+        String customerName = text(recordData.get(FIELD_CUSTOMER_NAME));
+        String mobile = text(recordData.get(FIELD_MOBILE));
+        return "挂账客户 · "
+                + (StringUtils.hasText(customerName) ? customerName : "未知客户")
+                + " · "
+                + (StringUtils.hasText(mobile) ? mobile : "无手机号");
+    }
+
+    public static Long resolveRecordId(Long recordId, String businessKey) {
+        return recordId != null ? recordId : parseBusinessRecordId(businessKey);
+    }
+
+    public static Long parseBusinessRecordId(String businessKey) {
+        if (!StringUtils.hasText(businessKey)) {
+            return null;
+        }
+        int index = businessKey.lastIndexOf(':');
+        if (index < 0 || index == businessKey.length() - 1) {
+            return null;
+        }
+        try {
+            return Long.parseLong(businessKey.substring(index + 1));
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    public static String businessKey(Long id) {
+        return id == null ? null : BUSINESS_TYPE + ":" + id;
+    }
+
+    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 + "\""
+                + "}}";
+    }
+
+    public static boolean isApplicantModifyNode(String taskDefKey) {
+        return NODE_APPLICANT_MODIFY.equals(taskDefKey);
+    }
+
+    public static boolean isApprovalNode(String taskDefKey) {
+        return NODE_BOSS_APPROVE.equals(taskDefKey);
+    }
+
+    public static boolean isRejectAction(String approvalResult) {
+        return ACTION_REJECT.equalsIgnoreCase(approvalResult);
+    }
+
+    public static boolean isApproveAction(String approvalResult) {
+        return ACTION_APPROVE.equalsIgnoreCase(approvalResult);
+    }
+
+    public static String variableExpression(String variableName) {
+        return "${" + variableName + "}";
+    }
+
+    public static String rejectConditionExpression() {
+        return "${" + VAR_APPROVAL_RESULT + " == '" + ACTION_REJECT + "'}";
+    }
+
+    /**
+     * 节点字段权限默认值(仅作种子/兜底,部署后以 BPMN 节点配置为准)。
+     */
+    public static String nodeFieldPermissionsJson(String nodeKey) {
+        List<String[]> permissions;
+        if (NODE_BOSS_APPROVE.equals(nodeKey)) {
+            permissions = Collections.singletonList(new String[]{FIELD_BOSS_REMARK, "false"});
+        } else if (NODE_APPLICANT_MODIFY.equals(nodeKey)) {
+            permissions = Arrays.asList(
+                    new String[]{FIELD_CUSTOMER_NAME, "true"},
+                    new String[]{FIELD_MOBILE, "false"},
+                    new String[]{FIELD_COMPANY_NAME, "false"},
+                    new String[]{FIELD_ADDRESS, "false"},
+                    new String[]{FIELD_BIRTHDAY, "false"},
+                    new String[]{FIELD_ID_CARD, "false"},
+                    new String[]{FIELD_OCCUPATION, "false"},
+                    new String[]{FIELD_EMERGENCY_CONTACT, "false"},
+                    new String[]{FIELD_EMERGENCY_CONTACT_PHONE, "false"},
+                    new String[]{FIELD_INVOICE_TITLE, "false"},
+                    new String[]{FIELD_TAX_NO, "false"},
+                    new String[]{FIELD_BANK_NAME, "false"},
+                    new String[]{FIELD_BANK_ACCOUNT, "false"},
+                    new String[]{FIELD_INVOICE_ADDRESS_PHONE, "false"},
+                    new String[]{FIELD_REMARK, "false"},
+                    new String[]{FIELD_APPLICANT_MODIFY_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;
+    }
+
+    private static String text(Object value) {
+        return value == null ? null : String.valueOf(value);
+    }
+}

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

@@ -0,0 +1,20 @@
+package com.mdframe.forge.business.core.restaurant.vo;
+
+import lombok.Data;
+
+/**
+ * 挂账客户审批流程初始化结果。
+ */
+@Data
+public class CreditCustomerFlowInitVO {
+
+    private String modelKey;
+
+    private String modelId;
+
+    private String deploymentId;
+
+    private Integer status;
+
+    private String message;
+}

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

@@ -44,7 +44,7 @@ public class CreditCustomerVO {
 
     private String invoiceAddressPhone;
 
-    /** 挂账状态:NORMAL-正常挂账, SUSPENDED-暂停挂账, BLACKLIST-黑名单 */
+    /** 挂账状态:0-正常挂账, 1-暂停挂账, 2-黑名单 */
     private String creditStatus;
 
     /** 累计挂账金额(单位:元) */
@@ -53,8 +53,51 @@ public class CreditCustomerVO {
     /** 未结账单数 */
     private Integer unsettledBillCount;
 
+    /** 年龄 */
+    private Integer age;
+
+    /** 首次挂账时间(yyyy-MM-dd HH:mm:ss) */
+    private String firstCreditTime;
+
+    /** 最后消费时间(yyyy-MM-dd HH:mm:ss) */
+    private String lastConsumeTime;
+
     private String remark;
 
+    // ==================== 审批流程字段 ====================
+
+    /** 审批状态:DRAFT/IN_PROCESS/NEED_MODIFY/APPROVED/REJECTED/CANCELED */
+    private String approvalStatus;
+
+    /** 流程业务Key */
+    private String businessKey;
+
+    /** 流程实例ID */
+    private String processInstanceId;
+
+    /** 申请人ID */
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long applicantId;
+
+    /** 申请人姓名 */
+    private String applicantName;
+
+    /** 审批人(老板)用户ID */
+    @JsonSerialize(using = ToStringSerializer.class)
+    private Long approverId;
+
+    /** 审批人(老板)姓名 */
+    private String approverName;
+
+    /** 老板审核意见 */
+    private String bossRemark;
+
+    /** 申请人修改说明 */
+    private String applicantModifyRemark;
+
+    /** 驳回/取消原因 */
+    private String rejectReason;
+
     private LocalDateTime createTime;
 
     private LocalDateTime updateTime;

Fichier diff supprimé car celui-ci est trop grand
+ 84 - 0
forge-server/forge-business/forge-restaurant/src/main/resources/flow/credit_customer_approval.bpmn20.xml


+ 77 - 14
forge-server/forge-business/forge-restaurant/src/main/resources/mapper/business/restaurant/CreditCustomerMapper.xml

@@ -2,16 +2,32 @@
 <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 <mapper namespace="com.mdframe.forge.business.core.restaurant.mapper.CreditCustomerMapper">
 
+    <sql id="VoColumns">
+        c.id, c.customer_name, c.mobile, c.birthday, c.id_card, c.occupation,
+        c.company_name, c.address, c.emergency_contact, c.emergency_contact_phone,
+        c.invoice_title, c.tax_no, c.bank_name, c.bank_account,
+        c.invoice_address_phone, c.credit_status, c.total_arrears_amount,
+        c.unsettled_bill_count, c.age, c.first_credit_time, c.last_consume_time,
+        c.remark,
+        c.approval_status, c.business_key, c.process_instance_id,
+        c.applicant_id, c.applicant_name, c.approver_id,
+        COALESCE(boss_user.real_name, boss_user.username) AS approver_name,
+        c.boss_remark, c.applicant_modify_remark, c.reject_reason,
+        c.create_time, c.update_time,
+        su.username AS create_name
+    </sql>
+
+    <sql id="VoJoins">
+        LEFT JOIN sys_user su ON su.id = c.create_by
+        LEFT JOIN sys_user boss_user ON boss_user.id = c.approver_id AND boss_user.del_flag = 0
+    </sql>
+
     <!-- 分页查询挂账客户列表 -->
     <select id="selectPage" resultType="com.mdframe.forge.business.core.restaurant.vo.CreditCustomerVO">
-        SELECT c.id, c.customer_name, c.mobile, c.birthday, c.id_card, c.occupation,
-               c.company_name, c.address, c.emergency_contact, c.emergency_contact_phone,
-               c.invoice_title, c.tax_no, c.bank_name, c.bank_account,
-               c.invoice_address_phone, c.credit_status, c.total_arrears_amount,
-               c.unsettled_bill_count, c.remark, c.create_time, c.update_time,
-               su.username AS create_name
+        SELECT
+        <include refid="VoColumns" />
         FROM restaurant_credit_customer c
-        LEFT JOIN sys_user su ON su.id = c.create_by
+        <include refid="VoJoins" />
         WHERE c.tenant_id = #{tenantId}
           AND c.del_flag = 0
         <if test="customerName != null and customerName != ''">
@@ -23,24 +39,71 @@
         <if test="creditStatus != null and creditStatus != ''">
             AND c.credit_status = #{creditStatus}
         </if>
+        <if test="approvalStatus != null and approvalStatus != ''">
+            AND c.approval_status = #{approvalStatus}
+        </if>
         ORDER BY c.create_time DESC
     </select>
 
     <!-- 查询挂账客户详情 -->
     <select id="selectDetail" resultType="com.mdframe.forge.business.core.restaurant.vo.CreditCustomerVO">
-        SELECT c.id, c.customer_name, c.mobile, c.birthday, c.id_card, c.occupation,
-               c.company_name, c.address, c.emergency_contact, c.emergency_contact_phone,
-               c.invoice_title, c.tax_no, c.bank_name, c.bank_account,
-               c.invoice_address_phone, c.credit_status, c.total_arrears_amount,
-               c.unsettled_bill_count, c.remark, c.create_time, c.update_time,
-               su.username AS create_name
+        SELECT
+        <include refid="VoColumns" />
         FROM restaurant_credit_customer c
-        LEFT JOIN sys_user su ON su.id = c.create_by
+        <include refid="VoJoins" />
         WHERE c.tenant_id = #{tenantId}
           AND c.id = #{id}
           AND c.del_flag = 0
     </select>
 
+    <!-- 批量查询挂账客户详情(待办/已办列表摘要用) -->
+    <select id="selectDetailsByIds" resultType="com.mdframe.forge.business.core.restaurant.vo.CreditCustomerVO">
+        SELECT
+        <include refid="VoColumns" />
+        FROM restaurant_credit_customer c
+        <include refid="VoJoins" />
+        WHERE c.tenant_id = #{tenantId}
+          AND c.del_flag = 0
+          AND c.id IN
+        <foreach collection="ids" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+        ORDER BY c.create_time DESC
+    </select>
+
+    <!-- 按流程业务Key查询实体(流程回调用) -->
+    <select id="selectByBusinessKey" resultType="com.mdframe.forge.business.core.restaurant.domain.CreditCustomer">
+        SELECT id, tenant_id, customer_name, mobile, birthday, id_card, occupation,
+               company_name, address, emergency_contact, emergency_contact_phone,
+               invoice_title, tax_no, bank_name, bank_account, invoice_address_phone,
+               credit_status, total_arrears_amount, unsettled_bill_count,
+               age, first_credit_time, last_consume_time, remark,
+               approval_status, business_key, process_instance_id,
+               applicant_id, applicant_name, approver_id,
+               boss_remark, applicant_modify_remark, reject_reason,
+               del_flag, create_by, create_time, create_dept, update_by, update_time
+        FROM restaurant_credit_customer
+        WHERE tenant_id = #{tenantId}
+          AND business_key = #{businessKey}
+          AND del_flag = 0
+        ORDER BY update_time DESC, id DESC
+        LIMIT 1
+    </select>
+
+    <!-- 批量查询活动待办节点(状态对账修复用) -->
+    <select id="selectActiveTaskDefKeysByBusinessKeys" resultType="java.util.Map">
+        SELECT business_key AS businessKey,
+               task_def_key AS taskDefKey
+        FROM sys_flow_task
+        WHERE tenant_id = #{tenantId}
+          AND status IN (0, 1)
+          AND business_key IN
+        <foreach collection="businessKeys" item="businessKey" open="(" separator="," close=")">
+            #{businessKey}
+        </foreach>
+        ORDER BY create_time DESC, id DESC
+    </select>
+
     <!-- 检查手机号重复(排除自身) -->
     <select id="checkMobileExists" resultType="int">
         SELECT COUNT(1)

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

@@ -22,6 +22,12 @@
         <if test="settleType != null and settleType != ''">
             AND o.settle_type = #{settleType}
         </if>
+        <if test="beginTime != null and beginTime != ''">
+            AND o.create_time &gt;= #{beginTime}
+        </if>
+        <if test="endTime != null and endTime != ''">
+            AND o.create_time &lt;= #{endTime}
+        </if>
         ORDER BY o.create_time DESC
     </select>
 
@@ -53,4 +59,33 @@
         LIMIT 1
     </select>
 
+    <!-- 导出订单列表(无分页,支持包房/状态/结账方式/下单时间范围过滤) -->
+    <select id="selectExportList" resultType="com.mdframe.forge.business.core.restaurant.vo.DishOrderVO">
+        SELECT o.id, o.order_no, o.room_id, o.room_no, o.room_name, o.order_status,
+               o.total_amount, o.settle_type, o.discount_rate, o.discount_amount,
+               o.payable_amount, o.settle_time, o.taste_remark, o.special_requirements,
+               o.remark, o.create_time, o.update_time,
+               su.username AS create_name
+        FROM restaurant_dish_order o
+        LEFT JOIN sys_user su ON su.id = o.create_by
+        WHERE o.tenant_id = #{tenantId}
+          AND o.del_flag = 0
+        <if test="roomId != null">
+            AND o.room_id = #{roomId}
+        </if>
+        <if test="orderStatus != null and orderStatus != ''">
+            AND o.order_status = #{orderStatus}
+        </if>
+        <if test="settleType != null and settleType != ''">
+            AND o.settle_type = #{settleType}
+        </if>
+        <if test="beginTime != null and beginTime != ''">
+            AND o.create_time &gt;= #{beginTime}
+        </if>
+        <if test="endTime != null and endTime != ''">
+            AND o.create_time &lt;= #{endTime}
+        </if>
+        ORDER BY o.create_time DESC
+    </select>
+
 </mapper>

Fichier diff supprimé car celui-ci est trop grand
+ 10 - 8
forge-server/forge-business/forge-restaurant/餐饮模块迁移分析文档.md


+ 2 - 1
forge-server/forge-framework/forge-plugin-parent/forge-plugin-capability-parent/forge-plugin-capability-high-risk-approval/src/main/resources/mapper/CapabilityApprovalMapper.xml

@@ -17,11 +17,12 @@
           AND del_flag = 0
         LIMIT 1
     </select>
+    <!-- 禁止 LIMIT 1 FOR UPDATE 连写:JSqlParser 重写会重排为 FOR UPDATE LIMIT 1(MySQL 语法错误);主键 id 唯一,无需 LIMIT -->
     <select id="selectForUpdate" resultType="com.mdframe.forge.plugin.capability.highrisk.domain.AiCapabilityApproval">
         SELECT <include refid="Columns"/>
         FROM ai_capability_approval
         WHERE tenant_id = #{tenantId} AND id = #{id} AND del_flag = 0
-        LIMIT 1 FOR UPDATE
+        FOR UPDATE
     </select>
     <select id="selectTenantById" resultType="com.mdframe.forge.plugin.capability.highrisk.domain.AiCapabilityApproval">
         SELECT <include refid="Columns"/>

+ 4 - 2
forge-server/forge-framework/forge-plugin-parent/forge-plugin-flow/src/main/java/com/mdframe/forge/starter/flow/service/impl/FlowModelServiceImpl.java

@@ -467,7 +467,8 @@ public class FlowModelServiceImpl extends ServiceImpl<FlowModelMapper, FlowModel
     @Override
     public FlowModel getModelByKey(String modelKey) {
         LambdaQueryWrapper<FlowModel> wrapper = new LambdaQueryWrapper<>();
-        wrapper.eq(FlowModel::getModelKey, modelKey);
+        wrapper.eq(FlowModel::getModelKey, modelKey)
+                .eq(FlowModel::getDelFlag, 0);
         return getOne(wrapper);
     }
 
@@ -590,7 +591,8 @@ public class FlowModelServiceImpl extends ServiceImpl<FlowModelMapper, FlowModel
     @Override
     public boolean checkModelKeyExists(String modelKey, String excludeId) {
         LambdaQueryWrapper<FlowModel> wrapper = new LambdaQueryWrapper<>();
-        wrapper.eq(FlowModel::getModelKey, modelKey);
+        wrapper.eq(FlowModel::getModelKey, modelKey)
+                .eq(FlowModel::getDelFlag, 0);
         if (excludeId != null && !excludeId.isEmpty()) {
             wrapper.ne(FlowModel::getId, excludeId);
         }

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

@@ -7,16 +7,16 @@
         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
         FROM sys_flow_task t
-        LEFT JOIN sys_flow_model m ON t.process_def_key = m.model_key
+        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
         WHERE t.task_id = #{taskId}
     </select>
 
+    <!-- 禁止在本语句加 LIMIT:租户拦截器会用 JSqlParser 重写 SQL,LIMIT 1 FOR UPDATE 会被重排为 FOR UPDATE LIMIT 1(MySQL 语法错误);task_id 唯一,无需 LIMIT -->
     <select id="selectByTaskIdForUpdate" resultType="com.mdframe.forge.starter.flow.entity.FlowTask">
         SELECT t.*
         FROM sys_flow_task t
         WHERE t.task_id = #{taskId}
-        LIMIT 1
         FOR UPDATE
     </select>
 
@@ -25,7 +25,7 @@
         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
         FROM sys_flow_task t
-        LEFT JOIN sys_flow_model m ON t.process_def_key = m.model_key
+        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
         WHERE t.task_id = #{taskId} OR t.id = #{taskId}
         ORDER BY CASE WHEN t.task_id = #{taskId} THEN 0 ELSE 1 END
@@ -37,7 +37,7 @@
         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
         FROM sys_flow_task t
-        LEFT JOIN sys_flow_model m ON t.process_def_key = m.model_key
+        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
         <where>
             AND (t.assignee = #{userId}
@@ -75,7 +75,7 @@
         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
         FROM sys_flow_task t
-        LEFT JOIN sys_flow_model m ON t.process_def_key = m.model_key
+        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
         <where>
             AND t.assignee = #{userId}
@@ -107,7 +107,7 @@
         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
         FROM sys_flow_task t
-        LEFT JOIN sys_flow_model m ON t.process_def_key = m.model_key
+        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
         <where>
             AND t.start_user_id = #{userId}
@@ -138,7 +138,7 @@
         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
         FROM sys_flow_task t
-        LEFT JOIN sys_flow_model m ON t.process_def_key = m.model_key
+        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
         WHERE t.due_date IS NOT NULL
           AND t.due_date &lt;= #{now}