feat: 新增盘库管理全流程功能模块

1.  新增批量新增盘库差异记录API
2.  新增盘库Excel对比工具函数
3.  新增盘库申请页面与库区明细组件
4.  优化流程图页面,新增流程图下载功能
5.  重构盘库主页面流程状态与操作逻辑
6.  新增多组件拆分与页面模块化改造
This commit is contained in:
2026-06-26 15:41:21 +08:00
parent dc47a91d0f
commit 39eaab139e
9 changed files with 2994 additions and 1613 deletions

View File

@@ -0,0 +1,606 @@
<template>
<div class="wh-panel" v-loading="whLoading">
<div v-if="warehouseList.length === 0 && !whLoading" class="wh-empty">暂无绑定库区</div>
<div v-if="warehouseList.length > 0" class="doc-tabs">
<div class="doc-tabs-header">
<div v-for="(wh, idx) in warehouseList" :key="wh.relId" class="doc-tab-item"
:class="{ active: activeIdx === idx }" @click="switchWh(idx)">
<span class="doc-tab-label">{{ wh.warehouseName || wh.actualWarehouseName || '库区' + (idx + 1) }}</span>
<span class="doc-tab-badge">{{ wh.systemCoilCount || 0 }}</span>
</div>
</div>
<div class="doc-tabs-content">
<div v-for="(wh, idx) in warehouseList" :key="wh.relId" v-show="activeIdx === idx">
<!-- ====== 草稿 (0)操作台 ====== -->
<template v-if="planStatus === 0">
<div class="console-section">
<div class="console-steps">
<div class="console-step" :class="{ done: wh.snapshotCoilLogic }">
<span class="step-icon"><i class="el-icon-camera"></i></span>
<span class="step-info">
<span class="step-title">生成快照</span>
<span class="step-desc">{{ wh.snapshotCoilLogic ? '已生成' : '未生成' }}
<el-button v-if="wh.snapshotCoilLogic" type="text" size="mini" icon="el-icon-view" @click.stop="showPreview(wh.snapshotCoilLogic)">预览</el-button>
</span>
<span v-if="wh.snapshotCoilLogic && wh.systemCoilCount != null" class="step-stats">系统<b>{{ wh.systemCoilCount }}</b> | 总重<b>{{ wh.systemTotalWeight }}</b> kg</span>
</span>
<el-button size="mini" type="primary" plain :loading="genLoading" @click="doGenSnapshot(wh)">生成</el-button>
</div>
<div class="console-step" :class="{ done: wh.snapshotCoilStats }">
<span class="step-icon"><i class="el-icon-upload2"></i></span>
<span class="step-info">
<span class="step-title">上传实盘</span>
<span class="step-desc">{{ wh.snapshotCoilStats ? '已上传' : '未上传' }}
<el-button v-if="wh.snapshotCoilStats" type="text" size="mini" icon="el-icon-view" @click.stop="showPreview(wh.snapshotCoilStats)">预览</el-button>
</span>
<span v-if="wh.snapshotCoilStats && wh.actualCoilCount != null" class="step-stats">实盘<b>{{ wh.actualCoilCount }}</b> | 总重<b>{{ wh.actualTotalWeight }}</b> kg</span>
</span>
<el-button size="mini" type="primary" plain @click="openImport(wh)">上传</el-button>
</div>
<div class="console-step" :class="{ done: diffDoneMap[wh.relId] }">
<span class="step-icon"><i class="el-icon-s-data"></i></span>
<span class="step-info">
<span class="step-title">执行对比</span>
<span class="step-desc">{{ diffDoneMap[wh.relId] ? '已完成' : '未对比' }}
<span style="color:#909399;font-size:10px;">阈值</span>
<el-input-number v-model="diffThreshold" size="mini" style="width:90px;" :controls="false" />
<span style="color:#909399;font-size:10px;">T</span>
</span>
</span>
<el-button size="mini" type="primary" plain :loading="diffLoading" @click="doCompare(wh)">对比</el-button>
</div>
<div class="console-step">
<span class="step-icon"><i class="el-icon-document-checked"></i></span>
<span class="step-info">
<span class="step-title">查看结果</span>
<span class="step-desc">对比后可查看并保存</span>
</span>
<el-button size="mini" type="success" plain :disabled="!diffDoneMap[wh.relId]" @click="openDiffDialog(wh)">查看结果</el-button>
</div>
</div>
</div>
<!-- 对比结果摘要 -->
<div v-if="diffDoneMap[wh.relId]" class="wh-diff-summary">
<div class="result-header">对比结果摘要</div>
<div class="result-grid">
<div class="result-item miss">盘亏{{ diffResult.missing.length }}</div>
<div class="result-item extra">盘盈{{ diffResult.extra.length }}</div>
<div class="result-item diff">不一致{{ diffResult.mismatch.length }}</div>
<div class="result-item weight-similar">重量不同{{ diffResult.weightSimilar.length }}</div>
</div>
</div>
<div style="text-align:right;margin-bottom:8px;">
<el-button type="primary" size="small" icon="el-icon-s-promotion" @click="$emit('submit-approval')">提交审批</el-button>
</div>
</template>
<!-- ====== 概览卡片状态 0/1/4 ====== -->
<div v-if="planStatus !== 3" class="wh-overview">
<div class="wh-box">
<div class="wh-box-title">盘点范围</div>
<div class="wh-box-body">
<div v-if="wh.warehouseName">逻辑库区{{ wh.warehouseName }}</div>
<div v-if="wh.actualWarehouseName">实际库区{{ wh.actualWarehouseName }}</div>
<div v-if="wh.ioStartTime">{{ formatTime(wh.ioStartTime) }} ~ {{ formatTime(wh.ioEndTime) || '' }}</div>
<div v-if="!wh.warehouseName && !wh.actualWarehouseName && !wh.ioStartTime" style="color:#909399;"></div>
</div>
</div>
<div class="wh-box">
<div class="wh-box-title">系统快照
<el-button v-if="wh.snapshotCoilLogic" type="text" size="mini" icon="el-icon-view" @click="showPreview(wh.snapshotCoilLogic)">预览</el-button>
</div>
<div class="wh-box-body">
<div v-if="wh.snapshotCoilLogic">系统<b>{{ wh.systemCoilCount || 0 }}</b> | 总重<b>{{ wh.systemTotalWeight || 0 }}</b> kg</div>
<div v-else style="color:#909399;">未生成</div>
</div>
</div>
<div class="wh-box">
<div class="wh-box-title">实盘数据
<el-button v-if="wh.snapshotCoilStats" type="text" size="mini" icon="el-icon-view" @click="showPreview(wh.snapshotCoilStats)">预览</el-button>
</div>
<div class="wh-box-body">
<div v-if="wh.snapshotCoilStats">实盘<b>{{ wh.actualCoilCount || 0 }}</b> | 总重<b>{{ wh.actualTotalWeight || 0 }}</b> kg</div>
<div v-else style="color:#909399;">未上传</div>
</div>
</div>
</div>
<!-- ===== 待审批操作 ===== -->
<div v-if="planStatus === 1" style="text-align:right;margin-bottom:8px;">
<el-button size="small" type="danger" icon="el-icon-close" @click="$emit('reject')">驳回</el-button>
<el-button size="small" type="primary" icon="el-icon-check" @click="$emit('approve')">审批通过</el-button>
</div>
<div v-if="planStatus === 3" style="text-align:right;margin-bottom:8px;">
<el-button size="small" type="success" icon="el-icon-circle-check" @click="$emit('archive')">归档封存</el-button>
</div>
<!-- ====== 差异明细表格 ====== -->
<div class="section-title">差异明细</div>
<template v-if="planStatus === 0">
<div style="display:flex;justify-content:flex-end;margin-bottom:4px;">
<el-button size="mini" type="danger" plain icon="el-icon-delete" :disabled="discSelected.length === 0" @click="batchDelDisc(wh)">批量删除</el-button>
</div>
<el-table v-loading="discLoadingMap[wh.relId]" :data="discMap[wh.relId] || []" border size="small" style="width:100%" height="300"
@selection-change="function(s) { discSelected = s; }">
<el-table-column type="selection" width="36" />
<el-table-column label="类型" align="center" width="80">
<template slot-scope="ds">
<el-tag v-if="ds.row.discrepancyType === 1" type="success" size="mini">盘盈</el-tag>
<el-tag v-else-if="ds.row.discrepancyType === 2" type="danger" size="mini">盘亏</el-tag>
<el-tag v-else-if="ds.row.discrepancyType === 3" type="warning" size="mini">不符</el-tag>
<el-tag v-else size="mini">偏差</el-tag>
</template>
</el-table-column>
<el-table-column label="钢卷号" align="center" prop="enterCoilNo" width="140" />
<el-table-column label="差异详情" align="center" prop="discrepancyDetail" min-width="140" show-overflow-tooltip />
<el-table-column label="原因分析" align="center" min-width="140">
<template slot-scope="ds"><el-input v-model="ds.row.reasonAnalysis" size="mini" placeholder="原因分析" @blur="saveDiscRow(ds.row)" /></template>
</el-table-column>
<el-table-column label="处理建议" align="center" min-width="140">
<template slot-scope="ds"><el-input v-model="ds.row.processSuggestion" size="mini" placeholder="处理建议" @blur="saveDiscRow(ds.row)" /></template>
</el-table-column>
<el-table-column label="" align="center" width="50">
<template slot-scope="ds"><el-button size="mini" type="text" icon="el-icon-delete" style="color:#f56c6c;" @click="delOneDisc(ds.row, wh.relId)" /></template>
</el-table-column>
</el-table>
</template>
<template v-else-if="planStatus === 1">
<el-table v-loading="discLoadingMap[wh.relId]" :data="discMap[wh.relId] || []" border size="small" style="width:100%" height="300">
<el-table-column label="类型" align="center" width="80">
<template slot-scope="ds">
<el-tag v-if="ds.row.discrepancyType === 1" type="success" size="mini">盘盈</el-tag>
<el-tag v-else-if="ds.row.discrepancyType === 2" type="danger" size="mini">盘亏</el-tag>
<el-tag v-else-if="ds.row.discrepancyType === 3" type="warning" size="mini">不符</el-tag>
<el-tag v-else size="mini">偏差</el-tag>
</template>
</el-table-column>
<el-table-column label="钢卷号" align="center" prop="enterCoilNo" width="140" />
<el-table-column label="差异详情" align="center" prop="discrepancyDetail" min-width="120" show-overflow-tooltip />
<el-table-column label="原因分析" align="center" min-width="100">
<template slot-scope="ds">{{ ds.row.reasonAnalysis || '-' }}</template>
</el-table-column>
<el-table-column label="处理建议" align="center" min-width="100">
<template slot-scope="ds">{{ ds.row.processSuggestion || '-' }}</template>
</el-table-column>
<el-table-column label="需处理" align="center" width="80">
<template slot-scope="ds">
<el-tag v-if="ds.row.processStatus === 1" type="warning" size="mini"></el-tag>
<el-tag v-else type="info" size="mini"></el-tag>
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="80">
<template slot-scope="ds">
<el-button size="mini" type="text" icon="el-icon-edit" @click="$emit('mark-disc', ds.row)">标记</el-button>
</template>
</el-table-column>
</el-table>
</template>
<template v-else-if="planStatus === 3">
<el-table v-loading="discLoadingMap[wh.relId]" :data="discMap[wh.relId] || []" border size="small" style="width:100%" height="300">
<el-table-column label="类型" align="center" width="80">
<template slot-scope="ds">
<el-tag v-if="ds.row.discrepancyType === 1" type="success" size="mini">盘盈</el-tag>
<el-tag v-else-if="ds.row.discrepancyType === 2" type="danger" size="mini">盘亏</el-tag>
<el-tag v-else-if="ds.row.discrepancyType === 3" type="warning" size="mini">不符</el-tag>
<el-tag v-else size="mini">偏差</el-tag>
</template>
</el-table-column>
<el-table-column label="钢卷号" align="center" prop="enterCoilNo" width="140" />
<el-table-column label="差异详情" align="center" prop="discrepancyDetail" min-width="120" show-overflow-tooltip />
<el-table-column label="原因分析" align="center" min-width="100">
<template slot-scope="ds">{{ ds.row.reasonAnalysis || '-' }}</template>
</el-table-column>
<el-table-column label="处理建议" align="center" min-width="100">
<template slot-scope="ds">{{ ds.row.processSuggestion || '-' }}</template>
</el-table-column>
<el-table-column label="处理结果" align="center" min-width="150">
<template slot-scope="ds">
<el-input v-model="ds.row._processResult" size="mini" placeholder="选填" :disabled="ds.row.processStatus === 2" />
</template>
</el-table-column>
<el-table-column label="状态" align="center" width="90">
<template slot-scope="ds">
<el-tag v-if="ds.row.processStatus === 2" type="success" size="mini">已处理</el-tag>
<el-tag v-else type="info" size="mini">待处理</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="90">
<template slot-scope="ds">
<el-button v-if="ds.row.processStatus !== 2" size="mini" type="primary" @click="$emit('process-disc', { row: ds.row, relId: wh.relId })">处理</el-button>
<span v-else style="color:#67c23a;font-size:12px;">已完成</span>
</template>
</el-table-column>
</el-table>
</template>
<template v-else-if="planStatus === 4">
<el-table v-loading="discLoadingMap[wh.relId]" :data="discMap[wh.relId] || []" border size="small" style="width:100%" height="300">
<el-table-column label="类型" align="center" width="80">
<template slot-scope="ds">
<el-tag v-if="ds.row.discrepancyType === 1" type="success" size="mini">盘盈</el-tag>
<el-tag v-else-if="ds.row.discrepancyType === 2" type="danger" size="mini">盘亏</el-tag>
<el-tag v-else-if="ds.row.discrepancyType === 3" type="warning" size="mini">不符</el-tag>
<el-tag v-else size="mini">偏差</el-tag>
</template>
</el-table-column>
<el-table-column label="钢卷号" align="center" prop="enterCoilNo" width="140" />
<el-table-column label="差异详情" align="center" prop="discrepancyDetail" min-width="120" show-overflow-tooltip />
<el-table-column label="原因分析" align="center" min-width="100">
<template slot-scope="ds">{{ ds.row.reasonAnalysis || '-' }}</template>
</el-table-column>
<el-table-column label="处理建议" align="center" min-width="100">
<template slot-scope="ds">{{ ds.row.processSuggestion || '-' }}</template>
</el-table-column>
<el-table-column label="处理结果" align="center" min-width="120">
<template slot-scope="ds">{{ ds.row.processResult || '-' }}</template>
</el-table-column>
<el-table-column label="处理状态" align="center" width="90">
<template slot-scope="ds">
<el-tag v-if="ds.row.processStatus === 2" type="success" size="mini">已处理</el-tag>
<el-tag v-else type="info" size="mini">未处理</el-tag>
</template>
</el-table-column>
<el-table-column label="处理人" align="center" prop="processUserName" width="90" />
<el-table-column label="处理时间" align="center" width="150">
<template slot-scope="ds">{{ ds.row.processTime || '-' }}</template>
</el-table-column>
</el-table>
</template>
<div v-if="!(discMap[wh.relId] && discMap[wh.relId].length > 0) && !discLoadingMap[wh.relId]" class="wh-empty-tip">暂无差异记录</div>
<div v-else style="text-align:right;padding:4px 0;">
<el-pagination background layout="prev, pager, next, total" :total="discTotalMap[wh.relId] || 0" :page-size="discPageSize" :current-page="discPageMap[wh.relId] || 1" @current-change="function(p) { loadDisc(wh.relId, p); }" small />
</div>
</div>
</div>
</div>
<!-- 导入实盘Excel对话框 -->
<el-dialog title="导入实盘Excel" :visible.sync="importOpen" width="1000px" append-to-body @close="resetImport">
<div>
<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px;">
<el-upload ref="impUpload" action="" :auto-upload="false" :show-file-list="false" :on-change="onImpFile" accept=".xlsx,.xls"><el-button type="primary" icon="el-icon-upload2">选择Excel</el-button></el-upload>
<span v-if="impFile" style="color:#67c23a;font-size:13px;"><i class="el-icon-success"></i> {{ impFile.name }}</span>
<el-button type="warning" icon="el-icon-upload" :disabled="!impFile || impErrors.length > 0 || importing" :loading="importing" @click="doImport">上传</el-button>
<el-button type="default" icon="el-icon-refresh" :disabled="importing" @click="resetImport">重置</el-button>
</div>
<div style="margin-bottom:10px;"><el-link type="primary" icon="el-icon-download" @click="downloadTpl">下载模板</el-link></div>
<div v-if="impErrors.length > 0" style="margin-top:10px;"><el-alert title="校验失败" type="error" :description="'共' + impErrors.length + '条错误'" show-icon /></div>
<div v-if="impPreview.length > 0 && impErrors.length === 0" style="margin-top:10px;">
<el-alert title="校验通过" type="success" :description="'共 ' + impPreview.length + ' 条数据' " show-icon :closable="false" />
<el-table :data="impPreview" border size="small" max-height="300" stripe style="margin-top:8px;">
<el-table-column prop="enterCoilNo" label="入场钢卷号" width="140" />
<el-table-column prop="currentCoilNo" label="当前钢卷号" width="140" />
<el-table-column prop="netWeight" label="重量(kg)" width="100" />
<el-table-column prop="specification" label="规格" width="120" />
<el-table-column prop="material" label="材质" width="100" />
<el-table-column prop="manufacturer" label="生产厂家" width="120" />
<el-table-column prop="qualityStatus" label="品质" width="100" />
<el-table-column prop="actualWarehouseName" label="实际库区" width="100" />
<el-table-column prop="length" label="长度" width="80" />
</el-table>
</div>
</div>
</el-dialog>
<!-- 预览弹窗 -->
<el-dialog title="文件预览" :visible.sync="previewOpen" width="80%" top="5vh" append-to-body :before-close="function(){previewOpen=false;previewUrl='';}">
<div style="height:75vh;"><vue-office-excel v-if="previewUrl" :src="previewUrl" @render-error="function(){ $message.error('预览失败'); }" /><span v-else>加载中...</span></div>
</el-dialog>
<!-- 对比结果详情弹窗 -->
<el-dialog title="对比结果详情" :visible.sync="diffDialogOpen" width="90%" top="3vh" append-to-body>
<div v-if="diffDialogWh">
<div style="display:flex;justify-content:flex-end;gap:8px;margin-bottom:10px;">
<el-button type="primary" size="small" icon="el-icon-check" :loading="saving" @click="doSaveResult(diffDialogWh)">保存结果</el-button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div class="diff-block">
<div class="diff-block-title" style="color:#f56c6c;">盘亏{{ diffResult.missing.length }}</div>
<el-table :data="diffResult.missing" border size="small" height="220">
<el-table-column prop="enterCoilNo" label="入场钢卷号" width="130" />
<el-table-column prop="currentCoilNo" label="当前钢卷号" width="130" />
<el-table-column prop="netWeight" label="重量(kg)" width="100" />
<el-table-column prop="specification" label="规格" width="120" />
<el-table-column prop="material" label="材质" width="100" />
</el-table>
</div>
<div class="diff-block">
<div class="diff-block-title" style="color:#67c23a;">盘盈{{ diffResult.extra.length }}</div>
<el-table :data="diffResult.extra" border size="small" height="220">
<el-table-column prop="enterCoilNo" label="入场钢卷号" width="130" />
<el-table-column prop="currentCoilNo" label="当前钢卷号" width="130" />
<el-table-column prop="netWeight" label="重量(kg)" width="100" />
<el-table-column prop="specification" label="规格" width="120" />
<el-table-column prop="material" label="材质" width="100" />
</el-table>
</div>
<div class="diff-block">
<div class="diff-block-title" style="color:#e6a23c;">字段不一致{{ diffResult.mismatch.length }}</div>
<el-table :data="diffResult.mismatch" border size="small" height="220">
<el-table-column prop="enterCoilNo" label="入场钢卷号" width="130" />
<el-table-column prop="currentCoilNo" label="当前钢卷号" width="130" />
<el-table-column label="差异" min-width="200">
<template slot-scope="ds"><span v-for="(d, i) in ds.row.diffs" :key="i">{{ d.label }}快照[{{ d.snapshotValue }}] 实盘[{{ d.physicalValue }}]<br /></span></template>
</el-table-column>
</el-table>
</div>
<div class="diff-block">
<div class="diff-block-title" style="color:#409eff;">重量不同阈值内{{ diffResult.weightSimilar.length }}</div>
<el-table :data="diffResult.weightSimilar" border size="small" height="220">
<el-table-column prop="enterCoilNo" label="入场钢卷号" width="130" />
<el-table-column prop="currentCoilNo" label="当前钢卷号" width="130" />
<el-table-column label="差异" min-width="200">
<template slot-scope="ds"><span v-for="(d, i) in ds.row.diffs" :key="i">{{ d.label }}快照[{{ d.snapshotValue }}] 实盘[{{ d.physicalValue }}]<br /></span></template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script>
import { listCountPlanWarehouse } from "@/api/flow/countPlanWarehouse";
import { listCountDiscrepancy, updateCountDiscrepancy, batchAddCountDiscrepancy, delCountDiscrepancy } from "@/api/flow/countDiscrepancy";
import { exportCoilWithAll } from "@/api/wms/coil";
import { uploadFile, listByIds } from "@/api/system/oss";
import { updateCountPlanWarehouse } from "@/api/flow/countPlanWarehouse";
import { parseTime } from '@/utils/klp';
import * as XLSX from 'xlsx';
import { downloadAndParseExcel, SNAPSHOT_COL_MAP, PHYSICAL_HEADER_MAP, compareCoils } from '../utils/compare.js';
import VueOfficeExcel from '@vue-office/excel';
import '@vue-office/excel/lib/index.css';
const IMP_TEMPLATE = ['入场钢卷号', '当前钢卷号', '重量(kg)', '规格', '材质', '生产厂家', '品质', '实际库区', '长度', '备注'];
export default {
name: 'WarehouseDetailPanel',
components: { VueOfficeExcel },
props: {
planId: { type: [Number, String], required: true },
planStatus: { type: Number, default: 0 }
},
data() {
return {
whLoading: false, warehouseList: [], activeIdx: 0,
discMap: {}, discLoadingMap: {}, discPageMap: {}, discTotalMap: {},
discPageSize: 20,
genLoading: false,
importOpen: false, importWh: null, impFile: null, impErrors: [], impPreview: [], importing: false,
previewOpen: false, previewUrl: '',
diffThreshold: 0.5, diffLoading: false, diffDoneMap: {},
diffResult: { missing: [], extra: [], mismatch: [], weightSimilar: [] },
diffDialogOpen: false, diffDialogWh: null, saving: false,
discSelected: []
};
},
mounted() { this.loadWarehouses(); },
watch: { planId() { this.activeIdx = 0; this.loadWarehouses(); } },
methods: {
formatTime(val) { if (!val) return ''; return parseTime(val, '{y}-{m}-{d} {h}:{i}'); },
loadWarehouses() {
if (!this.planId) return;
this.whLoading = true; var self = this;
listCountPlanWarehouse({ planId: this.planId, pageNum: 1, pageSize: 999 }).then(function(r) {
self.warehouseList = r.rows || [];
self.$emit('loaded', self.warehouseList);
if (self.warehouseList.length > 0) self.loadDisc(self.warehouseList[0].relId);
}).finally(function() { self.whLoading = false; });
},
loadDisc(relId, pageNum) {
pageNum = pageNum || this.discPageMap[relId] || 1;
this.$set(this.discPageMap, relId, pageNum);
this.$set(this.discLoadingMap, relId, true); var self = this;
listCountDiscrepancy({ relId, pageNum: pageNum, pageSize: this.discPageSize }).then(function(r) {
self.$set(self.discMap, relId, (r.rows || []).map(function(item) {
if (item._processResult === undefined) item._processResult = item.processResult || '';
return item;
}));
}).finally(function() { self.$set(self.discLoadingMap, relId, false); });
},
loadAllDisc() { var self = this; this.warehouseList.forEach(function(wh) { self.loadDisc(wh.relId); }); },
switchWh(idx) { this.activeIdx = idx; var wh = this.warehouseList[idx]; if (wh) this.loadDisc(wh.relId); },
refreshOneDisc(relId) { this.loadDisc(relId); },
getActiveWarehouse() { return this.warehouseList[this.activeIdx] || null; },
refreshAll() { this.activeIdx = 0; this.loadWarehouses(); },
// ===== 快照生成 =====
async doGenSnapshot(wh) {
if (!wh) return;
var p = { dataType: 1, status: 0 };
if (wh.warehouseId) p.warehouseId = String(wh.warehouseId);
if (wh.actualWarehouseId) p.actualWarehouseId = String(wh.actualWarehouseId);
if (!p.warehouseId && !p.actualWarehouseId) { this.$message.warning('无库区条件'); return; }
this.genLoading = true;
try {
var resp = await exportCoilWithAll(p);
var label = wh.warehouseName || wh.actualWarehouseName || '';
var file = new File([new Blob([resp])], label + '_快照.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
var up = await uploadFile(file);
await updateCountPlanWarehouse({ relId: wh.relId, snapshotCoilLogic: up.data.ossId });
var arr = await resp.arrayBuffer();
var wb = XLSX.read(new Uint8Array(arr), { type: 'array' });
var rows = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]);
var cnt = rows.length, tw = 0;
rows.forEach(function(r) { tw += Number(r['重量'] || r['重量(kg)'] || 0); });
await updateCountPlanWarehouse({ relId: wh.relId, systemCoilCount: cnt, systemTotalWeight: Math.round(tw * 100) / 100 });
this.$message.success('快照已生成');
this.refreshAll();
} catch (e) { this.$message.error('生成失败'); }
finally { this.genLoading = false; }
},
// ===== 实盘导入 =====
openImport(wh) { this.importWh = wh; this.resetImport(); this.importOpen = true; },
onImpFile(file) {
if (this.importing) return;
this.impFile = file.raw; this.impErrors = []; this.impPreview = [];
var self = this;
var fr = new FileReader();
fr.readAsArrayBuffer(file.raw);
fr.onload = function(e) {
try {
var wb = XLSX.read(new Uint8Array(e.target.result), { type: 'array' });
var data = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]], { header: 1 });
var h = data[0];
for (var i = 0; i < IMP_TEMPLATE.length; i++) { if (h[i] !== IMP_TEMPLATE[i]) self.impErrors.push({ rowNum: 1, errorMsg: '表头第' + (i + 1) + '列应为' + IMP_TEMPLATE[i] }); }
data.slice(1).filter(function(r) { return r.length > 0; }).forEach(function(r, i) {
if (!r[0] || !String(r[0]).trim()) self.impErrors.push({ rowNum: i + 2, errorMsg: '入场钢卷号为空' });
if (r[2] && isNaN(Number(r[2]))) self.impErrors.push({ rowNum: i + 2, errorMsg: '重量非数字' });
self.impPreview.push({
enterCoilNo: String(r[0] || '').trim(), currentCoilNo: String(r[1] || '').trim(), netWeight: r[2],
specification: r[3], material: r[4], manufacturer: r[5], qualityStatus: r[6],
actualWarehouseName: r[7], length: r[8]
});
});
if (self.impErrors.length === 0) self.$message.success('校验通过,共 ' + self.impPreview.length + ' 条');
} catch (ex) { self.impErrors.push({ rowNum: 0, errorMsg: ex.message }); }
};
},
async doImport() {
if (this.importing || this.impErrors.length > 0 || !this.impFile) return;
this.importing = true; var self = this;
try {
var up = await uploadFile(this.impFile);
var cnt = 0, tw = 0;
var fr2 = new FileReader();
fr2.readAsArrayBuffer(this.impFile);
await new Promise(function(resolve) {
fr2.onload = function(e) {
var wb = XLSX.read(new Uint8Array(e.target.result), { type: 'array' });
var data = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]], { header: 1 });
data.slice(1).filter(function(r) { return r.length > 0; }).forEach(function(r) { cnt++; tw += Number(r[2]) || 0; });
resolve();
};
});
await updateCountPlanWarehouse({ relId: self.importWh.relId, snapshotCoilStats: up.data.ossId, actualCoilCount: cnt, actualTotalWeight: Math.round(tw * 100) / 100 });
self.$message.success('上传成功');
self.importOpen = false;
self.refreshAll();
} catch (e) { self.$message.error('上传失败'); }
finally { self.importing = false; }
},
resetImport() { this.impFile = null; this.impErrors = []; this.impPreview = []; this.importing = false; if (this.$refs.impUpload) this.$refs.impUpload.clearFiles(); },
downloadTpl() {
var data = [IMP_TEMPLATE, ['LOT001', 'COIL001', 1000.5, '1.0*1000', 'Q235', '宝钢', '正品', 'A库区', 2000, '示例']];
var wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(data), '模板');
XLSX.writeFile(wb, '盘库实盘导入模板.xlsx');
},
// ===== 预览 =====
showPreview(ossId) {
var self = this; this.previewUrl = ''; this.previewOpen = true;
listByIds(ossId).then(function(r) { var list = r.data || r.rows || []; if (list.length > 0) self.previewUrl = list[0].url; else { self.$message.error('未找到'); self.previewOpen = false; } }).catch(function() { self.$message.error('失败'); self.previewOpen = false; });
},
// ===== 差异保存 =====
saveDiscRow(row) {
if (!row || !row.discrepancyId) return;
updateCountDiscrepancy({ discrepancyId: row.discrepancyId, reasonAnalysis: row.reasonAnalysis, processSuggestion: row.processSuggestion }).catch(function() {});
},
// ===== 对比 =====
async doCompare(wh) {
if (!wh.snapshotCoilLogic) { this.$message.warning('请先生成快照'); return; }
if (!wh.snapshotCoilStats) { this.$message.warning('请先上传实盘'); return; }
this.diffLoading = true; this.diffResult = { missing: [], extra: [], mismatch: [], weightSimilar: [] };
try {
var snapRows = await downloadAndParseExcel(wh.snapshotCoilLogic, SNAPSHOT_COL_MAP);
var physRows = await downloadAndParseExcel(wh.snapshotCoilStats, PHYSICAL_HEADER_MAP);
this.diffResult = compareCoils(snapRows, physRows, this.diffThreshold);
this.$set(this.diffDoneMap, wh.relId, true);
} catch (e) { this.$message.error('对比失败:' + (e.message || '')); }
finally { this.diffLoading = false; }
},
openDiffDialog(wh) {
if (!this.diffResult.missing.length && !this.diffResult.extra.length && !this.diffResult.mismatch.length && !this.diffResult.weightSimilar.length) {
this.$message.warning('无对比结果'); return;
}
this.diffDialogWh = wh;
this.diffDialogOpen = true;
},
async doSaveResult(wh) {
var r = this.diffResult;
if (!r.missing.length && !r.extra.length && !r.mismatch.length && !r.weightSimilar.length) { this.$message.warning('无对比结果'); return; }
var bos = [];
r.missing.forEach(function(item) { bos.push({ relId: wh.relId, discrepancyType: 2, enterCoilNo: item.enterCoilNo || '', currentCoilNo: item.currentCoilNo || '', discrepancyDetail: '盘亏' }); });
r.extra.forEach(function(item) { bos.push({ relId: wh.relId, discrepancyType: 1, enterCoilNo: item.enterCoilNo || '', currentCoilNo: item.currentCoilNo || '', discrepancyDetail: '盘盈' }); });
r.mismatch.forEach(function(item) { var d = (item.diffs || []).map(function(x) { return x.label + ': 快照[' + (x.snapshotValue ?? '') + '] 实盘[' + (x.physicalValue ?? '') + ']'; }).join('; '); bos.push({ relId: wh.relId, discrepancyType: 4, enterCoilNo: item.enterCoilNo || '', currentCoilNo: item.currentCoilNo || '', discrepancyDetail: '不一致:' + d }); });
r.weightSimilar.forEach(function(item) { var d = (item.diffs || []).map(function(x) { return x.label + ': 快照[' + (x.snapshotValue ?? '') + '] 实盘[' + (x.physicalValue ?? '') + ']'; }).join('; '); bos.push({ relId: wh.relId, discrepancyType: 4, enterCoilNo: item.enterCoilNo || '', currentCoilNo: item.currentCoilNo || '', discrepancyDetail: '重量不同(阈值内)' + d }); });
this.saving = true;
try {
await batchAddCountDiscrepancy(bos);
this.$message.success('已保存 ' + bos.length + ' 条差异记录');
this.diffDialogOpen = false;
this.loadDisc(wh.relId);
} catch (e) { this.$message.error('保存失败'); }
finally { this.saving = false; }
},
// 删除单条差异
delOneDisc(row, relId) {
var self = this;
this.$modal.confirm('确认删除该差异记录?').then(function() {
return delCountDiscrepancy(row.discrepancyId);
}).then(function() { self.$message.success('已删除'); self.loadDisc(relId); }).catch(function() {});
},
// 批量删除差异
batchDelDisc(wh) {
var self = this;
if (!this.discSelected || this.discSelected.length === 0) { this.$message.warning('请至少选择一条'); return; }
this.$modal.confirm('确认删除选中的 ' + this.discSelected.length + ' 条差异记录?').then(function() {
var ids = self.discSelected.map(function(row) { return row.discrepancyId; }).join(',');
return delCountDiscrepancy(ids);
}).then(function() { self.$message.success('批量删除成功'); self.discSelected = []; self.loadDisc(wh.relId); }).catch(function() {});
},
}
};
</script>
<style scoped>
.wh-panel { }
.wh-empty { padding: 16px 0; color: #909399; font-size: 13px; }
.doc-tabs-header { display: flex; border-bottom: 1px solid #d4d0c8; margin-bottom: 12px; }
.doc-tab-item { padding: 8px 16px; cursor: pointer; color: #8c8c8c; font-size: 13px; font-weight: 500; border-bottom: 2px solid transparent; margin-bottom: -1px; transition: color 0.2s, border-color 0.2s; display: flex; align-items: center; gap: 6px; user-select: none; }
.doc-tab-item:hover { color: #1a3c6e; }
.doc-tab-item.active { color: #1a3c6e; font-weight: 600; border-bottom-color: #1a3c6e; }
.doc-tab-label { white-space: nowrap; }
.doc-tab-badge { font-size: 11px; color: #909399; }
.doc-tab-item.active .doc-tab-badge { color: #1a3c6e; }
.wh-overview { display: flex; gap: 12px; margin-bottom: 14px; }
.wh-box { flex: 1; border: 1px solid #e8e4de; border-radius: 2px; overflow: hidden; }
.wh-box-title { padding: 6px 10px; background: #f7f5f0; font-size: 12px; font-weight: 600; color: #1a1a1a; border-bottom: 1px solid #e8e4de; display: flex; align-items: center; justify-content: space-between; }
.wh-box-body { padding: 8px 10px; font-size: 12px; color: #606266; line-height: 1.8; }
.wh-box-body b { color: #1a3c6e; }
.section-title { font-size: 14px; font-weight: 700; color: #1a1a1a; margin: 16px 0 10px; padding-bottom: 8px; border-bottom: 1px solid #d4d0c8; letter-spacing: 0.3px; }
.wh-empty-tip { padding: 8px 0; color: #909399; font-size: 13px; }
.console-section { margin: 12px 0; }
.console-steps { display: flex; flex-direction: column; gap: 6px; }
.console-step { display: flex; align-items: center; gap: 10px; padding: 8px 12px; background: #faf8f5; border: 1px solid #e8e4de; border-radius: 2px; }
.console-step.done { background: #f0f9eb; border-color: #c2e7b0; }
.step-icon { width: 28px; height: 28px; border-radius: 50%; background: #e4e7ed; display: flex; align-items: center; justify-content: center; font-size: 14px; color: #909399; flex-shrink: 0; }
.console-step.done .step-icon { background: #67c23a; color: #fff; }
.step-info { flex: 1; display: flex; flex-direction: column; }
.step-title { font-size: 13px; font-weight: 600; color: #303133; }
.step-desc { font-size: 11px; color: #909399; margin-top: 1px; }
.step-stats { font-size: 11px; color: #606266; margin-top: 2px; }
.step-stats b { color: #1a3c6e; }
.wh-diff-summary { margin: 10px 0; padding: 12px; background: #faf8f5; border: 1px solid #e8e4de; border-radius: 2px; }
.result-header { font-size: 13px; font-weight: 600; color: #1a1a1a; margin-bottom: 8px; padding-bottom: 6px; border-bottom: 1px solid #e0dcd6; }
.result-grid { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 8px; }
.result-item { padding: 8px; border-radius: 2px; font-size: 12px; font-weight: 500; text-align: center; }
.result-item.miss { background: #fef0f0; color: #f56c6c; }
.result-item.extra { background: #f0f9eb; color: #67c23a; }
.result-item.diff { background: #fdf6ec; color: #e6a23c; }
.result-item.weight-similar { background: #f0f5ff; color: #409eff; }
.diff-block { margin-bottom: 10px; }
.diff-block-title { font-size: 13px; font-weight: 600; padding: 4px 0; border-bottom: 1px solid #e4e7ed; margin-bottom: 4px; }
</style>