抓取生产工单,抓取发料异常

This commit is contained in:
hjq
2026-06-11 17:51:01 +08:00
parent 5b19790037
commit a160d5d48f
12 changed files with 965 additions and 58 deletions

View File

@@ -0,0 +1,405 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>绩效核查与 BOM 比对</title>
<!-- 引入 ElementUI 样式 -->
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
<!-- 引入 Vue.js -->
<script src="https://cdn.jsdelivr.net/npm/vue@2.7.14/dist/vue.js"></script>
<!-- 引入 ElementUI 组件库 -->
<script src="https://unpkg.com/element-ui/lib/index.js"></script>
<!-- 引入 axios -->
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<style>
body { margin: 0; padding: 20px; font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", Arial, sans-serif; background-color: #f0f2f5; }
.box-card { margin-bottom: 20px; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.page-header h2 { margin: 0; color: #303133; }
.action-row { margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; background-color: #f8f9fa; padding: 15px; border-radius: 4px; border-left: 5px solid #409EFF;}
.pagination-container { margin-top: 20px; text-align: right; }
.filter-row { margin-bottom: 15px; display: flex; gap: 15px;}
</style>
</head>
<body>
<div id="app">
<el-card class="box-card">
<div class="page-header">
<div style="display: flex; align-items: center;">
<h2 style="margin-right: 20px;"><i class="el-icon-data-analysis" style="margin-right: 10px; color: #409EFF;"></i>绩效核查与 BOM 比对</h2>
<el-tag type="success" effect="dark" style="font-size: 14px; padding: 0 15px; height: 32px; line-height: 30px;" v-if="dateRange.start !== '-'">
<i class="el-icon-date"></i> <span v-text="'当前核对月份数据:' + dateRange.start + ' 至 ' + dateRange.end"></span>
</el-tag>
</div>
<div>
<el-button type="info" plain icon="el-icon-back" @click="goBack" size="small">返回主控台</el-button>
</div>
</div>
<!-- 操作区域 -->
<div class="action-row">
<div style="display: flex; align-items: center;">
<span style="font-size: 14px; color: #606266; margin-right: 15px;">
<i class="el-icon-info"></i> 执行自动对账前,请先点击"提取并匹配工单"进行数据清洗。
</span>
<el-date-picker
v-model="dateRangeSelect"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="yyyy-MM-dd"
size="small"
style="width: 260px; margin-right: 15px;"
@change="handleDateChange">
</el-date-picker>
</div>
<el-button type="primary" icon="el-icon-magic-stick" @click="triggerMatch" :loading="matching">提取并匹配工单</el-button>
</div>
<!-- 标签页 -->
<el-tabs v-model="activeTab" type="border-card" @tab-click="handleTabClick">
<!-- Tab 1: 无工单明细视图 -->
<el-tab-pane label="无工单发料明细" name="unmatched">
<span slot="label"><i class="el-icon-warning-outline"></i> 无工单发料明细 <el-badge v-if="filteredUnmatchedData.length > 0" :value="filteredUnmatchedData.length" class="mark" type="warning" /></span>
<div class="filter-row">
<el-input v-model="unmatchedSearch" placeholder="搜索物料名称/代码/领料人" style="width: 300px" clearable prefix-icon="el-icon-search"></el-input>
</div>
<el-table :data="pagedUnmatchedData" v-loading="loadingUnmatched" border style="width: 100%" stripe size="small" :header-cell-style="{background:'#f5f7fa',color:'#606266'}">
<el-table-column type="index" label="序号" width="60" align="center"></el-table-column>
<el-table-column prop="execution_time" label="执行时间" width="160" sortable></el-table-column>
<el-table-column prop="issue_receipt_no" label="发料单号" width="150"></el-table-column>
<el-table-column prop="material_code" label="物料代码" width="120"></el-table-column>
<el-table-column prop="material_name" label="物料名称" min-width="150" show-overflow-tooltip></el-table-column>
<el-table-column prop="material_specification" label="规格" min-width="150" show-overflow-tooltip></el-table-column>
<el-table-column prop="issue_number" label="发料数量" width="90" align="right">
<template slot-scope="scope">
<span style="color: #F56C6C; font-weight: bold;" v-text="scope.row.issue_number"></span>
</template>
</el-table-column>
<el-table-column prop="executor_user_name" label="发料人" width="100" align="center"></el-table-column>
<el-table-column prop="warehouse_name" label="仓库" width="120" show-overflow-tooltip></el-table-column>
<el-table-column label="单据备注信息" min-width="200" show-overflow-tooltip>
<template slot-scope="scope">
<span v-if="scope.row.work_orders_remark" v-text="'【工单备注】' + scope.row.work_orders_remark + ' '"></span>
<span v-if="scope.row.detailed_remark" v-text="'【明细备注】' + scope.row.detailed_remark + ' '"></span>
<span v-if="scope.row.production_order_remark" v-text="'【生产单备注】' + scope.row.production_order_remark"></span>
</template>
</el-table-column>
</el-table>
<!-- 前端分页 -->
<div class="pagination-container">
<el-pagination
@size-change="val => { unmatchedPageSize = val; unmatchedPage = 1; }"
@current-change="val => { unmatchedPage = val; }"
:current-page="unmatchedPage"
:page-sizes="[20, 50, 100, 500]"
:page-size="unmatchedPageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="filteredUnmatchedData.length">
</el-pagination>
</div>
</el-tab-pane>
<!-- Tab 2: BOM发料差异视图 -->
<el-tab-pane label="BOM 发料对账" name="reconciliation">
<span slot="label"><i class="el-icon-finished"></i> BOM 发料对账</span>
<div class="filter-row">
<el-input v-model="reconSearch" placeholder="搜索工单号/物料名称/代码" style="width: 300px" clearable prefix-icon="el-icon-search"></el-input>
<el-select v-model="reconStatusFilter" placeholder="状态筛选" clearable style="width: 150px">
<el-option label="全部" value=""></el-option>
<el-option label="发料正常" value="发料正常"></el-option>
<el-option label="超领发料" value="超领发料"></el-option>
<el-option label="少领发料" value="少领发料"></el-option>
<el-option label="未发料" value="未发料"></el-option>
<el-option label="BOM外发料" value="BOM外发料"></el-option>
</el-select>
<el-button type="success" icon="el-icon-download" @click="exportReconData" size="small" style="margin-left: auto;">导出 Excel</el-button>
</div>
<el-table :data="pagedReconData" v-loading="loadingRecon" border style="width: 100%" stripe size="small" :header-cell-style="{background:'#f5f7fa',color:'#606266'}">
<el-table-column type="index" label="序号" width="60" align="center"></el-table-column>
<el-table-column prop="sfc" label="工单号 (SFC)" width="150" sortable></el-table-column>
<el-table-column prop="material_code" label="物料代码" width="120"></el-table-column>
<el-table-column prop="material_name" label="物料名称" min-width="180" show-overflow-tooltip></el-table-column>
<el-table-column label="发料对比" align="center">
<el-table-column prop="bom_qty" label="BOM 应发量" width="110" align="right">
<template slot-scope="scope">
<span v-text="Number(scope.row.bom_qty).toFixed(4)"></span>
</template>
</el-table-column>
<el-table-column prop="actual_qty" label="实际发料量" width="110" align="right">
<template slot-scope="scope">
<span style="font-weight: bold;" v-text="Number(scope.row.actual_qty).toFixed(4)"></span>
</template>
</el-table-column>
<el-table-column prop="diff_qty" label="差异数量 (实-应)" width="130" align="right">
<template slot-scope="scope">
<span :style="{
color: scope.row.diff_qty > 0 ? '#F56C6C' : (scope.row.diff_qty < 0 ? '#E6A23C' : '#67C23A'),
fontWeight: 'bold'
}" v-text="(scope.row.diff_qty > 0 ? '+' : '') + Number(scope.row.diff_qty).toFixed(4)">
</span>
</template>
</el-table-column>
</el-table-column>
<el-table-column prop="status" label="状态" width="120" align="center" sortable>
<template slot-scope="scope">
<el-tag :type="getStatusType(scope.row.status)" effect="dark" size="small" v-text="scope.row.status"></el-tag>
</template>
</el-table-column>
</el-table>
<!-- 前端分页 -->
<div class="pagination-container">
<el-pagination
@size-change="val => { reconPageSize = val; reconPage = 1; }"
@current-change="val => { reconPage = val; }"
:current-page="reconPage"
:page-sizes="[20, 50, 100, 500]"
:page-size="reconPageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="filteredReconData.length">
</el-pagination>
</div>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
<script>
new Vue({
el: '#app',
data() {
return {
activeTab: 'unmatched',
matching: false,
dateRange: { start: '-', end: '-' },
dateRangeSelect: [],
// Unmatched Tab Data
loadingUnmatched: false,
unmatchedData: [],
unmatchedSearch: '',
unmatchedPage: 1,
unmatchedPageSize: 50,
// Reconciliation Tab Data
loadingRecon: false,
reconData: [],
reconSearch: '',
reconStatusFilter: '',
reconPage: 1,
reconPageSize: 50
}
},
computed: {
// 前端过滤和分页计算属性
filteredUnmatchedData() {
let data = this.unmatchedData;
if (this.unmatchedSearch) {
const keyword = this.unmatchedSearch.toLowerCase();
data = data.filter(item =>
(item.material_name && item.material_name.toLowerCase().includes(keyword)) ||
(item.material_code && item.material_code.toLowerCase().includes(keyword)) ||
(item.executor_user_name && item.executor_user_name.toLowerCase().includes(keyword))
);
}
return data;
},
pagedUnmatchedData() {
const start = (this.unmatchedPage - 1) * this.unmatchedPageSize;
const end = start + this.unmatchedPageSize;
return this.filteredUnmatchedData.slice(start, end);
},
filteredReconData() {
let data = this.reconData;
if (this.reconStatusFilter) {
data = data.filter(item => item.status === this.reconStatusFilter);
}
if (this.reconSearch) {
const keyword = this.reconSearch.toLowerCase();
data = data.filter(item =>
(item.sfc && item.sfc.toLowerCase().includes(keyword)) ||
(item.material_name && item.material_name.toLowerCase().includes(keyword)) ||
(item.material_code && item.material_code.toLowerCase().includes(keyword))
);
}
return data;
},
pagedReconData() {
const start = (this.reconPage - 1) * this.reconPageSize;
const end = start + this.reconPageSize;
return this.filteredReconData.slice(start, end);
}
},
mounted() {
// 初始化加载数据
this.loadSummary();
this.loadUnmatchedData();
this.loadReconData();
},
methods: {
handleDateChange(val) {
if (val && val.length === 2) {
this.dateRange.start = val[0];
this.dateRange.end = val[1];
this.loadUnmatchedData();
this.loadReconData();
} else {
this.loadSummary();
}
},
loadSummary() {
axios.get('/api/analysis/summary')
.then(res => {
this.dateRange = res.data;
if (this.dateRange.start !== '-') {
this.dateRangeSelect = [this.dateRange.start, this.dateRange.end];
}
})
.catch(err => console.error(err));
},
goBack() {
window.location.href = '/';
},
handleTabClick(tab) {
// if (tab.name === 'unmatched') this.loadUnmatchedData();
// if (tab.name === 'reconciliation') this.loadReconData();
},
getStatusType(status) {
const map = {
'发料正常': 'success',
'超领发料': 'danger',
'少领发料': 'warning',
'未发料': 'info',
'BOM外发料': 'danger'
};
return map[status] || 'info';
},
triggerMatch() {
this.$confirm('此操作将基于最新抓取的发料单据和 BOM 表数据进行自动清洗匹配,确认执行?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.matching = true;
if (window.globalLogApp) {
window.globalLogApp.logDialogVisible = true;
}
axios.post('/api/analysis/match_work_orders')
.then(res => {
if (res.data.success) {
this.$message.success(res.data.message);
// 开启轮询等待后台任务完成,完成后再刷新数据
this.waitForMatchTask();
} else {
this.$message.error('执行失败: ' + res.data.message);
this.matching = false;
}
})
.catch(err => {
if (err.response && err.response.status === 409) {
this.$message.warning(err.response.data.message);
} else {
this.$message.error('请求出错,请检查后台日志');
}
this.matching = false;
});
}).catch(() => {});
},
waitForMatchTask() {
// 轮询检查任务状态,一旦结束就刷新列表
let checkInterval = setInterval(() => {
axios.get('/api/task_status')
.then(res => {
if (!res.data.is_busy) {
clearInterval(checkInterval);
this.matching = false;
this.$message.success('清洗匹配完成!');
this.loadSummary();
this.loadUnmatchedData();
this.loadReconData();
}
})
.catch(err => {});
}, 1000);
},
loadUnmatchedData() {
this.loadingUnmatched = true;
let url = '/api/analysis/unmatched_materials';
if (this.dateRangeSelect && this.dateRangeSelect.length === 2) {
url += `?start=${this.dateRangeSelect[0]}&end=${this.dateRangeSelect[1]}`;
}
axios.get(url)
.then(res => {
this.unmatchedData = res.data.rows;
this.unmatchedPage = 1;
})
.catch(err => {
this.$message.error('加载无工单明细失败');
})
.finally(() => {
this.loadingUnmatched = false;
});
},
loadReconData() {
this.loadingRecon = true;
let url = '/api/analysis/bom_reconciliation';
if (this.dateRangeSelect && this.dateRangeSelect.length === 2) {
url += `?start=${this.dateRangeSelect[0]}&end=${this.dateRangeSelect[1]}`;
}
axios.get(url)
.then(res => {
this.reconData = res.data.rows;
this.reconPage = 1;
})
.catch(err => {
this.$message.error('加载 BOM 比对数据失败');
})
.finally(() => {
this.loadingRecon = false;
});
},
exportReconData() {
// 简单的前端 CSV 导出
const headers = ['工单号(SFC)', '物料代码', '物料名称', 'BOM应发量', '实际发料量', '差异数量', '状态'];
let csvContent = "data:text/csv;charset=utf-8,\uFEFF" + headers.join(",") + "\n";
this.filteredReconData.forEach(row => {
const rowData = [
row.sfc,
row.material_code,
`"${row.material_name || ''}"`,
row.bom_qty,
row.actual_qty,
row.diff_qty,
row.status
];
csvContent += rowData.join(",") + "\n";
});
const encodedUri = encodeURI(csvContent);
const link = document.createElement("a");
link.setAttribute("href", encodedUri);
link.setAttribute("download", "BOM发料对账单.csv");
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
}
});
</script>
{% include "global_log.html" %}
</body>
</html>