feat(mes):优化在制品跟踪报表与工序统计功能

- 新增工序工单统计图表(Top10展示)- 更新表格列标题“生产订单号”为“派工单号”- 修改查询参数字段orderCode为planCode
-重构工序统计逻辑,支持按工序聚合展示完成率与状态
- 增加工序展开加载子计划列表功能
- 优化进度条显示,支持超额完成状态提示
- 添加日期范围、物料编号与名称筛选条件- 实现工序统计导出功能
- 调整图表Y轴最大值逻辑,支持超额完成区域标记- 修复部分字段显示与计算逻辑问题
master
zangch@mesnac.com 4 months ago
parent ee3b654db6
commit 7ee8b7dd58

@ -157,3 +157,59 @@ export const exportHourlyOutputByHour = (query) => {
responseType: 'blob'
});
};
/**
*
*/
export const processWorkOrderStats = (query): AxiosPromise<[]> => {
return request({
url: '/mes/prodReport/processWorkOrderStats',
method: 'get',
params: query
});
};
/**
*
*/
export const processWorkOrderStatsPage = (query): AxiosPromise<[]> => {
return request({
url: '/mes/prodReport/processWorkOrderStats/page',
method: 'get',
params: query
});
};
/**
*
*/
export const exportProcessWorkOrderStats = (query) => {
return request({
url: '/mes/prodReport/processWorkOrderStats/export',
method: 'post',
params: query,
responseType: 'blob'
});
};
/**
* plan_code + process_id
*/
export const planProcessChildren = (query): AxiosPromise<[]> => {
return request({
url: '/mes/prodReport/planProcessChildren',
method: 'get',
params: query
});
};
/**
*
*/
export const processPlanList = (query): AxiosPromise<[]> => {
return request({
url: '/mes/prodReport/processPlanList',
method: 'get',
params: query
});
};

@ -106,6 +106,18 @@
</el-col>
</el-row>
<!-- 工序工单统计按工序聚合 Top10 -->
<el-row :gutter="10" class="mb-[10px]">
<el-col :span="24">
<el-card shadow="never">
<template #header>
<span class="font-bold">工序工单统计</span>
</template>
<div ref="processStatsChartRef" style="width: 100%; height: 320px"></div>
</el-card>
</el-col>
</el-row>
<!-- 工序进度可视化 -->
<el-card shadow="never" class="mb-[10px]">
@ -126,7 +138,7 @@
</template>
<el-table v-loading="loading" :data="reportList" border>
<el-table-column label="生产订单号" align="center" prop="orderCode" v-if="columns[0].visible" width="140" />
<el-table-column label="派工单号" align="center" prop="dispatchCode" v-if="columns[0].visible" width="140" />
<el-table-column label="物料编号" align="center" prop="materialCode" v-if="columns[1].visible" width="120" />
<el-table-column label="物料名称" align="center" prop="materialName" v-if="columns[2].visible" width="180" show-overflow-tooltip />
<el-table-column label="规格型号" align="center" prop="materialSpec" v-if="columns[3].visible" width="120" show-overflow-tooltip />
@ -225,7 +237,7 @@ let processStatsChart: echarts.ECharts | null = null;
//
const columns = ref([
{ key: 0, label: '生产订单号', visible: true },
{ key: 0, label: '派工单号', visible: true },
{ key: 1, label: '物料编号', visible: false },
{ key: 2, label: '物料名称', visible: true },
{ key: 3, label: '规格型号', visible: true },
@ -247,7 +259,7 @@ const columns = ref([
const queryParams = ref<WipTrackingReportQuery>({
pageNum: 1,
pageSize: 10,
orderCode: '',
planCode: '',
materialCode: '',
materialName: '',
progressStatus: '',
@ -294,7 +306,7 @@ function resetQuery() {
queryParams.value = {
pageNum: 1,
pageSize: 10,
orderCode: '',
planCode: '',
materialCode: '',
materialName: '',
progressStatus: '',
@ -449,49 +461,49 @@ function updateProgressChart() {
function updateProcessStatsChart() {
if (!processStatsChart || !reportList.value.length) return;
const processStats = {
'已完成': 0,
'进行中': 0,
'未开始': 0
};
const nowTs = Date.now();
const statsMap = new Map<string, { total: number; notStarted: number; incomplete: number; delayed: number; completed: number }>();
reportList.value.forEach(item => {
if (item.processProgressList) {
item.processProgressList.forEach(process => {
if (process.isCompleted) {
processStats['已完成']++;
} else if (process.isInProgress) {
processStats['进行中']++;
} else {
processStats['未开始']++;
}
});
}
(item.processProgressList || []).forEach((proc: any) => {
const name = proc.processName || '未知工序';
const rec = statsMap.get(name) || { total: 0, notStarted: 0, incomplete: 0, delayed: 0, completed: 0 };
rec.total += 1;
const isCompleted = proc.isCompleted === 1 || !!proc.isCompleted;
const planStatus = proc.planStatus;
if (!isCompleted) rec.incomplete += 1; else rec.completed += 1;
if (planStatus === '0') rec.notStarted += 1;
const planEndTime = proc.planEndTime ? new Date(proc.planEndTime).getTime() : null;
if (planEndTime && planEndTime < nowTs && !isCompleted) rec.delayed += 1;
statsMap.set(name, rec);
});
});
const topEntries = Array.from(statsMap.entries()).sort((a, b) => b[1].total - a[1].total).slice(0, 10);
const processNames = topEntries.map(([n]) => n);
const notStartedData = topEntries.map(([, v]) => v.notStarted);
const incompleteData = topEntries.map(([, v]) => v.incomplete);
const delayedData = topEntries.map(([, v]) => v.delayed);
const completionRateData = topEntries.map(([, v]) => (v.total > 0 ? +(v.completed * 100 / v.total).toFixed(2) : 0));
const option = {
title: {
text: '工序完成率统计',
left: 'center',
textStyle: { fontSize: 14 }
},
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
title: { text: '工序工单统计Top10', left: 'center', textStyle: { fontSize: 14 } },
tooltip: { trigger: 'axis' },
legend: { data: ['未开工', '未完成', '延期', '完成率%'], bottom: 0 },
grid: { left: '3%', right: '6%', top: 40, bottom: 60, containLabel: true },
xAxis: { type: 'category', data: processNames },
yAxis: [
{ type: 'value', name: '工单数' },
{ type: 'value', name: '完成率%', min: 0, max: 100, position: 'right' }
],
series: [
{
name: '工序状态',
type: 'pie',
radius: ['40%', '70%'],
data: Object.entries(processStats).map(([name, value]) => ({ name, value })),
itemStyle: {
color: function(params: any) {
const colors = ['#722ed1', '#52c41a', '#d9d9d9'];
return colors[params.dataIndex];
}
}
}
{ name: '未开工', type: 'bar', stack: 'count', data: notStartedData },
{ name: '未完成', type: 'bar', stack: 'count', data: incompleteData },
{ name: '延期', type: 'bar', stack: 'count', data: delayedData },
{ name: '完成率%', type: 'line', yAxisIndex: 1, data: completionRateData, smooth: true }
]
};

@ -4,6 +4,34 @@
<div v-show="showSearch" class="mb-[10px]">
<el-card shadow="hover">
<el-form ref="queryFormRef" :model="queryParams" :inline="true">
<el-form-item label="日期范围" style="width: 300px">
<el-date-picker
v-model="dateRange"
value-format="YYYY-MM-DD"
type="daterange"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
</el-form-item>
<el-form-item label="物料编号" prop="materialCode">
<el-input
v-model="queryParams.materialCode"
placeholder="请输入物料编号"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="物料名称" prop="materialName">
<el-input
v-model="queryParams.materialName"
placeholder="请输入物料名称"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
<!-- <el-form-item label="日期范围" style="width: 300px">
<el-date-picker
v-model="dateRange"
@ -130,14 +158,14 @@
<el-card shadow="never">
<template #header>
<el-row :gutter="10" class="mb8">
<!-- <el-col :span="1.5">
<el-button type="warning" plain icon="Download" @click="handleExport" v-hasPermi="['mes:prodReport:export']"></el-button>
</el-col>-->
<!-- <el-col :span="1.5">
<el-button type="warning" plain icon="Download" @click="handleProcessExport" v-hasPermi="['mes:prodReport:export']"></el-button>
</el-col> -->
<right-toolbar v-model:showSearch="showSearch" :columns="columns" :search="true" @queryTable="getList" />
</el-row>
</template>
<el-table v-loading="loading" :data="processStatsList" border row-key="processName">
<el-table ref="processTableRef" v-loading="loading" :data="processStatsList" border row-key="processId" @expand-change="onProcessRowExpand">
<!-- 工序聚合统计列 -->
<el-table-column label="工序名称" align="center" prop="processName" v-if="columns[0].visible" min-width="160" show-overflow-tooltip />
<el-table-column label="计划数量" align="center" prop="planQty" v-if="columns[1].visible" width="120" />
@ -148,80 +176,38 @@
<div style="display:flex;align-items:center;justify-content:center;">
<span style="min-width:42px;text-align:right;font-size:12px;color:#606266;">{{ Number(scope.row.completionRateNum || 0) }}%</span>
<el-progress
:percentage="Number(scope.row.completionRateNum || 0)"
:percentage="Math.min(Number(scope.row.completionRateNum || 0), 100)"
:color="getProgressColor(Number(scope.row.completionRateNum || 0))"
:stroke-width="8"
:show-text="false"
style="width:90px;margin-left:8px;"
/>
<el-tag v-if="Number(scope.row.completionRateNum || 0) > 100" type="danger" size="small" style="margin-left:8px;"> +{{ Number(scope.row.completionRateNum || 0) - 100 }}%</el-tag>
</div>
</template>
</el-table-column>
<!-- 展开列列出该工序下的计划计划编号 -->
<el-table-column type="expand" width="50">
<template #default="scope">
<el-table :data="buildProcessPlanRows(scope.row)" border size="small" style="width: 100%" @expand-change="onPlanRowExpand">
<el-table-column type="expand" width="50">
<template #default="pScope">
<div>
<el-table v-if="planChildrenCache[getPlanChildKey(pScope.row)] && planChildrenCache[getPlanChildKey(pScope.row)].length"
:data="planChildrenCache[getPlanChildKey(pScope.row)]"
border size="small" style="width: 100%">
<el-table-column label="计划ID" prop="planId" width="120" />
<el-table-column label="计划编号" prop="planCode" width="140" show-overflow-tooltip />
<el-table-column label="工序ID" prop="processId" width="100" />
<el-table-column label="工序顺序" prop="processOrder" width="100" />
<el-table-column label="计划数" prop="planAmount" width="110" />
<el-table-column label="已完数" prop="completeAmount" width="110" />
<el-table-column label="完成率" width="160">
<template #default="cScope">
<div style="display:flex;align-items:center;">
<span style="min-width:42px;text-align:right;font-size:12px;color:#606266;">
{{ Number(cScope.row.planAmount) > 0 ? Math.round((Number(cScope.row.completeAmount || 0) * 100.0) / Number(cScope.row.planAmount)) : 0 }}%
</span>
<el-progress
:percentage="Number(cScope.row.planAmount) > 0 ? Math.round((Number(cScope.row.completeAmount || 0) * 100.0) / Number(cScope.row.planAmount)) : 0"
:color="getProgressColor(Number(cScope.row.planAmount) > 0 ? Math.round((Number(cScope.row.completeAmount || 0) * 100.0) / Number(cScope.row.planAmount)) : 0)"
:stroke-width="6"
:show-text="false"
style="width:100px;margin-left:8px;"
/>
</div>
</template>
</el-table-column>
<el-table-column label="班组" prop="teamName" width="140" show-overflow-tooltip />
<el-table-column label="工位" prop="stationName" width="140" show-overflow-tooltip />
<el-table-column label="实际开工" prop="realBeginTime" width="160" show-overflow-tooltip />
<el-table-column label="实际完工" prop="realEndTime" width="160" show-overflow-tooltip />
<el-table-column label="状态" prop="planStatus" width="120">
<template #default="cScope">
<el-tag :type="getPlanStatusTagType(cScope.row.planStatus)" size="small">
{{ getPlanStatusLabel(cScope.row.planStatus) }}
</el-tag>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="无子节点或未加载" />
</div>
</template>
</el-table-column>
<el-table :data="buildProcessPlanRows(scope.row)" border size="small" style="width: 100%" v-loading="isProcessLoading(scope.row.processId)" element-loading-text="加载中...">
<el-table-column label="计划编号" prop="planCode" width="140" show-overflow-tooltip />
<el-table-column label="物料编号" prop="materialCode" width="140" show-overflow-tooltip />
<el-table-column label="物料名称" prop="materialName" min-width="160" show-overflow-tooltip />
<el-table-column label="计划数" prop="planAmount" width="100" />
<el-table-column label="已完数" prop="completeAmount" width="100" />
<el-table-column label="剩余数" prop="remainingAmount" width="100" />
<!-- <el-table-column label="剩余数" prop="remainingAmount" width="100" /> -->
<el-table-column label="完成率" width="160">
<template #default="pScope">
<div style="display:flex;align-items:center;justify-content:flex-start;">
<span style="min-width:42px;text-align:right;font-size:12px;color:#606266;">{{ Number(pScope.row.processProgress || 0) }}%</span>
<el-progress
:percentage="Number(pScope.row.processProgress || 0)"
:percentage="Math.min(Number(pScope.row.processProgress || 0), 100)"
:color="getProgressColor(Number(pScope.row.processProgress || 0))"
:stroke-width="6"
:show-text="false"
style="width:100px;margin-left:8px;"
/>
<el-tag v-if="Number(pScope.row.processProgress || 0) > 100" type="danger" size="small" style="margin-left:8px;"> +{{ Number(pScope.row.processProgress || 0) - 100 }}%</el-tag>
</div>
</template>
</el-table-column>
@ -312,7 +298,7 @@ import type { ElFormInstance } from 'element-plus';
import * as echarts from 'echarts';
import { listWipTrackingReport, exportWipTrackingReport } from '@/api/mes/wipTrackingReport';
import { WipTrackingReportVO, WipTrackingReportQuery, ProcessProgressVO } from '@/api/mes/wipTrackingReport/types';
import { planProcessChildren } from '@/api/mes/prodReport';
import { planProcessChildren, processWorkOrderStatsPage, exportProcessWorkOrderStats, processPlanList } from '@/api/mes/prodReport';
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
@ -323,6 +309,7 @@ const showSearch = ref(true);
const total = ref(0);
const queryFormRef = ref<ElFormInstance>();
const dateRange = ref<string[]>(['', '']);
const processTableRef = ref<any>(null);
//
const statusChartRef = ref<HTMLDivElement | null>(null);
@ -345,6 +332,47 @@ const columns = ref([
// key = planCode__processId
const planChildrenCache = ref<Record<string, any[]>>({});
// key = processId
const processPlansCache = ref<Record<string, any[]>>({});
// key = processId
const processPlansLoading = ref<Record<string, boolean>>({});
function isProcessLoading(pid: any) {
const k = String(pid ?? '');
return !!processPlansLoading.value[k];
}
// ID
const expandedProcessIds = ref<number[]>([]);
function addExpanded(pid: any) {
const id = Number(pid);
if (!expandedProcessIds.value.includes(id)) expandedProcessIds.value.push(id);
}
function removeExpanded(pid: any) {
const id = Number(pid);
expandedProcessIds.value = expandedProcessIds.value.filter(x => x !== id);
}
function reExpandProcessRows() {
nextTick(() => {
expandedProcessIds.value.forEach((pid) => {
const row = (processStatsList.value || []).find((r: any) => String(r?.processId ?? '') === String(pid));
if (row && processTableRef.value?.toggleRowExpansion) {
processTableRef.value.toggleRowExpansion(row, true);
}
});
});
}
//
const skipReexpandOnce = ref(false);
function collapseAllProcessRows() {
nextTick(() => {
const rows = processStatsList.value || [];
if (processTableRef.value?.toggleRowExpansion) {
rows.forEach((row: any) => {
processTableRef.value.toggleRowExpansion(row, false);
});
}
});
}
const queryParams = ref<WipTrackingReportQuery>({
pageNum: 1,
@ -371,22 +399,29 @@ watch(dateRange, (newVal) => {
/** 查询在制品跟踪报表列表 */
function getList() {
loading.value = true;
//
//
//
processPlansCache.value = {};
planChildrenCache.value = {};
processPlansLoading.value = {};
const baseQuery = { ...queryParams.value };
const allQuery = { ...baseQuery, pageNum: 1, pageSize: 100000 };
listWipTrackingReport(allQuery)
.then((response: any) => {
// 使
reportList.value = response?.rows || [];
// total
processStatsList.value = buildProcessStatsFromReport(reportList.value);
total.value = processStatsList.value.length;
loading.value = false;
// 1)
const procReq = processWorkOrderStatsPage(baseQuery);
Promise.all([procReq])
.then(([procRes]: any[]) => {
//
processStatsList.value = procRes?.rows || [];
total.value = procRes?.total || (processStatsList.value?.length || 0);
nextTick(() => {
updateCharts();
//
if (!skipReexpandOnce.value) {
reExpandProcessRows();
}
//
skipReexpandOnce.value = false;
});
loading.value = false;
})
.catch(() => {
loading.value = false;
@ -396,6 +431,14 @@ function getList() {
/** 搜索按钮操作 */
function handleQuery() {
queryParams.value.pageNum = 1;
//
processPlansCache.value = {};
planChildrenCache.value = {};
processPlansLoading.value = {};
//
expandedProcessIds.value = [];
skipReexpandOnce.value = true;
collapseAllProcessRows();
getList();
}
@ -417,12 +460,12 @@ function resetQuery() {
}
/** 导出按钮操作 */
function handleExport() {
proxy?.$modal.confirm('是否确认导出所有在制品跟踪报表数据项').then(() => {
function handleProcessExport() {
proxy?.$modal.confirm('是否确认导出按工序统计数据').then(() => {
proxy?.$modal.loading('正在导出数据,请稍候...');
return exportWipTrackingReport(queryParams.value);
return exportProcessWorkOrderStats(queryParams.value);
}).then((response: any) => {
proxy?.$download.blob(response, '在制品跟踪报表.xlsx');
proxy?.$download.blob(response, '工序生产统计.xlsx');
proxy?.$modal.closeLoading();
}).catch(() => {
proxy?.$modal.closeLoading();
@ -431,6 +474,7 @@ function handleExport() {
/** 获取进度颜色 */
function getProgressColor(percentage: number) {
if (percentage > 100) return '#f56c6c';
if (percentage < 30) return '#f56c6c';
if (percentage < 70) return '#e6a23c';
return '#67c23a';
@ -489,43 +533,46 @@ function buildProcessTree(row: WipTrackingReportVO) {
* reportList 的每个订单的 processProgressList 筛选出工序名称匹配的项
*/
function buildProcessPlanRows(procRow: any) {
const rows: any[] = [];
const targetProcessName = procRow?.processName;
if (!targetProcessName) return rows;
(reportList.value || []).forEach(order => {
const procs = (order as any).processProgressList || [];
procs.forEach((p: any) => {
if (p?.processName === targetProcessName) {
const planAmount = Number(p?.planAmount ?? 0);
const completeAmount = Number(p?.completeAmount ?? 0);
const remainingAmount = p?.remainingAmount != null
? Number(p.remainingAmount)
: Math.max(planAmount - completeAmount, 0);
const progressNum = p?.processProgress != null
? Number(p.processProgress)
: (planAmount > 0 ? Math.round((completeAmount * 100.0) / planAmount) : 0);
rows.push({
planCode: p?.planCode || (order as any).planCode || '',
processId: p?.processId || '',
materialName: p?.materialName || (order as any).materialName || '',
materialCode: p?.materialCode || (order as any).materialCode || '',
planAmount,
completeAmount,
remainingAmount,
processProgress: progressNum,
isCompleted: (p?.isCompleted === 1) || !!p?.isCompleted,
isInProgress: (p?.isInProgress === 1) || !!p?.isInProgress
});
}
});
});
//
const key = String(procRow?.processId ?? '');
const rows = (key && processPlansCache.value[key]) ? processPlansCache.value[key] : [];
//
rows.sort((a, b) => Number(b.processProgress || 0) - Number(a.processProgress || 0));
return rows;
}
/** 外层工序展开时触发:加载该工序下计划列表(不分页) */
async function onProcessRowExpand(row: any, expanded: boolean) {
const key = String(row?.processId ?? '');
// expanded
if (!key) return;
//
if (typeof expanded === 'boolean') {
expanded ? addExpanded(row?.processId) : removeExpanded(row?.processId);
}
if (processPlansCache.value[key] && processPlansCache.value[key].length) return;
//
processPlansLoading.value[key] = true;
//
const query: any = {
processId: Number(row?.processId),
beginDate: queryParams.value.beginDate,
endDate: queryParams.value.endDate,
materialCode: queryParams.value.materialCode,
materialName: queryParams.value.materialName,
planCode: queryParams.value.planCode
};
try {
const res: any = await processPlanList(query);
const data = (res?.data || res?.rows || res) || [];
processPlansCache.value[key] = data;
} catch (e) {
processPlansCache.value[key] = [];
} finally {
//
processPlansLoading.value[key] = false;
}
}
/** 生成计划子节点缓存Key */
function getPlanChildKey(row: any) {
return `${row?.planCode || ''}__${row?.processId || ''}`;
@ -693,6 +740,9 @@ function updateProcessStatsChart() {
const completedData = sorted.map((r: any) => Number(r.completedQty || 0));
const uncompletedData = sorted.map((r: any) => Number(r.uncompletedQty || 0));
const completionRateData = sorted.map((r: any) => Number(r.completionRateNum || 0));
const maxRate = completionRateData.length ? Math.max(...completionRateData) : 0;
const yMax = Math.max(100, Math.ceil(maxRate / 10) * 10);
const showExcessArea = yMax > 100;
const option = {
title: { text: '工序生产统计Top10', left: 'center', textStyle: { fontSize: 14 } },
@ -702,12 +752,18 @@ function updateProcessStatsChart() {
xAxis: { type: 'category', data: processNames },
yAxis: [
{ type: 'value', name: '数量' },
{ type: 'value', name: '完成率%', min: 0, max: 100, position: 'right' }
{ type: 'value', name: '完成率%', min: 0, max: yMax, position: 'right', axisLabel: { formatter: '{value}%' } }
],
series: [
{ name: '已完成数量', type: 'bar', data: completedData },
{ name: '未完成数量', type: 'bar', data: uncompletedData },
{ name: '完成率%', type: 'line', yAxisIndex: 1, data: completionRateData, smooth: true }
{ name: '完成率%', type: 'line', yAxisIndex: 1, data: completionRateData, smooth: true,
markArea: showExcessArea ? {
itemStyle: { color: 'rgba(245,108,108,0.12)' },
label: { color: '#f56c6c', position: 'insideTop', formatter: '超额区间' },
data: [ [ { yAxis: 100 }, { yAxis: yMax } ] ]
} : undefined
}
]
};

Loading…
Cancel
Save