Files
klp-oa/klp-ui/src/views/wms/post/InvCount/components/WarehouseDetailPanel.vue
砂糖 b94b7823e5 refactor(盘库流程): 重构盘库流程页面与组件,完善排产明细功能
1.  重构盘库流程的步骤与状态映射,调整流程节点顺序与名称
2.  拆分通用盘库详情组件PlanDetailPanel,复用各流程页面
3.  新增计划审批、盘库执行页面,完善差异审批页面
4.  为排产单明细添加增删改查API与前端操作功能
5.  为排产日期添加格式化注解,完善参数接收格式
2026-06-27 11:15:13 +08:00

661 lines
42 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">
<!-- ====== 盘库执行中 (2)操作台 ====== -->
<template v-if="planStatus === 2">
<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-disc-approval')">提交差异审批</el-button>
</div>
</template>
<!-- ====== 草稿 (0) / 计划待审批 (1)库区清单 ====== -->
<template v-if="planStatus === 0 || planStatus === 1">
<div class="wh-detail-list">
<div class="wh-detail-row">
<span class="wh-detail-label">逻辑库区</span>
<span class="wh-detail-value">{{ wh.warehouseName || '-' }}</span>
</div>
<div class="wh-detail-row">
<span class="wh-detail-label">实际库区</span>
<span class="wh-detail-value">{{ wh.actualWarehouseName || '-' }}</span>
</div>
<div class="wh-detail-row">
<span class="wh-detail-label">出入库查询起始</span>
<span class="wh-detail-value">{{ wh.ioStartTime ? formatTime(wh.ioStartTime) : '-' }}</span>
</div>
<div class="wh-detail-row">
<span class="wh-detail-label">出入库查询截止</span>
<span class="wh-detail-value">{{ wh.ioEndTime ? formatTime(wh.ioEndTime) : '-' }}</span>
</div>
</div>
</template>
<!-- ====== 概览卡片状态 2/3/4/5 ====== -->
<div v-if="planStatus >= 2" 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>
<!-- ===== 计划待审批 (1) 操作 ===== -->
<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>
<!-- ===== 差异审批中 (3) 操作 ===== -->
<div v-if="planStatus === 3" 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('diff-approve')">审批通过</el-button>
</div>
<!-- ===== 差异处理中 (4) 操作 ===== -->
<div v-if="planStatus === 4" style="text-align:right;margin-bottom:8px;">
<el-button size="small" type="success" icon="el-icon-circle-check" @click="$emit('archive')">归档封存</el-button>
</div>
<!-- ====== 差异明细表格状态 2+ ====== -->
<template v-if="planStatus >= 2">
<div class="section-title">差异明细</div>
<template v-if="planStatus === 2">
<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>
</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>
</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="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 === 5">
<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>
</template>
</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 || '';
if (item._needResolve === undefined) item._needResolve = item.processStatus === 1;
if (item._approveRemark === undefined) item._approveRemark = item.remark || '';
return item;
}));
self.$set(self.discTotalMap, relId, r.total || 0);
}).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() {});
},
saveDiscMark(row) {
if (!row || !row.discrepancyId) return;
updateCountDiscrepancy({ discrepancyId: row.discrepancyId, processStatus: row._needResolve ? 1 : 0, remark: row._approveRemark }).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; }
.wh-detail-list { margin: 8px 0 16px; border: 1px solid #e8e4de; border-radius: 2px; overflow: hidden; }
.wh-detail-row { display: flex; padding: 8px 14px; font-size: 13px; border-bottom: 1px solid #f0ece6; }
.wh-detail-row:last-child { border-bottom: none; }
.wh-detail-label { width: 120px; flex-shrink: 0; color: #606266; font-weight: 500; }
.wh-detail-value { color: #303133; }
.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>