feat(mes): 新增小时产量统计报表功能

- 在生产报表模块中增加小时产量统计接口调用
- 实现按小时分桶的产量数据展示与导出功能
- 新增小时产量趋势图可视化组件
- 添加小时产量统计分析表及数据透视逻辑
- 支持按工序、机台、班次等多维度筛选查询
- 提供Excel和CSV格式的数据导出功能
- 实现响应式图表与表格联动展示
- 添加日期范围限制与参数校验逻辑
master
zangch@mesnac.com 3 months ago
parent f623416c34
commit bbc3ce1d60

@ -1,5 +1,7 @@
import request from '@/utils/request';
import { AxiosPromise } from 'axios';
import { HourlyOutputByHourVO } from '@/api/mes/prodReport/types';
/**
*
@ -132,3 +134,26 @@ export const yieldTrendByDate = (query): AxiosPromise<[]> => {
params: query
});
};
/**
*
*/
export const hourlyOutputByHour = (query): AxiosPromise<HourlyOutputByHourVO[]> => {
return request({
url: '/mes/prodReport/hourlyOutputByHour',
method: 'get',
params: query
});
};
/**
*
*/
export const exportHourlyOutputByHour = (query) => {
return request({
url: '/mes/prodReport/hourlyOutputByHour/export',
method: 'post',
params: query,
responseType: 'blob'
});
};

@ -0,0 +1,8 @@
// 小时产量统计 VO
export interface HourlyOutputByHourVO {
productionDate: string; // 日期YYYY-MM-DD
hourSlot: string; // 小时HH
productionQuantity: number; // 产量
qualifiedQuantity: number; // 合格
unqualifiedQuantity: number; // 不合格
}

@ -0,0 +1,341 @@
<template>
<div class="app-container">
<el-card shadow="never" class="mb-2">
<el-form :model="queryParams" inline label-width="90px">
<el-form-item label="开始日期">
<el-date-picker v-model="queryParams.beginDate" type="date" value-format="YYYY-MM-DD" placeholder="选择开始日期" :disabled-date="disableBeginDate" />
</el-form-item>
<el-form-item label="结束日期">
<el-date-picker v-model="queryParams.endDate" type="date" value-format="YYYY-MM-DD" placeholder="选择结束日期" :disabled-date="disableEndDate" />
</el-form-item>
<el-form-item label="工序">
<el-select v-model="queryParams.processId" filterable placeholder="请选择工序" style="width:220px">
<el-option v-for="p in processList" :key="p.processId" :label="p.processName" :value="p.processId" />
</el-select>
</el-form-item>
<el-form-item label="机台">
<el-select v-model="queryParams.machineId" filterable placeholder="请选择机台" style="width:220px" clearable>
<el-option v-for="m in machineList" :key="m.machineId" :label="m.machineName" :value="m.machineId" />
</el-select>
</el-form-item>
<el-form-item label="班次">
<el-select v-model="queryParams.shiftId" filterable placeholder="请选择班次" style="width:220px" clearable>
<el-option v-for="s in shiftList" :key="s.shiftId" :label="s.shiftName" :value="s.shiftId" />
</el-select>
</el-form-item>
<el-form-item label="班组">
<el-select v-model="queryParams.classTeamId" filterable placeholder="请选择班组" style="width:220px" clearable>
<el-option v-for="t in teamList" :key="t.classTeamId" :label="t.teamName" :value="t.classTeamId" />
</el-select>
</el-form-item>
<el-form-item label="物料">
<el-input v-model="queryParams.materialName" placeholder="输入物料名称模糊查询" style="width:220px" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery"></el-button>
<el-button @click="resetQuery"></el-button>
<el-button type="success" @click="handleExportExcel">Excel</el-button>
<el-button @click="handleExportCsv">CSV</el-button>
</el-form-item>
</el-form>
</el-card>
<el-row :gutter="12" class="mb-2">
<el-col :span="24">
<el-card shadow="hover" class="h-100">
<template #header>
<div class="card-header">小时产量趋势图按完成时间小时分布</div>
</template>
<div ref="trendChartRef" style="height: 360px"></div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="24">
<el-card shadow="hover">
<template #header>
<div class="card-header">小时产量统计分析表按完成时间小时分布</div>
</template>
<el-table :data="pivotTableData" border height="520" :cell-style="cellStyle">
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column prop="item" label="项目" width="150" />
<el-table-column prop="timeRange" label="时间段" width="220" />
<el-table-column prop="avg" label="平均" width="100">
<template #default="scope">{{ formatNumber(scope.row.avg) }}</template>
</el-table-column>
<el-table-column v-for="d in dateCols" :key="d" :label="formatDateLabel(d)" :prop="d" min-width="90">
<template #default="scope">{{ formatNumber(scope.row[d]) }}</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted, getCurrentInstance, watch } from 'vue';
import * as echarts from 'echarts';
import dayjs from 'dayjs';
import { hourlyOutputByHour } from '@/api/mes/prodReport';
import { getProcessInfoList } from '@/api/mes/baseProcessInfo';
import { getProdBaseMachineInfoList } from '@/api/mes/prodBaseMachineInfo';
import { getBaseShiftInfoList } from '@/api/mes/baseShiftInfo';
import { getBaseClassTeamInfoList } from '@/api/mes/baseClassTeamInfo';
const { proxy } = getCurrentInstance() as any;
const queryParams = reactive({
beginDate: '',
endDate: '',
processId: undefined as number | undefined,
machineId: undefined as number | undefined,
shiftId: undefined as number | undefined,
classTeamId: undefined as number | undefined,
materialName: ''
});
const processList = ref<any[]>([]);
const machineList = ref<any[]>([]);
const shiftList = ref<any[]>([]);
const teamList = ref<any[]>([]);
// (create_time)
function pad2(n: number | string): string { return String(n).padStart(2, '0'); }
const hours = Array.from({ length: 24 }, (_, i) => pad2(i)); // ["00",...,"23"]
function hourRangeLabel(hh: string): string {
const next = pad2((Number(hh) + 1) % 24);
return `${hh}:00:00 ~ ${next}:00:00`;
}
const trendChartRef = ref<HTMLDivElement | null>(null);
let trendChart: echarts.ECharts | null = null;
//
const dateCols = ref<string[]>([]);
const pivotTableData = ref<any[]>([]);
let rawList: any[] = [];
function normalizeHour(h: string): string {
if (!h) return '';
const hh = h.includes(':') ? h.split(':')[0] : h;
return hh.padStart(2, '0');
}
function buildDateRange(begin: string, end: string) {
const res: string[] = [];
let cur = dayjs(begin).startOf('day');
const endD = dayjs(end).startOf('day');
while (cur.isBefore(endD) || cur.isSame(endD)) {
res.push(cur.format('YYYY-MM-DD'));
cur = cur.add(1, 'day');
}
return res;
}
function buildPivot(list: any[]) {
const dates = buildDateRange(queryParams.beginDate, queryParams.endDate);
dateCols.value = dates;
const rows = hours.map((hh, idx) => ({
_seq: idx + 1,
item: `${hh}:00`,
timeRange: hourRangeLabel(hh),
key: hh,
avg: 0,
...Object.fromEntries(dates.map(d => [d, 0]))
}));
const mapByDateHour = new Map<string, number>();
list.forEach((it) => {
const d = it.productionDate; // YYYY-MM-DD
const hh = normalizeHour(it.hourSlot);
const qty = Number(it.productionQuantity || 0);
mapByDateHour.set(`${d}_${hh}`, (mapByDateHour.get(`${d}_${hh}`) || 0) + qty);
});
rows.forEach(row => {
dates.forEach(d => {
const qty = mapByDateHour.get(`${d}_${row.key}`) || 0;
row[d] = qty;
});
const values = dates.map(d => Number(row[d] || 0));
const sum = values.reduce((a, b) => a + b, 0);
row.avg = values.length ? sum / values.length : 0;
});
const totalRow: any = { item: '合计', timeRange: '', avg: 0 };
const averageRow: any = { item: '平均', timeRange: '', avg: 0 };
dates.forEach(d => {
const sum = rows.reduce((acc, r) => acc + Number(r[d] || 0), 0);
const avg = rows.length ? sum / rows.length : 0;
totalRow[d] = sum;
averageRow[d] = avg;
});
const sumOfRowAvg = rows.reduce((acc, r) => acc + Number(r.avg || 0), 0);
totalRow.avg = sumOfRowAvg;
averageRow.avg = dates.length ? (dates.reduce((a, d) => a + Number(totalRow[d] || 0), 0) / dates.length) : 0;
pivotTableData.value = [...rows, totalRow, averageRow];
}
function updateTrendChart() {
const dates = dateCols.value;
if (!trendChart && trendChartRef.value) trendChart = echarts.init(trendChartRef.value);
const series = hours.map(hh => ({
name: `${hh}:00`,
type: 'line',
smooth: true,
data: dates.map(d => {
const row = pivotTableData.value.find((r: any) => r.key === hh);
return row ? Number(row[d] || 0) : 0;
})
}));
trendChart?.setOption({
tooltip: { trigger: 'axis' },
legend: { type: 'scroll', top: 10 },
grid: { left: 40, right: 40, bottom: 30, containLabel: true },
xAxis: { type: 'category', data: dates.map(d => formatDateLabel(d)) },
yAxis: { type: 'value', name: '产量' },
series
});
}
function handleQuery() {
if (!queryParams.processId || !queryParams.beginDate || !queryParams.endDate) {
proxy.$modal.msgWarning('请选择工序并设置时间范围');
return;
}
// 31
const begin = dayjs(queryParams.beginDate);
const end = dayjs(queryParams.endDate);
if (end.isBefore(begin)) {
proxy.$modal.msgWarning('结束日期不能早于开始日期');
return;
}
if (end.diff(begin, 'day') > 31) {
proxy.$modal.msgWarning('时间范围最多为31天请调整开始/结束日期');
return;
}
//
hourlyOutputByHour(queryParams).then((res: any) => {
rawList = (res?.data) || [];
buildPivot(rawList);
updateTrendChart();
});
}
function resetQuery() {
Object.assign(queryParams, { beginDate: '', endDate: '', processId: undefined, machineId: undefined, shiftId: undefined, classTeamId: undefined, materialName: '' });
pivotTableData.value = [];
dateCols.value = [];
updateTrendChart();
}
function formatDateLabel(d: string) {
return dayjs(d).format('YYYY-MM-DD');
}
function formatNumber(v: any) {
const n = Number(v || 0);
return n % 1 === 0 ? n : n.toFixed(1);
}
// 3131
function disableBeginDate(date: Date) {
if (!queryParams.endDate) return false;
const end = dayjs(queryParams.endDate);
const d = dayjs(date);
return d.isAfter(end) || end.diff(d, 'day') > 31;
}
function disableEndDate(date: Date) {
if (!queryParams.beginDate) return false;
const begin = dayjs(queryParams.beginDate);
const d = dayjs(date);
return d.isBefore(begin) || d.diff(begin, 'day') > 31;
}
async function loadDicts() {
try {
const [pRes, sRes, tRes] = await Promise.all([
getProcessInfoList({}),
getBaseShiftInfoList({}),
getBaseClassTeamInfoList({})
]);
processList.value = pRes?.data || [];
shiftList.value = sRes?.data || [];
teamList.value = tRes?.data || [];
await loadMachinesByProcess();
} catch (e) {
console.error('加载筛选项失败', e);
}
}
async function loadMachinesByProcess() {
try {
const params: any = {};
if (queryParams.processId) params.processId = queryParams.processId;
const mRes = await getProdBaseMachineInfoList(params);
machineList.value = mRes?.data || [];
} catch (e) {
console.error('加载机台失败', e);
machineList.value = [];
}
}
watch(() => queryParams.processId, async () => {
queryParams.machineId = undefined;
await loadMachinesByProcess();
});
// /
function handleExportExcel() {
proxy?.download('/mes/prodReport/hourlyOutputByHour/export', { ...queryParams }, `小时产量报表_${Date.now()}.xlsx`);
}
function handleExportCsv() {
const headers = ['序号','项目','时间段','平均', ...dateCols.value.map(d => formatDateLabel(d))];
const bodyRows = pivotTableData.value.map((row: any, idx: number) => {
const isSummary = row.item === '合计' || row.item === '平均';
const seq = isSummary ? '' : String(idx + 1);
const vals = dateCols.value.map(d => formatNumber(row[d]));
return [seq, row.item, row.timeRange || '', formatNumber(row.avg), ...vals];
});
const csvContent = [headers.join(','), ...bodyRows.map(r => r.join(','))].join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `小时产量统计_${Date.now()}.csv`;
a.click();
window.URL.revokeObjectURL(url);
}
function onResize() {
trendChart?.resize();
}
function cellStyle({ row }: any) {
if (row.item === '合计' || row.item === '平均') {
return { fontWeight: 600 };
}
return {};
}
onMounted(() => {
queryParams.endDate = dayjs().format('YYYY-MM-DD');
queryParams.beginDate = dayjs(queryParams.endDate).subtract(14, 'day').format('YYYY-MM-DD');
loadDicts();
window.addEventListener('resize', onResize);
});
onUnmounted(() => {
window.removeEventListener('resize', onResize);
trendChart?.dispose();
trendChart = null;
});
</script>
<style scoped>
.app-container { padding: 12px; }
.mb-2 { margin-bottom: 12px; }
.h-100 { height: 100%; }
.card-header { font-weight: 600; }
</style>
Loading…
Cancel
Save