Files
klp-oa/klp-ui/src/views/wms/post/InvCount/components/WarehouseDetailPanel.vue
砂糖 39eaab139e feat: 新增盘库管理全流程功能模块
1.  新增批量新增盘库差异记录API
2.  新增盘库Excel对比工具函数
3.  新增盘库申请页面与库区明细组件
4.  优化流程图页面,新增流程图下载功能
5.  重构盘库主页面流程状态与操作逻辑
6.  新增多组件拆分与页面模块化改造
2026-06-26 15:41:21 +08:00

607 lines
39 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>