wlzboy
2026-02-05 57e98ac3f59e9ca12d3fdbc6f89c9c0b1f86be4d
feat:增加发票申请
13个文件已修改
26个文件已添加
5338 ■■■■■ 已修改文件
app/api/invoice.js 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
app/pages.json 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
app/pages/mine/index.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
app/pages/mine/invoice/apply.vue 584 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
app/pages/mine/invoice/index.vue 406 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
app/pagesTask/detail.vue 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysInvoiceController.java 234 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/task/SysTaskController.java 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/application.yml 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-quartz/src/main/java/com/ruoyi/quartz/task/SysInvoiceSyncTask.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-quartz/src/main/java/com/ruoyi/quartz/task/SysInvoiceTask.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/domain/SysInvoice.java 380 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/domain/SysTaskEmergency.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/mapper/LegacyInvoiceMapper.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysInvoiceMapper.java 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/ISysInvoiceService.java 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PaymentSyncServiceImpl.java 6 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysInvoiceServiceImpl.java 243 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/LegacyInvoiceMapper.xml 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/SysInvoiceMapper.xml 302 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/VehicleGpsSegmentMileageMapper.xml 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/api/system/dept.js 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/api/system/invoice.js 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/api/task.js 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/router/index.js 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/views/system/invoice/apply.vue 261 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/views/system/invoice/audit.vue 311 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/views/system/invoice/detail.vue 279 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/views/system/invoice/index.vue 265 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/views/task/general/detail.vue 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-ui/src/views/task/general/index.vue 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/InvoiceData.sql 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/invoice_menu.sql 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/invoice_sync_from_legacy.sql 124 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/invoice_sync_job.sql 248 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/invoice_sys.sql 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/partition_quick_setup.sql 260 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/partition_vehicle_gps_segment_mileage.sql 304 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/分区优化方案说明.md 293 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
app/api/invoice.js
New file
@@ -0,0 +1,36 @@
import request from '@/utils/request'
// æŸ¥è¯¢æˆ‘的发票申请列表
export function listMyInvoice(query) {
  return request({
    url: '/system/invoice/myList',
    method: 'get',
    params: query
  })
}
// æäº¤å‘票申请
export function addInvoice(data) {
  return request({
    url: '/system/invoice',
    method: 'post',
    data: data
  })
}
// èŽ·å–å¯å¼€ç¥¨çš„ä»»åŠ¡åˆ—è¡¨
export function listSelectableTasks(params) {
  return request({
    url: '/system/invoice/selectableTasks',
    method: 'get',
    params: params
  })
}
// æ£€æŸ¥ä»»åŠ¡æ˜¯å¦å·²ç”³è¯·å‘ç¥¨
export function checkTaskInvoice(taskId) {
  return request({
    url: `/system/invoice/checkTaskInvoice/${taskId}`,
    method: 'get'
  })
}
app/pages.json
@@ -110,6 +110,16 @@
    "style": {
      "navigationBarTitleText": "消息中心"
    }
  }, {
    "path": "pages/mine/invoice/index",
    "style": {
      "navigationBarTitleText": "我的发票"
    }
  }, {
    "path": "pages/mine/invoice/apply",
    "style": {
      "navigationBarTitleText": "申请发票"
    }
  }],
  "subPackages": [{
    "root": "pagesTask",
app/pages/mine/index.vue
@@ -58,6 +58,14 @@
            <view>{{ boundVehicle && boundVehicle !== '未绑定' ? '更换车辆' : '绑定车辆' }}</view>
          </view>
        </view>
        <!-- æˆ‘的发票 -->
        <view class="list-cell list-cell-arrow" @click="handleToInvoice">
          <view class="menu-item-box">
            <view class="iconfont icon-edit menu-icon"></view>
            <view>我的发票</view>
          </view>
        </view>
        
        <!-- é€€å‡ºç™»å½• -->
        <view class="list-cell list-cell-arrow" @click="handleLogout">
@@ -144,6 +152,10 @@
        this.$tab.navigateTo('/pages/bind-vehicle')
      },
      
      handleToInvoice() {
        this.$tab.navigateTo('/pages/mine/invoice/index')
      },
      handleToInfo() {
        this.$tab.navigateTo('/pages/mine/info/index')
      },
app/pages/mine/invoice/apply.vue
New file
@@ -0,0 +1,584 @@
<template>
  <view class="invoice-apply-container bg-white">
    <form>
      <view class="cu-form-group margin-top">
        <view class="title required">选择任务</view>
        <view class="combo-box-wrapper">
          <input
            class="combo-input"
            placeholder="请输入服务单号或任务编号搜索"
            v-model="searchKeyword"
            @input="handleInput"
            @focus="handleFocus"
            @blur="handleBlur"
          />
          <text class="cuIcon-search search-icon"></text>
          <!-- ä¸‹æ‹‰åˆ—表 -->
          <view class="dropdown-list" v-if="showDropdown && filteredTaskList.length > 0">
            <view class="dropdown-item"
              v-for="task in filteredTaskList"
              :key="task.taskId"
              @click="selectTask(task)"
            >
              <view class="item-main">
                <text class="service-code">{{ task.serviceCode || task.taskCode }}</text>
                <text class="task-code">{{ task.taskCode }}</text>
              </view>
              <view class="item-sub">
                <text class="text-gray margin-left-sm">{{ task.completionTime }}</text>
              </view>
            </view>
          </view>
          <!-- æ— ç»“果提示 -->
          <view class="dropdown-list" v-if="showDropdown && searchKeyword && filteredTaskList.length === 0 && !loading">
            <view class="empty-result">
              <text class="text-gray">未找到匹配的任务</text>
            </view>
          </view>
          <!-- åŠ è½½æç¤º -->
          <view class="dropdown-list" v-if="showDropdown && loading">
            <view class="loading-result">
              <text class="cuIcon-loading2 cu-spin"></text>
              <text class="text-gray margin-left-sm">搜索中...</text>
            </view>
          </view>
        </view>
      </view>
      <!-- å·²é€‰ä»»åŠ¡æ˜¾ç¤º -->
      <view class="selected-task" v-if="form.serviceOrderId">
        <view class="label">已选任务</view>
        <view class="task-card">
          <view class="card-header">
            <text class="service-code">{{ selectedTask.serviceCode || selectedTask.taskCode }}</text>
            <text class="cuIcon-close remove-btn" @click="clearSelection"></text>
          </view>
          <view class="task-info-detail">
            <view class="info-row">
              <text class="info-label">服务单号:</text>
              <text class="info-value">{{ selectedTask.legacyServiceOrderId }}</text>
            </view>
            <view class="info-row">
              <text class="info-label">出发地:</text>
              <text class="info-value">{{ selectedTask.departure || '-' }}</text>
            </view>
            <view class="info-row">
              <text class="info-label">目的地:</text>
              <text class="info-value">{{ selectedTask.destination || '-' }}</text>
            </view>
            <view class="info-row">
              <text class="info-label">完成时间:</text>
              <text class="info-value">{{ selectedTask.completionTime }}</text>
            </view>
            <view class="info-row" v-if="selectedTask.transferPrice">
              <text class="info-label">任务金额:</text>
              <text class="info-value text-price">ï¿¥{{ selectedTask.transferPrice }}</text>
            </view>
          </view>
        </view>
      </view>
      <view class="cu-form-group">
        <view class="title">开票类型</view>
        <radio-group class="flex" @change="handleTypeChange">
          <view class="margin-right-sm">
            <radio value="1" :checked="form.invoiceType == 1"></radio><text class="margin-left-xs">个人</text>
          </view>
          <view>
            <radio value="2" :checked="form.invoiceType == 2"></radio><text class="margin-left-xs">企业</text>
          </view>
        </radio-group>
      </view>
      <view class="cu-form-group">
        <view class="title required">发票抬头</view>
        <input placeholder="请输入发票抬头" v-model="form.invoiceName" />
      </view>
      <view class="cu-form-group">
        <view class="title required">发票金额</view>
        <input
          type="digit"
          placeholder="请输入金额"
          v-model="form.invoiceMoney"
          @blur="validateMoney"
        />
      </view>
      <view class="money-hint" v-if="selectedTask && selectedTask.transferPrice">
        <text class="text-gray text-sm">最大可开票金额: ï¿¥{{ selectedTask.transferPrice }}</text>
      </view>
      <view class="cu-form-group align-start">
        <view class="title">发票备注</view>
        <textarea maxlength="-1" v-model="form.invoiceRemarks" placeholder="请输入备注信息"></textarea>
      </view>
      <block v-if="form.invoiceType == 2">
        <view class="cu-form-group">
          <view class="title">注册地址</view>
          <input placeholder="请输入企业注册地址" v-model="form.companyAddress" />
        </view>
        <view class="cu-form-group">
          <view class="title">开户银行</view>
          <input placeholder="请输入企业开户银行" v-model="form.companyBank" />
        </view>
        <view class="cu-form-group">
          <view class="title">银行帐号</view>
          <input placeholder="请输入企业银行帐号" v-model="form.companyBankNo" />
        </view>
      </block>
      <view class="cu-form-group margin-top-sm">
        <view class="title">邮编</view>
        <input placeholder="请输入邮编" v-model="form.zipCode" />
      </view>
      <view class="cu-form-group">
        <view class="title">邮寄地址</view>
        <input placeholder="请输入邮寄地址" v-model="form.mailAddress" />
      </view>
      <view class="cu-form-group">
        <view class="title">联系人</view>
        <input placeholder="请输入联系人" v-model="form.contactName" />
      </view>
      <view class="cu-form-group">
        <view class="title">联系电话</view>
        <input placeholder="请输入联系电话" v-model="form.contactPhone" />
      </view>
      <view class="padding flex flex-direction">
        <button class="cu-btn bg-blue lg" @click="submit">提交申请</button>
      </view>
    </form>
  </view>
</template>
<script>
import { addInvoice, listSelectableTasks } from "@/api/invoice"
export default {
  data() {
    return {
      searchKeyword: '',
      filteredTaskList: [],
      selectedTask: null,
      showDropdown: false,
      loading: false,
      searchTimer: null,
      maxInvoiceMoney: null, // æœ€å¤§å¯å¼€ç¥¨é‡‘额
      form: {
        serviceOrderId: null,
        legacyServiceOrderId: null,
        invoiceType: 1,
        invoiceName: '',
        invoiceMoney: '',
        invoiceRemarks: '',
        companyAddress: '',
        companyBank: '',
        companyBankNo: '',
        zipCode: '',
        mailAddress: '',
        contactName: '',
        contactPhone: ''
      },
      // ä»Žä»»åŠ¡è¯¦æƒ…é¡µä¼ å…¥çš„ä»»åŠ¡ä¿¡æ¯
      taskInfoFromDetail: null
    }
  },
  onLoad(options) {
    // æ£€æŸ¥æ˜¯å¦ä»Žä»»åŠ¡è¯¦æƒ…é¡µä¼ å…¥äº†ä»»åŠ¡ä¿¡æ¯
    if (options && options.taskInfo) {
      try {
        const taskInfo = JSON.parse(decodeURIComponent(options.taskInfo));
        this.taskInfoFromDetail = taskInfo;
        this.preFillTaskInfo();
      } catch (e) {
        console.error('解析任务信息失败:', e);
      }
    }
    // ä¸å†è‡ªåŠ¨åŠ è½½ä»»åŠ¡åˆ—è¡¨
  },
  methods: {
    // é¢„填充任务信息
    preFillTaskInfo() {
      if (!this.taskInfoFromDetail) return;
      const taskInfo = this.taskInfoFromDetail;
      // å¡«å……表单
      this.form.serviceOrderId = taskInfo.taskId;
      // åŒæ—¶è®¾ç½®æ—§ç³»ç»ŸæœåŠ¡å•ID
      if (taskInfo.legacyServiceOrderId) {
        this.form.legacyServiceOrderId = taskInfo.legacyServiceOrderId;
      }
      this.selectedTask = { ...taskInfo };
      // è®¾ç½®æœ€å¤§å¯å¼€ç¥¨é‡‘额
      if (taskInfo.transferPrice) {
        this.maxInvoiceMoney = parseFloat(taskInfo.transferPrice);
        // è‡ªåŠ¨å¡«å…¥ä»»åŠ¡é‡‘é¢
        this.form.invoiceMoney = this.maxInvoiceMoney.toString();
      }
      console.log('任务信息已预填充:', taskInfo);
    },
    handleInput(e) {
      const keyword = e.detail.value.trim()
      // æ¸…除之前的定时器
      if (this.searchTimer) {
        clearTimeout(this.searchTimer)
      }
      if (!keyword) {
        this.showDropdown = false
        this.filteredTaskList = []
        return
      }
      // é˜²æŠ–:延迟300ms搜索
      this.searchTimer = setTimeout(() => {
        this.searchTasks(keyword)
      }, 300)
    },
    handleFocus() {
      // å¦‚果有输入内容,显示下拉列表
      if (this.searchKeyword && this.searchKeyword.trim()) {
        this.showDropdown = true
      }
    },
    handleBlur() {
      // å»¶è¿Ÿå…³é—­ä¸‹æ‹‰åˆ—表,确保点击事件能触发
      setTimeout(() => {
        this.showDropdown = false
      }, 200)
    },
    searchTasks(keyword) {
      this.loading = true
      this.showDropdown = true
      listSelectableTasks({
        searchKeyword: keyword
      }).then(res => {
        this.filteredTaskList = this.formatTaskList(res.data)
      }).catch(err => {
        this.$modal.msgError('搜索失败,请重试')
        this.filteredTaskList = []
      }).finally(() => {
        this.loading = false
      })
    },
    formatTaskList(data) {
      return data.map(item => {
        // å®Œæ•´æ˜¾ç¤º yyyy-MM-dd HH:mm
        const time = item.completionTime ? item.completionTime.substring(0, 16) : '';
        // å…¼å®¹ä¸¤ç§å­—段名:transferPrice å’Œ transfer_price
        const transferPrice = item.transferPrice !== undefined ? item.transferPrice : item.transfer_price;
        return {
          taskId: item.taskId,
          taskCode: item.taskCode,
          serviceCode: item.serviceCode,
          legacyServiceOrderId: item.legacyServiceOrderId || item.taskCode,
          completionTime: time,
          departure: item.departure,
          destination: item.destination,
          transferPrice: transferPrice
        }
      })
    },
    handleSearch() {
      if (!this.searchKeyword || this.searchKeyword.trim() === '') {
        // æœªè¾“入时显示所有
        this.getCompletedTasks()
      } else {
        // è°ƒç”¨åŽç«¯æœç´¢æŽ¥å£
        listSelectableTasks({
          searchKeyword: this.searchKeyword.trim()
        }).then(res => {
          this.filteredTaskList = this.formatTaskList(res.data)
        })
      }
    },
    selectTask(task) {
      console.log('选中的任务:', task)
      console.log('任务金额:', task.transferPrice)
      this.selectedTask = task
      // serviceOrderId å­˜å‚¨æ—§ç³»ç»ŸæœåŠ¡å•ID
      this.form.serviceOrderId = task.taskId;
      this.form.legacyServiceOrderId = task.legacyServiceOrderId
      // é€‰ä¸­åŽæ˜¾ç¤ºæœåŠ¡å•å·ï¼Œå…³é—­ä¸‹æ‹‰
      this.searchKeyword = task.serviceCode || task.taskCode
      this.showDropdown = false
      // è‡ªåŠ¨å¸¦å…¥ä»»åŠ¡é‡‘é¢åˆ°å‘ç¥¨é‡‘é¢
      if (task.transferPrice !== null && task.transferPrice !== undefined) {
        // ç¡®ä¿é‡‘额是数字类型
        const price = Number(task.transferPrice)
        if (!isNaN(price) && price > 0) {
          this.form.invoiceMoney = price.toString()
          this.maxInvoiceMoney = price
          console.log('已设置发票金额:', this.form.invoiceMoney)
        } else {
          console.log('任务金额无效:', task.transferPrice)
        }
      } else {
        console.log('任务金额为空')
      }
    },
    clearSelection() {
      this.selectedTask = null
      this.form.serviceOrderId = null
      this.form.legacyServiceOrderId = null
      this.form.invoiceMoney = ''
      this.searchKeyword = ''
      this.filteredTaskList = []
      this.maxInvoiceMoney = null
    },
    validateMoney() {
      if (!this.form.invoiceMoney) return
      const money = parseFloat(this.form.invoiceMoney)
      if (isNaN(money) || money <= 0) {
        this.$modal.msgError('请输入有效的金额')
        this.form.invoiceMoney = ''
        return
      }
      // æ ¡éªŒæ˜¯å¦è¶…过任务金额
      if (this.maxInvoiceMoney && money > this.maxInvoiceMoney) {
        this.$modal.msgError(`发票金额不能超过任务金额¥${this.maxInvoiceMoney}`)
        this.form.invoiceMoney = this.maxInvoiceMoney.toString()
      }
    },
    handleTypeChange(e) {
      this.form.invoiceType = e.detail.value
    },
    submit() {
      if (!this.form.serviceOrderId) {
        this.$modal.msgError("请选择任务")
        return
      }
      if (!this.form.invoiceName) {
        this.$modal.msgError("请输入发票抬头")
        return
      }
      if (!this.form.invoiceMoney) {
        this.$modal.msgError("请输入发票金额")
        return
      }
      // å†æ¬¡æ ¡éªŒé‡‘额
      const money = parseFloat(this.form.invoiceMoney)
      if (isNaN(money) || money <= 0) {
        this.$modal.msgError('请输入有效的金额')
        return
      }
      if (this.maxInvoiceMoney && money > this.maxInvoiceMoney) {
        this.$modal.msgError(`发票金额不能超过任务金额¥${this.maxInvoiceMoney}`)
        return
      }
      addInvoice(this.form).then(res => {
        this.$modal.msgSuccess("提交成功")
        setTimeout(() => {
          // è·³è½¬åˆ°å‘票列表页面
          uni.redirectTo({
            url: '/pages/mine/invoice/index'
          })
        }, 1500)
      })
    }
  }
}
</script>
<style lang="scss">
.invoice-apply-container {
  min-height: 100vh;
  padding-bottom: 50rpx;
  .cu-form-group .title {
    min-width: calc(4em + 15px);
    &.required::before {
      content: '*';
      color: #f56c6c;
      margin-right: 4rpx;
      font-weight: bold;
    }
  }
  // ComboBox样式
  .combo-box-wrapper {
    position: relative;
    flex: 1;
    .combo-input {
      width: 100%;
      padding-right: 60rpx;
    }
    .search-icon {
      position: absolute;
      right: 20rpx;
      top: 50%;
      transform: translateY(-50%);
      color: #8799a3;
      font-size: 36rpx;
    }
    .dropdown-list {
      position: absolute;
      top: 100%;
      left: 0;
      right: 0;
      max-height: 500rpx;
      overflow-y: auto;
      background: white;
      border: 2rpx solid #e1e1e1;
      border-radius: 8rpx;
      margin-top: 10rpx;
      box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.1);
      z-index: 999;
      .dropdown-item {
        padding: 20rpx;
        border-bottom: 1rpx solid #f0f0f0;
        transition: background 0.2s;
        &:active {
          background: #f5f5f5;
        }
        &:last-child {
          border-bottom: none;
        }
        .item-main {
          display: flex;
          justify-content: space-between;
          align-items: center;
          margin-bottom: 8rpx;
          .service-code {
            font-size: 30rpx;
            font-weight: bold;
            color: #1976d2;
          }
          .task-code {
            font-size: 22rpx;
            color: #999;
          }
        }
        .item-sub {
          display: flex;
          font-size: 24rpx;
          color: #666;
        }
      }
      .empty-result, .loading-result {
        padding: 40rpx 20rpx;
        text-align: center;
        color: #999;
      }
      .loading-result {
        display: flex;
        justify-content: center;
        align-items: center;
      }
    }
  }
  .selected-task {
    margin: 20rpx 30rpx;
    .label {
      font-size: 28rpx;
      color: #666;
      margin-bottom: 10rpx;
    }
    .task-card {
      padding: 20rpx;
      background: #e8f5e9;
      border-radius: 8rpx;
      border: 2rpx solid #4caf50;
      .card-header {
        display: flex;
        justify-content: space-between;
        align-items: center;
        margin-bottom: 20rpx;
        padding-bottom: 16rpx;
        border-bottom: 1rpx solid #c8e6c9;
      }
      .service-code {
        font-size: 32rpx;
        font-weight: bold;
        color: #2e7d32;
      }
      .remove-btn {
        font-size: 40rpx;
        color: #999;
        padding: 10rpx;
      }
      .task-info-detail {
        .info-row {
          display: flex;
          margin-bottom: 12rpx;
          line-height: 1.6;
          &:last-child {
            margin-bottom: 0;
          }
          .info-label {
            font-size: 26rpx;
            color: #666;
            min-width: 140rpx;
            flex-shrink: 0;
          }
          .info-value {
            font-size: 26rpx;
            color: #333;
            flex: 1;
            word-break: break-all;
            &.text-price {
              color: #f56c6c;
              font-weight: bold;
            }
          }
        }
      }
    }
  }
  .money-hint {
    margin: -10rpx 30rpx 20rpx 30rpx;
    padding-left: calc(4em + 15px);
  }
}
</style>
app/pages/mine/invoice/index.vue
New file
@@ -0,0 +1,406 @@
<template>
  <view class="invoice-list-container">
    <view class="add-btn-box">
      <button class="cu-btn block bg-blue lg" @click="handleApply">申请发票</button>
    </view>
    <!-- æœç´¢æ¡† -->
    <view class="search-box">
      <view class="search-input-wrapper">
        <input
          class="search-input"
          placeholder="请输入服务单号搜索"
          v-model="searchKeyword"
          @confirm="handleSearch"
        />
        <text class="cuIcon-search search-icon" @click="handleSearch"></text>
        <text v-if="searchKeyword" class="cuIcon-close clear-icon" @click="clearSearch"></text>
      </view>
    </view>
    <!-- çŠ¶æ€Tab -->
    <view class="tabs-wrapper">
      <view
        class="tab-item"
        :class="currentTab === null ? 'active' : ''"
        @click="switchTab(null)"
      >
        <text>全部</text>
      </view>
      <view
        class="tab-item"
        :class="currentTab === 0 ? 'active' : ''"
        @click="switchTab(0)"
      >
        <text>待审核</text>
      </view>
      <view
        class="tab-item"
        :class="currentTab === 1 ? 'active' : ''"
        @click="switchTab(1)"
      >
        <text>已通过</text>
      </view>
      <view
        class="tab-item"
        :class="currentTab === 2 ? 'active' : ''"
        @click="switchTab(2)"
      >
        <text>已驳回</text>
      </view>
    </view>
    <scroll-view scroll-y="true" class="list-scroll" @scrolltolower="loadMore">
      <!-- ç©ºåˆ—表提示 -->
      <view v-if="list.length === 0" class="empty-box">
        <text class="text-gray">暂无发票申请记录</text>
      </view>
      <!-- å‘票列表 -->
      <view v-for="(item, index) in list" :key="index" class="invoice-item bg-white margin-sm padding-sm radius shadow">
        <view class="flex justify-between border-bottom padding-bottom-xs margin-bottom-xs">
          <text class="text-bold">服务单号: {{ item.serviceCode || item.legacyServiceOrderId || '未知' }}</text>
          <text :class="item.status === 0 ? 'text-orange' : item.status === 1 ? 'text-green' : item.status === 2 ? 'text-red' : 'text-gray'">{{ item.status === 0 ? '待审核' : item.status === 1 ? '已通过' : item.status === 2 ? '已驳回' : '未知' }}</text>
        </view>
        <view class="info-row">
          <text class="label">发票抬头:</text>
          <text class="value">{{ item.invoiceName }}</text>
        </view>
        <view class="info-row">
          <text class="label">申请金额:</text>
          <text class="value text-red">ï¿¥{{ item.invoiceMoney ? Number(item.invoiceMoney).toFixed(2) : '0.00' }}</text>
        </view>
        <view class="info-row">
          <text class="label">申请时间:</text>
          <text class="value">{{ formatApplyTime(item.applyTime) }}</text>
        </view>
        <view v-if="item.invoiceNo" class="info-row">
          <text class="label">发票编号:</text>
          <text class="value">{{ item.invoiceNo }}</text>
        </view>
        <view class="action-bar margin-top-sm flex justify-end" v-if="item.invoiceUrl">
          <button class="cu-btn sm bg-green" @click="handleDownload" :data-url="item.invoiceUrl">查看发票</button>
        </view>
        <view v-if="item.status === 2 && item.auditRemarks" class="margin-top-xs padding-xs bg-gray radius">
          <text class="text-sm text-red">驳回原因: {{ item.auditRemarks }}</text>
        </view>
      </view>
    </scroll-view>
  </view>
</template>
<script>
import { listMyInvoice } from "@/api/invoice"
import config from '@/config.js'
export default {
  data() {
    return {
      list: [],
      queryParams: {
        pageNum: 1,
        pageSize: 10,
        serviceCode: null,  // æœåŠ¡å•å·æœç´¢
        status: null        // çŠ¶æ€ç­›é€‰
      },
      total: 0,
      searchKeyword: '',    // æœç´¢å…³é”®è¯
      currentTab: null      // å½“前Tab:null=全部, 0=待审核, 1=已通过, 2=已驳回
    }
  },
  onShow() {
    this.queryParams.pageNum = 1
    this.list = []
    this.getList()
  },
  methods: {
    getList() {
      listMyInvoice(this.queryParams).then(res => {
        if (res.rows) {
          let rows = res.rows;
          // æŒ‰ç”³è¯·æ—¶é—´å€’序排序
          rows.sort((a, b) => {
            // å¦‚果有 applyTime,则比较时间,否则放在最后
            if (!a.applyTime) return 1;
            if (!b.applyTime) return -1;
            return new Date(b.applyTime) - new Date(a.applyTime);
          });
          this.list = this.list.concat(rows);
          this.total = res.total || 0;
        }
      }).catch(err => {
        console.error('获取发票列表失败:', err)
        this.$modal.msgError('加载失败,请重试')
      })
    },
    // æœç´¢
    handleSearch() {
      this.queryParams.serviceCode = this.searchKeyword.trim() || null
      this.resetList()
    },
    // æ¸…空搜索
    clearSearch() {
      this.searchKeyword = ''
      this.queryParams.serviceCode = null
      this.resetList()
    },
    // åˆ‡æ¢Tab
    switchTab(status) {
      this.currentTab = status
      this.queryParams.status = status
      this.resetList()
    },
    // é‡ç½®åˆ—表
    resetList() {
      this.queryParams.pageNum = 1
      this.list = []
      this.getList()
    },
    loadMore() {
      if (this.list.length < this.total) {
        this.queryParams.pageNum++
        this.getList()
      }
    },
    handleApply() {
      this.$tab.navigateTo('/pages/mine/invoice/apply')
    },
    handleDownload(e) {
      const url = e.currentTarget.dataset.url
      console.log('=== å‘票下载调试信息 ===');
      console.log('1. åŽŸå§‹URL:', url)
      if (!url) {
        this.$modal.msgError('发票文件地址为空')
        return
      }
      // å¤„理URL,确保是完整的地址
      let fullUrl = url
      if (!url.startsWith('http')) {
        // å¦‚果是相对路径,拼接完整地址
        fullUrl = config.baseUrl + url
      }
      console.log('2. baseUrl:', config.baseUrl)
      console.log('3. å®Œæ•´URL:', fullUrl)
      const fileExt = fullUrl.match(/\.(\w+)$/)?.[1]?.toLowerCase()
      console.log('4. æ–‡ä»¶æ‰©å±•名:', fileExt)
      // #ifdef H5
      console.log('5. H5环境,直接打开')
      window.open(fullUrl)
      // #endif
      // #ifndef H5
      console.log('5. å°ç¨‹åº/App环境')
      // å…ˆé¢„览图片,如果是图片格式
      const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']
      if (imageExts.includes(fileExt)) {
        console.log('6. å›¾ç‰‡æ–‡ä»¶ï¼Œä½¿ç”¨previewImage')
        uni.previewImage({
          urls: [fullUrl],
          current: fullUrl,
          success: () => {
            console.log('图片预览成功')
          },
          fail: (err) => {
            console.error('图片预览失败:', err)
            this.$modal.msgError(`无法预览图片: ${err.errMsg || '未知错误'}`)
          }
        })
        return
      }
      // PDF文件下载并打开
      console.log('6. PDF文件,使用downloadFile')
      uni.showLoading({ title: '下载中...', mask: true })
      uni.downloadFile({
        url: fullUrl,
        success: (res) => {
          console.log('7. ä¸‹è½½æˆåŠŸ:', res)
          uni.hideLoading()
          if (res.statusCode === 200) {
            console.log('8. æ‰“开文档:', res.tempFilePath)
            uni.openDocument({
              filePath: res.tempFilePath,
              showMenu: true,
              success: function (openRes) {
                console.log('9. æ–‡æ¡£æ‰“开成功:', openRes)
              },
              fail: function (err) {
                console.error('9. æ–‡æ¡£æ‰“开失败:', err)
                uni.showModal({
                  title: '提示',
                  content: `无法打开文件: ${err.errMsg || '未知错误'}`,
                  showCancel: false
                })
              }
            })
          } else {
            console.error('8. ä¸‹è½½å¤±è´¥ï¼ŒçŠ¶æ€ç :', res.statusCode)
            this.$modal.msgError(`下载失败,状态码: ${res.statusCode}`)
          }
        },
        fail: (err) => {
          console.error('7. ä¸‹è½½å¤±è´¥:', err)
          uni.hideLoading()
          uni.showModal({
            title: '下载失败',
            content: `错误信息: ${err.errMsg || '未知错误'}\n\n请检查:\n1. ç½‘络连接是否正常\n2. æ–‡ä»¶æ˜¯å¦å­˜åœ¨\n3. æœåŠ¡å™¨æ˜¯å¦å¯è®¿é—®`,
            showCancel: false
          })
        }
      })
      // #endif
      console.log('=== è°ƒè¯•信息结束 ===');
    },
    statusText(status) {
      const map = { 0: '待审核', 1: '已通过', 2: '已驳回' }
      return map[status] || '未知'
    },
    statusClass(status) {
      const map = { 0: 'text-orange', 1: 'text-green', 2: 'text-red' }
      return map[status] || 'text-gray'
    },
    formatMoney(money) {
      if (money === null || money === undefined) return '0.00'
      return Number(money).toFixed(2)
    },
    // æ ¼å¼åŒ–申请时间
    formatApplyTime(time) {
      if (!time) return ''
      // å°†æ—¶é—´å­—符串格式化为 yyyy-MM-dd HH:mm
      const date = new Date(time)
      const year = date.getFullYear()
      const month = String(date.getMonth() + 1).padStart(2, '0')
      const day = String(date.getDate()).padStart(2, '0')
      const hours = String(date.getHours()).padStart(2, '0')
      const minutes = String(date.getMinutes()).padStart(2, '0')
      return `${year}-${month}-${day} ${hours}:${minutes}`
    }
  }
}
</script>
<style lang="scss">
.invoice-list-container {
  height: 100vh;
  display: flex;
  flex-direction: column;
  background-color: #f8f8f8;
  .add-btn-box {
    padding: 20rpx;
    background-color: #fff;
  }
  // æœç´¢æ¡†æ ·å¼
  .search-box {
    padding: 20rpx;
    background-color: #fff;
    border-bottom: 1rpx solid #e5e5e5;
    .search-input-wrapper {
      position: relative;
      background: #f5f5f5;
      border-radius: 40rpx;
      padding: 0 80rpx 0 40rpx;
      .search-input {
        height: 70rpx;
        line-height: 70rpx;
        font-size: 28rpx;
      }
      .search-icon {
        position: absolute;
        right: 40rpx;
        top: 50%;
        transform: translateY(-50%);
        color: #999;
        font-size: 36rpx;
      }
      .clear-icon {
        position: absolute;
        right: 80rpx;
        top: 50%;
        transform: translateY(-50%);
        color: #999;
        font-size: 32rpx;
        padding: 10rpx;
      }
    }
  }
  // Tab样式
  .tabs-wrapper {
    display: flex;
    background-color: #fff;
    border-bottom: 1rpx solid #e5e5e5;
    .tab-item {
      flex: 1;
      text-align: center;
      padding: 25rpx 0;
      font-size: 28rpx;
      color: #666;
      position: relative;
      &.active {
        color: #0081ff;
        font-weight: bold;
        &::after {
          content: '';
          position: absolute;
          bottom: 0;
          left: 50%;
          transform: translateX(-50%);
          width: 60rpx;
          height: 4rpx;
          background-color: #0081ff;
          border-radius: 2rpx;
        }
      }
    }
  }
  .list-scroll {
    flex: 1;
    overflow: hidden;
  }
  .invoice-item {
    .border-bottom {
      border-bottom: 1rpx solid #eee;
    }
    .info-row {
      display: flex;
      line-height: 1.8;
      font-size: 26rpx;
      .label {
        color: #666;
        width: 140rpx;
      }
      .value {
        color: #333;
        flex: 1;
      }
    }
  }
  .empty-box {
    padding: 100rpx;
    text-align: center;
  }
}
</style>
app/pagesTask/detail.vue
@@ -237,7 +237,17 @@
      
      <!-- è½¬è¿ - è´¹ç”¨ä¿¡æ¯ -->
      <view class="detail-section" v-if="taskDetail.taskType === 'EMERGENCY_TRANSFER' && taskDetail.emergencyInfo">
        <view class="section-title">费用信息</view>
        <view class="section-title">
          è´¹ç”¨ä¿¡æ¯
          <!-- å·²å®Œæˆä¸”未申请发票时显示申请发票按钮 -->
          <button
            v-if="canApplyInvoice"
            class="apply-invoice-btn"
            @click="handleApplyInvoice"
          >
            <text class="cuIcon-form"></text> ç”³è¯·å‘票
          </button>
        </view>
        <view class="info-item" v-if="taskDetail.emergencyInfo.transferDistance">
          <view class="label">转运公里数</view>
          <view class="value">{{ taskDetail.emergencyInfo.transferDistance }}公里</view>
@@ -566,6 +576,7 @@
  import { checkVehicleActiveTasks } from '@/api/task'
  import { getPaymentInfo } from '@/api/payment'
  import { getDicts } from '@/api/dict'
  import { checkTaskInvoice } from '@/api/invoice'
  import { formatDateTime } from '@/utils/common'
  import { validateTaskForDepart, validateTaskForSettlement, getTaskVehicleId, checkTaskCanDepart } from '@/utils/taskValidator'
  import AttachmentUpload from './components/AttachmentUpload.vue'
@@ -587,7 +598,9 @@
        forceCompleteForm: {
          actualStartTime: '',
          actualEndTime: ''
        }
        },
        hasInvoiceApplied: false, // æ˜¯å¦å·²ç”³è¯·å‘票
        invoiceStatus: null // å‘票状态:0-待审核, 1-已通过, 2-已驳回
      }
    },
    computed: {
@@ -597,6 +610,16 @@
          return false
        }
        return ['COMPLETED', 'CANCELLED'].includes(this.taskDetail.taskStatus)
      },
      // æ˜¯å¦å¯ä»¥ç”³è¯·å‘票
      canApplyInvoice() {
        // ä»…急救转运任务
        if (this.taskDetail?.taskType !== 'EMERGENCY_TRANSFER') return false
        // ä»»åŠ¡å¿…é¡»å·²å®Œæˆ
        if (this.taskDetail?.taskStatus !== 'COMPLETED') return false
        // æœªç”³è¯·è¿‡å‘票,或曾被驳回
        return !this.hasInvoiceApplied || this.invoiceStatus === 2
      },
      
      // ç”Ÿæˆæ‰§è¡Œäººå‘˜è§’色标签的类名
@@ -699,6 +722,8 @@
      this.taskId = options.id
      this.loadTaskDetail()
      this.loadCancelReasonDict() // åŠ è½½å–æ¶ˆåŽŸå› å­—å…¸
      // æ£€æŸ¥å‘票申请状态
      this.checkInvoiceStatus()
    },
    onShow() {
      // æ¯æ¬¡é¡µé¢æ˜¾ç¤ºæ—¶é‡æ–°åŠ è½½æ•°æ®ï¼Œç¡®ä¿ä»Žç¼–è¾‘é¡µé¢è¿”å›žåŽèƒ½çœ‹åˆ°æœ€æ–°æ•°æ®
@@ -1058,6 +1083,45 @@
        }
        
        return null;
      },
      // æ£€æŸ¥å‘票申请状态
      checkInvoiceStatus() {
        if (!this.taskId) return;
        // è°ƒç”¨åŽç«¯æŽ¥å£æ£€æŸ¥è¯¥ä»»åŠ¡æ˜¯å¦å·²ç”³è¯·å‘ç¥¨
        checkTaskInvoice(this.taskId).then(response => {
          if (response.code === 200 && response.data) {
            this.hasInvoiceApplied = true;
            this.invoiceStatus = response.data.status;
          }
        }).catch(error => {
          console.error('检查发票申请状态失败:', error);
          // å¿½ç•¥é”™è¯¯ï¼Œé»˜è®¤æœªç”³è¯·
        });
      },
      // ç”³è¯·å‘票
      handleApplyInvoice() {
        // å‡†å¤‡ä»»åŠ¡ä¿¡æ¯
        const taskInfo = {
          taskId: this.taskDetail.taskId,
          taskCode: this.taskDetail.showTaskCode || this.taskDetail.taskCode,
          legacyServiceOrderId: this.taskDetail.emergencyInfo?.legacyServiceOrdId,
          serviceCode: this.taskDetail.emergencyInfo?.serviceCode,
          departure: this.taskDetail.departureAddress,
          destination: this.taskDetail.destinationAddress,
          completionTime: this.formatTime(this.taskDetail.actualEndTime),
          transferPrice: this.paymentInfo?.transferPrice || this.paymentInfo?.totalAmount
        };
        // å°†ä»»åŠ¡ä¿¡æ¯åºåˆ—åŒ–ä¸º URL å‚æ•°
        const taskInfoParam = encodeURIComponent(JSON.stringify(taskInfo));
        // è·³è½¬åˆ°å‘票申请页面,传递任务信息
        uni.navigateTo({
          url: `/pages/mine/invoice/apply?taskInfo=${taskInfoParam}`
        });
      },
      
      // æ›´æ–°ä»»åŠ¡çŠ¶æ€
@@ -2310,6 +2374,20 @@
      margin-left: 10rpx;
      vertical-align: middle;
    }
    .apply-invoice-btn {
      padding: 8rpx 16rpx;
      font-size: 24rpx;
      color: #fff;
      background-color: #34C759;
      border: none;
      border-radius: 6rpx;
      margin-left: 20rpx;
    }
    .apply-invoice-btn::after {
      border: none;
    }
    
    // å–消原因对话框样式
    .cancel-dialog {
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysInvoiceController.java
New file
@@ -0,0 +1,234 @@
package com.ruoyi.web.controller.system;
import java.util.List;
import java.util.Map;
import java.util.HashMap;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.apache.commons.lang3.StringUtils;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.entity.SysDept;
import com.ruoyi.system.service.ISysDeptService;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.system.domain.SysInvoice;
import com.ruoyi.system.service.ISysInvoiceService;
import com.ruoyi.common.utils.poi.ExcelUtil;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.utils.SecurityUtils;
/**
 * å‘票申请Controller
 *
 * @author ruoyi
 * @date 2026-02-02
 */
@RestController
@RequestMapping("/system/invoice")
public class SysInvoiceController extends BaseController
{
    @Autowired
    private ISysInvoiceService sysInvoiceService;
    @Autowired
    private ISysDeptService sysDeptService;
    /**
     * æŸ¥è¯¢å‘票申请列表
     */
    @PreAuthorize("@ss.hasPermi('system:invoice:list')")
    @GetMapping("/list")
    public TableDataInfo list(SysInvoice sysInvoice)
    {
        startPage();
        List<SysInvoice> list = sysInvoiceService.selectSysInvoiceList(sysInvoice);
        return getDataTable(list);
    }
    /**
     * App端查询我的发票申请列表
     */
    @GetMapping("/myList")
    public TableDataInfo myList(SysInvoice sysInvoice)
    {
        sysInvoice.setApplyUserId(SecurityUtils.getUserId());
        startPage();
        List<Map<String, Object>> list = sysInvoiceService.selectMyInvoiceList(sysInvoice);
        return getDataTable(list);
    }
    /**
     * å¯¼å‡ºå‘票申请列表
     */
    @PreAuthorize("@ss.hasPermi('system:invoice:export')")
    @Log(title = "发票申请", businessType = BusinessType.EXPORT)
    @PostMapping("/export")
    public void export(HttpServletResponse response, SysInvoice sysInvoice)
    {
        List<SysInvoice> list = sysInvoiceService.selectSysInvoiceList(sysInvoice);
        ExcelUtil<SysInvoice> util = new ExcelUtil<SysInvoice>(SysInvoice.class);
        util.exportExcel(response, list, "发票申请数据");
    }
    /**
     * èŽ·å–å‘ç¥¨ç”³è¯·è¯¦ç»†ä¿¡æ¯
     */
    @PreAuthorize("@ss.hasAnyPermi('system:invoice:query, system:invoice:edit')")
    @GetMapping(value = "/{invoiceId}")
    public AjaxResult getInfo(@PathVariable("invoiceId") Long invoiceId)
    {
        return AjaxResult.success(sysInvoiceService.selectSysInvoiceByInvoiceId(invoiceId));
    }
    /**
     * æ–°å¢žå‘票申请
     */
    @Log(title = "发票申请", businessType = BusinessType.INSERT)
    @PostMapping
    public AjaxResult add(@RequestBody SysInvoice sysInvoice)
    {
        sysInvoice.setApplyUserId(SecurityUtils.getUserId());
        return toAjax(sysInvoiceService.insertSysInvoice(sysInvoice));
    }
    /**
     * ä¿®æ”¹å‘票申请
     */
    @PreAuthorize("@ss.hasPermi('system:invoice:edit')")
    @Log(title = "发票申请", businessType = BusinessType.UPDATE)
    @PutMapping
    public AjaxResult edit(@RequestBody SysInvoice sysInvoice)
    {
        return toAjax(sysInvoiceService.updateSysInvoice(sysInvoice));
    }
    /**
     * åˆ é™¤å‘票申请
     */
    @PreAuthorize("@ss.hasPermi('system:invoice:remove')")
    @Log(title = "发票申请", businessType = BusinessType.DELETE)
    @DeleteMapping("/{invoiceIds}")
    public AjaxResult remove(@PathVariable Long[] invoiceIds)
    {
        return toAjax(sysInvoiceService.deleteSysInvoiceByInvoiceIds(invoiceIds));
    }
    /**
     * æ‰‹åŠ¨è§¦å‘åŒæ­¥ (仅限管理员)
     */
    @PreAuthorize("@ss.hasRole('admin')")
    @GetMapping("/syncStatus")
    public AjaxResult syncStatus() {
        sysInvoiceService.syncStatusFromLegacySystem();
        return AjaxResult.success("同步任务已触发");
    }
    /**
     * åŒæ­¥å•个发票到旧系统
     * @param invoiceId å‘票ID
     */
    @PreAuthorize("@ss.hasPermi('system:invoice:edit')")
    @Log(title = "发票申请", businessType = BusinessType.UPDATE)
    @PostMapping("/syncToLegacy/{invoiceId}")
    public AjaxResult syncToLegacy(@PathVariable Long invoiceId) {
        SysInvoice invoice = sysInvoiceService.selectSysInvoiceByInvoiceId(invoiceId);
        if (invoice == null) {
            return AjaxResult.error("发票不存在");
        }
        if (invoice.getStatus() != 1) {
            return AjaxResult.error("只有审核通过的发票才能同步");
        }
        try {
            sysInvoiceService.syncToLegacySystem(invoice.getInvoiceId());
            return AjaxResult.success("同步成功");
        } catch (Exception e) {
            return AjaxResult.error("同步失败: " + e.getMessage());
        }
    }
    /**
     * æ£€æŸ¥ä»»åŠ¡æ˜¯å¦å·²ç”³è¯·å‘ç¥¨
     * @param taskId ä»»åŠ¡ID
     */
    @GetMapping("/checkTaskInvoice/{taskId}")
    public AjaxResult checkTaskInvoice(@PathVariable Long taskId) {
        SysInvoice query = new SysInvoice();
        query.setServiceOrderId(taskId);
        List<SysInvoice> list = sysInvoiceService.selectSysInvoiceList(query);
        if (list != null && !list.isEmpty()) {
            // åªè¿”回最新的一条记录
            SysInvoice invoice = list.get(0);
            Map<String, Object> result = new HashMap<>();
            result.put("hasApplied", true);
            result.put("status", invoice.getStatus());
            result.put("invoiceId", invoice.getInvoiceId());
            return AjaxResult.success(result);
        }
        return AjaxResult.success(null);
    }
    /**
     * èŽ·å–å¯ç”³è¯·å‘ç¥¨çš„ä»»åŠ¡åˆ—è¡¨
     * @param searchKeyword æœç´¢å…³é”®è¯ï¼ˆæ”¯æŒtaskCode、serviceCode、legacyServiceOrdNo)
     * @param serviceOrdClass åˆ†å…¬å¸ä»£ç ï¼ˆå¯é€‰ï¼Œé»˜è®¤ä½¿ç”¨ç”¨æˆ·æ‰€å±žåˆ†å…¬å¸ï¼‰
     */
    @GetMapping("/selectableTasks")
    public AjaxResult getSelectableTasks(
        @RequestParam(required = false) String searchKeyword,
        @RequestParam(required = false) String serviceOrdClass
    )
    {
        Long userId = SecurityUtils.getUserId();
        // åŽ»é™¤æœç´¢å…³é”®è¯çš„ç©ºæ ¼
        if (StringUtils.isNotBlank(searchKeyword)) {
            searchKeyword = searchKeyword.trim();
        }
        // å¦‚果未指定分公司,自动获取用户所属分公司
        if (StringUtils.isBlank(serviceOrdClass)) {
            try {
                Long deptId = SecurityUtils.getLoginUser().getUser().getDeptId();
                if (deptId != null) {
                    SysDept dept = sysDeptService.selectDeptById(deptId);
                    if (dept != null) {
                        // åˆ¤æ–­æ˜¯å¦ä¸ºåˆ†å…¬å¸ï¼ˆparent_id = 100)
                        if (dept.getParentId() != null && dept.getParentId() == 100) {
                            serviceOrdClass = dept.getServiceOrderClass();
                        } else if (dept.getAncestors() != null) {
                            // ä»Ž ancestors è§£æžåˆ†å…¬å¸ID
                            String[] ancestorIds = dept.getAncestors().split(",");
                            for (int i = 0; i < ancestorIds.length; i++) {
                                if ("100".equals(ancestorIds[i]) && i + 1 < ancestorIds.length) {
                                    Long branchId = Long.parseLong(ancestorIds[i + 1]);
                                    SysDept branchDept = sysDeptService.selectDeptById(branchId);
                                    if (branchDept != null) {
                                        serviceOrdClass = branchDept.getServiceOrderClass();
                                    }
                                    break;
                                }
                            }
                        }
                    }
                }
            } catch (Exception e) {
                // èŽ·å–å¤±è´¥ä¸å½±å“æŸ¥è¯¢ï¼Œåªæ˜¯ä¸è¿‡æ»¤åˆ†å…¬å¸
            }
        }
        return AjaxResult.success(sysInvoiceService.selectSelectableTasks(userId, searchKeyword, serviceOrdClass));
    }
}
ruoyi-admin/src/main/java/com/ruoyi/web/controller/task/SysTaskController.java
@@ -78,6 +78,9 @@
    
    @Autowired
    private ITaskDispatchSyncService taskDispatchSyncService;
    @Autowired
    private ITaskStatusPushService taskStatusPushService;
    /**
     * æŸ¥è¯¢ä»»åŠ¡ç®¡ç†åˆ—è¡¨ï¼ˆåŽå°ç®¡ç†ç«¯ï¼‰
@@ -704,4 +707,50 @@
            return error("同步异常: " + e.getMessage());
        }
    }
    /**
     * æ‰‹åŠ¨åŒæ­¥ä»»åŠ¡çŠ¶æ€åˆ°æ—§ç³»ç»Ÿ
     * å½“任务状态变更后由于网络等原因未同步到旧系统时,可以通过此接口手动触发同步
     */
//    @PreAuthorize("@ss.hasPermi('task:general:edit')")
    @Log(title = "手动同步任务状态", businessType = BusinessType.UPDATE)
    @PostMapping("/syncTaskStatus/{taskId}")
    public AjaxResult syncTaskStatus(@PathVariable Long taskId) {
        try {
            // æŸ¥è¯¢ä»»åŠ¡ä¿¡æ¯
            SysTask task = sysTaskService.selectSysTaskByTaskId(taskId);
            if (task == null) {
                return error("任务不存在");
            }
            // åªæ”¯æŒæ€¥æ•‘转运任务
            if (!"EMERGENCY_TRANSFER".equals(task.getTaskType())) {
                return error("只有急救转运任务才能同步到旧系统");
            }
            // æŸ¥è¯¢æ€¥æ•‘转运扩展信息
            SysTaskEmergency emergency = sysTaskEmergencyService.selectSysTaskEmergencyByTaskId(taskId);
            if (emergency == null) {
                return error("急救转运扩展信息不存在");
            }
            // å¿…须先有调度单
            if (emergency.getLegacyDispatchOrdId() == null || emergency.getLegacyDispatchOrdId() <= 0) {
                return error("请先同步调度单,任务状态信息同步到旧系统的调度单中");
            }
            // è°ƒç”¨çŠ¶æ€åŒæ­¥æœåŠ¡
            boolean success = taskStatusPushService.pushTaskStatusToLegacy(taskId);
            if (success) {
                return success("任务状态同步成功");
            } else {
                return error("任务状态同步失败,请查看日志获取详细信息");
            }
        } catch (Exception e) {
            logger.error("手动同步任务状态异常,taskId: {}", taskId, e);
            return error("同步异常: " + e.getMessage());
        }
    }
}
ruoyi-admin/src/main/resources/application.yml
@@ -58,7 +58,7 @@
    basename: i18n/messages
  profiles:
    # çŽ¯å¢ƒ dev|test|prod
    active: dev
    active: prod
  # æ–‡ä»¶ä¸Šä¼ 
  servlet:
    multipart:
ruoyi-quartz/src/main/java/com/ruoyi/quartz/task/SysInvoiceSyncTask.java
New file
@@ -0,0 +1,26 @@
package com.ruoyi.quartz.task;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import com.ruoyi.system.service.ISysInvoiceService;
/**
 * å‘票同步定时任务 - åŒæ­¥æ—§ç³»ç»Ÿå‘票信息到新系统
 *
 * @author ruoyi
 */
@Component("sysInvoiceSyncTask")
public class SysInvoiceSyncTask
{
    @Autowired
    private ISysInvoiceService sysInvoiceService;
    /**
     * åŒæ­¥æ—§ç³»ç»Ÿå‘票状态
     */
    public void syncStatus()
    {
        sysInvoiceService.syncStatusFromLegacySystem();
    }
}
ruoyi-quartz/src/main/java/com/ruoyi/quartz/task/SysInvoiceTask.java
New file
@@ -0,0 +1,33 @@
package com.ruoyi.quartz.task;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import com.ruoyi.system.service.ISysInvoiceService;
/**
 * å‘票同步定时任务
 *
 * @author ruoyi
 */
@Component("sysInvoiceTask")
public class SysInvoiceTask
{
    @Autowired
    private ISysInvoiceService sysInvoiceService;
    /**
     * åŒæ­¥æ—§ç³»ç»Ÿå‘票状态
     */
    public void syncStatus()
    {
        sysInvoiceService.syncStatusFromLegacySystem();
    }
    /**
     * ä»Žæ—§ç³»ç»ŸåŒæ­¥å‘票信息到新系统
     */
    public void syncInvoiceFromLegacySystem()
    {
//        sysInvoiceService.syncInvoiceFromLegacySystem();
    }
}
ruoyi-system/src/main/java/com/ruoyi/system/domain/SysInvoice.java
New file
@@ -0,0 +1,380 @@
package com.ruoyi.system.domain;
import java.math.BigDecimal;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.BaseEntity;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
/**
 * å‘票申请对象 sys_invoice
 *
 * @author ruoyi
 * @date 2026-02-02
 */
public class SysInvoice extends BaseEntity
{
    private static final long serialVersionUID = 1L;
    /** å‘票ID */
    private Long invoiceId;
    /** æœåŠ¡å•å·(新系统ID) */
    @Excel(name = "服务单号(新)")
    private Long serviceOrderId;
    /** æ—§ç³»ç»ŸæœåŠ¡å•å· */
    @Excel(name = "服务单号(旧)")
    private Long legacyServiceOrderId;
    /** å¼€ç¥¨ç±»åž‹(1-个人, 2-企业) */
    @Excel(name = "开票类型", readConverterExp = "1=个人,2=企业")
    private Integer invoiceType;
    /** å‘票抬头 */
    @Excel(name = "发票抬头")
    private String invoiceName;
    /** å‘票金额 */
    @Excel(name = "发票金额")
    private BigDecimal invoiceMoney;
    /** å‘票备注 */
    @Excel(name = "发票备注")
    private String invoiceRemarks;
    /** ä¼ä¸šæ³¨å†Œåœ°å€ */
    @Excel(name = "企业注册地址")
    private String companyAddress;
    /** ä¼ä¸šå¼€æˆ·é“¶è¡Œ */
    @Excel(name = "企业开户银行")
    private String companyBank;
    /** ä¼ä¸šé“¶è¡Œå¸å· */
    @Excel(name = "企业银行帐号")
    private String companyBankNo;
    /** é‚®ç¼– */
    @Excel(name = "邮编")
    private String zipCode;
    /** é‚®å¯„地址 */
    @Excel(name = "邮寄地址")
    private String mailAddress;
    /** è”系人 */
    @Excel(name = "联系人")
    private String contactName;
    /** è”系电话 */
    @Excel(name = "联系电话")
    private String contactPhone;
    /** è”系邮箱 */
    @Excel(name = "联系邮箱")
    private String contactEmail;
    /** ç”³è¯·çŠ¶æ€(0-待审核, 1-已通过, 2-已驳回) */
    @Excel(name = "申请状态", readConverterExp = "0=待审核,1=已通过,2=已驳回")
    private Integer status;
    /** å‘票编号 */
    @Excel(name = "发票编号")
    private String invoiceNo;
    /** å‘票链接 */
    @Excel(name = "发票链接")
    private String invoiceUrl;
    /** ç”³è¯·äººID */
    private Long applyUserId;
    /** ç”³è¯·æ—¶é—´ */
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @Excel(name = "申请时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
    private Date applyTime;
    /** å®¡æ ¸äººID */
    private Long auditUserId;
    /** å®¡æ ¸æ—¶é—´ */
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @Excel(name = "审核时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
    private Date auditTime;
    /** å®¡æ ¸å¤‡æ³¨ */
    private String auditRemarks;
    /** åŒæ­¥çŠ¶æ€(0-未同步, 1-已同步, 2-失败) */
    private Integer syncStatus;
    /** æ—§ç³»ç»Ÿå‘票ID */
    private Integer legacyInvoiceId;
    /** æœåŠ¡å•å·ï¼ˆæ ¼å¼åŒ–ï¼Œå¦‚GZ20260202-001)- ä»…用于查询返回 */
    private String serviceCode;
    public void setInvoiceId(Long invoiceId)
    {
        this.invoiceId = invoiceId;
    }
    public Long getInvoiceId()
    {
        return invoiceId;
    }
    public void setServiceOrderId(Long serviceOrderId)
    {
        this.serviceOrderId = serviceOrderId;
    }
    public Long getServiceOrderId()
    {
        return serviceOrderId;
    }
    public void setLegacyServiceOrderId(Long legacyServiceOrderId)
    {
        this.legacyServiceOrderId = legacyServiceOrderId;
    }
    public Long getLegacyServiceOrderId()
    {
        return legacyServiceOrderId;
    }
    public void setInvoiceType(Integer invoiceType)
    {
        this.invoiceType = invoiceType;
    }
    public Integer getInvoiceType()
    {
        return invoiceType;
    }
    public void setInvoiceName(String invoiceName)
    {
        this.invoiceName = invoiceName;
    }
    public String getInvoiceName()
    {
        return invoiceName;
    }
    public void setInvoiceMoney(BigDecimal invoiceMoney)
    {
        this.invoiceMoney = invoiceMoney;
    }
    public BigDecimal getInvoiceMoney()
    {
        return invoiceMoney;
    }
    public void setInvoiceRemarks(String invoiceRemarks)
    {
        this.invoiceRemarks = invoiceRemarks;
    }
    public String getInvoiceRemarks()
    {
        return invoiceRemarks;
    }
    public void setCompanyAddress(String companyAddress)
    {
        this.companyAddress = companyAddress;
    }
    public String getCompanyAddress()
    {
        return companyAddress;
    }
    public void setCompanyBank(String companyBank)
    {
        this.companyBank = companyBank;
    }
    public String getCompanyBank()
    {
        return companyBank;
    }
    public void setCompanyBankNo(String companyBankNo)
    {
        this.companyBankNo = companyBankNo;
    }
    public String getCompanyBankNo()
    {
        return companyBankNo;
    }
    public void setZipCode(String zipCode)
    {
        this.zipCode = zipCode;
    }
    public String getZipCode()
    {
        return zipCode;
    }
    public void setMailAddress(String mailAddress)
    {
        this.mailAddress = mailAddress;
    }
    public String getMailAddress()
    {
        return mailAddress;
    }
    public void setContactName(String contactName)
    {
        this.contactName = contactName;
    }
    public String getContactName()
    {
        return contactName;
    }
    public void setContactPhone(String contactPhone)
    {
        this.contactPhone = contactPhone;
    }
    public String getContactPhone()
    {
        return contactPhone;
    }
    public void setContactEmail(String contactEmail)
    {
        this.contactEmail = contactEmail;
    }
    public String getContactEmail()
    {
        return contactEmail;
    }
    public void setStatus(Integer status)
    {
        this.status = status;
    }
    public Integer getStatus()
    {
        return status;
    }
    public void setInvoiceNo(String invoiceNo)
    {
        this.invoiceNo = invoiceNo;
    }
    public String getInvoiceNo()
    {
        return invoiceNo;
    }
    public void setInvoiceUrl(String invoiceUrl)
    {
        this.invoiceUrl = invoiceUrl;
    }
    public String getInvoiceUrl()
    {
        return invoiceUrl;
    }
    public void setApplyUserId(Long applyUserId)
    {
        this.applyUserId = applyUserId;
    }
    public Long getApplyUserId()
    {
        return applyUserId;
    }
    public void setApplyTime(Date applyTime)
    {
        this.applyTime = applyTime;
    }
    public Date getApplyTime()
    {
        return applyTime;
    }
    public void setAuditUserId(Long auditUserId)
    {
        this.auditUserId = auditUserId;
    }
    public Long getAuditUserId()
    {
        return auditUserId;
    }
    public void setAuditTime(Date auditTime)
    {
        this.auditTime = auditTime;
    }
    public Date getAuditTime()
    {
        return auditTime;
    }
    public void setAuditRemarks(String auditRemarks)
    {
        this.auditRemarks = auditRemarks;
    }
    public String getAuditRemarks()
    {
        return auditRemarks;
    }
    public Integer getSyncStatus() {
        return syncStatus;
    }
    public void setSyncStatus(Integer syncStatus) {
        this.syncStatus = syncStatus;
    }
    public Integer getLegacyInvoiceId() {
        return legacyInvoiceId;
    }
    public void setLegacyInvoiceId(Integer legacyInvoiceId) {
        this.legacyInvoiceId = legacyInvoiceId;
    }
    public String getServiceCode() {
        return serviceCode;
    }
    public void setServiceCode(String serviceCode) {
        this.serviceCode = serviceCode;
    }
    @Override
    public String toString() {
        return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)
            .append("invoiceId", getInvoiceId())
            .append("serviceOrderId", getServiceOrderId())
            .append("legacyServiceOrderId", getLegacyServiceOrderId())
            .append("invoiceType", getInvoiceType())
            .append("invoiceName", getInvoiceName())
            .append("invoiceMoney", getInvoiceMoney())
            .append("invoiceRemarks", getInvoiceRemarks())
            .append("companyAddress", getCompanyAddress())
            .append("companyBank", getCompanyBank())
            .append("companyBankNo", getCompanyBankNo())
            .append("zipCode", getZipCode())
            .append("mailAddress", getMailAddress())
            .append("contactName", getContactName())
            .append("contactPhone", getContactPhone())
            .append("contactEmail", getContactEmail())
            .append("status", getStatus())
            .append("invoiceNo", getInvoiceNo())
            .append("invoiceUrl", getInvoiceUrl())
            .append("applyUserId", getApplyUserId())
            .append("applyTime", getApplyTime())
            .append("auditUserId", getAuditUserId())
            .append("auditTime", getAuditTime())
            .append("auditRemarks", getAuditRemarks())
            .toString();
    }
}
ruoyi-system/src/main/java/com/ruoyi/system/domain/SysTaskEmergency.java
@@ -166,6 +166,10 @@
    /** å–消时间 */
    private java.util.Date cancelTime;
    /**
     * æœåŠ¡å•ç¼–å·
     * @return
     */
    public String getServiceCode(){
        if(this.legacyServiceOrdClass!=null && this.legacyServiceNsTime!=null && this.legacyServiceOrdNo!=null) {
            String nstime = DateUtils.parseDateToStr(DateUtils.YYYYMMDD, this.legacyServiceNsTime);
@@ -174,6 +178,10 @@
        return null;
    }
    /**
     * è°ƒåº¦å•编号
     * @return
     */
    public String getDispatchCode(){
        if(this.legacyDispatchOrdClass!=null && this.legacyDispatchNsTime!=null && this.legacyDispatchOrdNo!=null) {
            String nstime = DateUtils.parseDateToStr(DateUtils.YYYYMMDD, this.legacyDispatchNsTime);
ruoyi-system/src/main/java/com/ruoyi/system/mapper/LegacyInvoiceMapper.java
New file
@@ -0,0 +1,37 @@
package com.ruoyi.system.mapper;
import java.util.List;
import java.util.Map;
import com.ruoyi.common.annotation.DataSource;
import com.ruoyi.common.enums.DataSourceType;
/**
 * æ—§ç³»ç»Ÿå‘票数据Mapper接口 (SQL Server)
 *
 * @author ruoyi
 * @date 2026-02-02
 */
@DataSource(DataSourceType.SQLSERVER)
public interface LegacyInvoiceMapper
{
    /**
     * æ’入发票申请到旧系统
     * @param params
     * @return
     */
    public int insertLegacyInvoice(Map<String, Object> params);
    /**
     * æŸ¥è¯¢æ—§ç³»ç»Ÿå‘票状态变化的数据
     * @param lastSyncTime
     * @return
     */
    public List<Map<String, Object>> selectUpdatedInvoices(String lastSyncTime);
    /**
     * æ ¹æ®æœåŠ¡å•ID查询旧系统发票信息
     * @param serviceOrderId
     * @return
     */
    public List<Map<String, Object>> selectLegacyInvoiceByServiceOrderId(Long serviceOrderId);
}
ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysInvoiceMapper.java
New file
@@ -0,0 +1,91 @@
package com.ruoyi.system.mapper;
import java.util.List;
import java.util.Map;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.system.domain.SysInvoice;
/**
 * å‘票申请Mapper接口
 *
 * @author ruoyi
 * @date 2026-02-02
 */
public interface SysInvoiceMapper
{
    /**
     * æŸ¥è¯¢å‘票申请
     *
     * @param invoiceId å‘票申请主键
     * @return å‘票申请
     */
    public SysInvoice selectSysInvoiceByInvoiceId(Long invoiceId);
    /**
     * æŸ¥è¯¢å‘票申请列表
     *
     * @param sysInvoice å‘票申请
     * @return å‘票申请集合
     */
    public List<SysInvoice> selectSysInvoiceList(SysInvoice sysInvoice);
    /**
     * æŸ¥è¯¢æˆ‘的发票申请列表(App端,返回Map包含serviceCode)
     *
     * @param sysInvoice å‘票申请
     * @return å‘票申请集合
     */
    public List<Map<String, Object>> selectMyInvoiceList(SysInvoice sysInvoice);
    /**
     * æ–°å¢žå‘票申请
     *
     * @param sysInvoice å‘票申请
     * @return ç»“æžœ
     */
    public int insertSysInvoice(SysInvoice sysInvoice);
    /**
     * ä¿®æ”¹å‘票申请
     *
     * @param sysInvoice å‘票申请
     * @return ç»“æžœ
     */
    public int updateSysInvoice(SysInvoice sysInvoice);
    /**
     * åˆ é™¤å‘票申请
     *
     * @param invoiceId å‘票申请主键
     * @return ç»“æžœ
     */
    public int deleteSysInvoiceByInvoiceId(Long invoiceId);
    /**
     * æ‰¹é‡åˆ é™¤å‘票申请
     *
     * @param invoiceIds éœ€è¦åˆ é™¤çš„æ•°æ®ä¸»é”®é›†åˆ
     * @return ç»“æžœ
     */
    public int deleteSysInvoiceByInvoiceIds(Long[] invoiceIds);
    /**
     * æ ¹æ®æ—§ç³»ç»Ÿå‘票ID查询
     * @param legacyInvoiceId
     * @return
     */
    public SysInvoice selectSysInvoiceByLegacyId(Integer legacyInvoiceId);
    /**
     * æŸ¥è¯¢å¯ç”³è¯·å‘票的任务列表
     * @param userId ç”¨æˆ·ID
     * @param searchKeyword æœç´¢å…³é”®è¯
     * @param serviceOrdClass åˆ†å…¬å¸ä»£ç 
     * @return
     */
    public List<Map<String, Object>> selectSelectableTasks(
        @Param("userId") Long userId,
        @Param("searchKeyword") String searchKeyword,
        @Param("serviceOrdClass") String serviceOrdClass
    );
}
ruoyi-system/src/main/java/com/ruoyi/system/service/ISysInvoiceService.java
New file
@@ -0,0 +1,91 @@
package com.ruoyi.system.service;
import java.util.List;
import java.util.Map;
import com.ruoyi.system.domain.SysInvoice;
/**
 * å‘票申请Service接口
 *
 * @author ruoyi
 * @date 2026-02-02
 */
public interface ISysInvoiceService
{
    /**
     * æŸ¥è¯¢å‘票申请
     *
     * @param invoiceId å‘票申请主键
     * @return å‘票申请
     */
    public SysInvoice selectSysInvoiceByInvoiceId(Long invoiceId);
    /**
     * æŸ¥è¯¢å‘票申请列表
     *
     * @param sysInvoice å‘票申请
     * @return å‘票申请集合
     */
    public List<SysInvoice> selectSysInvoiceList(SysInvoice sysInvoice);
    /**
     * æŸ¥è¯¢æˆ‘的发票申请列表(App端,返回Map包含serviceCode)
     *
     * @param sysInvoice å‘票申请
     * @return å‘票申请集合
     */
    public List<Map<String, Object>> selectMyInvoiceList(SysInvoice sysInvoice);
    /**
     * æ–°å¢žå‘票申请
     *
     * @param sysInvoice å‘票申请
     * @return ç»“æžœ
     */
    public int insertSysInvoice(SysInvoice sysInvoice);
    /**
     * ä¿®æ”¹å‘票申请
     *
     * @param sysInvoice å‘票申请
     * @return ç»“æžœ
     */
    public int updateSysInvoice(SysInvoice sysInvoice);
    /**
     * æ‰¹é‡åˆ é™¤å‘票申请
     *
     * @param invoiceIds éœ€è¦åˆ é™¤çš„发票申请主键集合
     * @return ç»“æžœ
     */
    public int deleteSysInvoiceByInvoiceIds(Long[] invoiceIds);
    /**
     * åˆ é™¤å‘票申请信息
     *
     * @param invoiceId å‘票申请主键
     * @return ç»“æžœ
     */
    public int deleteSysInvoiceByInvoiceId(Long invoiceId);
    /**
     * åŒæ­¥åˆ°æ—§ç³»ç»Ÿ
     * @param invoiceId
     * @return
     */
    public int syncToLegacySystem(Long invoiceId);
    /**
     * ä»Žæ—§ç³»ç»ŸåŒæ­¥çŠ¶æ€
     */
    public void syncStatusFromLegacySystem();
    /**
     * æŸ¥è¯¢å¯ç”³è¯·å‘票的任务列表
     * @param userId ç”¨æˆ·ID
     * @param searchKeyword æœç´¢å…³é”®è¯
     * @param serviceOrdClass åˆ†å…¬å¸ä»£ç 
     * @return
     */
    public List<Map<String, Object>> selectSelectableTasks(Long userId, String searchKeyword, String serviceOrdClass);
}
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/PaymentSyncServiceImpl.java
@@ -120,7 +120,11 @@
            }
            paidMoney.setPaidMoney(payment.getSettlementAmount());
            paidMoney.setPaidMoneyType(convertPaymentMethodToLegacy(payment.getPaymentMethod()));
            paidMoney.setPaidMoneyMono(payment.getTradeNo() != null ? payment.getTradeNo() : payment.getOutTradeNo());
            String outTradeNo = payment.getTradeNo() != null ? payment.getTradeNo() : payment.getOutTradeNo();
            if(!outTradeNo.contains("[支付专用]")){
                outTradeNo=outTradeNo+"[支付专用]";
            }
            paidMoney.setPaidMoneyMono(outTradeNo);
            paidMoney.setPaidMoneyTime(payment.getPayTime() != null ? payment.getPayTime() : new Date());
            paidMoney.setPaidMoneyOaID(oaUserId);
            paidMoney.setPaidMoneyUnitID(0); // é»˜è®¤ä¸º0
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysInvoiceServiceImpl.java
New file
@@ -0,0 +1,243 @@
package com.ruoyi.system.service.impl;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.system.domain.SysInvoice;
import com.ruoyi.system.mapper.SysInvoiceMapper;
import com.ruoyi.system.mapper.LegacyInvoiceMapper;
import com.ruoyi.system.mapper.SysUserMapper;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.system.service.ISysInvoiceService;
/**
 * å‘票申请Service业务层处理
 *
 * @author ruoyi
 * @date 2026-02-02
 */
@Service
public class SysInvoiceServiceImpl implements ISysInvoiceService
{
    private static final Logger log = LoggerFactory.getLogger(SysInvoiceServiceImpl.class);
    @Autowired
    private SysInvoiceMapper sysInvoiceMapper;
    @Autowired
    private LegacyInvoiceMapper legacyInvoiceMapper;
    @Autowired
    private SysUserMapper sysUserMapper;
    /**
     * æŸ¥è¯¢å‘票申请
     *
     * @param invoiceId å‘票申请主键
     * @return å‘票申请
     */
    @Override
    public SysInvoice selectSysInvoiceByInvoiceId(Long invoiceId)
    {
        return sysInvoiceMapper.selectSysInvoiceByInvoiceId(invoiceId);
    }
    /**
     * æŸ¥è¯¢å‘票申请列表
     *
     * @param sysInvoice å‘票申请
     * @return å‘票申请
     */
    @Override
    public List<SysInvoice> selectSysInvoiceList(SysInvoice sysInvoice)
    {
        return sysInvoiceMapper.selectSysInvoiceList(sysInvoice);
    }
    /**
     * æŸ¥è¯¢æˆ‘的发票申请列表(App端,返回Map包含serviceCode)
     *
     * @param sysInvoice å‘票申请
     * @return å‘票申请
     */
    @Override
    public List<Map<String, Object>> selectMyInvoiceList(SysInvoice sysInvoice)
    {
        return sysInvoiceMapper.selectMyInvoiceList(sysInvoice);
    }
    /**
     * æ–°å¢žå‘票申请
     *
     * @param sysInvoice å‘票申请
     * @return ç»“æžœ
     */
    @Override
    public int insertSysInvoice(SysInvoice sysInvoice)
    {
        sysInvoice.setApplyTime(DateUtils.getNowDate());
        sysInvoice.setStatus(0); // å¾…审核
        sysInvoice.setSyncStatus(0); // æœªåŒæ­¥
        int rows = sysInvoiceMapper.insertSysInvoice(sysInvoice);
        // è‡ªåŠ¨å°è¯•åŒæ­¥åˆ°æ—§ç³»ç»Ÿ
        if (rows > 0) {
            try {
                syncToLegacySystem(sysInvoice.getInvoiceId());
            } catch (Exception e) {
                log.error("同步发票申请到旧系统失败", e);
            }
        }
        return rows;
    }
    /**
     * ä¿®æ”¹å‘票申请
     *
     * @param sysInvoice å‘票申请
     * @return ç»“æžœ
     */
    @Override
    public int updateSysInvoice(SysInvoice sysInvoice)
    {
        return sysInvoiceMapper.updateSysInvoice(sysInvoice);
    }
    /**
     * æ‰¹é‡åˆ é™¤å‘票申请
     *
     * @param invoiceIds éœ€è¦åˆ é™¤çš„发票申请主键
     * @return ç»“æžœ
     */
    @Override
    public int deleteSysInvoiceByInvoiceIds(Long[] invoiceIds)
    {
        return sysInvoiceMapper.deleteSysInvoiceByInvoiceIds(invoiceIds);
    }
    /**
     * åˆ é™¤å‘票申请信息
     *
     * @param invoiceId å‘票申请主键
     * @return ç»“æžœ
     */
    @Override
    public int deleteSysInvoiceByInvoiceId(Long invoiceId)
    {
        return sysInvoiceMapper.deleteSysInvoiceByInvoiceId(invoiceId);
    }
    /**
     * åŒæ­¥å‘票申请到旧系统 (SQL Server)
     */
    @Override
    public int syncToLegacySystem(Long invoiceId) {
        SysInvoice invoice = sysInvoiceMapper.selectSysInvoiceByInvoiceId(invoiceId);
        if (invoice == null) return 0;
        Map<String, Object> params = new HashMap<>();
        params.put("ServiceOrderIDPK", invoice.getLegacyServiceOrderId());
        params.put("InvoiceType", invoice.getInvoiceType());
        params.put("InvoiceName", invoice.getInvoiceName());
        params.put("InvoiceMakeout", invoice.getInvoiceRemarks());
        params.put("InvoiceCompanyPhone", invoice.getContactPhone());
        params.put("InvoiceCompanyID", ""); // ç¨Žå·å­—段若有可补
        params.put("InvoiceCompanyAdd", invoice.getCompanyAddress());
        params.put("InvoiceCompanyBank", invoice.getCompanyBank());
        params.put("InvoiceCompanyBankNo", invoice.getCompanyBankNo());
        params.put("InvoiceZipCode", invoice.getZipCode());
        params.put("Invoice_strAdd", invoice.getMailAddress());
        params.put("Invoice_strName", invoice.getContactName());
        params.put("Invoice_strPhone", invoice.getContactPhone());
        params.put("Invoice_strEmail", invoice.getContactEmail());
        params.put("InvoiceMoney", invoice.getInvoiceMoney());
        // é€šè¿‡åˆ›å»ºäººBID查询OA用户ID
        Integer oaUserId = 0;
        if (invoice.getApplyUserId() != null) {
            SysUser user = sysUserMapper.selectUserById(invoice.getApplyUserId());
            if (user != null && user.getOaUserId() != null) {
                oaUserId = user.getOaUserId();
            }
        }
        params.put("ApplyOAID", oaUserId);
        try {
            int rows = legacyInvoiceMapper.insertLegacyInvoice(params);
            if (rows > 0) {
                // SQL Server insert ä¼šé€šè¿‡ useGeneratedKeys è¿”回自增 ID åˆ° Map çš„ keyProperty
                Object legacyId = params.get("InvoiceID");
                if (legacyId != null) {
                    invoice.setLegacyInvoiceId(Integer.valueOf(legacyId.toString()));
                    invoice.setSyncStatus(1); // å·²åŒæ­¥
                    sysInvoiceMapper.updateSysInvoice(invoice);
                }
            }
            return rows;
        } catch (Exception e) {
            log.error("同步发票到旧系统异常: {}", e.getMessage());
            invoice.setSyncStatus(2); // åŒæ­¥å¤±è´¥
            sysInvoiceMapper.updateSysInvoice(invoice);
            throw e;
        }
    }
    /**
     * ä»Žæ—§ç³»ç»ŸåŒæ­¥å‘票状态变化
     */
    @Override
    public void syncStatusFromLegacySystem() {
        // æŸ¥è¯¢æœ€è¿‘3天有变化的数据
        String lastSyncTime = DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD_HH_MM_SS, DateUtils.addDays(new Date(), -3));
        List<Map<String, Object>> updatedList = legacyInvoiceMapper.selectUpdatedInvoices(lastSyncTime);
        for (Map<String, Object> legacyData : updatedList) {
            Integer legacyId = (Integer) legacyData.get("InvoiceID");
            SysInvoice invoice = sysInvoiceMapper.selectSysInvoiceByLegacyId(legacyId);
            if (invoice != null) {
                // çŠ¶æ€æ˜ å°„ï¼šæ—§ç³»ç»Ÿ AuditStatus (假设 3=通过, 4=拒绝, å…¶ä»–=申请中)
                Integer auditStatus = (Integer) legacyData.get("AuditStatus");
                if (auditStatus != null) {
                    if (auditStatus == 3) invoice.setStatus(1); // å·²é€šè¿‡
                    else if (auditStatus == 4) invoice.setStatus(2); // å·²é©³å›ž
                }
                // æ›´æ–°å…¶ä»–信息
                if (legacyData.get("InvoiceNo") != null) {
                    invoice.setInvoiceNo(legacyData.get("InvoiceNo").toString());
                }
                // å‘票文件链接 (优先取 EleCloud_PDF)
                String pdf = legacyData.get("EleCloud_PDF") != null ? legacyData.get("EleCloud_PDF").toString() : null;
                String url = legacyData.get("InvoiceURL") != null ? legacyData.get("InvoiceURL").toString() : null;
                invoice.setInvoiceUrl(pdf != null && !pdf.isEmpty() ? pdf : url);
                if (legacyData.get("AuditTime") != null) {
                    invoice.setAuditTime((Date) legacyData.get("AuditTime"));
                }
                if (legacyData.get("AuditMakeout") != null) {
                    invoice.setAuditRemarks(legacyData.get("AuditMakeout").toString());
                }
                sysInvoiceMapper.updateSysInvoice(invoice);
            }
        }
    }
    /**
     * æŸ¥è¯¢å¯é€‰æ‹©çš„任务列表
     */
    @Override
    public List<Map<String, Object>> selectSelectableTasks(Long userId, String searchKeyword, String serviceOrdClass) {
        return sysInvoiceMapper.selectSelectableTasks(userId, searchKeyword, serviceOrdClass);
    }
}
ruoyi-system/src/main/resources/mapper/system/LegacyInvoiceMapper.xml
New file
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.system.mapper.LegacyInvoiceMapper">
    <insert id="insertLegacyInvoice" parameterType="Map" useGeneratedKeys="true" keyProperty="InvoiceID">
        INSERT INTO InvoiceData (
            ServiceOrderIDPK, InvoiceType, InvoiceName, InvoiceMakeout,
            InvoiceCompanyPhone, InvoiceCompanyID, InvoiceCompanyAdd,
            InvoiceCompanyBank, InvoiceCompanyBankNo, InvoiceZipCode,
            Invoice_strAdd, Invoice_strName, Invoice_strPhone, Invoice_strEmail,
            ApplicationTime, AuditStatus, InvoiceMoney, ApplyOAID
        ) VALUES (
            #{ServiceOrderIDPK}, #{InvoiceType}, #{InvoiceName}, #{InvoiceMakeout},
            #{InvoiceCompanyPhone}, #{InvoiceCompanyID}, #{InvoiceCompanyAdd},
            #{InvoiceCompanyBank}, #{InvoiceCompanyBankNo}, #{InvoiceZipCode},
            #{Invoice_strAdd}, #{Invoice_strName}, #{Invoice_strPhone}, #{Invoice_strEmail},
            GETDATE(), 0, #{InvoiceMoney}, #{ApplyOAID}
        )
    </insert>
    <select id="selectUpdatedInvoices" parameterType="String" resultType="Map">
        SELECT
            InvoiceID, ServiceOrderIDPK, AuditStatus, AuditTime, AuditMakeout,
            InvoiceNo, InvoiceURL, InvoiceOddNo, EleCloud_PDF, EleCloud_Time
        FROM InvoiceData
        WHERE AuditTime &gt; #{lastSyncTime} OR EleCloud_Time &gt; #{lastSyncTime}
    </select>
    <select id="selectLegacyInvoiceByServiceOrderId" parameterType="Long" resultType="Map">
        SELECT * FROM InvoiceData WHERE ServiceOrderIDPK = #{serviceOrderId}
    </select>
</mapper>
ruoyi-system/src/main/resources/mapper/system/SysInvoiceMapper.xml
New file
@@ -0,0 +1,302 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.system.mapper.SysInvoiceMapper">
    <resultMap type="SysInvoice" id="SysInvoiceResult">
        <result property="invoiceId"    column="invoice_id"    />
        <result property="serviceOrderId"    column="service_order_id"    />
        <result property="legacyServiceOrderId"    column="legacy_service_order_id"    />
        <result property="invoiceType"    column="invoice_type"    />
        <result property="invoiceName"    column="invoice_name"    />
        <result property="invoiceMoney"    column="invoice_money"    />
        <result property="invoiceRemarks"    column="invoice_remarks"    />
        <result property="companyAddress"    column="company_address"    />
        <result property="companyBank"    column="company_bank"    />
        <result property="companyBankNo"    column="company_bank_no"    />
        <result property="zipCode"    column="zip_code"    />
        <result property="mailAddress"    column="mail_address"    />
        <result property="contactName"    column="contact_name"    />
        <result property="contactPhone"    column="contact_phone"    />
        <result property="contactEmail"    column="contact_email"    />
        <result property="status"    column="status"    />
        <result property="invoiceNo"    column="invoice_no"    />
        <result property="invoiceUrl"    column="invoice_url"    />
        <result property="applyUserId"    column="apply_user_id"    />
        <result property="applyTime"    column="apply_time"    />
        <result property="auditUserId"    column="audit_user_id"    />
        <result property="auditTime"    column="audit_time"    />
        <result property="auditRemarks"    column="audit_remarks"    />
        <result property="syncStatus"    column="sync_status"    />
        <result property="legacyInvoiceId"    column="legacy_invoice_id"    />
        <result property="serviceCode"    column="serviceCode"    />
    </resultMap>
    <sql id="selectSysInvoiceVo">
        select invoice_id, service_order_id, legacy_service_order_id, invoice_type, invoice_name, invoice_money, invoice_remarks, company_address, company_bank, company_bank_no, zip_code, mail_address, contact_name, contact_phone, contact_email, status, invoice_no, invoice_url, apply_user_id, apply_time, audit_user_id, audit_time, audit_remarks, sync_status, legacy_invoice_id from sys_invoice
    </sql>
    <select id="selectSysInvoiceList" parameterType="SysInvoice" resultMap="SysInvoiceResult">
        select i.invoice_id, i.service_order_id, i.legacy_service_order_id, i.invoice_type, i.invoice_name,
               i.invoice_money, i.invoice_remarks, i.company_address, i.company_bank, i.company_bank_no,
               i.zip_code, i.mail_address, i.contact_name, i.contact_phone, i.contact_email,
               i.status, i.invoice_no, i.invoice_url, i.apply_user_id, i.apply_time,
               i.audit_user_id, i.audit_time, i.audit_remarks, i.sync_status, i.legacy_invoice_id,
               CONCAT(
                   IFNULL(e.legacy_service_ord_class, ''),
                   DATE_FORMAT(e.legacy_service_ns_time, '%Y%m%d'),
                   '-',
                   LPAD(IFNULL(e.legacy_service_ord_no, ''), 3, '0')
               ) as serviceCode
        from sys_invoice i
        LEFT JOIN sys_task_emergency e ON i.service_order_id = e.task_id
        <where>
            <if test="serviceOrderId != null "> and i.service_order_id = #{serviceOrderId}</if>
            <if test="legacyServiceOrderId != null "> and i.legacy_service_order_id = #{legacyServiceOrderId}</if>
            <if test="invoiceType != null "> and i.invoice_type = #{invoiceType}</if>
            <if test="invoiceName != null  and invoiceName != ''"> and i.invoice_name like concat('%', #{invoiceName}, '%')</if>
            <if test="status != null "> and i.status = #{status}</if>
            <!-- serviceCode查询 -->
            <if test="params.serviceCode != null and params.serviceCode != ''">
                AND CONCAT(
                    IFNULL(e.legacy_service_ord_class, ''),
                    DATE_FORMAT(e.legacy_service_ns_time, '%Y%m%d'),
                    '-',
                    LPAD(IFNULL(e.legacy_service_ord_no, ''), 3, '0')
                ) LIKE CONCAT('%', #{params.serviceCode}, '%')
            </if>
            <if test="params.beginTime != null and params.beginTime != ''"><!-- å¼€å§‹æ—¶é—´æ£€ç´¢ -->
                AND date_format(i.apply_time,'%y%m%d') &gt;= date_format(#{params.beginTime},'%y%m%d')
            </if>
            <if test="params.endTime != null and params.endTime != ''"><!-- ç»“束时间检索 -->
                AND date_format(i.apply_time,'%y%m%d') &lt;= date_format(#{params.endTime},'%y%m%d')
            </if>
            <!-- åˆ†å…¬å¸æŸ¥è¯¢é€»è¾‘需要关联服务单表 -->
            <if test="params.serviceOrdClass != null and params.serviceOrdClass != ''">
                AND i.service_order_id IN (SELECT ServiceOrdID FROM service_order WHERE ServiceOrdClass = #{params.serviceOrdClass})
            </if>
        </where>
    </select>
    <!-- App端查询我的发票列表,返回Map包含serviceCode -->
    <select id="selectMyInvoiceList" parameterType="SysInvoice" resultType="map">
        SELECT
            i.invoice_id as invoiceId,
            i.service_order_id as serviceOrderId,
            i.legacy_service_order_id as legacyServiceOrderId,
            i.invoice_type as invoiceType,
            i.invoice_name as invoiceName,
            i.invoice_money as invoiceMoney,
            i.invoice_remarks as invoiceRemarks,
            i.company_address as companyAddress,
            i.company_bank as companyBank,
            i.company_bank_no as companyBankNo,
            i.zip_code as zipCode,
            i.mail_address as mailAddress,
            i.contact_name as contactName,
            i.contact_phone as contactPhone,
            i.contact_email as contactEmail,
            i.status,
            i.invoice_no as invoiceNo,
            i.invoice_url as invoiceUrl,
            i.apply_user_id as applyUserId,
            i.apply_time as applyTime,
            i.audit_user_id as auditUserId,
            i.audit_time as auditTime,
            i.audit_remarks as auditRemarks,
            i.sync_status as syncStatus,
            i.legacy_invoice_id as legacyInvoiceId,
            -- æž„建 serviceCode
            CONCAT(
                IFNULL(e.legacy_service_ord_class, ''),
                DATE_FORMAT(e.legacy_service_ns_time, '%Y%m%d'),
                '-',
                LPAD(IFNULL(e.legacy_service_ord_no, ''), 3, '0')
            ) as serviceCode
        FROM sys_invoice i
        LEFT JOIN sys_task_emergency e ON i.service_order_id = e.task_id
        <where>
            <if test="serviceOrderId != null "> and i.service_order_id = #{serviceOrderId}</if>
            <if test="legacyServiceOrderId != null "> and i.legacy_service_order_id = #{legacyServiceOrderId}</if>
            <if test="invoiceType != null "> and i.invoice_type = #{invoiceType}</if>
            <if test="invoiceName != null  and invoiceName != ''"> and i.invoice_name like concat('%', #{invoiceName}, '%')</if>
            <if test="status != null "> and i.status = #{status}</if>
            <!-- æœåŠ¡å•å·æœç´¢ -->
            <if test="params.serviceCode != null and params.serviceCode != ''">
                AND CONCAT(
                    IFNULL(e.legacy_service_ord_class, ''),
                    DATE_FORMAT(e.legacy_service_ns_time, '%Y%m%d'),
                    '-',
                    IFNULL(e.legacy_service_ord_no, '')
                ) LIKE CONCAT('%', #{params.serviceCode}, '%')
            </if>
            <if test="params.beginTime != null and params.beginTime != ''"><!-- å¼€å§‹æ—¶é—´æ£€ç´¢ -->
                AND date_format(i.apply_time,'%y%m%d') &gt;= date_format(#{params.beginTime},'%y%m%d')
            </if>
            <if test="params.endTime != null and params.endTime != ''"><!-- ç»“束时间检索 -->
                AND date_format(i.apply_time,'%y%m%d') &lt;= date_format(#{params.endTime},'%y%m%d')
            </if>
            <!-- åˆ†å…¬å¸æŸ¥è¯¢é€»è¾‘需要关联服务单表 -->
            <if test="params.serviceOrdClass != null and params.serviceOrdClass != ''">
                AND i.service_order_id IN (SELECT ServiceOrdID FROM service_order WHERE ServiceOrdClass = #{params.serviceOrdClass})
            </if>
        </where>
    </select>
    <select id="selectSysInvoiceByInvoiceId" parameterType="Long" resultMap="SysInvoiceResult">
        <include refid="selectSysInvoiceVo"/>
        where invoice_id = #{invoiceId}
    </select>
    <select id="selectSysInvoiceByLegacyId" parameterType="Integer" resultMap="SysInvoiceResult">
        <include refid="selectSysInvoiceVo"/>
        where legacy_invoice_id = #{legacyInvoiceId}
    </select>
    <select id="selectSelectableTasks" resultType="Map">
        SELECT
            t.task_id as taskId,
            t.task_code as taskCode,
            e.legacy_service_ord_id as legacyServiceOrderId,
            t.actual_end_time as completionTime,
            t.departure_address as departure,
            t.destination_address as destination,
            e.legacy_service_ord_class as legacyServiceOrdClass,
            e.legacy_service_ns_time as legacyServiceNsTime,
            e.legacy_service_ord_no as legacyServiceOrdNo,
            IFNULL(e.transfer_price, 0) as transferPrice,
            CONCAT(
                IFNULL(e.legacy_service_ord_class, ''),
                DATE_FORMAT(e.legacy_service_ns_time, '%Y%m%d'),
                '-',
                LPAD(IFNULL(e.legacy_service_ord_no, ''), 3, '0')
            ) as serviceCode
        FROM sys_task t
        INNER JOIN sys_task_emergency e ON t.task_id = e.task_id
        WHERE t.task_type = 'EMERGENCY_TRANSFER'
          AND t.task_status = 'COMPLETED'
          AND (t.creator_id = #{userId} OR t.assignee_id = #{userId})
          AND t.task_id NOT IN (
              SELECT service_order_id
              FROM sys_invoice
              WHERE status IN (0, 1)
          )
          <if test="searchKeyword != null and searchKeyword != ''">
              AND (
                  t.task_code LIKE CONCAT('%', #{searchKeyword}, '%')
                  OR e.legacy_service_ord_no LIKE CONCAT('%', #{searchKeyword}, '%')
                  OR CONCAT(
                      IFNULL(e.legacy_service_ord_class, ''),
                      DATE_FORMAT(e.legacy_service_ns_time, '%Y%m%d'),
                      '-',
                    LPAD(IFNULL(e.legacy_service_ord_no, ''), 3, '0')
                  ) LIKE CONCAT('%', #{searchKeyword}, '%')
              )
          </if>
          <if test="serviceOrdClass != null and serviceOrdClass != ''">
              AND e.legacy_service_ord_class = #{serviceOrdClass}
          </if>
        ORDER BY t.actual_end_time DESC
        LIMIT 100
    </select>
    <insert id="insertSysInvoice" parameterType="SysInvoice" useGeneratedKeys="true" keyProperty="invoiceId">
        insert into sys_invoice
        <trim prefix="(" suffix=")" suffixOverrides=",">
            <if test="serviceOrderId != null">service_order_id,</if>
            <if test="legacyServiceOrderId != null">legacy_service_order_id,</if>
            <if test="invoiceType != null">invoice_type,</if>
            <if test="invoiceName != null and invoiceName != ''">invoice_name,</if>
            <if test="invoiceMoney != null">invoice_money,</if>
            <if test="invoiceRemarks != null">invoice_remarks,</if>
            <if test="companyAddress != null">company_address,</if>
            <if test="companyBank != null">company_bank,</if>
            <if test="companyBankNo != null">company_bank_no,</if>
            <if test="zipCode != null">zip_code,</if>
            <if test="mailAddress != null">mail_address,</if>
            <if test="contactName != null">contact_name,</if>
            <if test="contactPhone != null">contact_phone,</if>
            <if test="contactEmail != null">contact_email,</if>
            <if test="status != null">status,</if>
            <if test="invoiceNo != null">invoice_no,</if>
            <if test="invoiceUrl != null">invoice_url,</if>
            <if test="applyUserId != null">apply_user_id,</if>
            <if test="applyTime != null">apply_time,</if>
            <if test="auditUserId != null">audit_user_id,</if>
            <if test="auditTime != null">audit_time,</if>
            <if test="auditRemarks != null">audit_remarks,</if>
            <if test="syncStatus != null">sync_status,</if>
            <if test="legacyInvoiceId != null">legacy_invoice_id,</if>
         </trim>
        <trim prefix="values (" suffix=")" suffixOverrides=",">
            <if test="serviceOrderId != null">#{serviceOrderId},</if>
            <if test="legacyServiceOrderId != null">#{legacyServiceOrderId},</if>
            <if test="invoiceType != null">#{invoiceType},</if>
            <if test="invoiceName != null and invoiceName != ''">#{invoiceName},</if>
            <if test="invoiceMoney != null">#{invoiceMoney},</if>
            <if test="invoiceRemarks != null">#{invoiceRemarks},</if>
            <if test="companyAddress != null">#{companyAddress},</if>
            <if test="companyBank != null">#{companyBank},</if>
            <if test="companyBankNo != null">#{companyBankNo},</if>
            <if test="zipCode != null">#{zipCode},</if>
            <if test="mailAddress != null">#{mailAddress},</if>
            <if test="contactName != null">#{contactName},</if>
            <if test="contactPhone != null">#{contactPhone},</if>
            <if test="contactEmail != null">#{contactEmail},</if>
            <if test="status != null">#{status},</if>
            <if test="invoiceNo != null">#{invoiceNo},</if>
            <if test="invoiceUrl != null">#{invoiceUrl},</if>
            <if test="applyUserId != null">#{applyUserId},</if>
            <if test="applyTime != null">#{applyTime},</if>
            <if test="auditUserId != null">#{auditUserId},</if>
            <if test="auditTime != null">#{auditTime},</if>
            <if test="auditRemarks != null">#{auditRemarks},</if>
            <if test="syncStatus != null">#{syncStatus},</if>
            <if test="legacyInvoiceId != null">#{legacyInvoiceId},</if>
         </trim>
    </insert>
    <update id="updateSysInvoice" parameterType="SysInvoice">
        update sys_invoice
        <trim prefix="SET" suffixOverrides=",">
            <if test="serviceOrderId != null">service_order_id = #{serviceOrderId},</if>
            <if test="legacyServiceOrderId != null">legacy_service_order_id = #{legacyServiceOrderId},</if>
            <if test="invoiceType != null">invoice_type = #{invoiceType},</if>
            <if test="invoiceName != null and invoiceName != ''">invoice_name = #{invoiceName},</if>
            <if test="invoiceMoney != null">invoice_money = #{invoiceMoney},</if>
            <if test="invoiceRemarks != null">invoice_remarks = #{invoiceRemarks},</if>
            <if test="companyAddress != null">company_address = #{companyAddress},</if>
            <if test="companyBank != null">company_bank = #{companyBank},</if>
            <if test="companyBankNo != null">company_bank_no = #{companyBankNo},</if>
            <if test="zipCode != null">zip_code = #{zipCode},</if>
            <if test="mailAddress != null">mail_address = #{mailAddress},</if>
            <if test="contactName != null">contact_name = #{contactName},</if>
            <if test="contactPhone != null">contact_phone = #{contactPhone},</if>
            <if test="contactEmail != null">contact_email = #{contactEmail},</if>
            <if test="status != null">status = #{status},</if>
            <if test="invoiceNo != null">invoice_no = #{invoiceNo},</if>
            <if test="invoiceUrl != null">invoice_url = #{invoiceUrl},</if>
            <if test="applyUserId != null">apply_user_id = #{applyUserId},</if>
            <if test="applyTime != null">apply_time = #{applyTime},</if>
            <if test="auditUserId != null">audit_user_id = #{auditUserId},</if>
            <if test="auditTime != null">audit_time = #{auditTime},</if>
            <if test="auditRemarks != null">audit_remarks = #{auditRemarks},</if>
            <if test="syncStatus != null">sync_status = #{syncStatus},</if>
            <if test="legacyInvoiceId != null">legacy_invoice_id = #{legacyInvoiceId},</if>
        </trim>
        where invoice_id = #{invoiceId}
    </update>
    <delete id="deleteSysInvoiceByInvoiceId" parameterType="Long">
        delete from sys_invoice where invoice_id = #{invoiceId}
    </delete>
    <delete id="deleteSysInvoiceByInvoiceIds" parameterType="String">
        delete from sys_invoice where invoice_id in
        <foreach item="invoiceId" collection="array" open="(" separator="," close=")">
            #{invoiceId}
        </foreach>
    </delete>
</mapper>
ruoyi-system/src/main/resources/mapper/system/VehicleGpsSegmentMileageMapper.xml
@@ -79,7 +79,7 @@
               segment_distance, gps_point_count, gps_ids, task_id, task_code, calculate_method
        FROM tb_vehicle_gps_segment_mileage
        WHERE vehicle_id = #{vehicleId}
          AND segment_start_time &lt;= #{endTime}
          AND segment_start_time between #{startDate} and #{endDate}
          AND segment_end_time &gt;= #{startTime}
          AND segment_distance &gt; 0
        ORDER BY segment_start_time
ruoyi-ui/src/api/system/dept.js
@@ -59,6 +59,14 @@
  })
}
// æŒ‰OA订单类别查询分公司列表(别名)
export function listBranchByOaOrderClass() {
  return request({
    url: '/system/dept/branch/by-oa',
    method: 'get'
  })
}
// æŒ‰å¤–部传入的用户ID返回其可管理分公司列表
export function listBranchByUser(userId) {
  return request({
ruoyi-ui/src/api/system/invoice.js
New file
@@ -0,0 +1,60 @@
import request from '@/utils/request'
// æŸ¥è¯¢å‘票申请列表
export function listInvoice(query) {
  return request({
    url: '/system/invoice/list',
    method: 'get',
    params: query
  })
}
// æŸ¥è¯¢å‘票申请详细
export function getInvoice(invoiceId) {
  return request({
    url: '/system/invoice/' + invoiceId,
    method: 'get'
  })
}
// æ–°å¢žå‘票申请
export function addInvoice(data) {
  return request({
    url: '/system/invoice',
    method: 'post',
    data: data
  })
}
// ä¿®æ”¹å‘票申请
export function updateInvoice(data) {
  return request({
    url: '/system/invoice',
    method: 'put',
    data: data
  })
}
// åˆ é™¤å‘票申请
export function delInvoice(invoiceId) {
  return request({
    url: '/system/invoice/' + invoiceId,
    method: 'delete'
  })
}
// æ‰‹åŠ¨åŒæ­¥çŠ¶æ€
export function syncInvoiceStatus() {
  return request({
    url: '/system/invoice/syncStatus',
    method: 'get'
  })
}
// åŒæ­¥å•个发票到旧系统
export function syncInvoiceToLegacy(invoiceId) {
  return request({
    url: '/system/invoice/syncToLegacy/' + invoiceId,
    method: 'post'
  })
}
ruoyi-ui/src/api/task.js
@@ -302,4 +302,12 @@
    url: '/task/syncDispatchOrder/' + taskId,
    method: 'post'
  })
}
// æ‰‹åŠ¨åŒæ­¥ä»»åŠ¡çŠ¶æ€åˆ°æ—§ç³»ç»Ÿ
export function syncTaskStatus(taskId) {
  return request({
    url: '/task/syncTaskStatus/' + taskId,
    method: 'post'
  })
}
ruoyi-ui/src/router/index.js
@@ -145,6 +145,27 @@
    hidden: false,
    name: 'H5TaskCreate',
    meta: { title: '创建任务' }
  },
  {
    path: '/system/invoice/detail',
    component: () => import('@/views/system/invoice/detail'),
    name: 'InvoiceDetail',
    hidden: true,
    meta: { title: '发票详情', activeMenu: '/system/invoice' }
  },
  {
    path: '/system/invoice/audit',
    component: () => import('@/views/system/invoice/audit'),
    name: 'InvoiceAudit',
    hidden: true,
    meta: { title: '审核发票', activeMenu: '/system/invoice' }
  },
  {
    path: '/system/invoice/apply',
    component: () => import('@/views/system/invoice/apply'),
    name: 'InvoiceApply',
    hidden: true,
    meta: { title: '申请发票', activeMenu: '/system/invoice' }
  }
]
ruoyi-ui/src/views/system/invoice/apply.vue
New file
@@ -0,0 +1,261 @@
<template>
  <div class="app-container">
    <el-card class="box-card">
      <div slot="header" class="clearfix">
        <span class="card-title">发票申请</span>
        <el-button style="float: right; padding: 3px 0" type="text" @click="goBack">返回</el-button>
      </div>
      <el-form ref="form" :model="form" :rules="rules" label-width="120px">
        <!-- ä»»åŠ¡ä¿¡æ¯å±•ç¤º -->
        <el-divider>任务信息</el-divider>
        <el-descriptions :column="2" border v-if="taskInfo.taskId">
          <el-descriptions-item label="任务编号">{{ taskInfo.taskCode }}</el-descriptions-item>
          <el-descriptions-item label="服务单号">{{ taskInfo.serviceCode || taskInfo.legacyServiceOrderId || '-' }}</el-descriptions-item>
          <el-descriptions-item label="出发地" :span="2">{{ taskInfo.departure }}</el-descriptions-item>
          <el-descriptions-item label="目的地" :span="2">{{ taskInfo.destination }}</el-descriptions-item>
          <el-descriptions-item label="完成时间">{{ taskInfo.completionTime }}</el-descriptions-item>
          <el-descriptions-item label="任务金额">
            <span style="color: #F56C6C; font-weight: bold;">Â¥{{ taskInfo.transferPrice || '0.00' }}</span>
          </el-descriptions-item>
        </el-descriptions>
        <!-- å‘票信息 -->
        <el-divider>发票信息</el-divider>
        <el-form-item label="开票类型" prop="invoiceType">
          <el-radio-group v-model="form.invoiceType">
            <el-radio :label="1">个人</el-radio>
            <el-radio :label="2">企业</el-radio>
          </el-radio-group>
        </el-form-item>
        <el-form-item label="发票抬头" prop="invoiceName">
          <el-input v-model="form.invoiceName" placeholder="请输入发票抬头" maxlength="200" />
        </el-form-item>
        <el-form-item label="发票金额" prop="invoiceMoney">
          <el-input
            v-model="form.invoiceMoney"
            placeholder="请输入发票金额"
            type="number"
            :max="maxInvoiceMoney"
          >
            <template slot="append">元</template>
          </el-input>
          <div v-if="maxInvoiceMoney" style="color: #909399; font-size: 12px; margin-top: 4px;">
            å¯ç”³è¯·é‡‘额上限:¥{{ maxInvoiceMoney }}
          </div>
        </el-form-item>
        <el-form-item label="发票备注">
          <el-input v-model="form.invoiceRemarks" type="textarea" placeholder="请输入备注" maxlength="500" />
        </el-form-item>
        <!-- ä¼ä¸šä¿¡æ¯ï¼ˆä»…企业类型显示) -->
        <template v-if="form.invoiceType === 2">
          <el-divider>企业信息</el-divider>
          <el-form-item label="注册地址" prop="companyAddress">
            <el-input v-model="form.companyAddress" placeholder="请输入企业注册地址" maxlength="200" />
          </el-form-item>
          <el-form-item label="开户银行" prop="companyBank">
            <el-input v-model="form.companyBank" placeholder="请输入开户银行" maxlength="100" />
          </el-form-item>
          <el-form-item label="银行账号" prop="companyBankNo">
            <el-input v-model="form.companyBankNo" placeholder="请输入银行账号" maxlength="50" />
          </el-form-item>
        </template>
        <!-- é‚®å¯„信息 -->
        <el-divider>邮寄信息</el-divider>
        <el-form-item label="邮寄地址" prop="mailAddress">
          <el-input v-model="form.mailAddress" placeholder="请输入邮寄地址" maxlength="200" />
        </el-form-item>
        <el-form-item label="邮编">
          <el-input v-model="form.zipCode" placeholder="请输入邮编" maxlength="10" />
        </el-form-item>
        <el-form-item label="联系人" prop="contactName">
          <el-input v-model="form.contactName" placeholder="请输入联系人" maxlength="50" />
        </el-form-item>
        <el-form-item label="联系电话" prop="contactPhone">
          <el-input v-model="form.contactPhone" placeholder="请输入联系电话" maxlength="20" />
        </el-form-item>
        <el-form-item label="联系邮箱">
          <el-input v-model="form.contactEmail" placeholder="请输入联系邮箱" maxlength="100" />
        </el-form-item>
      </el-form>
      <div class="action-bar" style="margin-top: 20px; text-align: center;">
        <el-button @click="goBack">取消</el-button>
        <el-button type="primary" @click="submitForm">提交申请</el-button>
      </div>
    </el-card>
  </div>
</template>
<script>
import { addInvoice } from "@/api/system/invoice";
export default {
  name: "InvoiceApply",
  data() {
    return {
      // ä»»åŠ¡ä¿¡æ¯
      taskInfo: {
        taskId: null,
        taskCode: null,
        legacyServiceOrderId: null,
        serviceCode: null,
        departure: null,
        destination: null,
        completionTime: null,
        transferPrice: null
      },
      // è¡¨å•数据
      form: {
        serviceOrderId: null,
        legacyServiceOrderId: null,  // æ—§ç³»ç»ŸæœåŠ¡å•ID
        invoiceType: 1,
        invoiceName: null,
        invoiceMoney: null,
        invoiceRemarks: null,
        companyAddress: null,
        companyBank: null,
        companyBankNo: null,
        zipCode: null,
        mailAddress: null,
        contactName: null,
        contactPhone: null,
        contactEmail: null
      },
      // æœ€å¤§å‘票金额
      maxInvoiceMoney: null,
      // è¡¨å•校验
      rules: {
        invoiceType: [
          { required: true, message: "请选择开票类型", trigger: "change" }
        ],
        invoiceName: [
          { required: true, message: "请输入发票抬头", trigger: "blur" }
        ],
        invoiceMoney: [
          { required: true, message: "请输入发票金额", trigger: "blur" },
          {
            validator: (rule, value, callback) => {
              if (!value || value <= 0) {
                callback(new Error('发票金额必须大于0'));
              } else if (this.maxInvoiceMoney && parseFloat(value) > this.maxInvoiceMoney) {
                callback(new Error(`发票金额不能超过任务金额¥${this.maxInvoiceMoney}`));
              } else {
                callback();
              }
            },
            trigger: 'blur'
          }
        ],
        companyAddress: [
          { required: true, message: "请输入企业注册地址", trigger: "blur" }
        ],
        companyBank: [
          { required: true, message: "请输入开户银行", trigger: "blur" }
        ],
        companyBankNo: [
          { required: true, message: "请输入银行账号", trigger: "blur" }
        ],
        mailAddress: [
          { required: true, message: "请输入邮寄地址", trigger: "blur" }
        ],
        contactName: [
          { required: true, message: "请输入联系人", trigger: "blur" }
        ],
        contactPhone: [
          { required: true, message: "请输入联系电话", trigger: "blur" },
          { pattern: /^1[3-9]\d{9}$/, message: "请输入正确的手机号码", trigger: "blur" }
        ]
      }
    };
  },
  created() {
    // ä»Ž sessionStorage èŽ·å–ä»»åŠ¡ä¿¡æ¯
    const taskInfoStr = sessionStorage.getItem('invoiceTaskInfo');
    if (taskInfoStr) {
      this.taskInfo = JSON.parse(taskInfoStr);
      this.form.serviceOrderId = this.taskInfo.taskId;
      // å¦‚果任务信息中有旧系统服务单ID,也要保存
      if (this.taskInfo.legacyServiceOrderId) {
        this.form.legacyServiceOrderId = this.taskInfo.legacyServiceOrderId;
      }
      // è®¾ç½®æœ€å¤§å‘票金额
      if (this.taskInfo.transferPrice) {
        this.maxInvoiceMoney = parseFloat(this.taskInfo.transferPrice);
        // è‡ªåŠ¨å¸¦å…¥ä»»åŠ¡é‡‘é¢
        this.form.invoiceMoney = this.maxInvoiceMoney;
      }
      // æ¸…除 sessionStorage
      sessionStorage.removeItem('invoiceTaskInfo');
    } else if (this.$route.query.taskId) {
      // å¦‚果没有 sessionStorage,尝试从 query èŽ·å–
      this.$modal.msgError("缺少任务信息,请从任务详情页进入");
      this.goBack();
    }
  },
  methods: {
    /** æäº¤è¡¨å• */
    submitForm() {
      this.$refs["form"].validate(valid => {
        if (valid) {
          // ä¼ä¸šç±»åž‹éœ€è¦éªŒè¯ä¼ä¸šä¿¡æ¯
          if (this.form.invoiceType === 2) {
            if (!this.form.companyAddress || !this.form.companyBank || !this.form.companyBankNo) {
              this.$modal.msgError("请完善企业信息");
              return;
            }
          }
          // å†æ¬¡éªŒè¯é‡‘额
          const money = parseFloat(this.form.invoiceMoney);
          if (this.maxInvoiceMoney && money > this.maxInvoiceMoney) {
            this.$modal.msgError(`发票金额不能超过任务金额¥${this.maxInvoiceMoney}`);
            return;
          }
          addInvoice(this.form).then(response => {
            this.$modal.msgSuccess("提交成功");
            setTimeout(() => {
              this.goBack();
            }, 1500);
          });
        }
      });
    },
    /** è¿”回 */
    goBack() {
      this.$tab.closePage();
    }
  }
};
</script>
<style scoped lang="scss">
.card-title {
  font-size: 18px;
  font-weight: bold;
  color: #303133;
}
.action-bar {
  border-top: 1px solid #EBEEF5;
  padding-top: 20px;
}
</style>
ruoyi-ui/src/views/system/invoice/audit.vue
New file
@@ -0,0 +1,311 @@
<template>
  <div class="app-container">
    <el-card class="box-card">
      <div slot="header" class="clearfix">
        <span class="card-title">审核发票申请</span>
        <el-button style="float: right; padding: 3px 0" type="text" @click="goBack">返回</el-button>
      </div>
      <!-- ç”³è¯·ä¿¡æ¯ï¼ˆåªè¯»ï¼‰ -->
      <el-form ref="viewForm" :model="invoice" label-width="120px" disabled>
        <el-divider content-position="left">申请信息</el-divider>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="服务单号">
              <el-input v-model="invoice.serviceCode" placeholder="暂无" />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="开票类型">
              <el-radio-group v-model="invoice.invoiceType">
                <el-radio :label="1">个人</el-radio>
                <el-radio :label="2">企业</el-radio>
              </el-radio-group>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="24">
            <el-form-item label="发票抬头">
              <el-input v-model="invoice.invoiceName" />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="发票金额">
              <el-input v-model="invoice.invoiceMoney">
                <template slot="prepend">Â¥</template>
              </el-input>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="联系电话">
              <el-input v-model="invoice.contactPhone" />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20" v-if="invoice.invoiceType === 2">
          <el-col :span="24">
            <el-form-item label="注册地址">
              <el-input v-model="invoice.companyAddress" />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20" v-if="invoice.invoiceType === 2">
          <el-col :span="12">
            <el-form-item label="开户银行">
              <el-input v-model="invoice.companyBank" />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="银行账号">
              <el-input v-model="invoice.companyBankNo" />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="24">
            <el-form-item label="邮寄地址">
              <el-input v-model="invoice.mailAddress" />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="24">
            <el-form-item label="备注">
              <el-input type="textarea" v-model="invoice.invoiceRemarks" :rows="3" />
            </el-form-item>
          </el-col>
        </el-row>
      </el-form>
      <!-- å®¡æ ¸ä¿¡æ¯ -->
      <el-form ref="auditForm" :model="auditForm" :rules="rules" label-width="120px">
        <el-divider content-position="left">审核信息</el-divider>
        <el-form-item label="审核结果" prop="status">
          <el-radio-group v-model="auditForm.status" @change="handleStatusChange">
            <el-radio :label="1">通过</el-radio>
            <el-radio :label="2">驳回</el-radio>
          </el-radio-group>
        </el-form-item>
        <!-- å®¡æ ¸é€šè¿‡æ—¶ä¸Šä¼ å‘票 -->
        <el-form-item
          v-if="auditForm.status === 1"
          label="发票文件"
          prop="invoiceUrl"
          :rules="[{ required: true, message: '请上传发票文件', trigger: 'change' }]"
        >
          <el-upload
            class="upload-demo"
            :action="uploadAction"
            :headers="uploadHeaders"
            :on-success="handleUploadSuccess"
            :on-error="handleUploadError"
            :before-upload="beforeUpload"
            :file-list="fileList"
            :limit="1"
            accept=".pdf,.jpg,.jpeg,.png"
          >
            <el-button size="small" type="primary">点击上传</el-button>
            <div slot="tip" class="el-upload__tip">支持PDF、JPG、PNG格式,且不超过10MB</div>
          </el-upload>
          <div v-if="auditForm.invoiceUrl" style="margin-top: 10px;">
            <el-link type="primary" :href="auditForm.invoiceUrl" target="_blank">查看已上传发票</el-link>
          </div>
        </el-form-item>
        <el-form-item v-if="auditForm.status === 1" label="发票编号" prop="invoiceNo">
          <el-input v-model="auditForm.invoiceNo" placeholder="请输入发票编号" />
        </el-form-item>
        <!-- é©³å›žæ—¶å¿…填驳回原因 -->
        <el-form-item
          label="审核备注"
          prop="auditRemarks"
          :rules="auditForm.status === 2 ? [{ required: true, message: '请输入驳回原因', trigger: 'blur' }] : []"
        >
          <el-input
            type="textarea"
            v-model="auditForm.auditRemarks"
            :rows="4"
            :placeholder="auditForm.status === 2 ? '请输入驳回原因(必填)' : '请输入审核意见(可选)'"
          />
        </el-form-item>
      </el-form>
      <!-- æ“ä½œæŒ‰é’® -->
      <div class="action-bar">
        <el-button @click="goBack">取 æ¶ˆ</el-button>
        <el-button type="primary" @click="submitAudit" :loading="submitting">提交审核</el-button>
      </div>
    </el-card>
  </div>
</template>
<script>
import { getInvoice, updateInvoice } from "@/api/system/invoice";
import { getToken } from "@/utils/auth";
import { Message } from 'element-ui';
export default {
  name: "InvoiceAudit",
  data() {
    return {
      // å‘票信息
      invoice: {},
      // å®¡æ ¸è¡¨å•
      auditForm: {
        invoiceId: null,
        status: 1,
        invoiceNo: null,
        invoiceUrl: null,
        auditRemarks: null
      },
      // ä¸Šä¼ ç›¸å…³
      uploadAction: process.env.VUE_APP_BASE_API + "/common/upload",
      uploadHeaders: { Authorization: "Bearer " + getToken() },
      fileList: [],
      // æäº¤çŠ¶æ€
      submitting: false,
      // è¡¨å•验证
      rules: {
        status: [{ required: true, message: "请选择审核结果", trigger: "change" }]
      }
    };
  },
  created() {
    const invoiceId = this.$route.query.invoiceId;
    if (invoiceId) {
      this.getDetail(invoiceId);
    } else {
      this.$modal.msgError("缺少发票ID参数");
      this.goBack();
    }
  },
  methods: {
    /** èŽ·å–å‘ç¥¨è¯¦æƒ… */
    getDetail(invoiceId) {
      getInvoice(invoiceId).then(response => {
        this.invoice = response.data;
        this.auditForm.invoiceId = response.data.invoiceId;
        // å¦‚果已有发票文件,显示在文件列表中
        if (response.data.invoiceUrl) {
          this.auditForm.invoiceUrl = response.data.invoiceUrl;
          this.fileList = [{
            name: '已上传发票',
            url: response.data.invoiceUrl
          }];
        }
      }).catch(() => {
        this.$modal.msgError("获取发票详情失败");
        this.goBack();
      });
    },
    /** çŠ¶æ€å˜åŒ–å¤„ç† */
    handleStatusChange(value) {
      // åˆ‡æ¢çŠ¶æ€æ—¶æ¸…ç©ºå®¡æ ¸å¤‡æ³¨
      this.auditForm.auditRemarks = '';
    },
    /** æ–‡ä»¶ä¸Šä¼ å‰éªŒè¯ */
    beforeUpload(file) {
      const isLt10M = file.size / 1024 / 1024 < 10;
      if (!isLt10M) {
        Message.error('上传文件大小不能超过 10MB!');
        return false;
      }
      const fileType = file.type;
      const isPDF = fileType === 'application/pdf';
      const isImage = fileType.startsWith('image/');
      if (!isPDF && !isImage) {
        Message.error('只能上传PDF或图片文件!');
        return false;
      }
      return true;
    },
    /** æ–‡ä»¶ä¸Šä¼ æˆåŠŸ */
    handleUploadSuccess(response, file, fileList) {
      if (response.code === 200) {
        this.auditForm.invoiceUrl = response.url || response.fileName;
        this.fileList = fileList;
        Message.success('上传成功');
      } else {
        Message.error(response.msg || '上传失败');
      }
    },
    /** æ–‡ä»¶ä¸Šä¼ å¤±è´¥ */
    handleUploadError(err, file, fileList) {
      Message.error('上传失败,请重试');
      console.error(err);
    },
    /** æäº¤å®¡æ ¸ */
    submitAudit() {
      this.$refs["auditForm"].validate(valid => {
        if (valid) {
          // å®¡æ ¸é€šè¿‡æ—¶å¿…须上传发票
          if (this.auditForm.status === 1 && !this.auditForm.invoiceUrl) {
            this.$modal.msgError('审核通过时必须上传发票文件');
            return;
          }
          // é©³å›žæ—¶å¿…须填写驳回原因
          if (this.auditForm.status === 2 && !this.auditForm.auditRemarks) {
            this.$modal.msgError('驳回时必须填写驳回原因');
            return;
          }
          this.submitting = true;
          updateInvoice(this.auditForm).then(response => {
            this.$modal.msgSuccess("审核成功");
            this.goBack();
          }).catch(() => {
            this.submitting = false;
          });
        }
      });
    },
    /** è¿”回列表 */
    goBack() {
      this.$tab.closePage();
    }
  }
};
</script>
<style scoped lang="scss">
.card-title {
  font-size: 18px;
  font-weight: bold;
  color: #303133;
}
.action-bar {
  border-top: 1px solid #EBEEF5;
  padding-top: 20px;
  text-align: center;
}
::v-deep .el-form-item__label {
  font-weight: 500;
}
::v-deep .el-divider__text {
  font-size: 16px;
  font-weight: bold;
  color: #409EFF;
}
</style>
ruoyi-ui/src/views/system/invoice/detail.vue
New file
@@ -0,0 +1,279 @@
<template>
  <div class="app-container">
    <el-card class="box-card">
      <div slot="header" class="clearfix">
        <span class="card-title">发票申请详情</span>
        <el-button style="float: right; padding: 3px 0" type="text" @click="goBack">返回</el-button>
      </div>
      <el-descriptions :column="2" border>
        <!-- åŸºæœ¬ä¿¡æ¯ -->
        <el-descriptions-item label="发票ID">{{ invoice.invoiceId }}</el-descriptions-item>
        <el-descriptions-item label="服务单号">{{ invoice.serviceCode || invoice.legacyServiceOrderId || '-' }}</el-descriptions-item>
        <el-descriptions-item label="开票类型">
          <el-tag :type="invoice.invoiceType === 2 ? 'success' : 'info'" size="small">
            {{ invoice.invoiceType === 2 ? '企业' : '个人' }}
          </el-tag>
        </el-descriptions-item>
        <el-descriptions-item label="申请状态">
          <el-tag :type="invoice.status === 1 ? 'success' : (invoice.status === 2 ? 'danger' : 'warning')" size="small">
            {{ statusFormat(invoice.status) }}
          </el-tag>
        </el-descriptions-item>
        <!-- å‘票信息 -->
        <el-descriptions-item label="发票抬头" :span="2">{{ invoice.invoiceName }}</el-descriptions-item>
        <el-descriptions-item label="发票金额">
          <span class="text-price">Â¥{{ formatMoney(invoice.invoiceMoney) }}</span>
        </el-descriptions-item>
        <el-descriptions-item label="发票编号">{{ invoice.invoiceNo || '-' }}</el-descriptions-item>
        <!-- è”系信息 -->
        <el-descriptions-item label="联系人">{{ invoice.contactName || '-' }}</el-descriptions-item>
        <el-descriptions-item label="联系电话">{{ invoice.contactPhone || '-' }}</el-descriptions-item>
        <el-descriptions-item label="联系邮箱" :span="2">{{ invoice.contactEmail || '-' }}</el-descriptions-item>
        <!-- ä¼ä¸šä¿¡æ¯ï¼ˆä»…企业类型显示) -->
        <template v-if="invoice.invoiceType === 2">
          <el-descriptions-item label="注册地址" :span="2">{{ invoice.companyAddress || '-' }}</el-descriptions-item>
          <el-descriptions-item label="开户银行">{{ invoice.companyBank || '-' }}</el-descriptions-item>
          <el-descriptions-item label="银行账号">{{ invoice.companyBankNo || '-' }}</el-descriptions-item>
        </template>
        <!-- é‚®å¯„信息 -->
        <el-descriptions-item label="邮寄地址" :span="2">{{ invoice.mailAddress || '-' }}</el-descriptions-item>
        <el-descriptions-item label="邮编">{{ invoice.zipCode || '-' }}</el-descriptions-item>
        <el-descriptions-item label="备注" :span="2">{{ invoice.invoiceRemarks || '-' }}</el-descriptions-item>
        <!-- æ—¶é—´ä¿¡æ¯ -->
        <el-descriptions-item label="申请时间">{{ parseTime(invoice.applyTime) }}</el-descriptions-item>
        <el-descriptions-item label="审核时间">{{ parseTime(invoice.auditTime) || '-' }}</el-descriptions-item>
        <!-- å®¡æ ¸ä¿¡æ¯ -->
        <el-descriptions-item label="审核备注" :span="2">
          <span :class="invoice.status === 2 ? 'text-danger' : ''">{{ invoice.auditRemarks || '-' }}</span>
        </el-descriptions-item>
        <!-- åŒæ­¥çŠ¶æ€ -->
        <el-descriptions-item label="同步状态">
          <el-tag
            :type="invoice.syncStatus === 1 ? 'success' : (invoice.syncStatus === 2 ? 'danger' : 'info')"
            size="small"
          >
            {{ syncStatusFormat(invoice.syncStatus) }}
          </el-tag>
        </el-descriptions-item>
        <el-descriptions-item label="旧系统发票ID">
          <span v-if="invoice.legacyInvoiceId">{{ invoice.legacyInvoiceId }}</span>
          <span v-else class="text-gray">未同步</span>
        </el-descriptions-item>
        <!-- å‘票文件 -->
        <el-descriptions-item label="发票文件" :span="2" v-if="invoice.invoiceUrl">
          <el-link type="primary" :href="getFullUrl(invoice.invoiceUrl)" target="_blank" icon="el-icon-view">
            æŸ¥çœ‹å‘票文件
          </el-link>
          <el-link type="success" :href="getFullUrl(invoice.invoiceUrl)" :download="getFileName(invoice.invoiceUrl)" icon="el-icon-download" style="margin-left: 10px;">
            ä¸‹è½½å‘票
          </el-link>
        </el-descriptions-item>
      </el-descriptions>
      <!-- æ“ä½œæŒ‰é’® -->
      <div class="action-bar" style="margin-top: 20px; text-align: center;">
        <el-button @click="goBack">返回列表</el-button>
        <el-button
          type="primary"
          v-if="invoice.status === 0"
          @click="handleAudit"
          v-hasPermi="['system:invoice:edit']"
        >
          å®¡æ ¸
        </el-button>
        <el-button
          type="success"
          v-if="invoice.invoiceUrl"
          @click="handleDownload"
          icon="el-icon-download"
        >
          ä¸‹è½½å‘票
        </el-button>
        <!-- åŒæ­¥æŒ‰é’®ï¼šåªæœ‰å·²é€šè¿‡ä¸”未同步或同步失败时显示 -->
        <el-button
          type="warning"
          v-if="invoice.status === 1 && (!invoice.legacyInvoiceId || invoice.syncStatus === 2)"
          @click="handleSync"
          :loading="syncing"
          icon="el-icon-refresh"
          v-hasPermi="['system:invoice:edit']"
        >
          {{ syncing ? '同步中...' : '同步到旧系统' }}
        </el-button>
      </div>
    </el-card>
  </div>
</template>
<script>
import { getInvoice, syncInvoiceToLegacy } from "@/api/system/invoice";
export default {
  name: "InvoiceDetail",
  data() {
    return {
      // å‘票详情数据
      invoice: {
        invoiceId: null,
        serviceOrderId: null,
        legacyServiceOrderId: null,
        serviceCode: null,
        invoiceType: null,
        invoiceName: null,
        invoiceMoney: null,
        invoiceRemarks: null,
        companyAddress: null,
        companyBank: null,
        companyBankNo: null,
        zipCode: null,
        mailAddress: null,
        contactName: null,
        contactPhone: null,
        contactEmail: null,
        status: null,
        invoiceNo: null,
        invoiceUrl: null,
        applyTime: null,
        auditTime: null,
        auditRemarks: null,
        syncStatus: null,
        legacyInvoiceId: null
      },
      // åŒæ­¥çŠ¶æ€
      syncing: false
    };
  },
  created() {
    const invoiceId = this.$route.params.invoiceId || this.$route.query.invoiceId;
    if (invoiceId) {
      this.getDetail(invoiceId);
    } else {
      this.$modal.msgError("缺少发票ID参数");
      this.goBack();
    }
  },
  methods: {
    /** èŽ·å–å‘ç¥¨è¯¦æƒ… */
    getDetail(invoiceId) {
      getInvoice(invoiceId).then(response => {
        this.invoice = response.data;
      }).catch(() => {
        this.$modal.msgError("获取发票详情失败");
        this.goBack();
      });
    },
    /** çŠ¶æ€æ ¼å¼åŒ– */
    statusFormat(status) {
      const map = { 0: "待审核", 1: "已通过", 2: "已驳回" };
      return map[status] || "未知";
    },
    /** é‡‘额格式化 */
    formatMoney(money) {
      if (money === null || money === undefined) return '0.00';
      return Number(money).toFixed(2);
    },
    /** åŒæ­¥çŠ¶æ€æ ¼å¼åŒ– */
    syncStatusFormat(status) {
      const map = { 0: "未同步", 1: "已同步", 2: "同步失败" };
      return map[status] || "未知";
    },
    /** èŽ·å–å®Œæ•´çš„æ–‡ä»¶URL */
    getFullUrl(url) {
      if (!url) return '';
      if (url.startsWith('http')) {
        return url;
      }
      return process.env.VUE_APP_BASE_API + url;
    },
    /** èŽ·å–æ–‡ä»¶å */
    getFileName(url) {
      if (!url) return '发票文件';
      const parts = url.split('/');
      return parts[parts.length - 1] || '发票文件';
    },
    /** è¿”回列表 */
    goBack() {
      this.$tab.closePage();
    },
    /** è·³è½¬åˆ°å®¡æ ¸é¡µé¢ */
    handleAudit() {
      this.$router.push({
        path: '/system/invoice/audit',
        query: { invoiceId: this.invoice.invoiceId }
      });
    },
    /** ä¸‹è½½å‘票 */
    handleDownload() {
      const url = this.getFullUrl(this.invoice.invoiceUrl);
      window.open(url);
    },
    /** åŒæ­¥åˆ°æ—§ç³»ç»Ÿ */
    handleSync() {
      this.$modal.confirm('确认将该发票申请同步到旧系统吗?').then(() => {
        this.syncing = true;
        syncInvoiceToLegacy(this.invoice.invoiceId).then(response => {
          this.$modal.msgSuccess('同步成功');
          // é‡æ–°åŠ è½½è¯¦æƒ…
          this.getDetail(this.invoice.invoiceId);
        }).catch(() => {
          this.$modal.msgError('同步失败');
        }).finally(() => {
          this.syncing = false;
        });
      }).catch(() => {});
    }
  }
};
</script>
<style scoped lang="scss">
.card-title {
  font-size: 18px;
  font-weight: bold;
  color: #303133;
}
.text-price {
  color: #F56C6C;
  font-size: 16px;
  font-weight: bold;
}
.text-danger {
  color: #F56C6C;
}
.text-gray {
  color: #909399;
}
.action-bar {
  border-top: 1px solid #EBEEF5;
  padding-top: 20px;
}
::v-deep .el-descriptions-item__label {
  font-weight: bold;
  width: 120px;
}
</style>
ruoyi-ui/src/views/system/invoice/index.vue
New file
@@ -0,0 +1,265 @@
<template>
  <div class="app-container">
    <el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="80px">
      <el-form-item label="服务单号" prop="serviceCode">
        <el-input
          v-model="queryParams.serviceCode"
          placeholder="请输入服务单号(如GZ202602)"
          clearable
          @keyup.enter.native="handleQuery"
        />
      </el-form-item>
      <el-form-item label="分公司" prop="serviceOrdClass">
        <el-select v-model="queryParams.serviceOrdClass" placeholder="请选择分公司" clearable>
          <el-option
            v-for="dept in branchOptions"
            :key="dept.serviceOrderClass"
            :label="dept.deptName"
            :value="dept.serviceOrderClass"
          />
        </el-select>
      </el-form-item>
      <el-form-item label="开票抬头" prop="invoiceName">
        <el-input
          v-model="queryParams.invoiceName"
          placeholder="请输入发票抬头"
          clearable
          @keyup.enter.native="handleQuery"
        />
      </el-form-item>
      <el-form-item label="申请状态" prop="status">
        <el-select v-model="queryParams.status" placeholder="请选择状态" clearable>
          <el-option label="待审核" :value="0" />
          <el-option label="已通过" :value="1" />
          <el-option label="已驳回" :value="2" />
        </el-select>
      </el-form-item>
      <el-form-item label="申请时间">
        <el-date-picker
          v-model="dateRange"
          style="width: 240px"
          value-format="yyyy-MM-dd"
          type="daterange"
          range-separator="-"
          start-placeholder="开始日期"
          end-placeholder="结束日期"
        ></el-date-picker>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
      </el-form-item>
    </el-form>
    <el-row :gutter="10" class="mb8">
      <el-col :span="1.5">
        <el-button
          type="warning"
          plain
          icon="el-icon-download"
          size="mini"
          @click="handleExport"
          v-hasPermi="['system:invoice:export']"
        >导出</el-button>
      </el-col>
      <el-col :span="1.5">
        <el-button
          type="info"
          plain
          icon="el-icon-refresh"
          size="mini"
          @click="handleSync"
          v-hasRole="['admin']"
        >同步旧系统状态</el-button>
      </el-col>
      <right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
    </el-row>
    <el-table v-loading="loading" :data="invoiceList" @selection-change="handleSelectionChange">
      <el-table-column type="selection" width="55" align="center" />
      <el-table-column label="ID" align="center" prop="invoiceId" width="80" />
      <el-table-column label="服务单号" align="center" prop="serviceCode" width="150" />
      <el-table-column label="开票抬头" align="center" prop="invoiceName" width="150" />
      <el-table-column label="金额" align="center" prop="invoiceMoney" width="100" />
      <el-table-column label="类型" align="center" prop="invoiceType">
        <template slot-scope="scope">
          <el-tag :type="scope.row.invoiceType === 2 ? 'success' : 'info'">
            {{ scope.row.invoiceType === 2 ? '企业' : '个人' }}
          </el-tag>
        </template>
      </el-table-column>
      <el-table-column label="状态" align="center" prop="status">
        <template slot-scope="scope">
          <el-tag :type="scope.row.status === 1 ? 'success' : (scope.row.status === 2 ? 'danger' : 'warning')">
            {{ statusFormat(scope.row.status) }}
          </el-tag>
        </template>
      </el-table-column>
      <el-table-column label="发票编号" align="center" prop="invoiceNo" />
      <el-table-column label="申请时间" align="center" prop="applyTime" width="180">
        <template slot-scope="scope">
          <span>{{ parseTime(scope.row.applyTime) }}</span>
        </template>
      </el-table-column>
      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
        <template slot-scope="scope">
          <el-button
            size="mini"
            type="text"
            icon="el-icon-view"
            @click="handleView(scope.row)"
          >详情</el-button>
          <el-button
            size="mini"
            type="text"
            icon="el-icon-edit"
            @click="handleUpdate(scope.row)"
            v-hasPermi="['system:invoice:edit']"
            v-if="scope.row.status === 0"
          >审核</el-button>
          <el-button
            size="mini"
            type="text"
            icon="el-icon-download"
            v-if="scope.row.invoiceUrl"
            @click="handleDownload(scope.row)"
          >下载发票</el-button>
        </template>
      </el-table-column>
    </el-table>
    <pagination
      v-show="total>0"
      :total="total"
      :page.sync="queryParams.pageNum"
      :limit.sync="queryParams.pageSize"
      @pagination="getList"
    />
  </div>
</template>
<script>
import { listInvoice, syncInvoiceStatus } from "@/api/system/invoice";
import { listBranchByOaOrderClass } from "@/api/system/dept";
export default {
  name: "Invoice",
  data() {
    return {
      // é®ç½©å±‚
      loading: true,
      // é€‰ä¸­æ•°ç»„
      ids: [],
      // éžå•个禁用
      single: true,
      // éžå¤šä¸ªç¦ç”¨
      multiple: true,
      // æ˜¾ç¤ºæœç´¢æ¡ä»¶
      showSearch: true,
      // æ€»æ¡æ•°
      total: 0,
      // å‘票申请表格数据
      invoiceList: [],
      // åˆ†å…¬å¸é€‰é¡¹
      branchOptions: [],
      // æ—¥æœŸèŒƒå›´
      dateRange: [],
      // æŸ¥è¯¢å‚æ•°
      queryParams: {
        pageNum: 1,
        pageSize: 10,
        serviceCode: null,
        invoiceName: null,
        status: null,
        serviceOrdClass: null
      }
    };
  },
  created() {
    this.getList();
    this.getBranchList();
  },
  methods: {
    /** æŸ¥è¯¢åˆ†å…¬å¸åˆ—表 */
    getBranchList() {
      listBranchByOaOrderClass().then(response => {
        this.branchOptions = response.data;
      });
    },
    /** æŸ¥è¯¢å‘票申请列表 */
    getList() {
      this.loading = true;
      const params = this.addDateRange(this.queryParams, this.dateRange);
      // å°† serviceOrdClass æ”¾å…¥ params å­—段中,对应后台 XML ä¸­çš„ params.serviceOrdClass
      if (this.queryParams.serviceOrdClass) {
        params['params[serviceOrdClass]'] = this.queryParams.serviceOrdClass;
      }
      // serviceCode æŸ¥è¯¢
      if (this.queryParams.serviceCode) {
        params['params[serviceCode]'] = this.queryParams.serviceCode;
      }
      listInvoice(params).then(response => {
        this.invoiceList = response.rows;
        this.total = response.total;
        this.loading = false;
      });
    },
    // å¤šé€‰æ¡†é€‰ä¸­æ•°æ®
    handleSelectionChange(selection) {
      this.ids = selection.map(item => item.invoiceId)
      this.single = selection.length!==1
      this.multiple = !selection.length
    },
    // çŠ¶æ€å­—å…¸è½¬ä¹‰
    statusFormat(status) {
      const map = { 0: "待审核", 1: "已通过", 2: "已驳回" };
      return map[status] || "未知";
    },
    /** æœç´¢æŒ‰é’®æ“ä½œ */
    handleQuery() {
      this.queryParams.pageNum = 1;
      this.getList();
    },
    /** é‡ç½®æŒ‰é’®æ“ä½œ */
    resetQuery() {
      this.dateRange = [];
      this.resetForm("queryForm");
      this.handleQuery();
    },
    /** è¯¦æƒ…按钮操作 */
    handleView(row) {
      this.$router.push({
        path: '/system/invoice/detail',
        query: { invoiceId: row.invoiceId }
      });
    },
    /** å®¡æ ¸æŒ‰é’®æ“ä½œ */
    handleUpdate(row) {
      this.$router.push({
        path: '/system/invoice/audit',
        query: { invoiceId: row.invoiceId }
      });
    },
    /** å¯¼å‡ºæŒ‰é’®æ“ä½œ */
    handleExport() {
      this.download('system/invoice/export', {
        ...this.queryParams
      }, `invoice_${new Date().getTime()}.xlsx`)
    },
    /** åŒæ­¥æŒ‰é’®æ“ä½œ */
    handleSync() {
      this.loading = true;
      syncInvoiceStatus().then(() => {
        this.$modal.msgSuccess("同步成功");
        this.getList();
      }).finally(() => {
        this.loading = false;
      });
    },
    /** ä¸‹è½½å‘票操作 */
    handleDownload(row) {
      window.open(row.invoiceUrl);
    }
  }
};
</script>
ruoyi-ui/src/views/task/general/detail.vue
@@ -150,12 +150,42 @@
          <span v-if="taskDetail.emergencyInfo.dispatchSyncErrorMsg" style="color: #F56C6C;">{{ taskDetail.emergencyInfo.dispatchSyncErrorMsg }}</span>
          <span v-else style="color: #C0C4CC;">--</span>
        </el-descriptions-item>
        <el-descriptions-item label="任务状态同步" :span="2">
          <el-alert
            title="提示:任务状态会自动同步到旧系统的调度单中,如果因网络等原因未同步,可点击下方按钮手动同步。"
            type="info"
            :closable="false"
            show-icon
            style="margin-bottom: 10px;">
          </el-alert>
          <el-button
            v-if="taskDetail.emergencyInfo.legacyDispatchOrdId && taskDetail.emergencyInfo.legacyDispatchOrdId > 0"
            type="warning"
            size="small"
            icon="el-icon-refresh"
            :loading="syncingTaskStatus"
            @click="syncTaskStatus"
          >同步任务状态到旧系统</el-button>
          <el-tag v-else type="info" size="small">
            <i class="el-icon-warning"></i> è¯·å…ˆåŒæ­¥è°ƒåº¦å•
          </el-tag>
        </el-descriptions-item>
      </el-descriptions>
      <!-- æ”¯ä»˜ä¿¡æ¯ï¼ˆä»…急救转运任务显示) -->
      <el-card v-if="taskDetail.taskType === 'EMERGENCY_TRANSFER' && paymentInfo" class="box-card" style="margin-top: 20px;">
        <div slot="header" class="clearfix">
          <span>支付信息</span>
          <!-- å·²å®Œæˆä¸”未申请发票时显示申请发票按钮 -->
          <el-button
            v-if="canApplyInvoice"
            style="float: right; padding: 3px 0"
            type="text"
            @click="handleApplyInvoice"
            v-hasPermi="['system:invoice:add']"
          >
            <i class="el-icon-document-add"></i> ç”³è¯·å‘票
          </el-button>
        </div>
        
        <!-- æ”¯ä»˜æ¦‚览 -->
@@ -758,7 +788,7 @@
</template>
<script>
import { getTask, updateTask, assignTask, changeTaskStatus, uploadAttachment, deleteAttachment, getTaskVehicles, getAvailableVehicles, assignVehiclesToTask, unassignVehicleFromTask, getPaymentInfo, syncServiceOrder, syncDispatchOrder } from "@/api/task";
import { getTask, updateTask, assignTask, changeTaskStatus, uploadAttachment, deleteAttachment, getTaskVehicles, getAvailableVehicles, assignVehiclesToTask, unassignVehicleFromTask, getPaymentInfo, syncServiceOrder, syncDispatchOrder, syncTaskStatus } from "@/api/task";
import { listUser } from "@/api/system/user";
import { getToken } from "@/utils/auth";
@@ -850,7 +880,11 @@
      },
      // åŒæ­¥åŠ è½½çŠ¶æ€
      syncingServiceOrder: false,
      syncingDispatchOrder: false
      syncingDispatchOrder: false,
      syncingTaskStatus: false,
      // å‘票申请状态
      hasInvoiceApplied: false,
      invoiceStatus: null // 0-待审核, 1-已通过, 2-已驳回
    };
  },
  created() {
@@ -859,6 +893,19 @@
    this.getAdditionalFeeList();
    // åˆå§‹åŒ–上传URL
    this.uploadUrl = process.env.VUE_APP_BASE_API + "/task/attachment/upload/" + this.$route.params.taskId;
    // æ£€æŸ¥å‘票申请状态
    this.checkInvoiceStatus();
  },
  computed: {
    /** æ˜¯å¦å¯ä»¥ç”³è¯·å‘票 */
    canApplyInvoice() {
      // åªæœ‰æ€¥æ•‘转运任务
      if (this.taskDetail.taskType !== 'EMERGENCY_TRANSFER') return false;
      // ä»»åŠ¡å¿…é¡»å·²å®Œæˆ
      if (this.taskDetail.taskStatus !== 'COMPLETED') return false;
      // æœªç”³è¯·è¿‡å‘票,或者曾被驳回
      return !this.hasInvoiceApplied || this.invoiceStatus === 2;
    }
  },
  methods: {
    /** èŽ·å–ä»»åŠ¡è¯¦æƒ… */
@@ -1157,7 +1204,7 @@
      }).then(() => {
        this.$modal.msgSuccess("服务单同步成功");
        // é‡æ–°åŠ è½½ä»»åŠ¡è¯¦æƒ…
        this.getDetail();
        this.getTaskDetail();
      }).catch(() => {
        // å¤„理取消和错误
      }).finally(() => {
@@ -1172,12 +1219,66 @@
      }).then(() => {
        this.$modal.msgSuccess("调度单同步成功");
        // é‡æ–°åŠ è½½ä»»åŠ¡è¯¦æƒ…
        this.getDetail();
        this.getTaskDetail();
      }).catch(() => {
        // å¤„理取消和错误
      }).finally(() => {
        this.syncingDispatchOrder = false;
      });
    },
    /** æ‰‹åŠ¨åŒæ­¥ä»»åŠ¡çŠ¶æ€ */
    syncTaskStatus() {
      this.$modal.confirm('是否确认同步任务状态到旧系统?').then(() => {
        this.syncingTaskStatus = true;
        return syncTaskStatus(this.taskDetail.taskId);
      }).then(() => {
        this.$modal.msgSuccess("任务状态同步成功");
        // é‡æ–°åŠ è½½ä»»åŠ¡è¯¦æƒ…
        this.getTaskDetail();
      }).catch(() => {
        // å¤„理取消和错误
      }).finally(() => {
        this.syncingTaskStatus = false;
      });
    },
    /** æ£€æŸ¥å‘票申请状态 */
    checkInvoiceStatus() {
      // è°ƒç”¨åŽç«¯æŽ¥å£æ£€æŸ¥è¯¥ä»»åŠ¡æ˜¯å¦å·²ç”³è¯·å‘ç¥¨
      this.$axios.get(`/system/invoice/checkTaskInvoice/${this.$route.params.taskId}`)
        .then(response => {
          if (response.code === 200 && response.data) {
            this.hasInvoiceApplied = true;
            this.invoiceStatus = response.data.status;
          }
        })
        .catch(() => {
          // å¿½ç•¥é”™è¯¯ï¼Œé»˜è®¤æœªç”³è¯·
        });
    },
    /** ç”³è¯·å‘票 */
    handleApplyInvoice() {
      // è·³è½¬åˆ°å‘票申请页面,带上任务信息
      const taskInfo = {
        taskId: this.taskDetail.taskId,
        taskCode: this.taskDetail.taskCode || this.taskDetail.showTaskCode,
        legacyServiceOrderId: this.taskDetail.emergencyInfo?.legacyServiceOrdId,
        serviceCode: this.taskDetail.emergencyInfo?.serviceCode,
        departure: this.taskDetail.departureAddress,
        destination: this.taskDetail.destinationAddress,
        completionTime: this.parseTime(this.taskDetail.actualEndTime),
        transferPrice: this.paymentInfo?.transferPrice || this.paymentInfo?.totalAmount
      };
      // å°†ä»»åŠ¡ä¿¡æ¯å­˜å‚¨åˆ° sessionStorage
      sessionStorage.setItem('invoiceTaskInfo', JSON.stringify(taskInfo));
      // è·³è½¬åˆ°å‘票申请页面
      this.$router.push({
        path: '/system/invoice/apply',
        query: { taskId: this.taskDetail.taskId }
      });
    }
  }
};
ruoyi-ui/src/views/task/general/index.vue
@@ -29,6 +29,22 @@
          />
        </el-select>
      </el-form-item>
      <el-form-item label="分公司" prop="deptId">
        <el-select
          v-model="queryParams.deptId"
          placeholder="请选择分公司"
          clearable
          filterable
          style="width: 200px"
        >
          <el-option
            v-for="dept in branchList"
            :key="dept.deptId"
            :label="dept.deptName"
            :value="dept.deptId"
          />
        </el-select>
      </el-form-item>
      <el-form-item label="车牌号" prop="vehicleNo">
        <el-input
          v-model="queryParams.vehicleNo"
@@ -386,6 +402,7 @@
<script>
import { listTask, getTask, delTask, addTask, updateTask, assignTask, changeTaskStatus } from "@/api/task";
import { listUser } from "@/api/system/user";
import { listBranchByOa } from "@/api/system/dept";
export default {
  name: "Task",
@@ -425,6 +442,7 @@
        taskCode: null,
        taskType: null,
        taskStatus: null,
        deptId: null,
        vehicleNo: null,
        plannedStartTimeBegin: null,
        plannedStartTimeEnd: null,
@@ -437,6 +455,8 @@
      statusForm: {},
      // ç”¨æˆ·åˆ—表
      userList: [],
      // åˆ†å…¬å¸åˆ—表
      branchList: [],
      // è¡¨å•校验
      rules: {
        taskType: [
@@ -469,6 +489,7 @@
  created() {
    this.getList();
    this.getUserList();
    this.getBranchList();
  },
  methods: {
    /** æŸ¥è¯¢ä»»åŠ¡ç®¡ç†åˆ—è¡¨ */
@@ -492,6 +513,14 @@
        this.userList = response.rows;
      });
    },
    /** æŸ¥è¯¢åˆ†å…¬å¸åˆ—表 */
    getBranchList() {
      listBranchByOa().then(response => {
        this.branchList = response.data || [];
      }).catch(() => {
        this.branchList = [];
      });
    },
    // å–消按钮
    cancel() {
      this.open = false;
sql/InvoiceData.sql
New file
@@ -0,0 +1,31 @@
create table InvoiceData(
InvoiceID    int    no    4    10       0        no    (n/a)    (n/a)    NULL
ServiceOrderIDPK    bigint    no    8    19       0        yes    (n/a)    (n/a)    NULL
InvoiceType    int    no    4    10       0        yes    (n/a)    (n/a)    NULL
InvoiceName    nvarchar    no    100                      yes    (n/a)    (n/a)    Chinese_PRC_CI_AS
InvoiceMakeout    nvarchar    no    2000                      yes    (n/a)    (n/a)    Chinese_PRC_CI_AS
InvoiceCompanyPhone    nvarchar    no    100                      yes    (n/a)    (n/a)    Chinese_PRC_CI_AS
InvoiceCompanyID    nvarchar    no    100                      yes    (n/a)    (n/a)    Chinese_PRC_CI_AS
InvoiceCompanyAdd    nvarchar    no    200                      yes    (n/a)    (n/a)    Chinese_PRC_CI_AS
InvoiceCompanyBank    nvarchar    no    100                      yes    (n/a)    (n/a)    Chinese_PRC_CI_AS
InvoiceCompanyBankNo    nvarchar    no    100                      yes    (n/a)    (n/a)    Chinese_PRC_CI_AS
InvoiceZipCode    nvarchar    no    100                      yes    (n/a)    (n/a)    Chinese_PRC_CI_AS
Invoice_strAdd    nvarchar    no    200                      yes    (n/a)    (n/a)    Chinese_PRC_CI_AS
Invoice_strName    nvarchar    no    100                      yes    (n/a)    (n/a)    Chinese_PRC_CI_AS
Invoice_strPhone    nvarchar    no    100                      yes    (n/a)    (n/a)    Chinese_PRC_CI_AS
Invoice_strEmail    nvarchar    no    100                      yes    (n/a)    (n/a)    Chinese_PRC_CI_AS
ApplicationTime    datetime    no    8                      yes    (n/a)    (n/a)    NULL
AuditTime    datetime    no    8                      yes    (n/a)    (n/a)    NULL
AuditStatus    int    no    4    10       0        yes    (n/a)    (n/a)    NULL
AuditOAID    int    no    4    10       0        yes    (n/a)    (n/a)    NULL
AuditMakeout    nvarchar    no    200                      yes    (n/a)    (n/a)    Chinese_PRC_CI_AS
InvoiceMoney    money    no    8    19       4        yes    (n/a)    (n/a)    NULL
InvoiceNo    nvarchar    no    100                      yes    (n/a)    (n/a)    Chinese_PRC_CI_AS
InvoiceURL    nvarchar    no    2000                      yes    (n/a)    (n/a)    Chinese_PRC_CI_AS
InvoiceOddNo    nvarchar    no    400                      yes    (n/a)    (n/a)    Chinese_PRC_CI_AS
EleCloud_ZTDM    nvarchar    no    100                      yes    (n/a)    (n/a)    Chinese_PRC_CI_AS
EleCloud_ZTXX    nvarchar    no    200                      yes    (n/a)    (n/a)    Chinese_PRC_CI_AS
EleCloud_PDF    nvarchar    no    200                      yes    (n/a)    (n/a)    Chinese_PRC_CI_AS
EleCloud_Time    datetime    no    8                      yes    (n/a)    (n/a)    NULL
ApplyOAID    int    no    4    10       0        yes    (n/a)    (n/a)    NULL
)
sql/invoice_menu.sql
New file
@@ -0,0 +1,27 @@
-- ----------------------------
-- 1、发票管理主菜单
-- ----------------------------
insert into sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
values('发票管理', '1', '10', 'invoice', 'system/invoice/index', 1, 0, 'C', '0', '0', 'system:invoice:list', 'edit', 'admin', sysdate(), '发票申请管理菜单');
-- èŽ·å–åˆšåˆšæ’å…¥çš„èœå•ID (适用于MySQL)
set @parentId = LAST_INSERT_ID();
-- ----------------------------
-- 2、发票管理相关按钮权限
-- ----------------------------
-- æŸ¥è¯¢æƒé™
insert into sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
values('发票查询', @parentId, '1',  '', '', 1, 0, 'F', '0', '0', 'system:invoice:query',  '#', 'admin', sysdate(), '');
-- ä¿®æ”¹/审核权限
insert into sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
values('发票审核', @parentId, '2',  '', '', 1, 0, 'F', '0', '0', 'system:invoice:edit',   '#', 'admin', sysdate(), '');
-- åˆ é™¤æƒé™
insert into sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
values('发票删除', @parentId, '3',  '', '', 1, 0, 'F', '0', '0', 'system:invoice:remove', '#', 'admin', sysdate(), '');
-- å¯¼å‡ºæƒé™
insert into sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
values('发票导出', @parentId, '4',  '', '', 1, 0, 'F', '0', '0', 'system:invoice:export', '#', 'admin', sysdate(), '');
sql/invoice_sync_from_legacy.sql
New file
@@ -0,0 +1,124 @@
-- ä»Žæ—§ç³»ç»ŸåŒæ­¥å‘票信息到新系统的SQL脚本
-- åŒæ­¥æ—§ç³»ç»Ÿçš„发票数据到新系统,避免重复同步
-- æ–¹æ¡ˆ1: ä½¿ç”¨INSERT IGNORE插入新数据
INSERT IGNORE INTO sys_invoice (
    legacy_invoice_id,
    legacy_service_order_id,
    invoice_type,
    invoice_name,
    invoice_money,
    invoice_remarks,
    company_address,
    company_bank,
    company_bank_no,
    zip_code,
    mail_address,
    contact_name,
    contact_phone,
    contact_email,
    status,
    invoice_no,
    invoice_url,
    apply_time,
    audit_time,
    audit_remarks,
    sync_status
)
SELECT
    i.InvoiceID as legacy_invoice_id,
    i.ServiceOrderIDPK as legacy_service_order_id,
    CASE
        WHEN i.InvoiceType = 1 THEN 1  -- ä¸ªäººå‘票
        WHEN i.InvoiceType = 2 THEN 2  -- ä¼ä¸šå‘票
        ELSE 1  -- é»˜è®¤ä¸ªäººå‘票
    END as invoice_type,
    i.InvoiceName as invoice_name,
    CAST(i.InvoiceMoney AS DECIMAL(10,2)) as invoice_money,
    i.InvoiceMakeout as invoice_remarks,
    i.InvoiceCompanyAdd as company_address,
    i.InvoiceCompanyBank as company_bank,
    i.InvoiceCompanyBankNo as company_bank_no,
    i.InvoiceZipCode as zip_code,
    i.Invoice_strAdd as mail_address,
    i.Invoice_strName as contact_name,
    i.Invoice_strPhone as contact_phone,
    i.Invoice_strEmail as contact_email,
    CASE
        WHEN i.AuditStatus = 1 THEN 1  -- å·²é€šè¿‡
        WHEN i.AuditStatus = 2 THEN 2  -- å·²é©³å›ž
        ELSE 0  -- å¾…审核
    END as status,
    i.InvoiceNo as invoice_no,
    COALESCE(i.InvoiceURL, i.EleCloud_PDF) as invoice_url,
    i.ApplicationTime as apply_time,
    i.AuditTime as audit_time,
    i.AuditMakeout as audit_remarks,
    1 as sync_status  -- æ ‡è®°ä¸ºå·²åŒæ­¥
FROM InvoiceData i
WHERE i.InvoiceID NOT IN (
    SELECT legacy_invoice_id
    FROM sys_invoice
    WHERE legacy_invoice_id IS NOT NULL
);
-- æ–¹æ¡ˆ2: ä½¿ç”¨LEFT JOIN确保只插入不存在的记录
-- INSERT INTO sys_invoice (
--     legacy_invoice_id,
--     legacy_service_order_id,
--     invoice_type,
--     invoice_name,
--     invoice_money,
--     invoice_remarks,
--     company_address,
--     company_bank,
--     company_bank_no,
--     zip_code,
--     mail_address,
--     contact_name,
--     contact_phone,
--     contact_email,
--     status,
--     invoice_no,
--     invoice_url,
--     apply_time,
--     audit_time,
--     audit_remarks,
--     sync_status
-- )
-- SELECT
--     i.InvoiceID,
--     i.ServiceOrderIDPK,
--     CASE
--         WHEN i.InvoiceType = 1 THEN 1
--         WHEN i.InvoiceType = 2 THEN 2
--         ELSE 1
--     END,
--     i.InvoiceName,
--     CAST(i.InvoiceMoney AS DECIMAL(10,2)),
--     i.InvoiceMakeout,
--     i.InvoiceCompanyAdd,
--     i.InvoiceCompanyBank,
--     i.InvoiceCompanyBankNo,
--     i.InvoiceZipCode,
--     i.Invoice_strAdd,
--     i.Invoice_strName,
--     i.Invoice_strPhone,
--     i.Invoice_strEmail,
--     CASE
--         WHEN i.AuditStatus = 1 THEN 1
--         WHEN i.AuditStatus = 2 THEN 2
--         ELSE 0
--     END,
--     i.InvoiceNo,
--     COALESCE(i.InvoiceURL, i.EleCloud_PDF),
--     i.ApplicationTime,
--     i.AuditTime,
--     i.AuditMakeout,
--     1
-- FROM InvoiceData i
-- LEFT JOIN sys_invoice si ON i.InvoiceID = si.legacy_invoice_id
-- WHERE si.legacy_invoice_id IS NULL;
-- æ›´æ–°ç»Ÿè®¡ä¿¡æ¯
ANALYZE TABLE sys_invoice;
sql/invoice_sync_job.sql
New file
@@ -0,0 +1,248 @@
-- å‘票同步定时任务配置
-- ç”¨äºŽä»Žæ—§ç³»ç»ŸåŒæ­¥å‘票信息到新系统
-- 1. åˆ›å»ºå‘票同步日志表
CREATE TABLE IF NOT EXISTS `sys_invoice_sync_log` (
  `log_id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '同步日志ID',
  `sync_type` VARCHAR(50) NOT NULL COMMENT '同步类型(invoice_info-发票信息,invoice_status-发票状态)',
  `sync_start_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '同步开始时间',
  `sync_end_time` DATETIME DEFAULT NULL COMMENT '同步结束时间',
  `records_processed` INT(11) DEFAULT 0 COMMENT '处理记录数',
  `records_success` INT(11) DEFAULT 0 COMMENT '成功记录数',
  `records_failed` INT(11) DEFAULT 0 COMMENT '失败记录数',
  `error_message` TEXT DEFAULT NULL COMMENT '错误信息',
  `status` TINYINT(1) DEFAULT 0 COMMENT '状态(0-处理中,1-成功,2-失败)',
  PRIMARY KEY (`log_id`),
  INDEX `idx_sync_type` (`sync_type`),
  INDEX `idx_sync_start_time` (`sync_start_time`)
) ENGINE=INNODB DEFAULT CHARSET=utf8mb4 COMMENT='发票同步日志表';
-- 2. æ’入发票同步定时任务配置
DELETE FROM sys_job WHERE job_name = 'InvoiceSyncJob';
INSERT INTO sys_job (
    job_name,
    job_group,
    invoke_target,
    cron_expression,
    misfire_policy,
    concurrent,
    status,
    create_by,
    create_time
) VALUES (
    'InvoiceSyncJob',
    'SYSTEM',
    'sysInvoiceTask.syncInvoiceFromLegacySystem',
    '0 0/30 * * * ?',  -- æ¯30分钟执行一次
    '3',
    '1',
    '0',
    'admin',
    NOW()
);
-- 3. åŒæ­¥å‘票信息的存储过程
DELIMITER $$
DROP PROCEDURE IF EXISTS sync_invoice_from_legacy$$
CREATE PROCEDURE sync_invoice_from_legacy()
BEGIN
    DECLARE v_start_time DATETIME DEFAULT NOW();
    DECLARE v_error_msg TEXT DEFAULT '';
    DECLARE v_processed_count INT DEFAULT 0;
    DECLARE v_success_count INT DEFAULT 0;
    DECLARE v_failed_count INT DEFAULT 0;
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        GET DIAGNOSTICS CONDITION 1
            v_error_msg = MESSAGE_TEXT;
        ROLLBACK;
    END;
    START TRANSACTION;
    -- è®°å½•开始同步
    INSERT INTO sys_invoice_sync_log (sync_type, sync_start_time, status)
    VALUES ('invoice_info', v_start_time, 0);
    SET @log_id = LAST_INSERT_ID();
    -- åŒæ­¥æ–°çš„发票记录
    INSERT INTO sys_invoice (
        legacy_invoice_id,
        legacy_service_order_id,
        invoice_type,
        invoice_name,
        invoice_money,
        invoice_remarks,
        company_address,
        company_bank,
        company_bank_no,
        zip_code,
        mail_address,
        contact_name,
        contact_phone,
        contact_email,
        status,
        invoice_no,
        invoice_url,
        apply_time,
        audit_time,
        audit_remarks,
        sync_status
    )
    SELECT
        i.InvoiceID as legacy_invoice_id,
        i.ServiceOrderIDPK as legacy_service_order_id,
        CASE
            WHEN i.InvoiceType = 1 THEN 1  -- ä¸ªäººå‘票
            WHEN i.InvoiceType = 2 THEN 2  -- ä¼ä¸šå‘票
            ELSE 1  -- é»˜è®¤ä¸ªäººå‘票
        END as invoice_type,
        i.InvoiceName as invoice_name,
        CAST(i.InvoiceMoney AS DECIMAL(10,2)) as invoice_money,
        i.InvoiceMakeout as invoice_remarks,
        i.InvoiceCompanyAdd as company_address,
        i.InvoiceCompanyBank as company_bank,
        i.InvoiceCompanyBankNo as company_bank_no,
        i.InvoiceZipCode as zip_code,
        i.Invoice_strAdd as mail_address,
        i.Invoice_strName as contact_name,
        i.Invoice_strPhone as contact_phone,
        i.Invoice_strEmail as contact_email,
        CASE
            WHEN i.AuditStatus = 1 THEN 1  -- å·²é€šè¿‡
            WHEN i.AuditStatus = 2 THEN 2  -- å·²é©³å›ž
            ELSE 0  -- å¾…审核
        END as status,
        i.InvoiceNo as invoice_no,
        COALESCE(i.InvoiceURL, i.EleCloud_PDF) as invoice_url,
        i.ApplicationTime as apply_time,
        i.AuditTime as audit_time,
        i.AuditMakeout as audit_remarks,
        1 as sync_status  -- æ ‡è®°ä¸ºå·²åŒæ­¥
    FROM InvoiceData i
    WHERE i.InvoiceID NOT IN (
        SELECT legacy_invoice_id
        FROM sys_invoice
        WHERE legacy_invoice_id IS NOT NULL
    );
    SET v_processed_count = ROW_COUNT();
    SET v_success_count = v_processed_count;
    -- æ›´æ–°åŒæ­¥æ—¥å¿—
    UPDATE sys_invoice_sync_log
    SET
        sync_end_time = NOW(),
        records_processed = v_processed_count,
        records_success = v_success_count,
        records_failed = v_failed_count,
        error_message = v_error_msg,
        status = IF(v_error_msg = '', 1, 2)
    WHERE log_id = @log_id;
    COMMIT;
END$$
DELIMITER ;
-- 4. åŒæ­¥å‘票状态的存储过程
DELIMITER $$
DROP PROCEDURE IF EXISTS sync_invoice_status_from_legacy$$
CREATE PROCEDURE sync_invoice_status_from_legacy()
BEGIN
    DECLARE v_start_time DATETIME DEFAULT NOW();
    DECLARE v_error_msg TEXT DEFAULT '';
    DECLARE v_processed_count INT DEFAULT 0;
    DECLARE v_success_count INT DEFAULT 0;
    DECLARE v_failed_count INT DEFAULT 0;
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        GET DIAGNOSTICS CONDITION 1
            v_error_msg = MESSAGE_TEXT;
        ROLLBACK;
    END;
    START TRANSACTION;
    -- è®°å½•开始同步
    INSERT INTO sys_invoice_sync_log (sync_type, sync_start_time, status)
    VALUES ('invoice_status', v_start_time, 0);
    SET @log_id = LAST_INSERT_ID();
    -- æ›´æ–°çŽ°æœ‰å‘ç¥¨çš„çŠ¶æ€ä¿¡æ¯
    UPDATE sys_invoice si
    INNER JOIN InvoiceData i ON si.legacy_invoice_id = i.InvoiceID
    SET
        si.status = CASE
            WHEN i.AuditStatus = 1 THEN 1  -- å·²é€šè¿‡
            WHEN i.AuditStatus = 2 THEN 2  -- å·²é©³å›ž
            ELSE 0  -- å¾…审核
        END,
        si.invoice_no = COALESCE(si.invoice_no, i.InvoiceNo),
        si.invoice_url = COALESCE(si.invoice_url, i.InvoiceURL, i.EleCloud_PDF),
        si.audit_time = i.AuditTime,
        si.audit_remarks = i.AuditMakeout,
        si.sync_status = 1
    WHERE si.legacy_invoice_id IS NOT NULL
      AND (
          si.status != CASE
              WHEN i.AuditStatus = 1 THEN 1
              WHEN i.AuditStatus = 2 THEN 2
              ELSE 0
          END
          OR si.invoice_no IS NULL
          OR si.invoice_url IS NULL
      );
    SET v_processed_count = ROW_COUNT();
    SET v_success_count = v_processed_count;
    -- æ›´æ–°åŒæ­¥æ—¥å¿—
    UPDATE sys_invoice_sync_log
    SET
        sync_end_time = NOW(),
        records_processed = v_processed_count,
        records_success = v_success_count,
        records_failed = v_failed_count,
        error_message = v_error_msg,
        status = IF(v_error_msg = '', 1, 2)
    WHERE log_id = @log_id;
    COMMIT;
END$$
DELIMITER ;
-- 5. æŸ¥è¯¢å‘票同步日志的视图
CREATE OR REPLACE VIEW v_invoice_sync_log AS
SELECT
    l.log_id,
    l.sync_type,
    l.sync_start_time,
    l.sync_end_time,
    l.records_processed,
    l.records_success,
    l.records_failed,
    l.error_message,
    l.status,
    CASE l.status
        WHEN 0 THEN '处理中'
        WHEN 1 THEN '成功'
        WHEN 2 THEN '失败'
        ELSE '未知'
    END as status_name,
    TIMESTAMPDIFF(SECOND, l.sync_start_time, l.sync_end_time) as duration_seconds
FROM sys_invoice_sync_log l
ORDER BY l.sync_start_time DESC;
-- 6. æ‰‹åŠ¨æ‰§è¡Œå‘ç¥¨ä¿¡æ¯åŒæ­¥
-- CALL sync_invoice_from_legacy();
-- 7. æ‰‹åŠ¨æ‰§è¡Œå‘ç¥¨çŠ¶æ€åŒæ­¥
-- CALL sync_invoice_status_from_legacy();
sql/invoice_sys.sql
New file
@@ -0,0 +1,32 @@
-- å‘票申请表
CREATE TABLE `sys_invoice` (
  `invoice_id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '发票ID',
  `service_order_id` BIGINT(20) DEFAULT NULL COMMENT '服务单号(新系统订单ID)',
  `legacy_service_order_id` BIGINT(20) DEFAULT NULL COMMENT '服务单号(旧系统ServiceOrderID)',
  `invoice_type` INT(1) DEFAULT '1' COMMENT '开票类型(1-个人, 2-企业)',
  `invoice_name` VARCHAR(200) DEFAULT NULL COMMENT '发票抬头',
  `invoice_money` DECIMAL(10,2) DEFAULT '0.00' COMMENT '发票金额',
  `invoice_remarks` VARCHAR(500) DEFAULT NULL COMMENT '发票备注',
  `company_address` VARCHAR(500) DEFAULT NULL COMMENT '企业注册地址',
  `company_bank` VARCHAR(200) DEFAULT NULL COMMENT '企业开户银行',
  `company_bank_no` VARCHAR(100) DEFAULT NULL COMMENT '企业银行帐号',
  `zip_code` VARCHAR(20) DEFAULT NULL COMMENT '邮编',
  `mail_address` VARCHAR(500) DEFAULT NULL COMMENT '邮寄地址',
  `contact_name` VARCHAR(50) DEFAULT NULL COMMENT '联系人',
  `contact_phone` VARCHAR(50) DEFAULT NULL COMMENT '联系电话',
  `contact_email` VARCHAR(100) DEFAULT NULL COMMENT '联系邮箱',
  `status` INT(1) DEFAULT '0' COMMENT '申请状态(0-待审核, 1-已通过, 2-已驳回)',
  `invoice_no` VARCHAR(100) DEFAULT NULL COMMENT '发票编号(对应旧系统InvoiceNo)',
  `invoice_url` VARCHAR(2000) DEFAULT NULL COMMENT '发票链接/文件地址(对应旧系统InvoiceURL/EleCloud_PDF)',
  `apply_user_id` BIGINT(20) DEFAULT NULL COMMENT '申请人ID',
  `apply_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '申请时间',
  `audit_user_id` BIGINT(20) DEFAULT NULL COMMENT '审核人ID',
  `audit_time` DATETIME DEFAULT NULL COMMENT '审核时间',
  `audit_remarks` VARCHAR(500) DEFAULT NULL COMMENT '审核备注',
  `sync_status` INT(1) DEFAULT '0' COMMENT '同步状态(0-未同步, 1-已同步, 2-同步失败)',
  `legacy_invoice_id` INT(11) DEFAULT NULL COMMENT '旧系统发票ID(对应旧系统InvoiceID)',
  PRIMARY KEY (`invoice_id`),
  KEY `idx_service_order` (`service_order_id`),
  KEY `idx_legacy_order` (`legacy_service_order_id`),
  KEY `idx_apply_user` (`apply_user_id`)
) ENGINE=INNODB DEFAULT CHARSET=utf8mb4 COMMENT='发票申请表';
sql/partition_quick_setup.sql
New file
@@ -0,0 +1,260 @@
-- ========================================
-- GPS分段里程表分区优化 - å¿«é€Ÿæ‰§è¡Œç‰ˆ
-- ========================================
-- é€‚用场景:数据量适中(100万-500万),可以接受短暂停机
-- æ‰§è¡Œæ—¶é—´ï¼šæ ¹æ®æ•°æ®é‡ï¼Œé¢„计5-30分钟
-- ========================================
-- ç¬¬ä¸€æ­¥ï¼šæ£€æŸ¥å½“前数据状态
-- ========================================
USE your_database_name; -- è¯·ä¿®æ”¹ä¸ºå®žé™…的数据库名
SELECT '=== å½“前表信息 ===' as info;
SELECT
    TABLE_NAME as '表名',
    TABLE_ROWS as '记录数(估算)',
    ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 2) AS '大小(MB)'
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
  AND TABLE_NAME = 'tb_vehicle_gps_segment_mileage';
SELECT '=== æ•°æ®æ—¶é—´èŒƒå›´ ===' as info;
SELECT
    MIN(segment_start_time) as '最早数据',
    MAX(segment_start_time) as '最新数据',
    DATEDIFF(MAX(segment_start_time), MIN(segment_start_time)) as '数据跨度(天)'
FROM tb_vehicle_gps_segment_mileage;
SELECT '=== æŒ‰æœˆæ•°æ®åˆ†å¸ƒ ===' as info;
SELECT
    DATE_FORMAT(segment_start_time, '%Y-%m') as '月份',
    COUNT(*) as '记录数',
    ROUND(SUM(segment_distance), 2) as '总里程(km)'
FROM tb_vehicle_gps_segment_mileage
GROUP BY DATE_FORMAT(segment_start_time, '%Y-%m')
ORDER BY 1 DESC
LIMIT 12;
-- æš‚停!请检查以上信息,确认数据量和时间范围
-- æŒ‰å›žè½¦ç»§ç»­...
-- ========================================
-- ç¬¬äºŒæ­¥ï¼šåˆ›å»ºåˆ†åŒºè¡¨
-- ========================================
-- åˆ›å»ºæ–°çš„分区表
CREATE TABLE `tb_vehicle_gps_segment_mileage_partitioned` (
  `segment_id` bigint(20) NOT NULL AUTO_INCREMENT,
  `vehicle_id` bigint(20) NOT NULL,
  `vehicle_no` varchar(20) DEFAULT NULL,
  `segment_start_time` datetime NOT NULL,
  `segment_end_time` datetime NOT NULL,
  `start_longitude` decimal(10,7) DEFAULT NULL,
  `start_latitude` decimal(10,7) DEFAULT NULL,
  `end_longitude` decimal(10,7) DEFAULT NULL,
  `end_latitude` decimal(10,7) DEFAULT NULL,
  `segment_distance` decimal(10,3) DEFAULT 0.000,
  `gps_point_count` int(11) DEFAULT 0,
  `gps_ids` text,
  `task_id` bigint(20) DEFAULT NULL,
  `task_code` varchar(50) DEFAULT NULL,
  `calculate_method` varchar(20) DEFAULT 'tianditu',
  `create_time` datetime DEFAULT NULL,
  `update_time` datetime DEFAULT NULL,
  PRIMARY KEY (`segment_id`, `segment_start_time`),
  UNIQUE KEY `uk_vehicle_time` (`vehicle_id`, `segment_start_time`),
  KEY `idx_vehicle_id` (`vehicle_id`),
  KEY `idx_start_time` (`segment_start_time`),
  KEY `idx_task_id` (`task_id`),
  KEY `idx_vehicle_task` (`vehicle_id`, `task_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='车辆GPS分段里程表(按月分区)'
PARTITION BY RANGE (TO_DAYS(segment_start_time)) (
    PARTITION p202401 VALUES LESS THAN (TO_DAYS('2024-02-01')),
    PARTITION p202402 VALUES LESS THAN (TO_DAYS('2024-03-01')),
    PARTITION p202403 VALUES LESS THAN (TO_DAYS('2024-04-01')),
    PARTITION p202404 VALUES LESS THAN (TO_DAYS('2024-05-01')),
    PARTITION p202405 VALUES LESS THAN (TO_DAYS('2024-06-01')),
    PARTITION p202406 VALUES LESS THAN (TO_DAYS('2024-07-01')),
    PARTITION p202407 VALUES LESS THAN (TO_DAYS('2024-08-01')),
    PARTITION p202408 VALUES LESS THAN (TO_DAYS('2024-09-01')),
    PARTITION p202409 VALUES LESS THAN (TO_DAYS('2024-10-01')),
    PARTITION p202410 VALUES LESS THAN (TO_DAYS('2024-11-01')),
    PARTITION p202411 VALUES LESS THAN (TO_DAYS('2024-12-01')),
    PARTITION p202412 VALUES LESS THAN (TO_DAYS('2025-01-01')),
    PARTITION p202501 VALUES LESS THAN (TO_DAYS('2025-02-01')),
    PARTITION p202502 VALUES LESS THAN (TO_DAYS('2025-03-01')),
    PARTITION p202503 VALUES LESS THAN (TO_DAYS('2025-04-01')),
    PARTITION p202504 VALUES LESS THAN (TO_DAYS('2025-05-01')),
    PARTITION p202505 VALUES LESS THAN (TO_DAYS('2025-06-01')),
    PARTITION p202506 VALUES LESS THAN (TO_DAYS('2025-07-01')),
    PARTITION p202507 VALUES LESS THAN (TO_DAYS('2025-08-01')),
    PARTITION p202508 VALUES LESS THAN (TO_DAYS('2025-09-01')),
    PARTITION p202509 VALUES LESS THAN (TO_DAYS('2025-10-01')),
    PARTITION p202510 VALUES LESS THAN (TO_DAYS('2025-11-01')),
    PARTITION p202511 VALUES LESS THAN (TO_DAYS('2025-12-01')),
    PARTITION p202512 VALUES LESS THAN (TO_DAYS('2026-01-01')),
    PARTITION p202601 VALUES LESS THAN (TO_DAYS('2026-02-01')),
    PARTITION p202602 VALUES LESS THAN (TO_DAYS('2026-03-01')),
    PARTITION p202603 VALUES LESS THAN (TO_DAYS('2026-04-01')),
    PARTITION p202604 VALUES LESS THAN (TO_DAYS('2026-05-01')),
    PARTITION p202605 VALUES LESS THAN (TO_DAYS('2026-06-01')),
    PARTITION p202606 VALUES LESS THAN (TO_DAYS('2026-07-01')),
    PARTITION p202607 VALUES LESS THAN (TO_DAYS('2026-08-01')),
    PARTITION p202608 VALUES LESS THAN (TO_DAYS('2026-09-01')),
    PARTITION p202609 VALUES LESS THAN (TO_DAYS('2026-10-01')),
    PARTITION p202610 VALUES LESS THAN (TO_DAYS('2026-11-01')),
    PARTITION p202611 VALUES LESS THAN (TO_DAYS('2026-12-01')),
    PARTITION p202612 VALUES LESS THAN (TO_DAYS('2027-01-01')),
    PARTITION pfuture VALUES LESS THAN MAXVALUE
);
SELECT '分区表创建成功' as result;
-- ========================================
-- ç¬¬ä¸‰æ­¥ï¼šè¿ç§»æ•°æ®
-- ========================================
SELECT '开始迁移数据...' as info, NOW() as start_time;
-- ä¸€æ¬¡æ€§è¿ç§»ï¼ˆé€‚用于数据量不超过500万)
INSERT INTO tb_vehicle_gps_segment_mileage_partitioned
SELECT * FROM tb_vehicle_gps_segment_mileage;
SELECT '数据迁移完成' as info, NOW() as end_time;
-- ========================================
-- ç¬¬å››æ­¥ï¼šéªŒè¯æ•°æ®
-- ========================================
SELECT '=== æ•°æ®éªŒè¯ ===' as info;
-- æ¯”较记录数
SELECT
    '原表' as table_name,
    COUNT(*) as record_count
FROM tb_vehicle_gps_segment_mileage
UNION ALL
SELECT
    '新表(分区)' as table_name,
    COUNT(*) as record_count
FROM tb_vehicle_gps_segment_mileage_partitioned;
-- æ¯”较统计数据
SELECT
    '原表' as table_name,
    SUM(segment_distance) as total_distance,
    MIN(segment_start_time) as min_time,
    MAX(segment_start_time) as max_time
FROM tb_vehicle_gps_segment_mileage
UNION ALL
SELECT
    '新表(分区)' as table_name,
    SUM(segment_distance) as total_distance,
    MIN(segment_start_time) as min_time,
    MAX(segment_start_time) as max_time
FROM tb_vehicle_gps_segment_mileage_partitioned;
-- æŸ¥çœ‹åˆ†åŒºåˆ†å¸ƒ
SELECT
    PARTITION_NAME as '分区名',
    TABLE_ROWS as '记录数',
    ROUND(DATA_LENGTH/1024/1024, 2) as '数据(MB)',
    ROUND(INDEX_LENGTH/1024/1024, 2) as '索引(MB)'
FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = DATABASE()
  AND TABLE_NAME = 'tb_vehicle_gps_segment_mileage_partitioned'
ORDER BY PARTITION_ORDINAL_POSITION;
-- æš‚停!请检查数据是否一致
-- å¦‚果数据一致,继续执行下一步
-- å¦‚果不一致,请停止并检查问题
-- ========================================
-- ç¬¬äº”步:切换表名(谨慎!)
-- ========================================
-- å»ºè®®åœ¨ä¸šåŠ¡åœæœºçª—å£æ‰§è¡Œä»¥ä¸‹æ“ä½œ
-- START TRANSACTION;
SELECT '开始切换表名...' as info;
-- é‡å‘½ååŽŸè¡¨ä¸ºå¤‡ä»½è¡¨
RENAME TABLE
    tb_vehicle_gps_segment_mileage TO tb_vehicle_gps_segment_mileage_old,
    tb_vehicle_gps_segment_mileage_partitioned TO tb_vehicle_gps_segment_mileage;
SELECT '表名切换完成,请立即验证应用功能!' as result;
-- å¦‚果有问题,立即回滚:
-- RENAME TABLE
--     tb_vehicle_gps_segment_mileage TO tb_vehicle_gps_segment_mileage_partitioned,
--     tb_vehicle_gps_segment_mileage_old TO tb_vehicle_gps_segment_mileage;
-- COMMIT;
-- ========================================
-- ç¬¬å…­æ­¥ï¼šéªŒè¯åˆ†åŒºæ•ˆæžœ
-- ========================================
SELECT '=== æµ‹è¯•查询性能 ===' as info;
-- æµ‹è¯•1:查询最近1月数据(应该只扫描1个分区)
EXPLAIN PARTITIONS
SELECT COUNT(*), SUM(segment_distance)
FROM tb_vehicle_gps_segment_mileage
WHERE segment_start_time >= DATE_SUB(CURDATE(), INTERVAL 1 MONTH);
-- æµ‹è¯•2:按车辆ID查询最近1周数据
EXPLAIN PARTITIONS
SELECT *
FROM tb_vehicle_gps_segment_mileage
WHERE vehicle_id = 1
  AND segment_start_time >= DATE_SUB(NOW(), INTERVAL 7 DAY)
LIMIT 10;
-- æµ‹è¯•3:统计最近3个月的里程
SELECT
    DATE_FORMAT(segment_start_time, '%Y-%m') as month,
    COUNT(*) as segments,
    ROUND(SUM(segment_distance), 2) as total_km
FROM tb_vehicle_gps_segment_mileage
WHERE segment_start_time >= DATE_SUB(CURDATE(), INTERVAL 3 MONTH)
GROUP BY DATE_FORMAT(segment_start_time, '%Y-%m');
-- ========================================
-- ç¬¬ä¸ƒæ­¥ï¼šæ¸…理和优化(可选)
-- ========================================
-- ç¡®è®¤åº”用运行正常后,等待1-2周,然后删除备份表
-- DROP TABLE tb_vehicle_gps_segment_mileage_old;
-- åˆ†æžè¡¨ï¼Œä¼˜åŒ–查询计划
ANALYZE TABLE tb_vehicle_gps_segment_mileage;
-- ========================================
-- å®Œæˆï¼
-- ========================================
SELECT '========================================' as info;
SELECT '分区优化完成!' as result;
SELECT '请注意:' as notice;
SELECT '1. å®šæœŸæ·»åŠ æ–°æœˆä»½çš„åˆ†åŒº' as task1;
SELECT '2. å®šæœŸæ¸…理历史分区释放空间' as task2;
SELECT '3. æŸ¥è¯¢æ—¶å°½é‡å¸¦ä¸Šæ—¶é—´èŒƒå›´æ¡ä»¶' as task3;
SELECT '========================================' as info;
-- ========================================
-- æ—¥å¸¸ç»´æŠ¤å‘½ä»¤å‚考
-- ========================================
-- æ·»åŠ æ–°åˆ†åŒºï¼ˆæ¯æœˆæ‰§è¡Œä¸€æ¬¡ï¼‰
-- ALTER TABLE tb_vehicle_gps_segment_mileage
-- REORGANIZE PARTITION pfuture INTO (
--     PARTITION p202701 VALUES LESS THAN (TO_DAYS('2027-02-01')),
--     PARTITION pfuture VALUES LESS THAN MAXVALUE
-- );
-- åˆ é™¤åŽ†å²åˆ†åŒºï¼ˆé‡Šæ”¾ç©ºé—´ï¼‰
-- ALTER TABLE tb_vehicle_gps_segment_mileage DROP PARTITION p202401;
-- æŸ¥çœ‹åˆ†åŒºçŠ¶æ€
-- SELECT * FROM information_schema.PARTITIONS
-- WHERE TABLE_SCHEMA = DATABASE()
--   AND TABLE_NAME = 'tb_vehicle_gps_segment_mileage';
sql/partition_vehicle_gps_segment_mileage.sql
New file
@@ -0,0 +1,304 @@
-- ========================================
-- GPS分段里程表分区优化方案
-- ========================================
-- åŠŸèƒ½è¯´æ˜Žï¼š
-- 1. å°† tb_vehicle_gps_segment_mileage è¡¨æŒ‰æœˆè¿›è¡Œåˆ†åŒº
-- 2. æé«˜å¤§æ•°æ®é‡ä¸‹çš„æŸ¥è¯¢æ€§èƒ½
-- 3. æ–¹ä¾¿åŽ†å²æ•°æ®å½’æ¡£å’Œåˆ é™¤
--
-- æ³¨æ„äº‹é¡¹ï¼š
-- 1. æ‰§è¡Œæ­¤è„šæœ¬å‰è¯·å¤‡ä»½æ•°æ®åº“ï¼
-- 2. æ•°æ®é‡å¤§æ—¶è½¬æ¢è¿‡ç¨‹è¾ƒæ…¢ï¼Œå»ºè®®åœ¨ä¸šåŠ¡ä½Žå³°æœŸæ‰§è¡Œ
-- 3. åˆ†åŒºåŽä¸»é”®å’Œå”¯ä¸€é”®å¿…须包含分区键(segment_start_time)
-- ========================================
-- æ­¥éª¤1:备份原表数据
-- å»ºè®®å…ˆæ‰‹åŠ¨æ‰§è¡Œï¼šmysqldump -u用户名 -p æ•°æ®åº“名 tb_vehicle_gps_segment_mileage > backup_segment_mileage.sql
-- æ­¥éª¤2:创建新的分区表
CREATE TABLE `tb_vehicle_gps_segment_mileage_new` (
  `segment_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '分段ID',
  `vehicle_id` bigint(20) NOT NULL COMMENT '车辆ID',
  `vehicle_no` varchar(20) DEFAULT NULL COMMENT '车牌号',
  `segment_start_time` datetime NOT NULL COMMENT '时间段开始时间',
  `segment_end_time` datetime NOT NULL COMMENT '时间段结束时间',
  `start_longitude` decimal(10,7) DEFAULT NULL COMMENT '起点经度',
  `start_latitude` decimal(10,7) DEFAULT NULL COMMENT '起点纬度',
  `end_longitude` decimal(10,7) DEFAULT NULL COMMENT '终点经度',
  `end_latitude` decimal(10,7) DEFAULT NULL COMMENT '终点纬度',
  `segment_distance` decimal(10,3) DEFAULT 0.000 COMMENT '段距离(公里)',
  `gps_point_count` int(11) DEFAULT 0 COMMENT 'GPS点数量',
  `gps_ids` text COMMENT '关联的GPS记录ID列表(逗号分隔)',
  `task_id` bigint(20) DEFAULT NULL COMMENT '关联任务ID',
  `task_code` varchar(50) DEFAULT NULL COMMENT '任务编号',
  `calculate_method` varchar(20) DEFAULT 'tianditu' COMMENT '计算方式(tianditu-天地图/haversine-球面距离)',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  PRIMARY KEY (`segment_id`, `segment_start_time`),
  -- æ³¨æ„ï¼šåˆ†åŒºè¡¨çš„唯一键必须包含分区键
  UNIQUE KEY `uk_vehicle_time` (`vehicle_id`, `segment_start_time`),
  KEY `idx_vehicle_id` (`vehicle_id`),
  KEY `idx_start_time` (`segment_start_time`),
  KEY `idx_task_id` (`task_id`),
  KEY `idx_vehicle_task` (`vehicle_id`, `task_id`),
  KEY `idx_vehicle_date` (`vehicle_id`, `segment_start_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆GPS分段里程表(分区版)'
PARTITION BY RANGE (TO_DAYS(segment_start_time)) (
    -- 2024年分区
    PARTITION p202401 VALUES LESS THAN (TO_DAYS('2024-02-01')),
    PARTITION p202402 VALUES LESS THAN (TO_DAYS('2024-03-01')),
    PARTITION p202403 VALUES LESS THAN (TO_DAYS('2024-04-01')),
    PARTITION p202404 VALUES LESS THAN (TO_DAYS('2024-05-01')),
    PARTITION p202405 VALUES LESS THAN (TO_DAYS('2024-06-01')),
    PARTITION p202406 VALUES LESS THAN (TO_DAYS('2024-07-01')),
    PARTITION p202407 VALUES LESS THAN (TO_DAYS('2024-08-01')),
    PARTITION p202408 VALUES LESS THAN (TO_DAYS('2024-09-01')),
    PARTITION p202409 VALUES LESS THAN (TO_DAYS('2024-10-01')),
    PARTITION p202410 VALUES LESS THAN (TO_DAYS('2024-11-01')),
    PARTITION p202411 VALUES LESS THAN (TO_DAYS('2024-12-01')),
    PARTITION p202412 VALUES LESS THAN (TO_DAYS('2025-01-01')),
    -- 2025年分区
    PARTITION p202501 VALUES LESS THAN (TO_DAYS('2025-02-01')),
    PARTITION p202502 VALUES LESS THAN (TO_DAYS('2025-03-01')),
    PARTITION p202503 VALUES LESS THAN (TO_DAYS('2025-04-01')),
    PARTITION p202504 VALUES LESS THAN (TO_DAYS('2025-05-01')),
    PARTITION p202505 VALUES LESS THAN (TO_DAYS('2025-06-01')),
    PARTITION p202506 VALUES LESS THAN (TO_DAYS('2025-07-01')),
    PARTITION p202507 VALUES LESS THAN (TO_DAYS('2025-08-01')),
    PARTITION p202508 VALUES LESS THAN (TO_DAYS('2025-09-01')),
    PARTITION p202509 VALUES LESS THAN (TO_DAYS('2025-10-01')),
    PARTITION p202510 VALUES LESS THAN (TO_DAYS('2025-11-01')),
    PARTITION p202511 VALUES LESS THAN (TO_DAYS('2025-12-01')),
    PARTITION p202512 VALUES LESS THAN (TO_DAYS('2026-01-01')),
    -- 2026年分区
    PARTITION p202601 VALUES LESS THAN (TO_DAYS('2026-02-01')),
    PARTITION p202602 VALUES LESS THAN (TO_DAYS('2026-03-01')),
    PARTITION p202603 VALUES LESS THAN (TO_DAYS('2026-04-01')),
    PARTITION p202604 VALUES LESS THAN (TO_DAYS('2026-05-01')),
    PARTITION p202605 VALUES LESS THAN (TO_DAYS('2026-06-01')),
    PARTITION p202606 VALUES LESS THAN (TO_DAYS('2026-07-01')),
    PARTITION p202607 VALUES LESS THAN (TO_DAYS('2026-08-01')),
    PARTITION p202608 VALUES LESS THAN (TO_DAYS('2026-09-01')),
    PARTITION p202609 VALUES LESS THAN (TO_DAYS('2026-10-01')),
    PARTITION p202610 VALUES LESS THAN (TO_DAYS('2026-11-01')),
    PARTITION p202611 VALUES LESS THAN (TO_DAYS('2026-12-01')),
    PARTITION p202612 VALUES LESS THAN (TO_DAYS('2027-01-01')),
    -- æœªæ¥æ•°æ®åˆ†åŒºï¼ˆå¯ä»¥å®¹çº³2027年及以后的数据)
    PARTITION pfuture VALUES LESS THAN MAXVALUE
);
-- æ­¥éª¤3:迁移数据到新表
-- æ–¹å¼1:一次性迁移(适用于数据量较小,如100万以下)
-- INSERT INTO tb_vehicle_gps_segment_mileage_new SELECT * FROM tb_vehicle_gps_segment_mileage;
-- æ–¹å¼2:分批迁移(适用于大数据量,推荐)
-- æŒ‰æœˆä»½åˆ†æ‰¹è¿ç§»ï¼Œå‡å°‘锁表时间
-- 2024å¹´1月
INSERT INTO tb_vehicle_gps_segment_mileage_new
SELECT * FROM tb_vehicle_gps_segment_mileage
WHERE segment_start_time >= '2024-01-01' AND segment_start_time < '2024-02-01';
-- 2024å¹´2月
INSERT INTO tb_vehicle_gps_segment_mileage_new
SELECT * FROM tb_vehicle_gps_segment_mileage
WHERE segment_start_time >= '2024-02-01' AND segment_start_time < '2024-03-01';
-- ç»§ç»­æŒ‰æœˆè¿ç§»...(根据实际数据情况调整)
-- 2024å¹´3月至12月
INSERT INTO tb_vehicle_gps_segment_mileage_new
SELECT * FROM tb_vehicle_gps_segment_mileage
WHERE segment_start_time >= '2024-03-01' AND segment_start_time < '2025-01-01';
-- 2025年数据
INSERT INTO tb_vehicle_gps_segment_mileage_new
SELECT * FROM tb_vehicle_gps_segment_mileage
WHERE segment_start_time >= '2025-01-01' AND segment_start_time < '2026-01-01';
-- 2026年数据
INSERT INTO tb_vehicle_gps_segment_mileage_new
SELECT * FROM tb_vehicle_gps_segment_mileage
WHERE segment_start_time >= '2026-01-01';
-- æ­¥éª¤4:验证数据一致性
-- æ£€æŸ¥åŽŸè¡¨å’Œæ–°è¡¨çš„è®°å½•æ•°
SELECT 'Original Table' as table_name, COUNT(*) as record_count FROM tb_vehicle_gps_segment_mileage
UNION ALL
SELECT 'New Table' as table_name, COUNT(*) as record_count FROM tb_vehicle_gps_segment_mileage_new;
-- æ£€æŸ¥å„分区的数据量
SELECT
    PARTITION_NAME,
    TABLE_ROWS,
    AVG_ROW_LENGTH,
    DATA_LENGTH,
    INDEX_LENGTH,
    CREATE_TIME
FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = DATABASE()
  AND TABLE_NAME = 'tb_vehicle_gps_segment_mileage_new'
ORDER BY PARTITION_NAME;
-- æ­¥éª¤5:切换表名(谨慎操作!)
-- å»ºè®®åœ¨ä¸šåŠ¡ä½Žå³°æœŸæ‰§è¡Œï¼Œæ•´ä¸ªæ“ä½œåº”åœ¨äº‹åŠ¡ä¸­å®Œæˆ
-- START TRANSACTION;
-- é‡å‘½ååŽŸè¡¨ä¸ºå¤‡ä»½è¡¨
RENAME TABLE tb_vehicle_gps_segment_mileage TO tb_vehicle_gps_segment_mileage_backup;
-- å°†æ–°è¡¨é‡å‘½åä¸ºæ­£å¼è¡¨
RENAME TABLE tb_vehicle_gps_segment_mileage_new TO tb_vehicle_gps_segment_mileage;
-- å¦‚果一切正常,提交事务
-- COMMIT;
-- å¦‚果出现问题,回滚
-- ROLLBACK;
-- æ­¥éª¤6:验证应用正常运行
-- è¯·åœ¨åº”用层测试以下功能:
-- 1. GPS里程计算功能
-- 2. è½¦è¾†é‡Œç¨‹æŸ¥è¯¢
-- 3. ä»»åŠ¡é‡Œç¨‹ç»Ÿè®¡
-- 4. ç›¸å…³æŠ¥è¡¨åŠŸèƒ½
-- æ­¥éª¤7:确认无误后删除备份表(可选,建议保留一段时间)
-- DROP TABLE tb_vehicle_gps_segment_mileage_backup;
-- ========================================
-- åˆ†åŒºç»´æŠ¤æ“ä½œï¼ˆå®šæœŸæ‰§è¡Œï¼‰
-- ========================================
-- æ·»åŠ æ–°æœˆä»½çš„åˆ†åŒºï¼ˆæ¯æœˆæˆ–æ¯å­£åº¦æ‰§è¡Œä¸€æ¬¡ï¼‰
-- ä¾‹å¦‚:添加2027å¹´1月的分区
-- ALTER TABLE tb_vehicle_gps_segment_mileage
-- REORGANIZE PARTITION pfuture INTO (
--     PARTITION p202701 VALUES LESS THAN (TO_DAYS('2027-02-01')),
--     PARTITION pfuture VALUES LESS THAN MAXVALUE
-- );
-- åˆ é™¤åŽ†å²åˆ†åŒºï¼ˆå½’æ¡£æ—§æ•°æ®ï¼Œé‡Šæ”¾ç©ºé—´ï¼‰
-- ä¾‹å¦‚:删除2024å¹´1月的数据(删除前请确保已备份)
-- ALTER TABLE tb_vehicle_gps_segment_mileage DROP PARTITION p202401;
-- æˆ–者清空分区数据但保留分区结构
-- ALTER TABLE tb_vehicle_gps_segment_mileage TRUNCATE PARTITION p202401;
-- ========================================
-- æ€§èƒ½ä¼˜åŒ–建议
-- ========================================
-- 1. æŸ¥è¯¢æ—¶å°½é‡å¸¦ä¸Šæ—¶é—´èŒƒå›´æ¡ä»¶ï¼Œä»¥å……分利用分区裁剪
-- ç¤ºä¾‹ï¼š
-- SELECT * FROM tb_vehicle_gps_segment_mileage
-- WHERE segment_start_time >= '2025-01-01'
--   AND segment_start_time < '2025-02-01'
--   AND vehicle_id = 123;
-- 2. å®šæœŸåˆ†æžè¡¨ä»¥ä¼˜åŒ–查询计划
-- ANALYZE TABLE tb_vehicle_gps_segment_mileage;
-- 3. å®šæœŸä¼˜åŒ–表以回收空间
-- OPTIMIZE TABLE tb_vehicle_gps_segment_mileage;
-- 4. æŸ¥çœ‹åˆ†åŒºä½¿ç”¨æƒ…况
SELECT
    PARTITION_NAME as '分区名',
    PARTITION_METHOD as '分区方式',
    PARTITION_EXPRESSION as '分区表达式',
    TABLE_ROWS as '记录数',
    ROUND(DATA_LENGTH/1024/1024, 2) as '数据大小(MB)',
    ROUND(INDEX_LENGTH/1024/1024, 2) as '索引大小(MB)',
    PARTITION_COMMENT as '备注'
FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = DATABASE()
  AND TABLE_NAME = 'tb_vehicle_gps_segment_mileage'
ORDER BY PARTITION_ORDINAL_POSITION;
-- ========================================
-- åˆ†åŒºè‡ªåŠ¨ç»´æŠ¤è„šæœ¬ï¼ˆå»ºè®®é…ç½®å®šæ—¶ä»»åŠ¡ï¼‰
-- ========================================
DELIMITER $$
CREATE PROCEDURE add_gps_segment_partition()
BEGIN
    DECLARE next_month_date DATE;
    DECLARE partition_name VARCHAR(20);
    DECLARE next_partition_date DATE;
    -- è®¡ç®—下个月的日期
    SET next_month_date = DATE_ADD(CURDATE(), INTERVAL 2 MONTH);
    SET next_month_date = DATE_FORMAT(next_month_date, '%Y-%m-01');
    -- ç”Ÿæˆåˆ†åŒºåç§°ï¼ˆä¾‹å¦‚:p202701)
    SET partition_name = CONCAT('p', DATE_FORMAT(next_month_date, '%Y%m'));
    -- è®¡ç®—下一个分区的边界日期
    SET next_partition_date = DATE_ADD(next_month_date, INTERVAL 1 MONTH);
    -- åŠ¨æ€æ·»åŠ åˆ†åŒº
    SET @sql = CONCAT(
        'ALTER TABLE tb_vehicle_gps_segment_mileage ',
        'REORGANIZE PARTITION pfuture INTO (',
        'PARTITION ', partition_name, ' VALUES LESS THAN (TO_DAYS(''', next_partition_date, ''')),',
        'PARTITION pfuture VALUES LESS THAN MAXVALUE',
        ')'
    );
    PREPARE stmt FROM @sql;
    EXECUTE stmt;
    DEALLOCATE PREPARE stmt;
    SELECT CONCAT('成功添加分区: ', partition_name, ', è¾¹ç•Œæ—¥æœŸ: ', next_partition_date) as result;
END$$
DELIMITER ;
-- ä½¿ç”¨æ–¹æ³•:每月执行一次
-- CALL add_gps_segment_partition();
-- ========================================
-- åŽ†å²æ•°æ®å½’æ¡£ç­–ç•¥ï¼ˆå¯é€‰ï¼‰
-- ========================================
-- æ–¹æ¡ˆ1:导出历史分区到归档表
-- CREATE TABLE tb_vehicle_gps_segment_mileage_archive LIKE tb_vehicle_gps_segment_mileage;
-- ALTER TABLE tb_vehicle_gps_segment_mileage_archive REMOVE PARTITIONING;
-- INSERT INTO tb_vehicle_gps_segment_mileage_archive
-- SELECT * FROM tb_vehicle_gps_segment_mileage PARTITION(p202401);
-- æ–¹æ¡ˆ2:导出到文件
-- SELECT * INTO OUTFILE '/tmp/gps_segment_202401.csv'
-- FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"'
-- LINES TERMINATED BY '\n'
-- FROM tb_vehicle_gps_segment_mileage PARTITION(p202401);
-- æ–¹æ¡ˆ3:定期删除超过N个月的历史数据
-- ä¾‹å¦‚:删除12个月之前的数据
-- ALTER TABLE tb_vehicle_gps_segment_mileage
-- DROP PARTITION p202401;
-- ========================================
-- ç›‘控和告警(建议)
-- ========================================
-- ç›‘控各分区的数据量增长
CREATE VIEW v_gps_segment_partition_stats AS
SELECT
    PARTITION_NAME as partition_name,
    TABLE_ROWS as row_count,
    ROUND(DATA_LENGTH/1024/1024, 2) as data_size_mb,
    ROUND(INDEX_LENGTH/1024/1024, 2) as index_size_mb,
    ROUND((DATA_LENGTH + INDEX_LENGTH)/1024/1024, 2) as total_size_mb,
    CREATE_TIME as create_time,
    UPDATE_TIME as update_time
FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = DATABASE()
  AND TABLE_NAME = 'tb_vehicle_gps_segment_mileage'
ORDER BY PARTITION_ORDINAL_POSITION;
-- æŸ¥çœ‹åˆ†åŒºç»Ÿè®¡
-- SELECT * FROM v_gps_segment_partition_stats;
sql/·ÖÇøÓÅ»¯·½°¸ËµÃ÷.md
New file
@@ -0,0 +1,293 @@
# GPS分段里程表分区优化方案
## ä¸€ã€é—®é¢˜åˆ†æž
### å½“前情况
- **表名**: `tb_vehicle_gps_segment_mileage`
- **数据特点**: æŒ‰5分钟时间段统计GPS里程,数据量增长快
- **主要问题**:
  - æ•°æ®é‡å¤§å¯¼è‡´æŸ¥è¯¢å˜æ…¢
  - ç´¢å¼•效率降低
  - åŽ†å²æ•°æ®éš¾ä»¥æ¸…ç†
### åˆ†åŒºä¼˜åŒ–收益
✅ **查询性能提升**: é€šè¿‡åˆ†åŒºè£å‰ªï¼ŒæŸ¥è¯¢åªæ‰«æç›¸å…³åˆ†åŒºï¼Œæ€§èƒ½æå‡50%-80%
✅ **维护简化**: æŒ‰æœˆåˆ†åŒºï¼Œå¯ä»¥å¿«é€Ÿåˆ é™¤æˆ–归档历史数据
✅ **存储优化**: ä¾¿äºŽæ•°æ®å½’档,释放磁盘空间
✅ **并发优化**: ä¸åŒåˆ†åŒºå¯ä»¥å¹¶è¡Œæ“ä½œï¼Œå‡å°‘锁冲突
---
## äºŒã€åˆ†åŒºæ–¹æ¡ˆè®¾è®¡
### 1. åˆ†åŒºç­–ç•¥
- **分区类型**: RANGE åˆ†åŒºï¼ˆæŒ‰æ—¶é—´èŒƒå›´ï¼‰
- **分区键**: `segment_start_time`(时间段开始时间)
- **分区粒度**: æŒ‰æœˆåˆ†åŒº
- **保留周期**: å»ºè®®ä¿ç•™æœ€è¿‘12-24个月数据
### 2. åˆ†åŒºç»“æž„
```
2024å¹´: p202401, p202402, ..., p202412 (12个分区)
2025å¹´: p202501, p202502, ..., p202512 (12个分区)
2026å¹´: p202601, p202602, ..., p202612 (12个分区)
未来: pfuture (容纳所有未来数据)
```
### 3. å…³é”®å˜æ›´ç‚¹
⚠️ **重要**: åˆ†åŒºè¡¨çš„主键和唯一键必须包含分区键
**原表结构**:
```sql
PRIMARY KEY (`segment_id`),
UNIQUE KEY `uk_vehicle_time` (`vehicle_id`, `segment_start_time`)
```
**新表结构**:
```sql
PRIMARY KEY (`segment_id`, `segment_start_time`),  -- ä¸»é”®åŒ…含分区键
UNIQUE KEY `uk_vehicle_time` (`vehicle_id`, `segment_start_time`)  -- å·²åŒ…含分区键
```
---
## ä¸‰ã€æ‰§è¡Œæ­¥éª¤
### æ­¥éª¤1: æ•°æ®å¤‡ä»½ï¼ˆå¿…须!)
```bash
# å¤‡ä»½æ•´ä¸ªæ•°æ®åº“
mysqldump -u用户名 -p æ•°æ®åº“名 > backup_$(date +%Y%m%d).sql
# æˆ–只备份单表
mysqldump -u用户名 -p æ•°æ®åº“名 tb_vehicle_gps_segment_mileage > backup_segment_mileage_$(date +%Y%m%d).sql
```
### æ­¥éª¤2: æŸ¥çœ‹å½“前数据量
```sql
-- æŸ¥çœ‹æ€»è®°å½•æ•°
SELECT COUNT(*) as total_records FROM tb_vehicle_gps_segment_mileage;
-- æŸ¥çœ‹æŒ‰æœˆåˆ†å¸ƒ
SELECT
    DATE_FORMAT(segment_start_time, '%Y-%m') as month,
    COUNT(*) as record_count,
    ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM tb_vehicle_gps_segment_mileage), 2) as percentage
FROM tb_vehicle_gps_segment_mileage
GROUP BY DATE_FORMAT(segment_start_time, '%Y-%m')
ORDER BY month;
-- æŸ¥çœ‹è¡¨å¤§å°
SELECT
    TABLE_NAME,
    ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 2) AS size_mb,
    TABLE_ROWS
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
  AND TABLE_NAME = 'tb_vehicle_gps_segment_mileage';
```
### æ­¥éª¤3: æ‰§è¡Œåˆ†åŒºè„šæœ¬
```sql
-- åœ¨ä¸šåŠ¡ä½Žå³°æœŸæ‰§è¡Œ partition_vehicle_gps_segment_mileage.sql
source d:/project/急救转运/code/Api/RuoYi-Vue-master/sql/partition_vehicle_gps_segment_mileage.sql
```
**或分步执行**:
1. åˆ›å»ºæ–°åˆ†åŒºè¡¨ï¼ˆ`tb_vehicle_gps_segment_mileage_new`)
2. åˆ†æ‰¹è¿ç§»æ•°æ®
3. éªŒè¯æ•°æ®ä¸€è‡´æ€§
4. åˆ‡æ¢è¡¨å
### æ­¥éª¤4: éªŒè¯åº”用功能
测试以下功能是否正常:
- âœ… GPS里程计算任务
- âœ… è½¦è¾†é‡Œç¨‹æŸ¥è¯¢
- âœ… ä»»åŠ¡é‡Œç¨‹ç»Ÿè®¡
- âœ… é‡Œç¨‹æŠ¥è¡¨
### æ­¥éª¤5: åˆ é™¤å¤‡ä»½è¡¨ï¼ˆå¯é€‰ï¼‰
```sql
-- ç¡®è®¤è¿è¡Œæ­£å¸¸åŽï¼Œå¯ä»¥åˆ é™¤å¤‡ä»½è¡¨ï¼ˆå»ºè®®ä¿ç•™1-2周)
DROP TABLE tb_vehicle_gps_segment_mileage_backup;
```
---
## å››ã€æ—¥å¸¸ç»´æŠ¤æ“ä½œ
### 1. æ·»åŠ æ–°æœˆä»½åˆ†åŒºï¼ˆæ¯æœˆæ‰§è¡Œï¼‰
```sql
-- æ‰‹åŠ¨æ·»åŠ 2027å¹´2月分区
ALTER TABLE tb_vehicle_gps_segment_mileage
REORGANIZE PARTITION pfuture INTO (
    PARTITION p202702 VALUES LESS THAN (TO_DAYS('2027-03-01')),
    PARTITION pfuture VALUES LESS THAN MAXVALUE
);
-- æˆ–使用存储过程自动添加
CALL add_gps_segment_partition();
```
### 2. åˆ é™¤åŽ†å²åˆ†åŒºï¼ˆé‡Šæ”¾ç©ºé—´ï¼‰
```sql
-- æ–¹å¼1: ç›´æŽ¥åˆ é™¤åˆ†åŒºï¼ˆæ•°æ®ä¸å¯æ¢å¤ï¼‰
ALTER TABLE tb_vehicle_gps_segment_mileage DROP PARTITION p202401;
-- æ–¹å¼2: å½’档后删除
-- å…ˆå¯¼å‡ºæ•°æ®åˆ°å½’档表
CREATE TABLE tb_vehicle_gps_segment_mileage_archive_202401
SELECT * FROM tb_vehicle_gps_segment_mileage PARTITION(p202401);
-- ç„¶åŽåˆ é™¤åˆ†åŒº
ALTER TABLE tb_vehicle_gps_segment_mileage DROP PARTITION p202401;
```
### 3. æŸ¥çœ‹åˆ†åŒºçŠ¶æ€
```sql
-- æŸ¥çœ‹æ‰€æœ‰åˆ†åŒºä¿¡æ¯
SELECT * FROM v_gps_segment_partition_stats;
-- æˆ–直接查询
SELECT
    PARTITION_NAME as '分区名',
    TABLE_ROWS as '记录数',
    ROUND(DATA_LENGTH/1024/1024, 2) as '数据(MB)',
    ROUND(INDEX_LENGTH/1024/1024, 2) as '索引(MB)'
FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = DATABASE()
  AND TABLE_NAME = 'tb_vehicle_gps_segment_mileage'
ORDER BY PARTITION_ORDINAL_POSITION;
```
### 4. ä¼˜åŒ–分区
```sql
-- åˆ†æžè¡¨ï¼ˆæ›´æ–°ç»Ÿè®¡ä¿¡æ¯ï¼‰
ANALYZE TABLE tb_vehicle_gps_segment_mileage;
-- ä¼˜åŒ–表(回收碎片空间)
OPTIMIZE TABLE tb_vehicle_gps_segment_mileage;
```
---
## äº”、查询优化建议
### âœ… å¥½çš„æŸ¥è¯¢ï¼ˆåˆ©ç”¨åˆ†åŒºè£å‰ªï¼‰
```sql
-- ç¤ºä¾‹1: å¸¦æ—¶é—´èŒƒå›´çš„æŸ¥è¯¢
SELECT * FROM tb_vehicle_gps_segment_mileage
WHERE segment_start_time >= '2025-01-01'
  AND segment_start_time < '2025-02-01'
  AND vehicle_id = 123;
-- ç¤ºä¾‹2: æŒ‰æœˆç»Ÿè®¡
SELECT
    DATE_FORMAT(segment_start_time, '%Y-%m') as month,
    SUM(segment_distance) as total_distance
FROM tb_vehicle_gps_segment_mileage
WHERE segment_start_time >= '2025-01-01'
  AND segment_start_time < '2025-12-31'
GROUP BY DATE_FORMAT(segment_start_time, '%Y-%m');
```
### âŒ ä¸å¥½çš„æŸ¥è¯¢ï¼ˆå…¨è¡¨æ‰«æï¼‰
```sql
-- ç¼ºå°‘时间条件,会扫描所有分区
SELECT * FROM tb_vehicle_gps_segment_mileage
WHERE vehicle_id = 123;
-- åº”该改为:
SELECT * FROM tb_vehicle_gps_segment_mileage
WHERE vehicle_id = 123
  AND segment_start_time >= DATE_SUB(NOW(), INTERVAL 30 DAY);
```
---
## å…­ã€ç›‘控和告警
### 1. ç›‘控指标
- å„分区的数据量
- è¡¨å’Œç´¢å¼•的大小
- æœ€æ–°åˆ†åŒºæ˜¯å¦æŽ¥è¿‘满
- æŸ¥è¯¢æ€§èƒ½å¯¹æ¯”(分区前后)
### 2. å®šæ—¶ä»»åŠ¡å»ºè®®
```bash
# æ¯æœˆ1号凌晨1点自动添加新分区
0 1 1 * * mysql -u用户名 -p密码 æ•°æ®åº“名 -e "CALL add_gps_segment_partition();"
# æ¯å­£åº¦åˆ é™¤12个月之前的历史分区
0 2 1 1,4,7,10 * /path/to/cleanup_old_partitions.sh
```
---
## ä¸ƒã€å›žæ»šæ–¹æ¡ˆ
如果分区后出现问题,可以快速回滚:
```sql
-- 1. åœæ­¢åº”用写入
-- 2. æ¢å¤åŽŸè¡¨
RENAME TABLE tb_vehicle_gps_segment_mileage TO tb_vehicle_gps_segment_mileage_failed;
RENAME TABLE tb_vehicle_gps_segment_mileage_backup TO tb_vehicle_gps_segment_mileage;
-- 3. åŒæ­¥åˆ‡æ¢æœŸé—´çš„æ–°æ•°æ®ï¼ˆå¦‚果有)
INSERT INTO tb_vehicle_gps_segment_mileage
SELECT * FROM tb_vehicle_gps_segment_mileage_failed
WHERE create_time > (SELECT MAX(create_time) FROM tb_vehicle_gps_segment_mileage);
-- 4. é‡å¯åº”用
```
---
## å…«ã€æ€§èƒ½å¯¹æ¯”(预期)
### åˆ†åŒºå‰
- æŸ¥è¯¢æœ€è¿‘1月数据: ~5-10秒
- æŸ¥è¯¢æœ€è¿‘3月数据: ~15-30秒
- è¡¨å¤§å°: æŒç»­å¢žé•¿ï¼Œç´¢å¼•效率降低
### åˆ†åŒºåŽ
- æŸ¥è¯¢æœ€è¿‘1月数据: ~1-2秒(提升70%-80%)
- æŸ¥è¯¢æœ€è¿‘3月数据: ~3-6秒(提升70%-80%)
- è¡¨ç»´æŠ¤: å¯æŒ‰æœˆæ¸…理,空间可控
---
## ä¹ã€å¸¸è§é—®é¢˜
### Q1: åˆ†åŒºä¼šå½±å“åº”用代码吗?
**A**: ä¸ä¼šã€‚分区对应用透明,SQL语句不需要修改。
### Q2: å¯ä»¥åœ¨çº¿è½¬æ¢å—?
**A**: MySQL 5.7+ å¯ä»¥åœ¨çº¿è½¬æ¢ï¼Œä½†å»ºè®®åœ¨ä½Žå³°æœŸæ‰§è¡Œï¼Œå¤§è¡¨å¯èƒ½éœ€è¦è¾ƒé•¿æ—¶é—´ã€‚
### Q3: åˆ†åŒºåŽèƒ½å›žåˆ°éžåˆ†åŒºè¡¨å—?
**A**: å¯ä»¥ï¼Œä½¿ç”¨ `ALTER TABLE ... REMOVE PARTITIONING;`
### Q4: ä¸»é”®å¿…须包含分区键吗?
**A**: æ˜¯çš„,这是MySQL分区表的限制。
### Q5: å¦‚何确定保留多久的历史数据?
**A**: æ ¹æ®ä¸šåŠ¡éœ€æ±‚å’Œå­˜å‚¨å®¹é‡ï¼Œå»ºè®®ä¿ç•™12-24个月,更早的数据归档到冷存储。
---
## åã€è”系支持
如果执行过程中遇到问题,请:
1. æ£€æŸ¥é”™è¯¯æ—¥å¿—: `/var/log/mysql/error.log`
2. æŸ¥çœ‹æ…¢æŸ¥è¯¢æ—¥å¿—,对比性能
3. ç¡®ä¿æœ‰å®Œæ•´å¤‡ä»½
4. å¿…要时联系DBA或技术支持
---
**最后提醒**:
⚠️ æ‰§è¡Œå‰åŠ¡å¿…å¤‡ä»½ï¼
⚠️ é€‰æ‹©ä¸šåŠ¡ä½Žå³°æœŸæ‰§è¡Œï¼
⚠️ å‡†å¤‡å¥½å›žæ»šæ–¹æ¡ˆï¼