doum
7 天以前 074bcb8394fab66ce531c219e1e7de7c142ff2d5
新增智能电表、空调管理
已添加11个文件
已修改24个文件
3441 ■■■■ 文件已修改
admin/public/template/yw_device.xlsx 补丁 | 查看 | 原始文档 | blame | 历史
admin/src/api/Inspection/device.js 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin/src/api/ywWorkDesk.js 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin/src/components/business/DailyEnergyTrendPanel.vue 240 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin/src/components/business/ElectricalWarningWorkbench.vue 310 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin/src/components/business/OperaDeviceImportWindow.vue 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin/src/components/business/commonFunctions.vue 25 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin/src/components/common/Menu.vue 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin/src/utils/menu.js 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin/src/views/business/components/YwCustomerConditionerTab.vue 245 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin/src/views/business/components/YwElectricalRemote.vue 239 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin/src/views/business/ywcustomerrecharge.vue 12 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin/src/views/business/ywelectricalactions.vue 4 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin/src/views/index.vue 1002 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin/src/views/operation/device.vue 7 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/db/business.yw_device.importExcel.grant.sql 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/db/business.yw_device.permissions.sql 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_admin/src/main/java/com/doumee/cloud/admin/YwCustomerRechargeCloudController.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_admin/src/main/java/com/doumee/cloud/admin/YwDeviceCloudController.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_admin/src/main/java/com/doumee/cloud/admin/YwWorkDeskCloutController.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/core/conditoner/ConditionerUtil.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/core/device/model/request/EleControlApiRequest.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/dao/admin/request/DeviceImport.java 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/YwElectricalActions.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/vo/DailyEnergyStatVO.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/vo/WarningTypeStatVO.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/YwDeviceService.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/YwElectricalWarningService.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/YwWorkDeskEnergyService.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/YwCustomerRechargeBizServiceImpl.java 293 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/YwDeviceServiceImpl.java 332 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/YwElectricalActionsServiceImpl.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/YwElectricalBizServiceImpl.java 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/YwElectricalWarningServiceImpl.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/YwWorkDeskEnergyServiceImpl.java 228 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin/public/template/yw_device.xlsx
Binary files differ
admin/src/api/Inspection/device.js
@@ -52,3 +52,8 @@
export function getDeviceCateData (data) {
  return request.post('/visitsAdmin/cloudService/business/ywDevice/getDeviceCateData', data)
}
// æ‰¹é‡å¯¼å…¥
export function importExcel (data) {
  return request.post('/visitsAdmin/cloudService/business/ywDevice/importExcel', data)
}
admin/src/api/ywWorkDesk.js
@@ -24,3 +24,25 @@
export function workDeskData () {
  return request.get('/visitsAdmin/cloudService/business/ywWorkDesk/workDeskData')
}
// ç”µè¡¨æŠ¥è­¦ç±»åž‹ç»Ÿè®¡
export function electricalWarningStats () {
  return request.get('/visitsAdmin/cloudService/business/ywWorkDesk/electricalWarningStats')
}
// ç”µè¡¨æŠ¥è­¦åˆ†é¡µ
export function electricalWarningPage (data) {
  return request.post('/visitsAdmin/cloudService/business/ywWorkDesk/electricalWarningPage', data, {
    trim: true
  })
}
// è¿‘30天智能电表每日电量/电费
export function electricalDailyEnergyStats () {
  return request.get('/visitsAdmin/cloudService/business/ywWorkDesk/electricalDailyEnergyStats')
}
// è¿‘30天空调多联机每日电量/电费
export function conditionerDailyEnergyStats () {
  return request.get('/visitsAdmin/cloudService/business/ywWorkDesk/conditionerDailyEnergyStats')
}
admin/src/components/business/DailyEnergyTrendPanel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,240 @@
<template>
  <div class="daily-energy-panel">
    <div class="panel-header">
      <h3 class="panel-header__title">{{ title }}</h3>
    </div>
    <div class="panel_body" v-loading="loading">
      <div class="chart_box">
        <div ref="mixChart" class="chart_item"></div>
        <div v-if="!loading && !hasData" class="chart_empty">暂无数据</div>
      </div>
    </div>
  </div>
</template>
<script>
import * as echarts from 'echarts'
export default {
  name: 'DailyEnergyTrendPanel',
  props: {
    title: {
      type: String,
      required: true
    },
    loadData: {
      type: Function,
      required: true
    }
  },
  data () {
    return {
      loading: false,
      statList: [],
      chartInstance: null
    }
  },
  computed: {
    hasData () {
      return this.statList.some(item => Number(item.totalKwh) > 0 || Number(item.totalFee) > 0)
    }
  },
  mounted () {
    this.fetchStats()
    window.addEventListener('resize', this.handleResize)
  },
  beforeDestroy () {
    window.removeEventListener('resize', this.handleResize)
    this.disposeChart()
  },
  methods: {
    fetchStats () {
      this.loading = true
      this.loadData()
        .then(res => {
          this.statList = res || []
          this.$nextTick(() => {
            this.renderChart()
          })
        })
        .finally(() => {
          this.loading = false
        })
    },
    disposeChart () {
      if (this.chartInstance) {
        this.chartInstance.dispose()
        this.chartInstance = null
      }
    },
    renderChart () {
      const chartDom = this.$refs.mixChart
      if (!chartDom) {
        return
      }
      if (!this.chartInstance) {
        this.chartInstance = echarts.init(chartDom)
      }
      const dates = this.statList.map(item => item.statDate.substring(5))
      const kwhData = this.statList.map(item => Number(item.totalKwh || 0))
      const feeData = this.statList.map(item => Number(item.totalFee || 0))
      this.chartInstance.setOption({
        tooltip: {
          trigger: 'axis',
          axisPointer: { type: 'cross' },
          backgroundColor: 'rgba(255,255,255,0.96)',
          borderColor: '#EEF2F8',
          borderWidth: 1,
          textStyle: { color: '#333', fontSize: 12 },
          extraCssText: 'border-radius: 6px;'
        },
        legend: {
          data: ['总电量(kWh)', '总电费(元)'],
          top: 0,
          right: 0,
          textStyle: { fontSize: 12, color: '#666' }
        },
        grid: {
          left: 50,
          right: 50,
          top: 36,
          bottom: 28
        },
        xAxis: {
          type: 'category',
          data: dates,
          axisLabel: {
            fontSize: 11,
            color: '#999',
            interval: 4
          },
          axisLine: { lineStyle: { color: '#E8E8E8' } }
        },
        yAxis: [
          {
            type: 'value',
            name: 'kWh',
            nameTextStyle: { color: '#999', fontSize: 11 },
            axisLabel: { fontSize: 11, color: '#999' },
            splitLine: { lineStyle: { color: '#F0F0F0' } }
          },
          {
            type: 'value',
            name: '元',
            nameTextStyle: { color: '#999', fontSize: 11 },
            axisLabel: { fontSize: 11, color: '#999' },
            splitLine: { show: false }
          }
        ],
        series: [
          {
            name: '总电量(kWh)',
            type: 'bar',
            barMaxWidth: 24,
            itemStyle: {
              color: {
                type: 'linear',
                x: 0,
                y: 0,
                x2: 0,
                y2: 1,
                colorStops: [
                  { offset: 0, color: '#5B9CF5' },
                  { offset: 1, color: '#3E80EF' }
                ]
              },
              borderRadius: [3, 3, 0, 0]
            },
            data: kwhData
          },
          {
            name: '总电费(元)',
            type: 'line',
            smooth: true,
            yAxisIndex: 1,
            symbol: 'circle',
            symbolSize: 5,
            itemStyle: { color: '#FFB020' },
            lineStyle: { width: 2, color: '#FFB020' },
            areaStyle: {
              color: {
                type: 'linear',
                x: 0,
                y: 0,
                x2: 0,
                y2: 1,
                colorStops: [
                  { offset: 0, color: 'rgba(255, 176, 32, 0.25)' },
                  { offset: 1, color: 'rgba(255, 176, 32, 0.02)' }
                ]
              }
            },
            data: feeData
          }
        ]
      }, true)
    },
    handleResize () {
      if (this.chartInstance) {
        this.chartInstance.resize()
      }
    }
  }
}
</script>
<style lang="scss" scoped>
.daily-energy-panel {
  flex: 1;
  min-width: 0;
  background: #FFFFFF;
  border-radius: 10px;
  padding: 20px;
  box-sizing: border-box;
  .panel-header {
    margin-bottom: 16px;
    &__title {
      margin: 0;
      padding-left: 10px;
      font-weight: 600;
      font-size: 18px;
      color: #222222;
      line-height: 1.4;
      border-left: 3px solid #3E80EF;
    }
  }
  .panel_body {
    min-height: 280px;
  }
  .chart_box {
    position: relative;
    width: 100%;
    padding: 12px;
    background: #FAFBFE;
    border-radius: 8px;
    border: 1px solid #EEF2F8;
    box-sizing: border-box;
    .chart_item {
      width: 100%;
      height: 280px;
    }
    .chart_empty {
      position: absolute;
      inset: 12px;
      display: flex;
      align-items: center;
      justify-content: center;
      color: #999;
      font-size: 14px;
      background: #FAFBFE;
      border-radius: 6px;
    }
  }
}
</style>
admin/src/components/business/ElectricalWarningWorkbench.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,310 @@
<template>
  <div class="electrical-warning-panel">
    <div class="panel-header">
      <h3 class="panel-header__title">日常电量管理报警信息</h3>
    </div>
    <div class="panel_body">
      <div class="chart_box" v-loading="chartLoading">
        <div ref="warningChart" class="warning_chart"></div>
        <div v-if="!chartLoading && !chartData.length" class="chart_empty">暂无报警数据</div>
      </div>
      <div class="list_box">
        <el-table
          v-loading="listLoading"
          :data="warningList"
          stripe
          highlight-current-row
          class="warning_table"
          @row-click="handleRowClick"
        >
          <el-table-column label="序号" width="60" align="center">
            <template slot-scope="{ $index }">
              {{ (pagination.page - 1) * pagination.pageSize + $index + 1 }}
            </template>
          </el-table-column>
          <el-table-column prop="electricalName" label="电表名称" min-width="120" align="center" show-overflow-tooltip />
          <el-table-column prop="deviceAddress" label="电表地址" min-width="120" align="center" show-overflow-tooltip />
          <el-table-column label="报警项" min-width="160" align="center">
            <template slot-scope="{ row }">
              <span v-if="row.warningName" class="warn-tag">{{ row.warningName }}</span>
              <span v-else>-</span>
            </template>
          </el-table-column>
          <el-table-column label="报警时间" min-width="150" align="center">
            <template slot-scope="{ row }">
              {{ formatDateTime(row.startTime) }}
            </template>
          </el-table-column>
          <el-table-column prop="msg" label="报警详情" min-width="160" align="center" show-overflow-tooltip />
        </el-table>
        <div class="pagination_wrap">
          <Pagination
            @size-change="handleSizeChange"
            @current-change="loadWarningList"
            :pagination="pagination"
          />
        </div>
      </div>
    </div>
    <YwElectricalRemote ref="remoteWindow" />
  </div>
</template>
<script>
import * as echarts from 'echarts'
import dayjs from 'dayjs'
import Pagination from '@/components/common/Pagination'
import { electricalWarningStats, electricalWarningPage } from '@/api/ywWorkDesk'
import YwElectricalRemote from '@/views/business/components/YwElectricalRemote'
const CHART_COLORS = ['#8EC5FF', '#FF8C69', '#6B5BFF', '#52C41A', '#FFB020', '#9254DE', '#36CFC9', '#F759AB']
export default {
  name: 'ElectricalWarningWorkbench',
  components: { Pagination, YwElectricalRemote },
  data () {
    return {
      chartLoading: false,
      listLoading: false,
      chartData: [],
      warningList: [],
      chartInstance: null,
      pagination: {
        page: 1,
        pageSize: 10,
        total: 0
      }
    }
  },
  mounted () {
    this.loadStats()
    this.loadWarningList(1)
    window.addEventListener('resize', this.handleResize)
  },
  beforeDestroy () {
    window.removeEventListener('resize', this.handleResize)
    if (this.chartInstance) {
      this.chartInstance.dispose()
      this.chartInstance = null
    }
  },
  methods: {
    loadStats () {
      this.chartLoading = true
      electricalWarningStats()
        .then(res => {
          this.chartData = (res || []).map((item, index) => ({
            name: `【电能表】${item.warningName}(${item.count})`,
            value: item.count,
            itemStyle: { color: CHART_COLORS[index % CHART_COLORS.length] }
          }))
          this.$nextTick(() => {
            this.renderChart()
          })
        })
        .finally(() => {
          this.chartLoading = false
        })
    },
    loadWarningList (page) {
      this.listLoading = true
      if (page) {
        this.pagination.page = page
      }
      electricalWarningPage({
        page: this.pagination.page,
        capacity: this.pagination.pageSize,
        model: {}
      })
        .then(res => {
          this.warningList = res.records || []
          this.pagination.total = res.total || 0
        })
        .finally(() => {
          this.listLoading = false
        })
    },
    handleSizeChange (size) {
      this.pagination.pageSize = size
      this.loadWarningList(1)
    },
    renderChart () {
      const chartDom = this.$refs.warningChart
      if (!chartDom) {
        return
      }
      if (!this.chartInstance) {
        this.chartInstance = echarts.init(chartDom)
      }
      this.chartInstance.setOption({
        tooltip: {
          trigger: 'item',
          formatter: '{b}: {c} ({d}%)',
          backgroundColor: 'rgba(255,255,255,0.96)',
          borderColor: '#EEF2F8',
          borderWidth: 1,
          textStyle: { color: '#333', fontSize: 12 }
        },
        legend: {
          type: 'scroll',
          orient: 'vertical',
          bottom: 8,
          left: 'center',
          height: 80,
          pageButtonItemGap: 6,
          pageIconSize: 10,
          itemWidth: 12,
          itemHeight: 12,
          itemGap: 8,
          textStyle: {
            fontSize: 12,
            color: '#666'
          }
        },
        series: [
          {
            type: 'pie',
            radius: ['45%', '62%'],
            center: ['50%', '38%'],
            avoidLabelOverlap: true,
            label: {
              show: false
            },
            labelLine: {
              show: false
            },
            data: this.chartData
          }
        ]
      }, true)
    },
    handleResize () {
      if (this.chartInstance) {
        this.chartInstance.resize()
      }
    },
    handleRowClick (row) {
      if (!row.electricalId) {
        this.$message.warning('该报警未关联电表,无法打开远程控制')
        return
      }
      this.$refs.remoteWindow.open({ id: row.electricalId }, 'basic')
    },
    formatDateTime (value) {
      if (!value) {
        return '-'
      }
      return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
    }
  }
}
</script>
<style lang="scss" scoped>
.electrical-warning-panel {
  width: 100%;
  background: #FFFFFF;
  border-radius: 10px;
  padding: 20px;
  box-sizing: border-box;
  .panel-header {
    margin-bottom: 16px;
    &__title {
      margin: 0;
      padding-left: 10px;
      font-weight: 600;
      font-size: 18px;
      color: #222222;
      line-height: 1.4;
      border-left: 3px solid #3E80EF;
    }
  }
  .panel_body {
    display: flex;
    align-items: stretch;
    gap: 20px;
  }
  .chart_box {
    position: relative;
    width: 400px;
    flex-shrink: 0;
    min-height: 340px;
    padding: 12px;
    background: #FAFBFE;
    border-radius: 8px;
    border: 1px solid #EEF2F8;
    box-sizing: border-box;
    .warning_chart {
      width: 100%;
      height: 316px;
    }
    .chart_empty {
      position: absolute;
      inset: 0;
      display: flex;
      align-items: center;
      justify-content: center;
      color: #999;
      font-size: 14px;
    }
  }
  .list_box {
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
    padding-left: 4px;
    border-left: 1px solid #EEF2F8;
  }
  .warning_table {
    width: 100%;
    ::v-deep tbody tr {
      cursor: pointer;
    }
  }
  .pagination_wrap {
    margin-top: 12px;
  }
  .warn-tag {
    display: inline-block;
    color: #f56c6c;
    border: 1px solid #f56c6c;
    border-radius: 4px;
    padding: 0 6px;
    margin: 2px;
    font-size: 12px;
    line-height: 20px;
    white-space: nowrap;
  }
}
@media (max-width: 1200px) {
  .electrical-warning-panel {
    .panel_body {
      flex-direction: column;
    }
    .chart_box {
      width: 100%;
    }
    .list_box {
      padding-left: 0;
      border-left: none;
      padding-top: 4px;
      border-top: 1px solid #EEF2F8;
    }
  }
}
</style>
admin/src/components/business/OperaDeviceImportWindow.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,86 @@
<template>
  <el-dialog
      class="center-title"
      :title="title"
      width="500px"
      top="30vh"
      :visible.sync="visible"
      :confirm-working="isWorking"
      @confirm="confirm"
  >
    <p class="tip-warn"><i class="el-icon-warning"></i>导入说明:<br>
      1.请先下载文件模板,并按照模板要求填写表格内容;<br>
      2.带*为必填项,关联房源格式为:楼宇/楼层/房源;<br>
      3.设备分类格式为:一级分类/二级分类;<br>
    </p>
    <el-form class="demo-form-inline">
      <el-form-item label="设备名单" required>
        <div style="width: 100%;display: flex;align-items: center;">
          <el-button type="primary" @click="clickRef">点击上传</el-button>
          <el-button type="text" @click="exportTemplate">点击下载模版.EXCEL</el-button>
        </div>
        <div style="font-size: 14px; color: black;" v-if="fileName">{{ fileName }}</div>
      </el-form-item>
    </el-form>
    <input type="file" style="position: fixed; left: 0; top: -50px;" accept=".xlsx" ref="fileExcel" @change="result" />
    <template v-slot:footer>
      <el-button @click="visible=false">返回</el-button>
    </template>
  </el-dialog>
</template>
<script>
import BaseOpera from '@/components/base/BaseOpera'
import GlobalWindow from '@/components/common/GlobalWindow'
import { importExcel } from '@/api/Inspection/device'
export default {
  name: 'OperaDeviceImportWindow',
  extends: BaseOpera,
  // eslint-disable-next-line vue/no-unused-components
  components: { GlobalWindow },
  data () {
    return {
      importing: false,
      fileName: ''
    }
  },
  methods: {
    open (title) {
      this.title = title
      this.fileName = ''
      this.visible = true
    },
    exportTemplate () {
      window.open('/template/yw_device.xlsx')
    },
    clickRef () {
      this.$refs.fileExcel.click()
    },
    result (e) {
      const file = e.target.files[0]
      if (!file) {
        return
      }
      this.fileName = file.name
      const data = new FormData()
      data.append('file', file)
      importExcel(data)
        .then(res => {
          this.$message.success(res || '操作成功')
          this.$emit('success')
          this.visible = false
        })
        .catch(() => {
          this.fileName = ''
        })
        .finally(() => {
          this.$refs.fileExcel.value = null
        })
    }
  }
}
</script>
<style lang="scss" scoped>
</style>
admin/src/components/business/commonFunctions.vue
@@ -9,7 +9,7 @@
            <span>请根据使用习惯自定义常用功能,支持拖动排</span>
            <draggable v-model="filterList" chosenClass="chosen" forceFallback="true" group="people" animation="1000">
                <transition-group class="dra">
                    <div class="list" v-for="(item, index) in filterList" :key="index">
                    <div class="list" v-for="item in filterList" :key="item.path || item.id">
                        <div class="list_checkbox">
                            <el-checkbox v-model="item.checked"> </el-checkbox>
                        </div>
@@ -25,7 +25,7 @@
<script>
  import BaseOpera from '@/components/base/BaseOpera'
  import GlobalWindow from '@/components/common/GlobalWindow'
  import { updMyYwQuickModel, getDefaultYwQuickList } from '@/api/ywWorkDesk'
  import { updMyYwQuickModel, getDefaultYwQuickList, getYwQuickList } from '@/api/ywWorkDesk'
  import draggable from 'vuedraggable'
  export default {
    name: 'commonFunctions',
@@ -39,14 +39,25 @@
    methods: {
      open (title) {
        this.title = title
        getDefaultYwQuickList({})
          .then(res => {
            res.forEach(item => {
              item.checked = false
        Promise.all([getDefaultYwQuickList(), getYwQuickList()])
          .then(([allList, myList]) => {
            const selectedPaths = (myList || []).map(item => item.path)
            const pathOrder = new Map(selectedPaths.map((path, index) => [path, index]))
            const selected = []
            const unselected = []
            ;(allList || []).forEach(item => {
              const entry = { ...item, checked: selectedPaths.includes(item.path) }
              if (entry.checked) {
                selected.push(entry)
              } else {
                unselected.push(entry)
              }
            })
            this.filterList = res
            selected.sort((a, b) => (pathOrder.get(a.path) || 0) - (pathOrder.get(b.path) || 0))
            this.filterList = [...selected, ...unselected]
            this.visible = true
          })
          .catch(e => this.$tip.apiFailed(e))
      },
      confirm () {
        this.isWorking = true
admin/src/components/common/Menu.vue
@@ -27,33 +27,50 @@
import { mapState } from 'vuex'
import MenuItems from './MenuItems'
import Scrollbar from './Scrollbar'
import { findMenuByUrl, getOpenMenuIndexes } from '@/utils/menu'
export default {
  name: 'Menu',
  components: { Scrollbar, MenuItems },
  computed: {
    ...mapState(['menuData']),
    // é€‰ä¸­çš„菜单index
    activeIndex() {
      let path = this.$route.path
      if (path.endsWith('/')) {
        path = path.substring(0, path.length - 1)
      }
      const menuConfig = this.__getMenuConfig(path, 'index', this.menuData.list)
      if (menuConfig == null) {
        return null
      } else {
        this.$store.commit('pushtags', menuConfig)
      }
      return menuConfig.index
    activeIndex () {
      const menuConfig = findMenuByUrl(this.$route.path, this.menuData.list)
      return menuConfig ? menuConfig.index : null
    },
    // é»˜è®¤å±•开的菜单index
    defaultOpeneds() {
      // return this.menuData.list.map(menu => menu.index)
      return [this.menuData.list[0].index]
    defaultOpeneds () {
      const openeds = getOpenMenuIndexes(this.$route.path, this.menuData.list)
      if (openeds.length > 0) {
        return openeds
      }
      return this.menuData.list.length > 0 ? [this.menuData.list[0].index] : []
    }
  },
  watch: {
    '$route': {
      handler () {
        this.syncMenuState()
      },
      immediate: true
    }
  },
  methods: {
    syncMenuState () {
      const menuConfig = findMenuByUrl(this.$route.path, this.menuData.list)
      if (menuConfig) {
        this.$store.commit('pushtags', menuConfig)
      }
      this.$nextTick(() => {
        const menuRef = this.$refs.menu
        if (!menuRef) {
          return
        }
        getOpenMenuIndexes(this.$route.path, this.menuData.list).forEach(index => {
          menuRef.open(index)
        })
      })
    },
    // å¤„理菜单选中
    handleSelect(menuIndex) {
      const menuConfig = this.__getMenuConfig(menuIndex, 'index', this.menuData.list)
admin/src/utils/menu.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,68 @@
function normalizePath (path) {
  if (!path) {
    return ''
  }
  let normalized = path
  if (normalized.endsWith('/')) {
    normalized = normalized.substring(0, normalized.length - 1)
  }
  return normalized
}
export function findMenuByUrl (url, menus) {
  const target = normalizePath(url)
  for (const menu of menus || []) {
    if (menu.url && normalizePath(menu.url) === target) {
      return menu
    }
    if (menu.children && menu.children.length > 0) {
      const child = findMenuByUrl(target, menu.children)
      if (child) {
        return child
      }
    }
  }
  return null
}
export function findMenuPathByUrl (url, menus, ancestors = []) {
  const target = normalizePath(url)
  for (const menu of menus || []) {
    const chain = [...ancestors, menu]
    if (menu.url && normalizePath(menu.url) === target) {
      return chain
    }
    if (menu.children && menu.children.length > 0) {
      const childChain = findMenuPathByUrl(target, menu.children, chain)
      if (childChain) {
        return childChain
      }
    }
  }
  return null
}
export function getOpenMenuIndexes (url, menus) {
  const chain = findMenuPathByUrl(url, menus)
  if (!chain || chain.length <= 1) {
    return []
  }
  return chain.slice(0, -1).map(menu => menu.index)
}
export function navigateByMenu (router, store, path, menus) {
  const menuConfig = findMenuByUrl(path, menus)
  if (menuConfig) {
    if (menuConfig.params) {
      router.push({
        path: menuConfig.url,
        query: { index: menuConfig.index, param: menuConfig.params }
      })
    } else {
      router.push(menuConfig.url)
    }
    store.commit('pushtags', menuConfig)
    return
  }
  router.push({ path })
}
admin/src/views/business/components/YwCustomerConditionerTab.vue
@@ -1,24 +1,34 @@
<template>
  <div v-loading="loading" class="conditioner-tab">
    <section class="config-section">
      <div class="section-title">计费配置</div>
      <el-form label-width="150px" size="small" class="config-form">
        <el-row :gutter="24">
    <el-form ref="configForm" :model="form" :rules="rules" label-width="150px" size="small" class="config-form">
      <section class="config-section">
        <div class="section-title">计费配置</div>
        <el-row :gutter="24" class="config-first-row">
          <el-col :span="12">
            <el-form-item label="计费开关">
              <el-switch v-model="form.isPwr" :active-value="1" :inactive-value="0" active-text="开启" inactive-text="关闭"/>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="18:00-09:00 ä¸åœæœº">
              <el-switch v-model="form.isRestStop" :active-value="1" :inactive-value="0" active-text="是" inactive-text="否"/>
            <el-form-item label="欠费额度" prop="stopMoney" class="stop-money-item">
              <div class="el-input-group stop-money-group">
                <el-input-number
                  v-model="form.stopMoney"
                  :min="0"
                  :precision="2"
                  :step="10"
                  controls-position="right"
                  class="stop-money-input"
                />
                <div class="el-input-group__append">元</div>
              </div>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="欠费额度(元)">
              <el-input-number v-model="form.stopMoney" :min="0" :precision="2" :step="10" controls-position="right" style="width: 100%"/>
            <el-form-item label="计费开关" prop="isPwr">
              <el-switch v-model="form.isPwr" :active-value="1" :inactive-value="0" active-text="开启" inactive-text="关闭"/>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="18:00-09:00 ä¸åœæœº" prop="isRestStop">
              <el-switch v-model="form.isRestStop" :active-value="1" :inactive-value="0" active-text="是" inactive-text="否"/>
            </el-form-item>
          </el-col>
        </el-row>
@@ -32,36 +42,38 @@
            placeholder="请输入备注(选填)"
          />
        </el-form-item>
      </el-form>
    </section>
      </section>
    <section class="config-section">
      <div class="section-header">
        <span class="section-title">关联内机</span>
        <el-button type="primary" size="small" icon="el-icon-plus" @click="openSelector">添加内机</el-button>
      </div>
      <el-table :data="form.conditioners" stripe size="small" class="device-table" empty-text="暂未关联内机,请点击添加">
        <el-table-column label="设备" min-width="200" align="left" show-overflow-tooltip>
          <template slot-scope="{ row }">{{ deviceLabel(row) }}</template>
        </el-table-column>
        <el-table-column prop="platformDevId" label="平台设备ID" min-width="110" align="center"/>
        <el-table-column label="在线" min-width="80" align="center">
          <template slot-scope="{ row }">
            <span :class="row.online === '在线' ? 'green' : 'red'">{{ row.online || '-' }}</span>
          </template>
        </el-table-column>
        <el-table-column label="电费占比%" min-width="130" align="center">
          <template slot-scope="{ row }">
            <el-input-number v-model="row.devRatio" :min="1" :max="100" size="small" controls-position="right"/>
          </template>
        </el-table-column>
        <el-table-column label="操作" width="80" align="center" fixed="right">
          <template slot-scope="{ $index }">
            <el-button type="text" class="red" @click="form.conditioners.splice($index, 1)">移除</el-button>
          </template>
        </el-table-column>
      </el-table>
    </section>
      <section class="config-section">
        <div class="section-header">
          <span class="section-title required-title">关联内机</span>
          <el-button type="primary" size="small" icon="el-icon-plus" @click="openSelector">添加内机</el-button>
        </div>
        <el-form-item prop="conditioners" label-width="0" class="conditioners-form-item">
          <el-table :data="form.conditioners" stripe size="small" class="device-table" empty-text="暂未关联内机,请点击添加">
            <el-table-column label="设备" min-width="200" align="left" show-overflow-tooltip>
              <template slot-scope="{ row }">{{ deviceLabel(row) }}</template>
            </el-table-column>
            <el-table-column prop="platformDevId" label="平台设备ID" min-width="110" align="center"/>
            <el-table-column label="在线" min-width="80" align="center">
              <template slot-scope="{ row }">
                <span :class="row.online === '在线' ? 'green' : 'red'">{{ row.online || '-' }}</span>
              </template>
            </el-table-column>
            <el-table-column label="电费占比%" min-width="130" align="center">
              <template slot-scope="{ row }">
                <el-input-number v-model="row.devRatio" :min="1" :max="100" size="small" controls-position="right"/>
              </template>
            </el-table-column>
            <el-table-column label="操作" width="80" align="center" fixed="right">
              <template slot-scope="{ $index }">
                <el-button type="text" class="red" @click="removeConditioner($index)">移除</el-button>
              </template>
            </el-table-column>
          </el-table>
        </el-form-item>
      </section>
    </el-form>
    <div class="footer-btns">
      <el-button type="primary" :loading="saving" v-permissions="['business:ywcustomerrecharge:bindDevice']" @click="save">保存配置</el-button>
@@ -105,11 +117,46 @@
      loading: false,
      saving: false,
      form: {
        isPwr: 1,
        isRestStop: 0,
        stopMoney: 0,
        isPwr: null,
        isRestStop: null,
        stopMoney: undefined,
        gsBz: '',
        conditioners: []
      },
      rules: {
        stopMoney: [{
          validator: (rule, value, callback) => {
            if (value === null || value === undefined || value === '') {
              callback(new Error('请输入欠费额度'))
            } else if (Number(value) < 0) {
              callback(new Error('欠费额度不能小于0'))
            } else {
              callback()
            }
          },
          trigger: 'blur'
        }],
        isPwr: [{
          validator: (rule, value, callback) => {
            if (value === 0 || value === 1) callback()
            else callback(new Error('请选择计费开关'))
          },
          trigger: 'change'
        }],
        isRestStop: [{
          validator: (rule, value, callback) => {
            if (value === 0 || value === 1) callback()
            else callback(new Error('请选择是否停机'))
          },
          trigger: 'change'
        }],
        conditioners: [{
          validator: (rule, value, callback) => {
            if (value && value.length) callback()
            else callback(new Error('请至少关联一台内机'))
          },
          trigger: 'change'
        }]
      },
      selectorVisible: false,
      selectorLoading: false,
@@ -143,14 +190,14 @@
        rechargeApi.conditionerPage(this.customerId, { page: 1, capacity: 500, model: {} })
      ]).then(([gs, page]) => {
        if (gs) {
          this.form.isPwr = gs.isPwr != null ? gs.isPwr : 1
          this.form.isRestStop = gs.isRestStop != null ? gs.isRestStop : 0
          this.form.stopMoney = gs.stopMoney != null ? gs.stopMoney : 0
          this.form.isPwr = gs.isPwr != null ? gs.isPwr : null
          this.form.isRestStop = gs.isRestStop != null ? gs.isRestStop : null
          this.form.stopMoney = gs.stopMoney != null ? gs.stopMoney : undefined
          this.form.gsBz = gs.gsBz || ''
        } else {
          this.form.isPwr = 1
          this.form.isRestStop = 0
          this.form.stopMoney = 0
          this.form.isPwr = null
          this.form.isRestStop = null
          this.form.stopMoney = undefined
          this.form.gsBz = ''
        }
        this.form.conditioners = (page.records || []).map(c => ({
@@ -162,29 +209,41 @@
          online: c.online,
          devRatio: c.devRatio != null ? c.devRatio : 100
        }))
        this.$nextTick(() => {
          if (this.$refs.configForm) {
            this.$refs.configForm.clearValidate()
          }
        })
      }).catch(e => this.$tip.apiFailed(e)).finally(() => { this.loading = false })
    },
    save () {
      if (!this.form.conditioners.length) {
        this.$tip.warning('请至少关联一台内机')
        return
      }
      this.saving = true
      rechargeApi.saveGsConfig({
        customerId: this.customerId,
        isPwr: this.form.isPwr,
        isRestStop: this.form.isRestStop,
        stopMoney: this.form.stopMoney,
        gsBz: this.form.gsBz,
        conditioners: this.form.conditioners.map(c => ({
          conditionerId: c.conditionerId,
          devRatio: c.devRatio
        }))
      }).then(() => {
        this.$tip.success('保存成功')
        this.loadConfig()
        this.$emit('success')
      }).catch(e => this.$tip.apiFailed(e)).finally(() => { this.saving = false })
      this.$refs.configForm.validate(valid => {
        if (!valid) return
        this.saving = true
        rechargeApi.saveGsConfig({
          customerId: this.customerId,
          isPwr: this.form.isPwr,
          isRestStop: this.form.isRestStop,
          stopMoney: this.form.stopMoney,
          gsBz: this.form.gsBz,
          conditioners: this.form.conditioners.map(c => ({
            conditionerId: c.conditionerId,
            devRatio: c.devRatio
          }))
        }).then(() => {
          this.$tip.success('保存成功')
          this.loadConfig()
          this.$emit('success')
        }).catch(e => this.$tip.apiFailed(e)).finally(() => { this.saving = false })
      })
    },
    removeConditioner (index) {
      this.form.conditioners.splice(index, 1)
      this.$nextTick(() => {
        if (this.$refs.configForm) {
          this.$refs.configForm.validateField('conditioners')
        }
      })
    },
    openSelector () {
      this.selectorVisible = true
@@ -226,6 +285,11 @@
        })
      })
      this.selectorVisible = false
      this.$nextTick(() => {
        if (this.$refs.configForm) {
          this.$refs.configForm.validateField('conditioners')
        }
      })
    }
  }
}
@@ -265,6 +329,31 @@
.config-form {
  margin-bottom: 0;
}
.config-first-row {
  margin-bottom: 4px;
  padding-bottom: 4px;
  border-bottom: 1px dashed #ebeef5;
}
.config-form ::v-deep .stop-money-item {
  margin-bottom: 8px;
}
.stop-money-group {
  display: inline-flex;
  width: 100%;
  max-width: 280px;
  vertical-align: middle;
}
.stop-money-group ::v-deep .stop-money-input {
  flex: 1;
  width: auto;
}
.stop-money-group ::v-deep .el-input-number {
  width: 100%;
}
.stop-money-group ::v-deep .el-input__inner {
  border-top-right-radius: 0;
  border-bottom-right-radius: 0;
}
.config-form ::v-deep .el-form-item {
  margin-bottom: 12px;
}
@@ -277,6 +366,20 @@
.config-form ::v-deep .el-switch {
  width: auto;
}
.required-title::before {
  content: '*';
  color: #f56c6c;
  margin-right: 4px;
}
.conditioners-form-item {
  margin-bottom: 0;
}
.conditioners-form-item ::v-deep .el-form-item__content {
  margin-left: 0 !important;
}
.conditioners-form-item ::v-deep .el-form-item__error {
  padding-top: 4px;
}
.device-table {
  width: 100%;
}
admin/src/views/business/components/YwElectricalRemote.vue
@@ -1,6 +1,6 @@
<template>
  <GlobalWindow title="电表远程控制" :visible.sync="visible" width="780px" :show-confirm="false">
    <el-tabs v-model="activeTab">
    <el-tabs v-model="activeTab" @tab-click="onTabClick">
      <el-tab-pane label="基本操作" name="basic">
        <div class="info-block">
          <p>采集器号:{{ info.collectorId }} <span :class="info.online === 1 ? 'green' : 'red'">{{ info.online === 1 ? '在线' : '离线' }}</span></p>
@@ -9,11 +9,23 @@
          <p>电表类型:{{ info.type }}</p>
          <p>关联房间:{{ info.roomNames }}</p>
        </div>
        <div class="meter-data-block" v-loading="infoLoading">
          <div class="meter-data-block__title">最新抄表数据</div>
          <p>当前总电量:{{ totalPower }}</p>
          <p>电量同步时间:{{ latest && latest.addTime ? latest.addTime : '-' }}</p>
          <p>当前剩余金额(元):{{ latest && latest.ye != null ? latest.ye : (info.balance != null ? info.balance : '0.0000') }}</p>
          <p>余额同步时间:{{ balanceSyncTime }}</p>
        </div>
        <div class="btn-row">
          <el-button type="primary" :loading="isOperating" @click="doOperate('resetPrepay', '确认清零并切换到预付费模式吗?')">清零并切换到预付费模式</el-button>
          <el-button type="primary" :loading="isOperating" @click="doOperate('resetPostpay', '确认清零并切换到后付费模式吗?')">清零并切换到后付费模式</el-button>
          <el-button type="primary" :loading="isOperating" @click="doOperate('trip', '确认拉闸吗?')">拉闸</el-button>
          <el-button type="primary" :loading="isOperating" @click="doOperate('close', '确认合闸吗?')">合闸</el-button>
        </div>
        <div class="btn-row btn-row--relay">
          <span class="btn-row__label">拉合闸操作</span>
          <el-button type="danger" :loading="isOperating" @click="doRelayOperate('trip')">拉闸</el-button>
          <el-button type="primary" :loading="isOperating" @click="doRelayOperate('close')">合闸</el-button>
          <el-button type="warning" :loading="isOperating" @click="doRelayOperate('powerProtect')">保电</el-button>
          <el-button type="danger" plain :loading="isOperating" @click="doRelayOperate('powerProtectRelease')">解除保电</el-button>
        </div>
      </el-tab-pane>
      <el-tab-pane label="开户" name="open">
@@ -39,18 +51,86 @@
          @confirm="doOperate('recharge', '确认充值吗?')"
        />
      </el-tab-pane>
      <el-tab-pane label="控制记录" name="records">
        <el-table v-loading="recordsLoading" :data="recordsList" stripe size="small" class="records-table">
          <el-table-column label="操作类型" min-width="100" align="center">
            <template slot-scope="{ row }">{{ formatActionType(row.actionType) }}</template>
          </el-table-column>
          <el-table-column prop="oprId" label="操作ID" min-width="150" align="center" show-overflow-tooltip />
          <el-table-column label="状态" min-width="80" align="center">
            <template slot-scope="{ row }">
              <span :class="statusClass(row.status)">{{ formatStatus(row.status) }}</span>
            </template>
          </el-table-column>
          <el-table-column prop="resultMsg" label="执行结果" min-width="140" align="center" show-overflow-tooltip>
            <template slot-scope="{ row }">{{ row.resultMsg || '-' }}</template>
          </el-table-column>
          <el-table-column prop="createDate" label="操作时间" min-width="150" align="center" />
          <el-table-column label="报文" min-width="120" align="center">
            <template slot-scope="{ row }">
              <el-button type="text" :disabled="!row.requestBody" @click="openJson('请求报文', row.requestBody)">请求</el-button>
              <el-button type="text" :disabled="!row.responseBody" @click="openJson('响应报文', row.responseBody)">响应</el-button>
            </template>
          </el-table-column>
          <el-table-column label="操作" min-width="100" align="center" fixed="right">
            <template slot-scope="{ row }">
              <el-button
                v-if="row.status === 0 && row.oprId"
                type="text"
                :loading="queryLoadingId === row.id"
                v-permissions="['business:ywelectricalactions:queryResult']"
                @click="handleQueryResult(row)"
              >查询结果</el-button>
            </template>
          </el-table-column>
        </el-table>
        <pagination
          small
          @size-change="handleRecordsSizeChange"
          @current-change="loadActionRecords"
          :pagination="recordsPagination"
        />
      </el-tab-pane>
    </el-tabs>
    <OperaInterfaceLogWindow ref="jsonWindow" />
    <template slot="footer">
      <el-button type="primary" icon="el-icon-refresh" :loading="isRefreshing" @click="refreshMeterData">刷新电表数据</el-button>
      <el-button @click="visible = false">返回</el-button>
    </template>
  </GlobalWindow>
</template>
<script>
import GlobalWindow from '@/components/common/GlobalWindow'
import Pagination from '@/components/common/Pagination'
import OperaInterfaceLogWindow from '@/components/business/OperaInterfaceLogWindow'
import { getRemoteInfo, operate } from '@/api/business/ywelectrical'
import * as actionsApi from '@/api/business/ywelectricalactions'
import AccountRechargePanel from './AccountRechargePanel'
const RELAY_ACTIONS = {
  trip: { label: '拉闸', message: '确认对该电表执行拉闸操作吗?' },
  close: { label: '合闸', message: '确认对该电表执行合闸操作吗?' },
  powerProtect: { label: '保电', message: '确认对该电表执行保电操作吗?' },
  powerProtectRelease: { label: '解除保电', message: '确认对该电表执行解除保电操作吗?' }
}
const ACTION_TYPE_MAP = {
  1: '预付费清零',
  2: '后付费清零',
  3: '远程销户',
  4: '拉闸',
  5: '合闸',
  6: '开户',
  7: '充值',
  8: '抄表',
  9: '保电',
  10: '解除保电'
}
export default {
  name: 'YwElectricalRemote',
  components: { GlobalWindow, AccountRechargePanel },
  components: { GlobalWindow, AccountRechargePanel, Pagination, OperaInterfaceLogWindow },
  data () {
    return {
      visible: false,
@@ -60,7 +140,23 @@
      latest: null,
      purchaseCount: '0',
      form: { money: 0, remark: '' },
      isOperating: false
      isOperating: false,
      isRefreshing: false,
      infoLoading: false,
      recordsLoading: false,
      recordsList: [],
      recordsPagination: { pageIndex: 1, pageSize: 10, total: 0 },
      queryLoadingId: null
    }
  },
  computed: {
    totalPower () {
      if (!this.latest) return '0.00kWh'
      const v = this.latest.zhygzdl || '0'
      return String(v).toLowerCase().indexOf('kwh') >= 0 ? v : v + 'kWh'
    },
    balanceSyncTime () {
      return (this.latest && this.latest.addTime) ? this.latest.addTime : (this.info.balanceTime || '-')
    }
  },
  methods: {
@@ -68,21 +164,86 @@
      this.electricalId = row.id
      this.activeTab = tab || 'basic'
      this.form = { money: 0, remark: '' }
      this.recordsPagination.pageIndex = 1
      this.recordsList = []
      this.visible = true
      this.loadInfo()
      if (this.activeTab === 'records') {
        this.loadActionRecords(1)
      }
    },
    onTabClick (tab) {
      if (tab.name === 'records' && this.recordsList.length === 0) {
        this.loadActionRecords(1)
      }
    },
    loadInfo () {
      if (!this.electricalId) return
      this.infoLoading = true
      getRemoteInfo(this.electricalId).then(res => {
        this.info = res.electrical || {}
        this.latest = res.latestData
        this.purchaseCount = res.purchaseCount || '0'
      }).catch(e => this.$tip.apiFailed(e))
      }).catch(e => this.$tip.apiFailed(e)).finally(() => { this.infoLoading = false })
    },
    refreshMeterData () {
      if (!this.electricalId) return
      this.isRefreshing = true
      operate({
        electricalId: this.electricalId,
        action: 'readMeter'
      })
        .then(res => {
          this.$tip.apiSuccess(res || '抄表请求已提交,正在刷新数据')
          this.loadInfo()
          this.$emit('success')
          if (this.activeTab === 'records') {
            this.loadActionRecords(this.recordsPagination.pageIndex)
          }
        })
        .catch(e => this.$tip.apiFailed(e))
        .finally(() => { this.isRefreshing = false })
    },
    loadActionRecords (page) {
      if (!this.electricalId) return
      if (page) {
        this.recordsPagination.pageIndex = page
      }
      this.recordsLoading = true
      actionsApi.fetchList({
        page: this.recordsPagination.pageIndex,
        capacity: this.recordsPagination.pageSize,
        model: { electricalId: this.electricalId }
      })
        .then(data => {
          this.recordsList = data.records || []
          this.recordsPagination.total = data.total || 0
        })
        .catch(e => this.$tip.apiFailed(e))
        .finally(() => { this.recordsLoading = false })
    },
    handleRecordsSizeChange (size) {
      this.recordsPagination.pageSize = size
      this.loadActionRecords(1)
    },
    readMeter () {
      this.submitOperate('readMeter', true)
    },
    doOperate (action, msg) {
      this.$dialog.actionConfirm(msg, '操作确认')
        .then(() => this.submitOperate(action, false))
        .catch(() => {})
    },
    relayMeterTip () {
      const name = this.info.name || '-'
      const address = this.info.address || '-'
      return `表名称:${name},表地址:${address}`
    },
    doRelayOperate (action) {
      const cfg = RELAY_ACTIONS[action]
      if (!cfg) return
      const meterTip = this.relayMeterTip()
      this.$dialog.actionConfirm(`${cfg.message}(${meterTip})`, '操作确认')
        .then(() => this.submitOperate(action, false))
        .catch(() => {})
    },
@@ -98,15 +259,79 @@
          this.$tip.apiSuccess(res || (silent ? '抄表请求已提交' : '提交成功,请在【日常用电管理-充值记录】中查看充值结果'))
          this.loadInfo()
          this.$emit('success')
          if (this.activeTab === 'records') {
            this.loadActionRecords(this.recordsPagination.pageIndex)
          }
        })
        .catch(e => this.$tip.apiFailed(e))
        .finally(() => { this.isOperating = false })
    },
    formatActionType (val) {
      return ACTION_TYPE_MAP[val] || val || '-'
    },
    formatStatus (val) {
      if (val === 0 || val === '0') return '处理中'
      if (val === 1 || val === '1') return '成功'
      if (val === 2 || val === '2') return '失败'
      return val == null || val === '' ? '-' : String(val)
    },
    statusClass (val) {
      if (val === 1 || val === '1') return 'green'
      if (val === 2 || val === '2') return 'red'
      if (val === 0 || val === '0') return 'orange'
      return ''
    },
    openJson (title, content) {
      if (!content) return
      this.$refs.jsonWindow.open(title, { content })
    },
    handleQueryResult (row) {
      this.queryLoadingId = row.id
      actionsApi.queryResult(row.id)
        .then(msg => {
          this.$tip.success(msg || '查询完成')
          this.loadActionRecords(this.recordsPagination.pageIndex)
        })
        .catch(e => this.$tip.apiFailed(e))
        .finally(() => { this.queryLoadingId = null })
    }
  }
}
</script>
<style scoped>
.info-block { margin-bottom: 16px; line-height: 28px; color: #303133; }
.info-block { margin-bottom: 12px; line-height: 28px; color: #303133; }
.meter-data-block {
  margin-bottom: 16px;
  padding: 12px 14px;
  background: #fafbfe;
  border: 1px solid #eef2f8;
  border-radius: 8px;
  line-height: 26px;
  color: #303133;
}
.meter-data-block__title {
  font-weight: 600;
  font-size: 14px;
  margin-bottom: 6px;
  color: #222;
}
.meter-data-block p { margin: 0; }
.btn-row .el-button { margin: 0 8px 8px 0; }
.btn-row--relay {
  margin-top: 4px;
  padding-top: 12px;
  border-top: 1px dashed #ebeef5;
}
.btn-row__label {
  display: block;
  width: 100%;
  margin-bottom: 8px;
  font-size: 13px;
  color: #909399;
}
.records-table { width: 100%; margin-top: 4px; }
.green { color: #67c23a; }
.red { color: #f56c6c; }
.orange { color: #e6a23c; }
</style>
admin/src/views/business/ywcustomerrecharge.vue
@@ -113,14 +113,20 @@
        capacity: this.tableData.pagination.pageSize,
        model: this.buildSearchModel()
      }).then(data => {
        this.tableData.list = data.records
        this.tableData.pagination.total = data.total
      }).catch(() => {}).finally(() => { this.isWorking.search = false })
        this.tableData.list = (data && data.records) || []
        this.tableData.pagination.total = (data && data.total) || 0
      }).catch(e => {
        this.$tip.apiFailed(e)
      }).finally(() => { this.isWorking.search = false })
    },
    search () {
      this.tableData.pagination.pageIndex = 1
      this.loadList()
    },
    handleSizeChange (size) {
      this.tableData.pagination.pageSize = size
      this.loadList()
    },
    buildSearchModel () {
      const model = {}
      if (this.searchForm.nameKeyword) model.nameKeyword = this.searchForm.nameKeyword
admin/src/views/business/ywelectricalactions.vue
@@ -96,7 +96,9 @@
  5: '合闸',
  6: '开户',
  7: '充值',
  8: '抄表'
  8: '抄表',
  9: '保电',
  10: '解除保电'
}
export default {
admin/src/views/index.vue
@@ -1,109 +1,139 @@
<template>
  <div class="main">
    <div class="main_left">
      <div class="main_left_head">
        <div class="head_item">
          <div class="head_item_left">
            <span>待办工单</span>
            <span>{{obj.waitDealWorkOrderSize || 0}}</span>
            <span @click="jump(1)">查看更多</span>
          </div>
          <img class="head_item_icon" src="@/assets/indexIcon/ic_daibangongdan@2x.png" />
        </div>
        <div class="head_item">
          <div class="head_item_left">
            <span>待巡检</span>
            <span>{{obj.waitTaskSize || 0}}</span>
            <span @click="jump(2)">查看更多</span>
          </div>
          <img class="head_item_icon" src="@/assets/indexIcon/ic_daixuncha@2x.png" />
        </div>
        <div class="head_item">
          <div class="head_item_left">
            <span>待盘点</span>
            <span>{{obj.stocktakingSize || 0}}</span>
            <span @click="jump(3)">查看更多</span>
          </div>
          <img class="head_item_icon" src="@/assets/indexIcon/ic_daipandian@2x.png" />
        </div>
      </div>
      <div class="main_left_menu">
        <div class="main_left_menu_title">
          <span>常用功能</span>
          <div class="main_left_menu_title_edit" @click="$refs.commonFunctions.open('常用功能管理')">
            <img src="@/assets/indexIcon/ic_daibangongdan@2x.png" />
            <span>自定义功能</span>
          </div>
        </div>
        <div class="main_left_menu_list">
          <div class="list_item" v-for="(item, index) in list" :key="index" @click="jump1(item.path)">
            <div class="list_item_left">
              <img :src="item.icoPath" />
              <span>{{item.name}}</span>
  <div class="dashboard">
    <section class="dashboard-top">
      <div class="dashboard-top__left">
        <div class="stat-cards card">
          <div class="stat-cards__item head_item--workorder" @click="jump(1)">
            <div class="stat-cards__content">
              <span class="stat-cards__label">待办工单</span>
              <span class="stat-cards__value">{{ obj.waitDealWorkOrderSize || 0 }}</span>
              <span class="stat-cards__link">查看更多</span>
            </div>
            <i class="el-icon-arrow-right"></i>
            <img class="stat-cards__icon" src="@/assets/indexIcon/ic_daibangongdan@2x.png" alt="" />
          </div>
          <div class="list_item1"></div>
          <div class="list_item1"></div>
          <div class="list_item1"></div>
          <div class="stat-cards__item head_item--inspect" @click="jump(2)">
            <div class="stat-cards__content">
              <span class="stat-cards__label">待巡检</span>
              <span class="stat-cards__value">{{ obj.waitTaskSize || 0 }}</span>
              <span class="stat-cards__link">查看更多</span>
            </div>
            <img class="stat-cards__icon" src="@/assets/indexIcon/ic_daixuncha@2x.png" alt="" />
          </div>
          <div class="stat-cards__item head_item--stock" @click="jump(3)">
            <div class="stat-cards__content">
              <span class="stat-cards__label">待盘点</span>
              <span class="stat-cards__value">{{ obj.stocktakingSize || 0 }}</span>
              <span class="stat-cards__link">查看更多</span>
            </div>
            <img class="stat-cards__icon" src="@/assets/indexIcon/ic_daipandian@2x.png" alt="" />
          </div>
        </div>
      </div>
    </div>
    <div class="main_right">
      <div class="main_right_title">日程</div>
      <div class="main_right_search">
        <el-select v-model="nian" style="width: 150px; margin-right: 15px;" @change="getMonthNoticess" placeholder="请选择">
          <el-option
            v-for="item in yearList"
            :key="item.val"
            :label="item.name"
            :value="item.val">
          </el-option>
        </el-select>
        <el-select v-model="yue" style="width: 150px;" @change="getMonthNoticess" placeholder="请选择">
          <el-option
            v-for="(item, index) in 12"
            :key="index"
            :label="item + '月'"
            :value="item > 9 ? item : `0${item}`">
          </el-option>
        </el-select>
      </div>
      <div class="main_right_date">
        <Calendar
          ref="Calendar"
          v-on:choseDay="clickDay"
          :markDateMore="markDateMore"
        ></Calendar>
      </div>
      <div class="main_right_rc">
        <div class="main_right_rc_title">当日日程({{dataList.length}})</div>
        <div class="main_right_rc_list" v-loading="loading">
          <div class="main_right_rc_list_row" v-for="(item, index) in dataList" :key="index">
            <div class="top">
              <div class="top_left">
                <div class="top_left_dian"></div>
                <div class="top_left_title">{{item.title}}</div>
        <div class="quick-menu card">
          <div class="section-header">
            <h3 class="section-header__title">常用功能</h3>
            <div class="section-header__action" @click="$refs.commonFunctions.open('常用功能管理')">
              <i class="el-icon-setting"></i>
              <span>自定义功能</span>
            </div>
          </div>
          <div class="quick-menu__grid">
            <div
              class="quick-menu__item"
              v-for="(item, index) in list"
              :key="index"
              @click="jump1(item.path)"
            >
              <div class="quick-menu__item-left">
                <img :src="item.icoPath" alt="" />
                <span>{{ item.name }}</span>
              </div>
              <div class="top_date">{{item.param1}}</div>
            </div>
            <div class="bottom">
              {{item.content}}
              <i class="el-icon-arrow-right quick-menu__arrow"></i>
            </div>
          </div>
        </div>
      </div>
    </div>
      <aside class="schedule-panel card">
        <div class="section-header">
          <h3 class="section-header__title">日程</h3>
        </div>
        <div class="schedule-panel__filters">
          <el-select v-model="nian" class="schedule-panel__select" @change="getMonthNoticess" placeholder="请选择">
            <el-option
              v-for="item in yearList"
              :key="item.val"
              :label="item.name"
              :value="item.val"
            />
          </el-select>
          <el-select v-model="yue" class="schedule-panel__select" @change="getMonthNoticess" placeholder="请选择">
            <el-option
              v-for="(item, index) in 12"
              :key="index"
              :label="item + '月'"
              :value="item > 9 ? item : `0${item}`"
            />
          </el-select>
        </div>
        <div class="schedule-panel__calendar">
          <Calendar
            ref="Calendar"
            v-on:choseDay="clickDay"
            :markDateMore="markDateMore"
          />
        </div>
        <div class="schedule-panel__list-wrap">
          <div class="schedule-panel__list-title">当日日程({{ dataList.length }})</div>
          <div class="schedule-panel__list" v-loading="loading">
            <template v-if="dataList.length">
              <div class="schedule-item" v-for="(item, index) in dataList" :key="index">
                <div class="schedule-item__top">
                  <div class="schedule-item__title-wrap">
                    <div class="schedule-item__dot"></div>
                    <div class="schedule-item__title">{{ item.title }}</div>
                  </div>
                  <div class="schedule-item__time">{{ item.param1 }}</div>
                </div>
                <div class="schedule-item__content">{{ item.content }}</div>
              </div>
            </template>
            <div v-else class="schedule-empty">当日暂无日程</div>
          </div>
        </div>
      </aside>
    </section>
    <section class="dashboard-warning">
      <ElectricalWarningWorkbench />
    </section>
    <section class="dashboard-energy">
      <DailyEnergyTrendPanel
        title="近30天智能电表每日总电量/总电费"
        :load-data="electricalDailyEnergyStats"
      />
      <DailyEnergyTrendPanel
        title="近30天空调多联机每日总电量/总电费"
        :load-data="conditionerDailyEnergyStats"
      />
    </section>
    <CommonFunctions ref="commonFunctions" @success="getYwQuickLists" />
  </div>
</template>
<script>
import CommonFunctions from '@/components/business/commonFunctions'
import { getYwQuickList, getMonthNotices, workDeskData } from '@/api/ywWorkDesk'
import ElectricalWarningWorkbench from '@/components/business/ElectricalWarningWorkbench'
import DailyEnergyTrendPanel from '@/components/business/DailyEnergyTrendPanel'
import { getYwQuickList, getMonthNotices, workDeskData, electricalDailyEnergyStats, conditionerDailyEnergyStats } from '@/api/ywWorkDesk'
import { navigateByMenu } from '@/utils/menu'
import { mapState } from 'vuex'
import Calendar from 'vue-calendar-component'
export default {
  data() {
  data () {
    return {
      list: [],
      value: new Date(),
@@ -116,16 +146,21 @@
      loading: false
    }
  },
  components: { CommonFunctions, Calendar },
  components: { CommonFunctions, Calendar, ElectricalWarningWorkbench, DailyEnergyTrendPanel },
  computed: {
    ...mapState(['menuData'])
  },
  created () {
    this.getWorkDeskData()
    this.getYwQuickLists()
    this.getYear()
  },
  methods: {
    getYear() {
      let currentYear = new Date().getFullYear();
      let currentMonth = new Date().getMonth() + 1;
    electricalDailyEnergyStats,
    conditionerDailyEnergyStats,
    getYear () {
      const currentYear = new Date().getFullYear()
      const currentMonth = new Date().getMonth() + 1
      this.nian = currentYear
      this.yue = currentMonth > 9 ? currentMonth : `0${currentMonth}`
      for (let i = currentYear - 10; i <= currentYear; i++) {
@@ -133,40 +168,27 @@
      }
      this.getMonthNoticess()
    },
    // èŽ·å–ä»£åŠž
    getWorkDeskData() {
      workDeskData({})
        .then(res => {
          this.obj = res
        })
    },
    clickDay(e) {
      this.loading = true
      let date = e.replace("/\\//g", "-")
      let arr = this.markDateMore.filter(item => {
        if (item.date === date) {
          return item
        }
    getWorkDeskData () {
      workDeskData({}).then(res => {
        this.obj = res
      })
      if (arr.length > 0) {
        this.dataList = arr[0].noticeList
      } else {
        this.dataList = []
      }
    },
    clickDay (e) {
      this.loading = true
      const date = e.replace('/\\//g', '-')
      const arr = this.markDateMore.filter(item => item.date === date)
      this.dataList = arr.length > 0 ? arr[0].noticeList : []
      this.loading = false
    },
    // èŽ·å–æ—¥ç¨‹
    getMonthNoticess() {
    getMonthNoticess () {
      getMonthNotices(this.nian + '-' + this.yue).then(res => {
        let arr = res.filter(item => {
          if (item.noticeList && item.noticeList.length > 0) {
            return item
          }
        })
        this.markDateMore = arr.map(item => {
          return { date: item.monthDate.replace("/-0/g", "-"), className: 'markRed', noticeList: item.noticeList }
        })
        let toDay = this.getDay()
        const arr = res.filter(item => item.noticeList && item.noticeList.length > 0)
        this.markDateMore = arr.map(item => ({
          date: item.monthDate.replace('/-0/g', '-'),
          className: 'markRed',
          noticeList: item.noticeList
        }))
        const toDay = this.getDay()
        this.markDateMore.forEach(item => {
          if (item.date === toDay) {
            this.dataList = item.noticeList
@@ -175,301 +197,477 @@
        this.$refs.Calendar.ChoseMonth(`${this.nian}-${this.yue}`, false)
      })
    },
    getYwQuickLists() {
      getYwQuickList({})
        .then(res => {
          this.list = res
        })
    getYwQuickLists () {
      getYwQuickList({}).then(res => {
        this.list = res
      })
    },
    getDay() {
      let date = new Date();
      let year = date.getFullYear();  // èŽ·å–å¹´ä»½
      let month = String(date.getMonth() + 1).padStart(2, '0');  // èŽ·å–æœˆä»½ï¼Œå¹¶è¡¥é›¶
      let day = String(date.getDate()).padStart(2, '0');  // èŽ·å–æ—¥æœŸï¼Œå¹¶è¡¥é›¶
      return `${year}-${month}-${day}`;
    getDay () {
      const date = new Date()
      const year = date.getFullYear()
      const month = String(date.getMonth() + 1).padStart(2, '0')
      const day = String(date.getDate()).padStart(2, '0')
      return `${year}-${month}-${day}`
    },
    jump(type) {
      if (type === 1) {
        this.$router.push({ path: '/workorder/workorderList' })
      } else if (type === 2) {
        this.$router.push({ path: '/Inspection/task' })
      } else if (type === 3) {
        this.$router.push({ path: '/stock/check' })
    jump (type) {
      const pathMap = {
        1: '/workorder/workorderList',
        2: '/Inspection/task',
        3: '/stock/check'
      }
      this.navigateToMenu(pathMap[type])
    },
    jump1(path) {
      this.$router.push({ path })
    jump1 (path) {
      this.navigateToMenu(path)
    },
    navigateToMenu (path) {
      navigateByMenu(this.$router, this.$store, path, this.menuData.list)
    }
  }
}
</script>
<style lang="scss" scoped>
  .main {
    width: 100%;
    padding: 15px;
    box-sizing: border-box;
    background: #F4F7FC;
.dashboard {
  --dash-primary: #3E80EF;
  --dash-primary-light: rgba(62, 128, 239, 0.08);
  --dash-text: #222222;
  --dash-text-secondary: #666666;
  --dash-text-muted: #999999;
  --dash-bg: #F4F7FC;
  --dash-card-bg: #FFFFFF;
  --dash-border: #EEF2F8;
  --dash-radius: 10px;
  --dash-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
  --dash-gap: 16px;
  width: 100%;
  min-height: 100%;
  padding: var(--dash-gap);
  box-sizing: border-box;
  background: var(--dash-bg);
  display: flex;
  flex-direction: column;
  gap: var(--dash-gap);
}
.card {
  background: var(--dash-card-bg);
  border-radius: var(--dash-radius);
  border: 1px solid var(--dash-border);
  box-shadow: var(--dash-shadow);
}
.section-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 16px;
  &__title {
    margin: 0;
    padding-left: 10px;
    font-weight: 600;
    font-size: 18px;
    color: var(--dash-text);
    line-height: 1.4;
    border-left: 3px solid var(--dash-primary);
  }
  &__action {
    cursor: pointer;
    display: flex;
    align-items: self-start;
    align-items: center;
    gap: 4px;
    font-size: 14px;
    color: var(--dash-primary);
    transition: opacity 0.2s;
    &:hover {
      opacity: 0.8;
    }
    i {
      font-size: 15px;
    }
  }
}
.dashboard-top {
  display: flex;
  align-items: flex-start;
  gap: var(--dash-gap);
  &__left {
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: var(--dash-gap);
  }
}
.stat-cards {
  display: flex;
  align-items: stretch;
  gap: 12px;
  padding: 20px;
  &__item {
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: space-between;
    .main_left {
      flex: 1;
      height: 100%;
      display: flex;
      flex-direction: column;
      .main_left_head {
        width: 100%;
        height: 199px;
        background: #FFFFFF;
        border-radius: 8px;
        display: flex;
        align-items: center;
        padding: 20px;
        box-sizing: border-box;
        .head_item {
          flex: 1;
          height: 100%;
          display: flex;
          align-items: center;
          justify-content: space-between;
          background: #F1F5FF;
          border-radius: 8px;
          margin-right: 15px;
          padding: 28px 20px;
          box-sizing: border-box;
          &:last-child {
            margin: 0 !important;
          }
          .head_item_left {
            flex: 1;
            display: flex;
            flex-direction: column;
            span {
              &:nth-child(1) {
                font-weight: 600;
                font-size: 17px;
                color: #222222;
              }
              &:nth-child(2) {
                font-weight: 600;
                font-size: 30px;
                color: #111111;
                margin-top: 6px;
                margin-bottom: 14px;
              }
              &:nth-child(3) {
                font-weight: 400;
                font-size: 13px;
                color: #999999;
                cursor: pointer;
              }
            }
          }
          .head_item_icon {
            width: 60px;
            height: 60px;
          }
        }
      }
      .main_left_menu {
        width: 100%;
        height: calc(100% - 214px);
        background: #FFFFFF;
        border-radius: 8px;
        margin-top: 15px;
        padding: 20px;
        box-sizing: border-box;
        .main_left_menu_title {
          width: 100%;
          display: flex;
          align-items: center;
          justify-content: space-between;
          span {
            font-weight: 600;
            font-size: 18px;
            color: #222222;
          }
          .main_left_menu_title_edit {
            cursor: pointer;
            display: flex;
            align-items: center;
            img {
              width: 15px;
              height: 15px;
            }
            span {
              font-weight: 400;
              font-size: 14px;
              color: #3E80EF;
              margin-left: 5px;
            }
          }
        }
        .main_left_menu_list {
          width: 100%;
          margin-top: 20px;
          display: flex;
          align-items: center;
          flex-wrap: wrap;
          justify-content: space-between;
          .list_item1 {
            width: 24%;
            height: 0;
          }
          .list_item {
            cursor: pointer;
            width: 24%;
            height: 70px;
            background: #FFFFFF;
            border-radius: 8px;
            border: 1px solid #EEEEEE;
            padding: 0 16px;
            box-sizing: border-box;
            margin-bottom: 15px;
            display: flex;
            align-items: center;
            justify-content: space-between;
            .list_item_left {
              display: flex;
              align-items: center;
              img {
                width: 40px;
                height: 40px;
                margin-right: 5px;
              }
              span {
                font-weight: 500;
                font-size: 15px;
                color: #222222;
              }
            }
          }
        }
    padding: 24px 20px;
    border-radius: 8px;
    cursor: pointer;
    transition: transform 0.2s, box-shadow 0.2s;
    &:hover {
      transform: translateY(-2px);
      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
      .stat-cards__icon {
        transform: scale(1.05);
      }
    }
    .main_right {
      flex-shrink: 0;
      width: 500px;
      height: 100%;
      margin-left: 15px;
      background: #FEFEFF;
      border-radius: 8px;
      padding: 20px;
      box-sizing: border-box;
      .main_right_title {
        font-weight: 600;
        font-size: 18px;
        color: #222222;
        margin-bottom: 12px;
    &.head_item--workorder {
      background: linear-gradient(135deg, #EEF4FF 0%, #F5F8FF 100%);
      .stat-cards__value {
        color: #3E80EF;
      }
      .main_right_rc {
        width: 100%;
        display: flex;
        flex-direction: column;
        .main_right_rc_title {
          font-weight: 500;
          font-size: 16px;
          color: #222222;
          margin-bottom: 15px;
        }
        .main_right_rc_list {
          width: 100%;
          height: 300px;
          overflow-y: scroll;
          .main_right_rc_list_row {
            width: 100%;
            padding: 15px;
            box-sizing: border-box;
            background: #F4F7FC;
            border-radius: 2px;
            margin-bottom: 10px;
            .top {
              width: 100%;
              display: flex;
              align-items: center;
              justify-content: space-between;
              .top_left {
                display: flex;
                align-items: center;
                .top_left_dian {
                  width: 10px;
                  height: 10px;
                  border-radius: 50%;
                  background: #FF9E00;
                  margin-right: 10px;
                }
                .top_left_title {
                  font-weight: 500;
                  font-size: 15px;
                  color: #222222;
                }
              }
              .top_date {
                font-weight: 400;
                font-size: 12px;
                color: #999999;
              }
            }
            .bottom {
              font-weight: 400;
              font-size: 13px;
              color: #666666;
              margin-top: 8px;
            }
          }
        }
      .stat-cards__link:hover {
        color: #3E80EF;
      }
      .main_right_date {
        width: 100%;
        padding-top: 15px;
        box-sizing: border-box;
        ::v-deep .wh_content_item {
          height: 50px;
          color: #222222;
          font-weight: 400;
          font-size: 15px;
        }
        ::v-deep .wh_item_date {
          width: 30px;
          height: 30px;
          font-size: 15px;
        }
        ::v-deep .wh_content_all {
          background-color: #ffffff;
        }
        ::v-deep .wh_top_changge {
          display: none;
        }
        ::v-deep .wh_container {
          max-width: 100%;
        }
        ::v-deep .wh_content_item .wh_isToday {
          background-color: rgba(62, 128, 239, 0.47);
          color: #ffffff;
        }
        ::v-deep .wh_item_date:hover {
          background-color: #3E80EF;
          border-radius: 50%;
          color: #ffffff;
        }
        ::v-deep .wh_content_item .wh_chose_day {
          background: #3E80EF;
          color: #fff;
        }
        ::v-deep .markRed {
          position: relative;
        }
        ::v-deep .markRed::after {
          content: '●';
          color: #FF9E00;
          font-size: 11px;
          position: absolute;
          bottom: -30px;
          left: 50%;
          transform: translate(-50%, 0);
        }
    }
    &.head_item--inspect {
      background: linear-gradient(135deg, #E8FAF0 0%, #F2FBF6 100%);
      .stat-cards__value {
        color: #36B37E;
      }
      .stat-cards__link:hover {
        color: #36B37E;
      }
    }
    &.head_item--stock {
      background: linear-gradient(135deg, #FFF6E8 0%, #FFFAF2 100%);
      .stat-cards__value {
        color: #FF9E00;
      }
      .stat-cards__link:hover {
        color: #FF9E00;
      }
    }
  }
  &__content {
    display: flex;
    flex-direction: column;
  }
  &__label {
    font-weight: 600;
    font-size: 16px;
    color: var(--dash-text);
  }
  &__value {
    font-weight: 700;
    font-size: 32px;
    line-height: 1.2;
    margin: 6px 0 12px;
  }
  &__link {
    font-size: 13px;
    color: var(--dash-text-muted);
    transition: color 0.2s;
  }
  &__icon {
    width: 56px;
    height: 56px;
    flex-shrink: 0;
    transition: transform 0.2s;
  }
}
.quick-menu {
  padding: 20px;
  &__grid {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: 12px;
  }
  &__item {
    cursor: pointer;
    height: 72px;
    background: var(--dash-card-bg);
    border-radius: 8px;
    border: 1px solid #EEEEEE;
    padding: 0 16px;
    display: flex;
    align-items: center;
    justify-content: space-between;
    transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
    &:hover {
      border-color: var(--dash-primary);
      box-shadow: 0 2px 8px rgba(62, 128, 239, 0.12);
      transform: translateY(-1px);
      .quick-menu__arrow {
        color: var(--dash-primary);
      }
    }
  }
  &__item-left {
    display: flex;
    align-items: center;
    min-width: 0;
    img {
      width: 40px;
      height: 40px;
      margin-right: 8px;
      flex-shrink: 0;
    }
    span {
      font-weight: 500;
      font-size: 14px;
      color: var(--dash-text);
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
  }
  &__arrow {
    color: #C0C4CC;
    font-size: 14px;
    flex-shrink: 0;
    transition: color 0.2s;
  }
}
.schedule-panel {
  flex-shrink: 0;
  width: 480px;
  padding: 20px;
  &__filters {
    display: flex;
    gap: 12px;
    margin-bottom: 12px;
  }
  &__select {
    width: 140px;
  }
  &__calendar {
    padding: 12px;
    background: #FAFBFE;
    border-radius: 8px;
    border: 1px solid var(--dash-border);
  }
  &__list-wrap {
    margin-top: 16px;
  }
  &__list-title {
    font-weight: 500;
    font-size: 15px;
    color: var(--dash-text);
    margin-bottom: 12px;
  }
  &__list {
    height: 280px;
    overflow-y: auto;
    padding-right: 4px;
    &::-webkit-scrollbar {
      width: 5px;
    }
    &::-webkit-scrollbar-thumb {
      background: #D8DCE6;
      border-radius: 3px;
    }
  }
}
.schedule-item {
  padding: 14px 14px 14px 12px;
  background: #FAFBFE;
  border-radius: 8px;
  border: 1px solid var(--dash-border);
  border-left: 3px solid #FF9E00;
  margin-bottom: 10px;
  &__top {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
  }
  &__title-wrap {
    display: flex;
    align-items: center;
    min-width: 0;
  }
  &__dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: #FF9E00;
    margin-right: 8px;
    flex-shrink: 0;
  }
  &__title {
    font-weight: 500;
    font-size: 14px;
    color: var(--dash-text);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  &__time {
    font-size: 12px;
    color: var(--dash-text-muted);
    flex-shrink: 0;
  }
  &__content {
    font-size: 13px;
    color: var(--dash-text-secondary);
    margin-top: 8px;
    line-height: 1.5;
  }
}
.schedule-empty {
  height: 120px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--dash-text-muted);
  font-size: 14px;
}
.dashboard-warning,
.dashboard-energy {
  display: flex;
  flex-direction: column;
  gap: var(--dash-gap);
}
::v-deep .electrical-warning-panel,
::v-deep .daily-energy-panel {
  margin-top: 0 !important;
  border: 1px solid var(--dash-border);
  box-shadow: var(--dash-shadow);
  border-radius: var(--dash-radius);
}
.schedule-panel__calendar {
  ::v-deep .wh_content_item {
    height: 50px;
    color: var(--dash-text);
    font-size: 14px;
  }
  ::v-deep .wh_item_date {
    width: 30px;
    height: 30px;
    font-size: 14px;
  }
  ::v-deep .wh_content_all {
    background-color: transparent;
  }
  ::v-deep .wh_top_changge {
    display: none;
  }
  ::v-deep .wh_container {
    max-width: 100%;
  }
  ::v-deep .wh_content_item .wh_isToday {
    background-color: rgba(62, 128, 239, 0.47);
    color: #ffffff;
  }
  ::v-deep .wh_item_date:hover {
    background-color: var(--dash-primary);
    border-radius: 50%;
    color: #ffffff;
  }
  ::v-deep .wh_content_item .wh_chose_day {
    background: var(--dash-primary);
    color: #fff;
  }
  ::v-deep .markRed {
    position: relative;
  }
  ::v-deep .markRed::after {
    content: '●';
    color: #FF9E00;
    font-size: 11px;
    position: absolute;
    bottom: -30px;
    left: 50%;
    transform: translate(-50%, 0);
  }
}
@media (max-width: 1400px) {
  .dashboard-top {
    flex-direction: column;
  }
  .schedule-panel {
    width: 100%;
  }
}
@media (max-width: 1200px) {
  .quick-menu__grid {
    grid-template-columns: repeat(3, 1fr);
  }
  .stat-cards {
    flex-direction: column;
  }
}
@media (max-width: 768px) {
  .quick-menu__grid {
    grid-template-columns: repeat(2, 1fr);
  }
}
</style>
admin/src/views/operation/device.vue
@@ -4,6 +4,8 @@
    <div class="mt20">
      <el-button type="primary" @click="handleEdit()" icon="el-icon-plus"
        v-permissions="['business:ywpatrolline:create']">新建</el-button>
      <el-button type="primary" v-permissions="['business:ywdevice:importExcel']"
        @click="$refs.deviceImport.open('设备批量导入')">批量导入</el-button>
    </div>
    <el-table v-loading="loading" :data="list" stripe>
      <el-table-column prop="code" label="设备编号" min-width="100" show-overflow-tooltip />
@@ -35,6 +37,7 @@
    </div>
    <Edit v-if="showEdit" ref="EditRef" @success="getList" @close="showEdit = false" />
    <Detail ref="DetailRef" />
    <OperaDeviceImportWindow ref="deviceImport" @success="getList(1)" />
  </div>
</template>
@@ -43,13 +46,15 @@
import QueryForm from '@/components/common/QueryForm'
import Edit from './components/deviceEdit'
import Detail from './components/deviceDetail'
import OperaDeviceImportWindow from '@/components/business/OperaDeviceImportWindow'
import { fetchList, deleteById } from '@/api/Inspection/device'
export default {
  components: {
    Pagination,
    QueryForm,
    Edit,
    Detail
    Detail,
    OperaDeviceImportWindow
  },
  data() {
    return {
server/db/business.yw_device.importExcel.grant.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
-- è®¾å¤‡ç®¡ç†æ‰¹é‡å¯¼å…¥ï¼šä¸ºè¶…级管理员角色补全 importExcel æƒé™ï¼ˆå¯é‡å¤æ‰§è¡Œï¼‰
-- æ‰§è¡ŒåŽè¯·é‡æ–°ç™»å½•以刷新 Redis ä¸­çš„æƒé™ç¼“å­˜
INSERT INTO `SYSTEM_ROLE_PERMISSION` (`ROLE_ID`, `PERMISSION_ID`, `CREATE_TIME`, `UPDATE_TIME`, `CREATE_USER`, `UPDATE_USER`, `DELETED`)
SELECT r.`ID`, p.`ID`, CURRENT_TIMESTAMP, NULL, 1, NULL, 0
FROM `SYSTEM_ROLE` r
INNER JOIN `SYSTEM_PERMISSION` p ON p.`CODE` = 'business:ywdevice:importExcel' AND p.`DELETED` = 0
WHERE r.`DELETED` = 0 AND (r.`CODE` = 'admin' OR r.`NAME` IN ('超级管理员', '管理员'))
  AND NOT EXISTS (
    SELECT 1 FROM `SYSTEM_ROLE_PERMISSION` rp
    WHERE rp.`ROLE_ID` = r.`ID` AND rp.`PERMISSION_ID` = p.`ID` AND rp.`DELETED` = 0
  );
server/db/business.yw_device.permissions.sql
@@ -3,4 +3,5 @@
INSERT INTO `SYSTEM_PERMISSION`(`CODE`, `NAME`, `REMARK`, `FIXED`, `CREATE_USER`, `CREATE_TIME`, `UPDATE_USER`, `UPDATE_TIME`, `DELETED`) VALUES ('business:ywdevice:update', '修改运维设备信息表', '', 0, 1, CURRENT_TIMESTAMP, NULL, NULL, 0);
INSERT INTO `SYSTEM_PERMISSION`(`CODE`, `NAME`, `REMARK`, `FIXED`, `CREATE_USER`, `CREATE_TIME`, `UPDATE_USER`, `UPDATE_TIME`, `DELETED`) VALUES ('business:ywdevice:query', '查询运维设备信息表', '', 0, 1, CURRENT_TIMESTAMP, NULL, NULL, 0);
INSERT INTO `SYSTEM_PERMISSION`(`CODE`, `NAME`, `REMARK`, `FIXED`, `CREATE_USER`, `CREATE_TIME`, `UPDATE_USER`, `UPDATE_TIME`, `DELETED`) VALUES ('business:ywdevice:exportExcel', '导出运维设备信息表(Excel)', '', 0, 1, CURRENT_TIMESTAMP, NULL, NULL, 0);
INSERT INTO `SYSTEM_PERMISSION`(`CODE`, `NAME`, `REMARK`, `FIXED`, `CREATE_USER`, `CREATE_TIME`, `UPDATE_USER`, `UPDATE_TIME`, `DELETED`) VALUES ('business:ywdevice:importExcel', '导入运维设备信息表(Excel)', '', 0, 1, CURRENT_TIMESTAMP, NULL, NULL, 0);
server/visits/dmvisit_admin/src/main/java/com/doumee/cloud/admin/YwCustomerRechargeCloudController.java
@@ -34,6 +34,9 @@
    public ApiResponse<PageData<YwCustomerRechargeMerchantVO>> merchantPage(
            @RequestBody PageWrap<YwCustomerRechargeQueryDTO> pageWrap,
            @RequestHeader(Constants.HEADER_USER_TOKEN) String token) {
        if (pageWrap == null) {
            pageWrap = new PageWrap<>();
        }
        return ApiResponse.success(ywCustomerRechargeBizService.findMerchantPage(pageWrap));
    }
server/visits/dmvisit_admin/src/main/java/com/doumee/cloud/admin/YwDeviceCloudController.java
@@ -13,12 +13,17 @@
import com.doumee.dao.business.vo.YwDeviceStatusDataVO;
import com.doumee.service.business.YwDeviceService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiImplicitParams;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import com.doumee.config.annotation.CloudRequiredPermission;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
@@ -90,6 +95,16 @@
        ExcelExporter.build(YwDevice.class).export(ywDeviceService.findPage(pageWrap).getRecords(), "运维设备信息表", response);
    }
    @ApiOperation("设备批量导入")
    @PostMapping("/importExcel")
    @ApiImplicitParams({
            @ApiImplicitParam(name = "file", value = "file", required = true, paramType = "query", dataType = "file", dataTypeClass = File.class),
    })
    @CloudRequiredPermission("business:ywdevice:importExcel")
    public ApiResponse<String> importExcel(@ApiParam(value = "file") MultipartFile file, @RequestHeader(Constants.HEADER_USER_TOKEN) String token) {
        return ApiResponse.success(ywDeviceService.importBatch(file, this.getLoginUser(token)));
    }
    @ApiOperation("根据ID查询")
    @GetMapping("/{id}")
    @CloudRequiredPermission("business:ywdevice:query")
server/visits/dmvisit_admin/src/main/java/com/doumee/cloud/admin/YwWorkDeskCloutController.java
@@ -10,10 +10,15 @@
import com.doumee.core.utils.Constants;
import com.doumee.dao.business.model.YwPatrolTaskRecord;
import com.doumee.dao.business.model.YwQuickModel;
import com.doumee.dao.business.model.YwElectricalWarning;
import com.doumee.dao.business.vo.DailyEnergyStatVO;
import com.doumee.dao.business.vo.MonthDataResponse;
import com.doumee.dao.business.vo.WarningTypeStatVO;
import com.doumee.dao.business.vo.WorkDeskDataResponse;
import com.doumee.dao.system.model.Notices;
import com.doumee.service.business.WorkbenchesService;
import com.doumee.service.business.YwElectricalWarningService;
import com.doumee.service.business.YwWorkDeskEnergyService;
import com.doumee.service.business.YwPatrolTaskRecordService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
@@ -36,6 +41,12 @@
    @Autowired
    private WorkbenchesService workbenchesService;
    @Autowired
    private YwElectricalWarningService ywElectricalWarningService;
    @Autowired
    private YwWorkDeskEnergyService ywWorkDeskEnergyService;
    @ApiOperation("获取快捷菜单模块信息")
    @GetMapping("/getYwQuickList")
@@ -75,4 +86,29 @@
        return ApiResponse.success(workbenchesService.getMyNotices(pageWrap,getLoginUser(token)));
    }
    @ApiOperation("工作台-电表报警类型统计")
    @GetMapping("/electricalWarningStats")
    public ApiResponse<List<WarningTypeStatVO>> electricalWarningStats(@RequestHeader(Constants.HEADER_USER_TOKEN) String token) {
        return ApiResponse.success(ywElectricalWarningService.warningTypeStats());
    }
    @ApiOperation("工作台-电表报警分页")
    @PostMapping("/electricalWarningPage")
    public ApiResponse<PageData<YwElectricalWarning>> electricalWarningPage(@RequestBody PageWrap<YwElectricalWarning> pageWrap,
                                                                            @RequestHeader(Constants.HEADER_USER_TOKEN) String token) {
        return ApiResponse.success(ywElectricalWarningService.findPage(pageWrap));
    }
    @ApiOperation("工作台-近30天智能电表每日电量/电费")
    @GetMapping("/electricalDailyEnergyStats")
    public ApiResponse<List<DailyEnergyStatVO>> electricalDailyEnergyStats(@RequestHeader(Constants.HEADER_USER_TOKEN) String token) {
        return ApiResponse.success(ywWorkDeskEnergyService.electricalDailyStats());
    }
    @ApiOperation("工作台-近30天空调多联机每日电量/电费")
    @GetMapping("/conditionerDailyEnergyStats")
    public ApiResponse<List<DailyEnergyStatVO>> conditionerDailyEnergyStats(@RequestHeader(Constants.HEADER_USER_TOKEN) String token) {
        return ApiResponse.success(ywWorkDeskEnergyService.conditionerDailyStats());
    }
}
server/visits/dmvisit_service/src/main/java/com/doumee/core/conditoner/ConditionerUtil.java
@@ -18,6 +18,8 @@
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.math.BigDecimal;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -290,12 +292,8 @@
        JSONObject body = new JSONObject(true);
        putSessionFields(body, req);
        body.put("is_pwr", req.getIs_pwr() != null ? req.getIs_pwr() : 1);
        if (req.getLi_dev() != null && !req.getLi_dev().isEmpty()) {
            body.put("li_dev", req.getLi_dev());
        }
        if (req.getD_dev() != null && !req.getD_dev().isEmpty()) {
            body.put("d_dev", req.getD_dev());
        }
        body.put("li_dev", req.getLi_dev() != null ? req.getLi_dev() : Collections.emptyList());
        body.put("d_dev", req.getD_dev() != null ? req.getD_dev() : Collections.emptyMap());
        body.put("gs_name", req.getGs_name());
        body.put("is_rest_stop", req.getIs_rest_stop() != null ? req.getIs_rest_stop() : 0);
        body.put("gs_bz", StringUtils.defaultString(req.getGs_bz()));
@@ -309,6 +307,7 @@
        }
        req.fillSessionDefaults();
        JSONObject body = new JSONObject(true);
        putSessionFields(body, req);
        body.put("id", req.getId());
        body.put("is_pwr", req.getIs_pwr() != null ? req.getIs_pwr() : 1);
        body.put("is_rest_stop", req.getIs_rest_stop() != null ? req.getIs_rest_stop() : 0);
@@ -317,16 +316,22 @@
            body.put("left_money", req.getLeft_money());
        }
        body.put("is_stop", req.getIs_stop() != null ? req.getIs_stop() : 0);
        body.put("li_dev", req.getLi_dev());
        body.put("d_dev", req.getD_dev());
        body.put("li_dev", req.getLi_dev() != null ? req.getLi_dev() : Collections.emptyList());
        body.put("d_dev", req.getD_dev() != null ? req.getD_dev() : Collections.emptyMap());
        body.put("gs_bz", StringUtils.defaultString(req.getGs_bz()));
        putStopMoney(body, req.getStop_money());
        return postJsonBody("/changeGs", body, Object.class);
    }
    private static void putStopMoney(JSONObject body, Object stopMoney) {
        if (stopMoney != null) {
            body.put("stop_money", stopMoney);
        if (stopMoney == null) {
            body.put("stop_money", "0");
            return;
        }
        if (stopMoney instanceof BigDecimal) {
            body.put("stop_money", ((BigDecimal) stopMoney).toPlainString());
        } else {
            body.put("stop_money", String.valueOf(stopMoney));
        }
    }
server/visits/dmvisit_service/src/main/java/com/doumee/core/device/model/request/EleControlApiRequest.java
@@ -11,6 +11,6 @@
@EqualsAndHashCode(callSuper = true)
public class EleControlApiRequest extends OpenAccountRequest {
    @ApiModelProperty("10拉闸 11合闸")
    @ApiModelProperty("10拉闸 11合闸 63保电 220解除保电")
    private int type;
}
server/visits/dmvisit_service/src/main/java/com/doumee/dao/admin/request/DeviceImport.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,64 @@
package com.doumee.dao.admin.request;
import com.doumee.core.annotation.excel.ExcelColumn;
import io.swagger.annotations.ApiModel;
import lombok.Data;
/**
 * è¿ç»´è®¾å¤‡æ‰¹é‡å¯¼å…¥
 */
@Data
@ApiModel("运维设备导入")
public class DeviceImport {
    @ExcelColumn(name = "*设备编号", value = "code", index = 0)
    private String code;
    @ExcelColumn(name = "*设备名称", value = "name", index = 1)
    private String name;
    @ExcelColumn(name = "*设备分类", value = "categoryPath", index = 2)
    private String categoryPath;
    @ExcelColumn(name = "设备型号", value = "modelNo", index = 3)
    private String modelNo;
    @ExcelColumn(name = "设备管理员", value = "adminUserName", index = 4)
    private String adminUserName;
    @ExcelColumn(name = "所在位置", value = "addr", index = 5)
    private String addr;
    @ExcelColumn(name = "*所属项目", value = "projectName", index = 6)
    private String projectName;
    @ExcelColumn(name = "*关联房源", value = "roomPath", index = 7)
    private String roomPath;
    @ExcelColumn(name = "购入时间", value = "buyDate", index = 8)
    private String buyDate;
    @ExcelColumn(name = "设备状态", value = "statusText", index = 9)
    private String statusText;
    @ExcelColumn(name = "运维内容", value = "content", index = 10)
    private String content;
    @ExcelColumn(name = "设备供应商", value = "supplier", index = 11)
    private String supplier;
    @ExcelColumn(name = "供应商联系人", value = "supplierLinker", index = 12)
    private String supplierLinker;
    @ExcelColumn(name = "供应商联系方式", value = "supplierPhone", index = 13)
    private String supplierPhone;
    @ExcelColumn(name = "维保负责人", value = "maintenanceUserName", index = 14)
    private String maintenanceUserName;
    @ExcelColumn(name = "维保到期日", value = "maintenanceOverDate", index = 15)
    private String maintenanceOverDate;
    @ExcelColumn(name = "维保说明", value = "maintenanceContent", index = 16)
    private String maintenanceContent;
}
server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/model/YwElectricalActions.java
@@ -28,7 +28,7 @@
    private String remark;
    @ApiModelProperty("电表ID")
    private Integer electricalId;
    @ApiModelProperty("操作类型 1预付费清零 2后付费清零 3远程销户 4拉闸 5合闸 6开户 7充值 8抄表")
    @ApiModelProperty("操作类型 1预付费清零 2后付费清零 3远程销户 4拉闸 5合闸 6开户 7充值 8抄表 9保电 10解除保电")
    private Integer actionType;
    private String oprId;
    private String requestBody;
server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/vo/DailyEnergyStatVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,24 @@
package com.doumee.dao.business.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.math.BigDecimal;
/**
 * æŒ‰æ—¥æ±‡æ€»ç”µé‡/电费
 */
@Data
@ApiModel("按日汇总电量/电费")
public class DailyEnergyStatVO {
    @ApiModelProperty("日期 yyyy-MM-dd")
    private String statDate;
    @ApiModelProperty("总电量 kWh")
    private BigDecimal totalKwh;
    @ApiModelProperty("总电费 å…ƒ")
    private BigDecimal totalFee;
}
server/visits/dmvisit_service/src/main/java/com/doumee/dao/business/vo/WarningTypeStatVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,22 @@
package com.doumee.dao.business.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
/**
 * ç”µè¡¨æŠ¥è­¦ç±»åž‹ç»Ÿè®¡
 */
@Data
@ApiModel("电表报警类型统计")
public class WarningTypeStatVO {
    @ApiModelProperty("报警事件id")
    private Integer warningDefId;
    @ApiModelProperty("报警项名称")
    private String warningName;
    @ApiModelProperty("数量")
    private Long count;
}
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/YwDeviceService.java
@@ -8,6 +8,7 @@
import com.doumee.dao.business.vo.YwDeviceDataVO;
import com.doumee.dao.business.vo.YwDeviceParentCateDataVO;
import com.doumee.dao.business.vo.YwDeviceStatusDataVO;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.Set;
@@ -112,4 +113,9 @@
    Set<YwDeviceParentCateDataVO> getDeviceCateData(YwDevice model);
    YwDeviceStatusDataVO getDeviceStatus(YwDevice model);
    /**
     * æ‰¹é‡å¯¼å…¥è®¾å¤‡
     */
    String importBatch(MultipartFile file, LoginUserInfo user);
}
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/YwElectricalWarningService.java
@@ -4,6 +4,7 @@
import com.doumee.core.model.PageWrap;
import com.doumee.dao.business.dto.WarningDefOptionDTO;
import com.doumee.dao.business.model.YwElectricalWarning;
import com.doumee.dao.business.vo.WarningTypeStatVO;
import java.util.List;
@@ -19,4 +20,6 @@
    String syncFromPlatform();
    List<WarningDefOptionDTO> listWarningDefOptions();
    List<WarningTypeStatVO> warningTypeStats();
}
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/YwWorkDeskEnergyService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,15 @@
package com.doumee.service.business;
import com.doumee.dao.business.vo.DailyEnergyStatVO;
import java.util.List;
/**
 * å·¥ä½œå°ç”µé‡/电费统计
 */
public interface YwWorkDeskEnergyService {
    List<DailyEnergyStatVO> electricalDailyStats();
    List<DailyEnergyStatVO> conditionerDailyStats();
}
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/YwCustomerRechargeBizServiceImpl.java
@@ -27,6 +27,7 @@
import com.doumee.service.business.YwCustomerRechargeBizService;
import com.doumee.service.business.YwElectricalBizService;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -40,14 +41,13 @@
import java.util.stream.Collectors;
@Service
@Slf4j
public class YwCustomerRechargeBizServiceImpl implements YwCustomerRechargeBizService {
    private static final String ONLINE_TEXT = "在线";
    @Autowired
    private YwCustomerMapper ywCustomerMapper;
    @Autowired
    private MemberMapper memberMapper;
    @Autowired
    private YwCustomerGsMapper ywCustomerGsMapper;
    @Autowired
@@ -69,14 +69,14 @@
    @Override
    public PageData<YwCustomerRechargeMerchantVO> findMerchantPage(PageWrap<YwCustomerRechargeQueryDTO> pageWrap) {
        if (pageWrap == null) {
            pageWrap = new PageWrap<>();
        }
        YwCustomerRechargeQueryDTO query = pageWrap.getModel() != null ? pageWrap.getModel() : new YwCustomerRechargeQueryDTO();
        boolean hasDeviceFilter = query.getElectricalStatusFilter() != null || query.getConditionerStatusFilter() != null;
        if (hasDeviceFilter) {
            List<YwCustomer> all = ywCustomerMapper.selectList(new QueryWrapper<YwCustomer>().lambda()
                    .eq(YwCustomer::getIsdeleted, Constants.ZERO)
                    .like(StringUtils.isNotBlank(query.getNameKeyword()), YwCustomer::getName, query.getNameKeyword())
                    .orderByDesc(YwCustomer::getCreateDate));
            List<YwCustomer> all = ywCustomerMapper.selectJoinList(YwCustomer.class, buildMerchantCustomerWrapper(query));
            List<YwCustomerRechargeMerchantVO> enriched = enrichMerchantList(all);
            List<YwCustomerRechargeMerchantVO> filtered = enriched.stream()
                    .filter(vo -> matchDeviceFilter(vo, query))
@@ -85,10 +85,7 @@
        }
        IPage<YwCustomer> page = new Page<>(pageWrap.getPage(), pageWrap.getCapacity());
        IPage<YwCustomer> result = ywCustomerMapper.selectPage(page, new QueryWrapper<YwCustomer>().lambda()
                .eq(YwCustomer::getIsdeleted, Constants.ZERO)
                .like(StringUtils.isNotBlank(query.getNameKeyword()), YwCustomer::getName, query.getNameKeyword())
                .orderByDesc(YwCustomer::getCreateDate));
        IPage<YwCustomer> result = ywCustomerMapper.selectJoinPage(page, YwCustomer.class, buildMerchantCustomerWrapper(query));
        List<YwCustomerRechargeMerchantVO> list = enrichMerchantList(result.getRecords());
        PageData<YwCustomerRechargeMerchantVO> data = new PageData<>();
        data.setRecords(list);
@@ -96,6 +93,17 @@
        data.setPage(result.getCurrent());
        data.setCapacity(result.getSize());
        return data;
    }
    private MPJLambdaWrapper<YwCustomer> buildMerchantCustomerWrapper(YwCustomerRechargeQueryDTO query) {
        return new MPJLambdaWrapper<YwCustomer>()
                .selectAll(YwCustomer.class)
                .selectAs(Member::getName, YwCustomer::getMemberName)
                .selectAs(Member::getPhone, YwCustomer::getMemberPhone)
                .leftJoin(Member.class, Member::getId, YwCustomer::getMemberId)
                .eq(YwCustomer::getIsdeleted, Constants.ZERO)
                .like(StringUtils.isNotBlank(query.getNameKeyword()), YwCustomer::getName, query.getNameKeyword())
                .orderByDesc(YwCustomer::getCreateDate);
    }
    private PageData<YwCustomerRechargeMerchantVO> manualPage(List<YwCustomerRechargeMerchantVO> list, long page, long capacity) {
@@ -146,45 +154,23 @@
        }
        List<Integer> customerIds = customers.stream().map(YwCustomer::getId).collect(Collectors.toList());
        Map<Integer, YwCustomerGs> gsMap = ywCustomerGsMapper.selectList(new QueryWrapper<YwCustomerGs>().lambda()
                        .eq(YwCustomerGs::getIsdeleted, Constants.ZERO)
                        .in(YwCustomerGs::getCustomerId, customerIds))
                .stream().collect(Collectors.toMap(YwCustomerGs::getCustomerId, g -> g, (a, b) -> a));
        Map<Integer, YwCustomerGs> gsMap = loadGsMap(customerIds);
        List<YwCustomerElectrical> relE = ywCustomerElectricalMapper.selectList(new QueryWrapper<YwCustomerElectrical>().lambda()
                .eq(YwCustomerElectrical::getIsdeleted, Constants.ZERO)
                .in(YwCustomerElectrical::getCustomerId, customerIds));
        List<YwCustomerElectrical> relE = loadCustomerElectricalRels(customerIds);
        Map<Integer, List<Integer>> customerElectricalIds = relE.stream()
                .collect(Collectors.groupingBy(YwCustomerElectrical::getCustomerId,
                        Collectors.mapping(YwCustomerElectrical::getElectricalId, Collectors.toList())));
        List<YwCustomerConditioner> relC = ywCustomerConditionerMapper.selectList(new QueryWrapper<YwCustomerConditioner>().lambda()
                .eq(YwCustomerConditioner::getIsdeleted, Constants.ZERO)
                .in(YwCustomerConditioner::getCustomerId, customerIds));
        List<YwCustomerConditioner> relC = loadCustomerConditionerRels(customerIds);
        Map<Integer, List<Integer>> customerConditionerIds = relC.stream()
                .collect(Collectors.groupingBy(YwCustomerConditioner::getCustomerId,
                        Collectors.mapping(YwCustomerConditioner::getConditionerId, Collectors.toList())));
        Set<Integer> allElectricalIds = relE.stream().map(YwCustomerElectrical::getElectricalId).collect(Collectors.toSet());
        Map<Integer, YwElectrical> electricalMap = allElectricalIds.isEmpty() ? Collections.emptyMap()
                : ywElectricalMapper.selectBatchIds(allElectricalIds).stream()
                .filter(e -> !Objects.equals(e.getIsdeleted(), Constants.ONE))
                .collect(Collectors.toMap(YwElectrical::getId, e -> e, (a, b) -> a));
        Map<Integer, YwElectrical> electricalMap = loadElectricalMap(allElectricalIds);
        Set<Integer> allConditionerIds = relC.stream().map(YwCustomerConditioner::getConditionerId).collect(Collectors.toSet());
        Map<Integer, YwConditioner> conditionerMap = allConditionerIds.isEmpty() ? Collections.emptyMap()
                : ywConditionerMapper.selectBatchIds(allConditionerIds).stream()
                .filter(c -> !Objects.equals(c.getIsdeleted(), Constants.ONE))
                .collect(Collectors.toMap(YwConditioner::getId, c -> c, (a, b) -> a));
        Set<Integer> memberIds = customers.stream()
                .map(YwCustomer::getMemberId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        Map<Integer, Member> memberMap = memberIds.isEmpty() ? Collections.emptyMap()
                : memberMapper.selectBatchIds(memberIds).stream()
                .filter(m -> !Objects.equals(m.getIsdeleted(), Constants.ONE))
                .collect(Collectors.toMap(Member::getId, m -> m, (a, b) -> a));
        Map<Integer, YwConditioner> conditionerMap = loadConditionerMap(allConditionerIds);
        List<YwCustomerRechargeMerchantVO> list = new ArrayList<>();
        for (YwCustomer c : customers) {
@@ -194,11 +180,8 @@
            vo.setName(c.getName());
            vo.setPhone(c.getPhone());
            vo.setCreateDate(c.getCreateDate());
            Member member = c.getMemberId() != null ? memberMap.get(c.getMemberId()) : null;
            if (member != null) {
                vo.setMemberName(member.getName());
                vo.setMemberPhone(member.getPhone());
            }
            vo.setMemberName(c.getMemberName());
            vo.setMemberPhone(StringUtils.defaultIfBlank(c.getMemberPhone(), c.getPhone()));
            List<Integer> eIds = customerElectricalIds.getOrDefault(c.getId(), Collections.emptyList());
            vo.setElectricalCount(eIds.size());
@@ -258,6 +241,86 @@
            list.add(vo);
        }
        return list;
    }
    private Map<Integer, YwCustomerGs> loadGsMap(List<Integer> customerIds) {
        if (CollectionUtils.isEmpty(customerIds)) {
            return Collections.emptyMap();
        }
        try {
            return ywCustomerGsMapper.selectList(new QueryWrapper<YwCustomerGs>().lambda()
                            .select(YwCustomerGs::getId, YwCustomerGs::getCustomerId,
                                    YwCustomerGs::getLeftMoney, YwCustomerGs::getSyncDate, YwCustomerGs::getPlatformGsId)
                            .eq(YwCustomerGs::getIsdeleted, Constants.ZERO)
                            .in(YwCustomerGs::getCustomerId, customerIds))
                    .stream().collect(Collectors.toMap(YwCustomerGs::getCustomerId, g -> g, (a, b) -> a));
        } catch (Exception e) {
            log.warn("load yw_customer_gs failed, skip gs stats: {}", e.getMessage());
            return Collections.emptyMap();
        }
    }
    private List<YwCustomerElectrical> loadCustomerElectricalRels(List<Integer> customerIds) {
        if (CollectionUtils.isEmpty(customerIds)) {
            return Collections.emptyList();
        }
        try {
            return ywCustomerElectricalMapper.selectList(new QueryWrapper<YwCustomerElectrical>().lambda()
                    .select(YwCustomerElectrical::getCustomerId, YwCustomerElectrical::getElectricalId)
                    .eq(YwCustomerElectrical::getIsdeleted, Constants.ZERO)
                    .in(YwCustomerElectrical::getCustomerId, customerIds));
        } catch (Exception e) {
            log.warn("load yw_customer_electrical failed, skip electrical stats: {}", e.getMessage());
            return Collections.emptyList();
        }
    }
    private List<YwCustomerConditioner> loadCustomerConditionerRels(List<Integer> customerIds) {
        if (CollectionUtils.isEmpty(customerIds)) {
            return Collections.emptyList();
        }
        try {
            return ywCustomerConditionerMapper.selectList(new QueryWrapper<YwCustomerConditioner>().lambda()
                    .select(YwCustomerConditioner::getCustomerId, YwCustomerConditioner::getConditionerId)
                    .eq(YwCustomerConditioner::getIsdeleted, Constants.ZERO)
                    .in(YwCustomerConditioner::getCustomerId, customerIds));
        } catch (Exception e) {
            log.warn("load yw_customer_conditioner failed, skip conditioner stats: {}", e.getMessage());
            return Collections.emptyList();
        }
    }
    private Map<Integer, YwElectrical> loadElectricalMap(Set<Integer> electricalIds) {
        if (CollectionUtils.isEmpty(electricalIds)) {
            return Collections.emptyMap();
        }
        try {
            return ywElectricalMapper.selectList(new QueryWrapper<YwElectrical>().lambda()
                            .select(YwElectrical::getId, YwElectrical::getName, YwElectrical::getAddress,
                                    YwElectrical::getBalance, YwElectrical::getOnline, YwElectrical::getIsdeleted)
                            .in(YwElectrical::getId, electricalIds)
                            .eq(YwElectrical::getIsdeleted, Constants.ZERO))
                    .stream().collect(Collectors.toMap(YwElectrical::getId, e -> e, (a, b) -> a));
        } catch (Exception e) {
            log.warn("load electrical for merchant page failed: {}", e.getMessage());
            return Collections.emptyMap();
        }
    }
    private Map<Integer, YwConditioner> loadConditionerMap(Set<Integer> conditionerIds) {
        if (CollectionUtils.isEmpty(conditionerIds)) {
            return Collections.emptyMap();
        }
        try {
            return ywConditionerMapper.selectList(new QueryWrapper<YwConditioner>().lambda()
                            .select(YwConditioner::getId, YwConditioner::getOnline, YwConditioner::getIsdeleted)
                            .in(YwConditioner::getId, conditionerIds)
                            .eq(YwConditioner::getIsdeleted, Constants.ZERO))
                    .stream().collect(Collectors.toMap(YwConditioner::getId, c -> c, (a, b) -> a));
        } catch (Exception e) {
            log.warn("load conditioner for merchant page failed: {}", e.getMessage());
            return Collections.emptyMap();
        }
    }
    @Override
@@ -367,19 +430,30 @@
    @Override
    public YwCustomerGs getCustomerGsConfig(Integer customerId) {
        return ywCustomerGsMapper.selectOne(new QueryWrapper<YwCustomerGs>().lambda()
                .eq(YwCustomerGs::getCustomerId, customerId)
                .eq(YwCustomerGs::getIsdeleted, Constants.ZERO)
                .last("limit 1"));
        try {
            return ywCustomerGsMapper.selectOne(new QueryWrapper<YwCustomerGs>().lambda()
                    .eq(YwCustomerGs::getCustomerId, customerId)
                    .eq(YwCustomerGs::getIsdeleted, Constants.ZERO)
                    .last("limit 1"));
        } catch (Exception e) {
            log.warn("load yw_customer_gs failed, retry without stop_money: {}", e.getMessage());
            return ywCustomerGsMapper.selectOne(new QueryWrapper<YwCustomerGs>().lambda()
                    .select(YwCustomerGs::getId, YwCustomerGs::getCustomerId, YwCustomerGs::getPlatformGsId,
                            YwCustomerGs::getIsPwr, YwCustomerGs::getIsRestStop, YwCustomerGs::getGsBz,
                            YwCustomerGs::getLeftMoney, YwCustomerGs::getSyncDate,
                            YwCustomerGs::getCreator, YwCustomerGs::getCreateDate,
                            YwCustomerGs::getEditor, YwCustomerGs::getEditDate, YwCustomerGs::getIsdeleted)
                    .eq(YwCustomerGs::getCustomerId, customerId)
                    .eq(YwCustomerGs::getIsdeleted, Constants.ZERO)
                    .last("limit 1"));
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void saveCustomerGsConfig(YwCustomerGsConfigDTO dto, LoginUserInfo user) {
        YwCustomer customer = requireCustomer(dto.getCustomerId());
        if (CollectionUtils.isEmpty(dto.getConditioners())) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "请至少关联一台空调内机");
        }
        validateGsConfigRequired(dto);
        conditionerBizService.ensureLogin();
        List<Integer> conditionerIds = dto.getConditioners().stream()
@@ -413,10 +487,10 @@
        }
        gs.setEditor(user.getId());
        gs.setEditDate(new Date());
        gs.setIsPwr(dto.getIsPwr() != null ? dto.getIsPwr() : Constants.ONE);
        gs.setIsRestStop(dto.getIsRestStop() != null ? dto.getIsRestStop() : Constants.ZERO);
        gs.setIsPwr(dto.getIsPwr());
        gs.setIsRestStop(dto.getIsRestStop());
        gs.setGsBz(StringUtils.defaultString(dto.getGsBz()));
        gs.setStopMoney(dto.getStopMoney() != null ? dto.getStopMoney() : BigDecimal.ZERO);
        gs.setStopMoney(dto.getStopMoney());
        if (StringUtils.isBlank(customer.getName())) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "客户名称不能为空");
@@ -432,6 +506,8 @@
        companyReq.setIs_rest_stop(gs.getIsRestStop());
        companyReq.setGs_bz(gs.getGsBz());
        companyReq.setStop_money(gs.getStopMoney());
        companyReq.setLi_dev(liDev);
        companyReq.setD_dev(dDev);
        if (gs.getPlatformGsId() == null) {
            ConditionerBaseResponse<Object> addResp = ConditionerUtil.addGs(companyReq);
@@ -464,29 +540,88 @@
        refreshGsLeftMoney(gs);
        if (gs.getId() == null) {
            ywCustomerGsMapper.insert(gs);
            try {
                ywCustomerGsMapper.insert(gs);
            } catch (Exception e) {
                if (gs.getStopMoney() != null) {
                    log.warn("insert yw_customer_gs with stop_money failed, retry without stop_money: {}", e.getMessage());
                    gs.setStopMoney(null);
                    ywCustomerGsMapper.insert(gs);
                } else {
                    throw e;
                }
            }
        } else {
            ywCustomerGsMapper.updateById(gs);
            try {
                ywCustomerGsMapper.updateById(gs);
            } catch (Exception e) {
                if (gs.getStopMoney() != null) {
                    log.warn("update yw_customer_gs with stop_money failed, retry without stop_money: {}", e.getMessage());
                    BigDecimal stopMoney = gs.getStopMoney();
                    gs.setStopMoney(null);
                    ywCustomerGsMapper.updateById(gs);
                    gs.setStopMoney(stopMoney);
                } else {
                    throw e;
                }
            }
        }
        ywCustomerConditionerMapper.update(null, new UpdateWrapper<YwCustomerConditioner>().lambda()
                .set(YwCustomerConditioner::getIsdeleted, Constants.ONE)
                .set(YwCustomerConditioner::getEditDate, new Date())
                .set(YwCustomerConditioner::getEditor, user.getId())
                .eq(YwCustomerConditioner::getCustomerId, dto.getCustomerId())
                .eq(YwCustomerConditioner::getIsdeleted, Constants.ZERO));
        saveCustomerConditionerRels(dto, user);
    }
    /**
     * å…³è”内机 upsert:表上有 uk(customer_id, conditioner_id),不能软删后重复 insert。
     */
    private void saveCustomerConditionerRels(YwCustomerGsConfigDTO dto, LoginUserInfo user) {
        List<YwCustomerConditioner> existingRels = ywCustomerConditionerMapper.selectList(
                new QueryWrapper<YwCustomerConditioner>().lambda()
                        .eq(YwCustomerConditioner::getCustomerId, dto.getCustomerId()));
        Map<Integer, YwCustomerConditioner> relByCondId = existingRels.stream()
                .collect(Collectors.toMap(YwCustomerConditioner::getConditionerId, r -> r, (a, b) -> a));
        Set<Integer> targetIds = dto.getConditioners().stream()
                .map(YwCustomerGsConfigDTO.ConditionerItem::getConditionerId)
                .filter(Objects::nonNull)
                .collect(Collectors.toSet());
        Date now = new Date();
        for (YwCustomerConditioner rel : existingRels) {
            if (!targetIds.contains(rel.getConditionerId())
                    && Objects.equals(rel.getIsdeleted(), Constants.ZERO)) {
                ywCustomerConditionerMapper.update(null, new UpdateWrapper<YwCustomerConditioner>().lambda()
                        .set(YwCustomerConditioner::getIsdeleted, Constants.ONE)
                        .set(YwCustomerConditioner::getEditDate, now)
                        .set(YwCustomerConditioner::getEditor, user.getId())
                        .eq(YwCustomerConditioner::getId, rel.getId()));
            }
        }
        for (YwCustomerGsConfigDTO.ConditionerItem item : dto.getConditioners()) {
            YwCustomerConditioner rel = new YwCustomerConditioner();
            rel.setCreator(user.getId());
            rel.setCreateDate(new Date());
            rel.setEditor(user.getId());
            rel.setEditDate(new Date());
            rel.setIsdeleted(Constants.ZERO);
            rel.setCustomerId(dto.getCustomerId());
            rel.setConditionerId(item.getConditionerId());
            rel.setDevRatio(item.getDevRatio() != null ? item.getDevRatio() : 100);
            ywCustomerConditionerMapper.insert(rel);
            if (item.getConditionerId() == null) {
                continue;
            }
            int ratio = item.getDevRatio() != null ? item.getDevRatio() : 100;
            YwCustomerConditioner rel = relByCondId.get(item.getConditionerId());
            if (rel != null) {
                rel.setIsdeleted(Constants.ZERO);
                rel.setDevRatio(ratio);
                rel.setEditor(user.getId());
                rel.setEditDate(now);
                ywCustomerConditionerMapper.updateById(rel);
            } else {
                YwCustomerConditioner created = new YwCustomerConditioner();
                created.setCreator(user.getId());
                created.setCreateDate(now);
                created.setEditor(user.getId());
                created.setEditDate(now);
                created.setIsdeleted(Constants.ZERO);
                created.setCustomerId(dto.getCustomerId());
                created.setConditionerId(item.getConditionerId());
                created.setDevRatio(ratio);
                ywCustomerConditionerMapper.insert(created);
                relByCondId.put(item.getConditionerId(), created);
            }
        }
    }
@@ -838,6 +973,24 @@
        }
    }
    private void validateGsConfigRequired(YwCustomerGsConfigDTO dto) {
        if (dto.getStopMoney() == null) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "欠费额度不能为空");
        }
        if (dto.getStopMoney().compareTo(BigDecimal.ZERO) < 0) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "欠费额度不能小于0");
        }
        if (dto.getIsPwr() == null) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "计费开关不能为空");
        }
        if (dto.getIsRestStop() == null) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "是否停机不能为空");
        }
        if (CollectionUtils.isEmpty(dto.getConditioners())) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "请至少关联一台空调内机");
        }
    }
    private String apiMsg(ConditionerBaseResponse<?> resp, String def) {
        return resp != null && StringUtils.isNotBlank(resp.getMessage()) ? resp.getMessage() : def;
    }
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/YwDeviceServiceImpl.java
@@ -9,14 +9,19 @@
import com.doumee.core.utils.Constants;
import com.doumee.core.utils.DateUtil;
import com.doumee.core.utils.Utils;
import com.doumee.core.annotation.excel.ExcelImporter;
import com.doumee.dao.admin.request.DeviceImport;
import com.doumee.dao.business.*;
import com.doumee.dao.business.YwDeviceMapper;
import com.doumee.dao.business.YwDeviceRecordMapper;
import com.doumee.dao.business.join.MemberJoinMapper;
import com.doumee.dao.business.model.*;
import com.doumee.dao.business.vo.YwDeviceCateDataVO;
import com.doumee.dao.business.vo.YwDeviceDataVO;
import com.doumee.dao.business.vo.YwDeviceParentCateDataVO;
import com.doumee.dao.business.vo.YwDeviceStatusDataVO;
import com.doumee.dao.system.MultifileMapper;
import com.doumee.dao.system.SystemUserMapper;
import com.doumee.dao.system.model.Multifile;
import com.doumee.dao.system.model.SystemUser;
import com.doumee.service.business.YwDeviceRecordService;
@@ -31,8 +36,11 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.web.multipart.MultipartFile;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.stream.Collectors;
@@ -57,6 +65,22 @@
    @Autowired
    private RedisTemplate<String,Object> redisTemplate;
    @Autowired
    private CategoryMapper categoryMapper;
    @Autowired
    private YwProjectMapper ywProjectMapper;
    @Autowired
    private YwBuildingMapper ywBuildingMapper;
    @Autowired
    private YwFloorMapper ywFloorMapper;
    @Autowired
    private YwRoomMapper ywRoomMapper;
    @Autowired
    private MemberJoinMapper memberJoinMapper;
    @Autowired
    private SystemUserMapper systemUserMapper;
    private static final int IMPORT_EXCEL_ROW_OFFSET = 3;
    @Override
    public Integer create(YwDevice ywDevice) {
@@ -434,7 +458,313 @@
        return ywDeviceStatusDataVO;
    }
    @Override
    @Transactional(rollbackFor = {BusinessException.class, Exception.class})
    public String importBatch(MultipartFile file, LoginUserInfo loginUserInfo) {
        List<DeviceImport> dataList;
        try {
            ExcelImporter ie = new ExcelImporter(file, 1, 0);
            dataList = ie.getDataList(DeviceImport.class, null);
        } catch (Exception e) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "对不起,导入文件解析失败,请检查表格格式!");
        }
        if (CollectionUtils.isEmpty(dataList)) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "对不起,录入数据为空!");
        }
        DeviceImportCache cache = buildImportCache();
        List<YwDevice> insertList = new ArrayList<>();
        List<YwDevice> updateList = new ArrayList<>();
        Set<String> fileCodes = new HashSet<>();
        Date now = new Date();
        for (int i = 0; i < dataList.size(); i++) {
            DeviceImport row = dataList.get(i);
            if (isImportBlankRow(row)) {
                continue;
            }
            YwDevice device = checkImportRow(row, i, cache, fileCodes, loginUserInfo, now);
            if (device.getId() != null) {
                updateList.add(device);
            } else {
                insertList.add(device);
            }
        }
        if (CollectionUtils.isEmpty(insertList) && CollectionUtils.isEmpty(updateList)) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "对不起,录入有效数据为空!");
        }
        if (!CollectionUtils.isEmpty(insertList)) {
            ywDeviceMapper.insert(insertList);
        }
        for (YwDevice device : updateList) {
            ywDeviceMapper.updateById(device);
        }
        return String.format("操作成功,本次导入 æ–°å¢ž%d个设备,更新%d个设备", insertList.size(), updateList.size());
    }
    private boolean isImportBlankRow(DeviceImport row) {
        return StringUtils.isBlank(row.getCode())
                && StringUtils.isBlank(row.getName())
                && StringUtils.isBlank(row.getProjectName())
                && StringUtils.isBlank(row.getRoomPath());
    }
    private DeviceImportCache buildImportCache() {
        DeviceImportCache cache = new DeviceImportCache();
        List<Category> categories = categoryMapper.selectList(new QueryWrapper<Category>().lambda()
                .eq(Category::getIsdeleted, Constants.ZERO)
                .eq(Category::getType, Constants.FIVE));
        Map<Integer, Category> categoryById = categories.stream()
                .collect(Collectors.toMap(Category::getId, c -> c, (a, b) -> a));
        for (Category category : categories) {
            if (category.getParentId() == null || category.getParentId() <= 0) {
                continue;
            }
            Category parent = categoryById.get(category.getParentId());
            if (parent == null || StringUtils.isBlank(parent.getName()) || StringUtils.isBlank(category.getName())) {
                continue;
            }
            cache.categoryPathMap.put(parent.getName() + "/" + category.getName(), category.getId());
        }
        List<YwProject> projects = ywProjectMapper.selectList(new QueryWrapper<YwProject>().lambda()
                .eq(YwProject::getIsdeleted, Constants.ZERO));
        for (YwProject project : projects) {
            if (StringUtils.isNotBlank(project.getName())) {
                cache.projectNameMap.put(project.getName(), project.getId());
            }
        }
        List<YwBuilding> buildings = ywBuildingMapper.selectList(new QueryWrapper<YwBuilding>().lambda()
                .eq(YwBuilding::getIsdeleted, Constants.ZERO));
        for (YwBuilding building : buildings) {
            if (building.getProjectId() != null && StringUtils.isNotBlank(building.getName())) {
                cache.buildingMap.put(building.getProjectId() + "|" + building.getName(), building.getId());
            }
        }
        List<YwFloor> floors = ywFloorMapper.selectList(new QueryWrapper<YwFloor>().lambda()
                .eq(YwFloor::getIsdeleted, Constants.ZERO));
        for (YwFloor floor : floors) {
            if (floor.getBuildingId() != null && StringUtils.isNotBlank(floor.getName())) {
                cache.floorMap.put(floor.getBuildingId() + "|" + floor.getName(), floor.getId());
            }
        }
        List<YwRoom> rooms = ywRoomMapper.selectList(new QueryWrapper<YwRoom>().lambda()
                .eq(YwRoom::getIsdeleted, Constants.ZERO));
        for (YwRoom room : rooms) {
            if (room.getProjectId() == null || room.getBuildingId() == null || room.getFloor() == null) {
                continue;
            }
            if (StringUtils.isNotBlank(room.getCode())) {
                cache.roomMap.put(roomKey(room.getProjectId(), room.getBuildingId(), room.getFloor(), room.getCode()), room.getId());
            }
            if (StringUtils.isNotBlank(room.getRoomNum())) {
                cache.roomMap.put(roomKey(room.getProjectId(), room.getBuildingId(), room.getFloor(), room.getRoomNum()), room.getId());
            }
        }
        List<YwDevice> devices = ywDeviceMapper.selectList(new QueryWrapper<YwDevice>().lambda()
                .eq(YwDevice::getIsdeleted, Constants.ZERO)
                .select(YwDevice::getId, YwDevice::getCode));
        for (YwDevice device : devices) {
            if (StringUtils.isNotBlank(device.getCode())) {
                cache.existingCodeIdMap.put(device.getCode(), device.getId());
            }
        }
        List<Member> members = memberJoinMapper.selectJoinList(Member.class, new MPJLambdaWrapper<Member>()
                .select(Member::getId, Member::getName)
                .leftJoin(Company.class, Company::getId, Member::getCompanyId)
                .eq(Member::getIsdeleted, Constants.ZERO)
                .eq(Company::getType, Constants.ONE));
        if (!CollectionUtils.isEmpty(members)) {
            List<Integer> memberIds = members.stream().map(Member::getId).filter(Objects::nonNull).collect(Collectors.toList());
            if (!CollectionUtils.isEmpty(memberIds)) {
                List<SystemUser> systemUsers = systemUserMapper.selectList(new QueryWrapper<SystemUser>().lambda()
                        .eq(SystemUser::getDeleted, Boolean.FALSE)
                        .in(SystemUser::getMemberId, memberIds));
                Map<Integer, Integer> memberUserMap = systemUsers.stream()
                        .filter(u -> u.getMemberId() != null)
                        .collect(Collectors.toMap(SystemUser::getMemberId, SystemUser::getId, (a, b) -> a));
                for (Member member : members) {
                    if (StringUtils.isBlank(member.getName())) {
                        continue;
                    }
                    Integer userId = memberUserMap.get(member.getId());
                    if (userId == null) {
                        continue;
                    }
                    if (cache.internalUserNameMap.containsKey(member.getName())) {
                        cache.duplicateInternalUserNames.add(member.getName());
                    }
                    cache.internalUserNameMap.put(member.getName(), userId);
                }
            }
        }
        return cache;
    }
    private String roomKey(Integer projectId, Integer buildingId, Integer floorId, String roomName) {
        return projectId + "|" + buildingId + "|" + floorId + "|" + roomName;
    }
    private YwDevice checkImportRow(DeviceImport row, int index, DeviceImportCache cache, Set<String> fileCodes,
                                    LoginUserInfo loginUserInfo, Date now) {
        int rowNum = index + IMPORT_EXCEL_ROW_OFFSET;
        if (StringUtils.isBlank(row.getCode())) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "对不起,第" + rowNum + "行设备编号不能为空,请检查表格内容!");
        }
        if (StringUtils.isBlank(row.getName())) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "对不起,第" + rowNum + "行设备名称不能为空,请检查表格内容!");
        }
        if (StringUtils.isBlank(row.getProjectName())) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "对不起,第" + rowNum + "行所属项目不能为空,请检查表格内容!");
        }
        if (StringUtils.isBlank(row.getRoomPath())) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "对不起,第" + rowNum + "行关联房源不能为空,请检查表格内容!");
        }
        String code = row.getCode().trim();
        if (fileCodes.contains(code)) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "对不起,第" + rowNum + "行设备编号【" + code + "】重复,请检查表格内容!");
        }
        fileCodes.add(code);
        Integer existingId = cache.existingCodeIdMap.get(code);
        Integer cateId = null;
        if (StringUtils.isNotBlank(row.getCategoryPath())) {
            String categoryPath = row.getCategoryPath().trim();
            String[] cateParts = categoryPath.split("/");
            if (cateParts.length < 2 || StringUtils.isBlank(cateParts[1])) {
                throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "对不起,第" + rowNum + "行设备分类【" + categoryPath + "】格式不正确,请使用一级/二级格式!");
            }
            cateId = cache.categoryPathMap.get(categoryPath);
            if (cateId == null) {
                throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "对不起,第" + rowNum + "行设备分类【" + categoryPath + "】未找到,请检查表格内容!");
            }
        }
        Integer projectId = cache.projectNameMap.get(row.getProjectName().trim());
        if (projectId == null) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "对不起,第" + rowNum + "行所属项目【" + row.getProjectName() + "】未找到,请检查表格内容!");
        }
        String[] roomParts = row.getRoomPath().trim().split("/");
        if (roomParts.length < 3 || StringUtils.isAnyBlank(roomParts[0], roomParts[1], roomParts[2])) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "对不起,第" + rowNum + "行关联房源【" + row.getRoomPath() + "】格式不正确,请使用楼宇/楼层/房源格式!");
        }
        Integer buildingId = cache.buildingMap.get(projectId + "|" + roomParts[0].trim());
        if (buildingId == null) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "对不起,第" + rowNum + "行关联房源【" + row.getRoomPath() + "】未找到,请检查表格内容!");
        }
        Integer floorId = cache.floorMap.get(buildingId + "|" + roomParts[1].trim());
        if (floorId == null) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "对不起,第" + rowNum + "行关联房源【" + row.getRoomPath() + "】未找到,请检查表格内容!");
        }
        Integer roomId = cache.roomMap.get(roomKey(projectId, buildingId, floorId, roomParts[2].trim()));
        if (roomId == null) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "对不起,第" + rowNum + "行关联房源【" + row.getRoomPath() + "】未找到,请检查表格内容!");
        }
        Integer status = parseImportStatus(row.getStatusText(), rowNum);
        Integer userId = resolveInternalUserId(row.getAdminUserName(), rowNum, "设备管理员", cache);
        Integer maintenanceUserId = resolveInternalUserId(row.getMaintenanceUserName(), rowNum, "维保负责人", cache);
        YwDevice device = new YwDevice();
        device.setCode(code);
        device.setName(row.getName().trim());
        device.setCateId(cateId);
        device.setModelNo(StringUtils.trimToNull(row.getModelNo()));
        device.setUserId(userId);
        device.setAddr(StringUtils.trimToNull(row.getAddr()));
        device.setProjectId(projectId);
        device.setBuildingId(buildingId);
        device.setFloorId(floorId);
        device.setRoomId(roomId);
        device.setBuyDate(parseImportDate(row.getBuyDate(), rowNum, "购入时间"));
        device.setStatus(status);
        device.setContent(StringUtils.trimToNull(row.getContent()));
        device.setSupplier(StringUtils.trimToNull(row.getSupplier()));
        device.setSupplierLinker(StringUtils.trimToNull(row.getSupplierLinker()));
        device.setSupplierPhone(StringUtils.trimToNull(row.getSupplierPhone()));
        device.setMaintenanceUserId(maintenanceUserId);
        device.setMaintenanceOverDate(parseImportDate(row.getMaintenanceOverDate(), rowNum, "维保到期日"));
        device.setMaintenanceContent(StringUtils.trimToNull(row.getMaintenanceContent()));
        if (existingId != null) {
            device.setId(existingId);
            device.setEditor(loginUserInfo.getId());
            device.setEditDate(now);
        } else {
            device.setCreator(loginUserInfo.getId());
            device.setCreateDate(now);
            device.setIsdeleted(Constants.ZERO);
        }
        return device;
    }
    private Integer resolveInternalUserId(String userName, int rowNum, String fieldLabel, DeviceImportCache cache) {
        if (StringUtils.isBlank(userName)) {
            return null;
        }
        String name = userName.trim();
        if (cache.duplicateInternalUserNames.contains(name)) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "对不起,第" + rowNum + "行" + fieldLabel + "【" + name + "】存在重名,请检查表格内容!");
        }
        Integer userId = cache.internalUserNameMap.get(name);
        if (userId == null) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "对不起,第" + rowNum + "行" + fieldLabel + "【" + name + "】未找到,请检查表格内容!");
        }
        return userId;
    }
    private Integer parseImportStatus(String statusText, int rowNum) {
        if (StringUtils.isBlank(statusText)) {
            return Constants.ZERO;
        }
        String text = statusText.trim();
        if ("正常".equals(text)) {
            return Constants.ZERO;
        }
        if ("损坏".equals(text)) {
            return Constants.ONE;
        }
        if ("报废".equals(text)) {
            return Constants.TWO;
        }
        throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "对不起,第" + rowNum + "行设备状态【" + statusText + "】不正确,仅支持正常/损坏/报废!");
    }
    private Date parseImportDate(String dateText, int rowNum, String fieldLabel) {
        if (StringUtils.isBlank(dateText)) {
            return null;
        }
        String text = dateText.trim();
        if (StringUtils.endsWith(text, ".0")) {
            text = StringUtils.substringBefore(text, ".0");
        }
        Date date = DateUtil.parseFromFormats(text);
        if (date != null) {
            return date;
        }
        try {
            return new SimpleDateFormat("yyyy-MM-dd").parse(text);
        } catch (Exception ignored) {
            // try excel serial number below
        }
        try {
            return org.apache.poi.ss.usermodel.DateUtil.getJavaDate(Double.parseDouble(text));
        } catch (Exception ignored) {
            // fall through to error
        }
        throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(),
                "对不起,第" + rowNum + "行" + fieldLabel + "【" + dateText + "】格式不正确,请使用yyyy-MM-dd格式!");
    }
    private static class DeviceImportCache {
        private final Map<String, Integer> categoryPathMap = new HashMap<>();
        private final Map<String, Integer> projectNameMap = new HashMap<>();
        private final Map<String, Integer> buildingMap = new HashMap<>();
        private final Map<String, Integer> floorMap = new HashMap<>();
        private final Map<String, Integer> roomMap = new HashMap<>();
        private final Map<String, Integer> existingCodeIdMap = new HashMap<>();
        private final Map<String, Integer> internalUserNameMap = new HashMap<>();
        private final Set<String> duplicateInternalUserNames = new HashSet<>();
    }
}
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/YwElectricalActionsServiceImpl.java
@@ -47,6 +47,9 @@
        if (model.getActionType() != null) {
            queryWrapper.eq(YwElectricalActions::getActionType, model.getActionType());
        }
        if (model.getElectricalId() != null) {
            queryWrapper.eq(YwElectricalActions::getElectricalId, model.getElectricalId());
        }
        if (model.getOperateTimeBegin() != null) {
            queryWrapper.ge(YwElectricalActions::getCreateDate, Utils.Date.getStart(model.getOperateTimeBegin()));
        }
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/YwElectricalBizServiceImpl.java
@@ -52,6 +52,8 @@
    public static final int ACTION_OPEN = 6;
    public static final int ACTION_RECHARGE = 7;
    public static final int ACTION_READ = 8;
    public static final int ACTION_POWER_PROTECT = 9;
    public static final int ACTION_POWER_PROTECT_RELEASE = 10;
    private static final long FIRST_STATUS_QUERY_DELAY_MS = 30_000L;
    private static final long STATUS_QUERY_MIN_INTERVAL_MS = 3_600_000L;
@@ -164,6 +166,10 @@
                return doEleControl(e, 10, ACTION_TRIP, user);
            case "close":
                return doEleControl(e, 11, ACTION_CLOSE, user);
            case "powerProtect":
                return doEleControl(e, 63, ACTION_POWER_PROTECT, user);
            case "powerProtectRelease":
                return doEleControl(e, 220, ACTION_POWER_PROTECT_RELEASE, user);
            case "openAccount":
                return doOpenAccount(e, dto, user);
            case "recharge":
@@ -209,7 +215,7 @@
        List<OpenAccountRequest> list = new ArrayList<>();
        list.add(req);
        ElectronicBaseResponse resp = ElectronicToolUtil.eleControl(list);
        return finishAsync(e, actionType, oprId, "/Api_v2/ele_security/ele_control", reqJson, resp, user);
        return finishAsync(e, actionType, oprId, "/Api_v2/ele_control", reqJson, resp, user);
    }
    private String doOpenAccount(YwElectrical e, YwElectricalOperateDTO dto, LoginUserInfo user) {
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/YwElectricalWarningServiceImpl.java
@@ -22,6 +22,7 @@
import com.doumee.dao.business.model.YwElectricalRoom;
import com.doumee.dao.business.model.YwElectricalWarning;
import com.doumee.dao.business.model.YwRoom;
import com.doumee.dao.business.vo.WarningTypeStatVO;
import com.doumee.service.business.YwElectricalWarningService;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import org.apache.commons.lang3.StringUtils;
@@ -159,6 +160,38 @@
        return list;
    }
    @Override
    public List<WarningTypeStatVO> warningTypeStats() {
        QueryWrapper<YwElectricalWarning> wrapper = new QueryWrapper<>();
        wrapper.select("warning_def_id", "count(1) as cnt")
                .eq("isdeleted", Constants.ZERO)
                .eq("device_type", ELECTRICAL_DEVICE_TYPE)
                .isNotNull("warning_def_id")
                .groupBy("warning_def_id")
                .orderByDesc("cnt");
        List<Map<String, Object>> rows = ywElectricalWarningMapper.selectMaps(wrapper);
        List<WarningTypeStatVO> list = new ArrayList<>();
        if (CollectionUtils.isEmpty(rows)) {
            return list;
        }
        for (Map<String, Object> row : rows) {
            Object defIdObj = row.get("warning_def_id");
            if (defIdObj == null) {
                continue;
            }
            Integer warningDefId = Integer.parseInt(String.valueOf(defIdObj));
            Object cntObj = row.get("cnt");
            long count = cntObj == null ? 0L : Long.parseLong(String.valueOf(cntObj));
            WarningTypeStatVO stat = new WarningTypeStatVO();
            stat.setWarningDefId(warningDefId);
            stat.setCount(count);
            ElectronicConstant.warningDefId def = ElectronicConstant.warningDefId.getByKey(warningDefId);
            stat.setWarningName(def != null ? def.getName() : "未知报警");
            list.add(stat);
        }
        return list;
    }
    private MPJLambdaWrapper<YwElectricalWarning> buildPageQuery(PageWrap<YwElectricalWarning> pageWrap) {
        MPJLambdaWrapper<YwElectricalWarning> queryWrapper = new MPJLambdaWrapper<>();
        YwElectricalWarning model = pageWrap.getModel() == null ? new YwElectricalWarning() : pageWrap.getModel();
server/visits/dmvisit_service/src/main/java/com/doumee/service/business/impl/YwWorkDeskEnergyServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,228 @@
package com.doumee.service.business.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.doumee.core.utils.Constants;
import com.doumee.dao.business.YwConditionerUsageMapper;
import com.doumee.dao.business.YwElectricalDataMapper;
import com.doumee.dao.business.model.YwConditionerUsage;
import com.doumee.dao.business.model.YwElectricalData;
import com.doumee.dao.business.vo.DailyEnergyStatVO;
import com.doumee.service.business.YwWorkDeskEnergyService;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
public class YwWorkDeskEnergyServiceImpl implements YwWorkDeskEnergyService {
    private static final int DAYS = 30;
    private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
    private static final String READ_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss";
    @Autowired
    private YwElectricalDataMapper ywElectricalDataMapper;
    @Autowired
    private YwConditionerUsageMapper ywConditionerUsageMapper;
    @Override
    public List<DailyEnergyStatVO> electricalDailyStats() {
        LocalDate end = LocalDate.now();
        LocalDate start = end.minusDays(DAYS - 1L);
        LocalDate queryStart = start.minusDays(1L);
        Map<String, DailyEnergyStatVO> bucket = initDailyBucket(start, end);
        List<YwElectricalData> rows = ywElectricalDataMapper.selectList(new QueryWrapper<YwElectricalData>().lambda()
                .eq(YwElectricalData::getIsdeleted, Constants.ZERO)
                .ge(YwElectricalData::getCreateDate, java.sql.Date.valueOf(queryStart))
                .le(YwElectricalData::getCreateDate, java.sql.Date.valueOf(end.plusDays(1))));
        SimpleDateFormat dayFmt = new SimpleDateFormat("yyyy-MM-dd");
        SimpleDateFormat readTimeFmt = new SimpleDateFormat(READ_TIME_PATTERN);
        Map<String, Map<String, MeterDayReading>> meterDayLatest = new HashMap<>();
        for (YwElectricalData row : rows) {
            String meterKey = resolveMeterKey(row);
            if (StringUtils.isBlank(meterKey)) {
                continue;
            }
            String dayKey = resolveElectricalDayKey(row, dayFmt);
            if (StringUtils.isBlank(dayKey)) {
                continue;
            }
            BigDecimal totalEnergy = resolveTotalEnergy(row);
            if (totalEnergy.compareTo(BigDecimal.ZERO) < 0) {
                continue;
            }
            long readingTime = resolveReadingTime(row, readTimeFmt);
            MeterDayReading snapshot = new MeterDayReading(totalEnergy, parseDecimal(row.getDqdj()), readingTime);
            upsertLatestReading(meterDayLatest, meterKey, dayKey, snapshot);
        }
        for (LocalDate day = start; !day.isAfter(end); day = day.plusDays(1)) {
            String todayKey = day.format(DATE_FMT);
            String yesterdayKey = day.minusDays(1).format(DATE_FMT);
            DailyEnergyStatVO stat = bucket.get(todayKey);
            for (Map<String, MeterDayReading> dayReadings : meterDayLatest.values()) {
                MeterDayReading todayReading = dayReadings.get(todayKey);
                MeterDayReading yesterdayReading = dayReadings.get(yesterdayKey);
                if (todayReading == null || yesterdayReading == null) {
                    continue;
                }
                BigDecimal usage = todayReading.totalEnergy.subtract(yesterdayReading.totalEnergy);
                if (usage.compareTo(BigDecimal.ZERO) <= 0) {
                    continue;
                }
                stat.setTotalKwh(stat.getTotalKwh().add(usage));
                stat.setTotalFee(stat.getTotalFee().add(usage.multiply(todayReading.price)));
            }
        }
        return normalizeBucket(bucket);
    }
    @Override
    public List<DailyEnergyStatVO> conditionerDailyStats() {
        LocalDate end = LocalDate.now();
        LocalDate start = end.minusDays(DAYS - 1L);
        Map<String, DailyEnergyStatVO> bucket = initDailyBucket(start, end);
        List<YwConditionerUsage> rows = ywConditionerUsageMapper.selectList(new QueryWrapper<YwConditionerUsage>().lambda()
                .eq(YwConditionerUsage::getIsdeleted, Constants.ZERO)
                .ge(YwConditionerUsage::getUsageDate, java.sql.Date.valueOf(start))
                .le(YwConditionerUsage::getUsageDate, java.sql.Date.valueOf(end)));
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
        for (YwConditionerUsage row : rows) {
            if (row.getUsageDate() == null) {
                continue;
            }
            String dayKey = sdf.format(row.getUsageDate());
            if (!bucket.containsKey(dayKey)) {
                continue;
            }
            DailyEnergyStatVO stat = bucket.get(dayKey);
            stat.setTotalKwh(stat.getTotalKwh().add(nullToZero(row.getSumDl())));
            stat.setTotalFee(stat.getTotalFee().add(nullToZero(row.getSumDf())));
        }
        return normalizeBucket(bucket);
    }
    private static void upsertLatestReading(Map<String, Map<String, MeterDayReading>> meterDayLatest,
                                            String meterKey,
                                            String dayKey,
                                            MeterDayReading snapshot) {
        Map<String, MeterDayReading> dayMap = meterDayLatest.computeIfAbsent(meterKey, key -> new HashMap<>());
        MeterDayReading existing = dayMap.get(dayKey);
        if (existing == null || snapshot.readingTime >= existing.readingTime) {
            dayMap.put(dayKey, snapshot);
        }
    }
    private static String resolveMeterKey(YwElectricalData row) {
        if (StringUtils.isNotBlank(row.getAddress())) {
            return row.getAddress().trim();
        }
        if (StringUtils.isNotBlank(row.getDeviceId())) {
            return "dev:" + row.getDeviceId().trim();
        }
        if (StringUtils.isNotBlank(row.getMid())) {
            return "mid:" + row.getMid().trim();
        }
        return null;
    }
    private static BigDecimal resolveTotalEnergy(YwElectricalData row) {
        BigDecimal total = parseDecimal(row.getZhygzdl());
        if (total.compareTo(BigDecimal.ZERO) <= 0) {
            total = parseDecimal(row.getZyje());
        }
        return total;
    }
    private static long resolveReadingTime(YwElectricalData row, SimpleDateFormat readTimeFmt) {
        if (StringUtils.isNotBlank(row.getAddTime())) {
            String addTime = row.getAddTime().trim();
            if (addTime.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}")) {
                try {
                    return readTimeFmt.parse(addTime).getTime();
                } catch (ParseException ignored) {
                    // fallback to createDate
                }
            }
        }
        Date createDate = row.getCreateDate();
        return createDate == null ? 0L : createDate.getTime();
    }
    private static List<DailyEnergyStatVO> normalizeBucket(Map<String, DailyEnergyStatVO> bucket) {
        List<DailyEnergyStatVO> list = new ArrayList<>(bucket.values());
        for (DailyEnergyStatVO stat : list) {
            stat.setTotalKwh(stat.getTotalKwh().setScale(2, RoundingMode.HALF_UP));
            stat.setTotalFee(stat.getTotalFee().setScale(2, RoundingMode.HALF_UP));
        }
        return list;
    }
    private static Map<String, DailyEnergyStatVO> initDailyBucket(LocalDate start, LocalDate end) {
        Map<String, DailyEnergyStatVO> bucket = new LinkedHashMap<>();
        for (LocalDate d = start; !d.isAfter(end); d = d.plusDays(1)) {
            DailyEnergyStatVO vo = new DailyEnergyStatVO();
            vo.setStatDate(d.format(DATE_FMT));
            vo.setTotalKwh(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP));
            vo.setTotalFee(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP));
            bucket.put(vo.getStatDate(), vo);
        }
        return bucket;
    }
    private static String resolveElectricalDayKey(YwElectricalData row, SimpleDateFormat sdf) {
        if (StringUtils.isNotBlank(row.getAddTime())) {
            String addTime = row.getAddTime().trim();
            if (addTime.length() >= 10) {
                return addTime.substring(0, 10);
            }
        }
        Date createDate = row.getCreateDate();
        return createDate == null ? null : sdf.format(createDate);
    }
    private static BigDecimal parseDecimal(String val) {
        if (StringUtils.isBlank(val)) {
            return BigDecimal.ZERO;
        }
        try {
            return new BigDecimal(val.trim());
        } catch (NumberFormatException e) {
            return BigDecimal.ZERO;
        }
    }
    private static BigDecimal nullToZero(BigDecimal val) {
        return val == null ? BigDecimal.ZERO : val;
    }
    private static class MeterDayReading {
        private final BigDecimal totalEnergy;
        private final BigDecimal price;
        private final long readingTime;
        private MeterDayReading(BigDecimal totalEnergy, BigDecimal price, long readingTime) {
            this.totalEnergy = totalEnergy;
            this.price = price;
            this.readingTime = readingTime;
        }
    }
}