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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,169 @@
<template>
<div class="app-container count-container">
<DragResizePanel :initialSize="280" :minSize="280" :maxSize="600">
<template #panelA>
<div class="left-panel">
<div class="panel-header">
<div class="header-title"><i class="el-icon-s-check"></i><span>盘库申请</span><el-button size="mini" type="text" icon="el-icon-refresh" @click="getList" title="刷新列表" /></div>
<el-select v-model="queryParams.planStatus" size="mini" @change="handleQuery" class="header-filter">
<el-option label="草稿" :value="0" />
<el-option label="待审批" :value="1" />
</el-select>
</div>
<div class="search-row"><el-input v-model="queryParams.planCode" placeholder="搜索计划编号..." clearable prefix-icon="el-icon-search" size="small" @keyup.enter.native="handleQuery" @clear="handleQuery" /><el-button type="primary" size="small" @click="handleAdd"><i class="el-icon-plus"></i></el-button></div>
<div v-loading="loading" class="list-body">
<div v-for="item in dataList" :key="item.planId" class="list-item" :class="{ active: currentRow && currentRow.planId === item.planId }" @click="handleRowClick(item)">
<div class="item-main"><span class="item-title">{{ item.planCode }}</span><span class="item-sub">{{ item.planName }}</span></div>
<div class="item-meta"><el-tag v-if="item.planStatus === 0" type="info" size="mini">草稿</el-tag><el-tag v-else size="mini">待审批</el-tag></div>
</div>
<div v-if="dataList.length === 0 && !loading" class="list-empty"><i class="el-icon-folder-opened"></i><span>暂无盘库计划</span></div>
</div>
<div class="list-footer"><pagination :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" /></div>
</div>
</template>
<template #panelB>
<div class="right-panel">
<div v-if="!currentRow" class="empty-tip"><i class="el-icon-info"></i><span>请在左侧列表中选择一条盘库计划</span></div>
<div v-else v-loading="detailLoading" class="detail-content">
<div class="doc-header">
<div class="doc-header-top">
<div class="doc-title-group"><div class="doc-title">{{ currentRow.planCode }}</div><div class="doc-subtitle">Application</div></div>
<div class="doc-header-right">
<el-button size="mini" type="text" icon="el-icon-refresh" @click="handleRefreshDetail">刷新</el-button>
<el-button v-if="currentRow.planStatus === 0" size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(currentRow)">编辑</el-button>
<el-button v-if="currentRow.planStatus === 0" size="mini" type="text" icon="el-icon-delete" @click="handleDelete(currentRow)">删除</el-button>
</div>
</div>
<div class="doc-status-row"><span class="doc-status-label">Status</span><el-tag v-if="currentRow.planStatus === 0" type="info" size="small">草稿</el-tag><el-tag v-else size="small">待审批</el-tag></div>
</div>
<div class="detail-meta">
<span><i class="el-icon-document"></i>{{ currentRow.planName }}</span>
<span v-if="currentRow.countDate"><i class="el-icon-date"></i>{{ parseTime(currentRow.countDate, '{y}-{m}-{d}') }}</span>
<span v-if="currentRow.countUserName"><i class="el-icon-user-solid"></i>{{ currentRow.countUserName }}</span>
</div>
<CountFlowSection :planStatus="currentRow.planStatus" />
<el-divider />
<div class="section-title">库区盘点明细 <span class="en-sub">· Warehouses</span>
<el-button v-if="currentRow.planStatus === 0" size="mini" type="primary" plain icon="el-icon-plus" @click="handleAddWarehouse">绑定库区</el-button>
</div>
<WarehouseDetailPanel ref="whPanel"
:planId="currentRow.planId"
:planStatus="currentRow.planStatus"
@submit-approval="handleSubmitApproval"
/>
<div class="section-gap" />
<div class="section-title">备注 <span class="en-sub">· Remarks</span></div>
<div class="remark-content">{{ currentRow.remark || '无' }}</div>
</div>
</div>
</template>
</DragResizePanel>
<el-dialog :title="planDialogTitle" :visible.sync="planDialogOpen" width="650px" append-to-body>
<el-form ref="planForm" :model="planForm" label-width="100px">
<el-form-item label="计划名称" prop="planName"><el-input v-model="planForm.planName" /></el-form-item>
<el-form-item label="盘库日期" prop="countDate"><el-date-picker v-model="planForm.countDate" type="date" value-format="yyyy-MM-dd" style="width:100%" /></el-form-item>
<el-form-item label="截止时间" prop="deadlineTime"><el-date-picker v-model="planForm.deadlineTime" type="datetime" value-format="yyyy-MM-dd HH:mm:ss" style="width:100%" /></el-form-item>
<el-form-item label="盘点人"><el-input v-model="planForm.countUserName" /></el-form-item>
<el-form-item label="负责人"><el-input v-model="planForm.principalUserName" /></el-form-item>
<el-form-item label="参与人"><el-input v-model="planForm.participantNames" /></el-form-item>
<el-form-item label="备注"><el-input v-model="planForm.remark" type="textarea" :rows="3" /></el-form-item>
</el-form>
<div slot="footer"><el-button :loading="btnLoading" type="primary" @click="submitPlan">确定</el-button><el-button @click="planDialogOpen = false">取消</el-button></div>
</el-dialog>
<el-dialog title="绑定库区" :visible.sync="whDialogOpen" width="550px" append-to-body>
<el-form :model="whForm" label-width="120px">
<el-form-item label="逻辑库区"><WarehouseSelect ref="wsRef" v-model="whForm.warehouseId" @change="onWhChange" /></el-form-item>
<el-form-item label="实际库区"><ActualWarehouseL1L2Select ref="awsRef" v-model="whForm.actualWarehouseId" @change="onAwhChange" /></el-form-item>
<el-form-item label="出入库起始"><el-date-picker v-model="whForm.ioStartTime" type="datetime" value-format="yyyy-MM-dd HH:mm:ss" style="width:100%" /></el-form-item>
<el-form-item label="出入库截止"><el-date-picker v-model="whForm.ioEndTime" type="datetime" value-format="yyyy-MM-dd HH:mm:ss" style="width:100%" /></el-form-item>
</el-form>
<div slot="footer"><el-button :loading="whBtnLoading" type="primary" @click="submitWh">确定</el-button><el-button @click="whDialogOpen = false">取消</el-button></div>
</el-dialog>
</div>
</template>
<script>
import { listCountPlan, getCountPlan, addCountPlan, updateCountPlan, delCountPlan } from "@/api/flow/countPlan";
import { addCountPlanWarehouse } from "@/api/flow/countPlanWarehouse";
import DragResizePanel from "@/components/DragResizePanel/index.vue";
import CountFlowSection from "./components/CountFlowSection.vue";
import WarehouseDetailPanel from "./components/WarehouseDetailPanel.vue";
import WarehouseSelect from "@/components/KLPService/WarehouseSelect/index.vue";
import ActualWarehouseL1L2Select from "@/components/KLPService/ActualWarehouseL1L2Select/index.vue";
import { parseTime } from '@/utils/klp'
export default {
name: "InvCountApply",
components: { DragResizePanel, CountFlowSection, WarehouseDetailPanel, WarehouseSelect, ActualWarehouseL1L2Select },
data() {
return {
loading: false, detailLoading: false, btnLoading: false, whBtnLoading: false, submitLoading: false, total: 0, dataList: [], currentRow: null,
queryParams: { pageNum: 1, pageSize: 10, planCode: undefined, planStatus: 0 },
planDialogOpen: false, planDialogTitle: '', planForm: {},
whDialogOpen: false, whForm: { warehouseId: undefined, actualWarehouseId: undefined, warehouseName: '', actualWarehouseName: '', ioStartTime: undefined, ioEndTime: undefined }
};
},
created() { this.getList(); },
methods: {
parseTime,
getList() { var self = this; this.loading = true; listCountPlan(this.queryParams).then(function(r) { self.dataList = r.rows; self.total = r.total; }).finally(function() { self.loading = false; }); },
handleQuery() { this.queryParams.pageNum = 1; this.getList(); },
handleRowClick(row) { this.currentRow = row; this.loadDetail(row.planId); },
loadDetail(planId) { var self = this; this.detailLoading = true; getCountPlan(planId).then(function(r) { self.currentRow = r.data; }).finally(function() { self.detailLoading = false; }); },
handleRefreshDetail() { if (this.currentRow) this.loadDetail(this.currentRow.planId); },
handleAdd() { this.planForm = {}; this.planDialogTitle = '新增盘库计划'; this.planDialogOpen = true; },
handleUpdate(row) { var self = this; getCountPlan(row.planId).then(function(r) { self.planForm = r.data; self.planDialogTitle = '修改'; self.planDialogOpen = true; }); },
submitPlan() { var self = this; this.btnLoading = true; var api = this.planForm.planId ? updateCountPlan : addCountPlan; api(this.planForm).then(function() { self.$modal.msgSuccess('成功'); self.planDialogOpen = false; self.getList(); }).finally(function() { self.btnLoading = false; }); },
handleDelete(row) { var self = this; this.$modal.confirm('确认删除?').then(function() { return delCountPlan(row.planId); }).then(function() { self.$modal.msgSuccess('已删除'); self.currentRow = null; self.getList(); }).catch(function() {}); },
handleSubmitApproval() { var self = this; this.$modal.confirm('确认提交审批?').then(function() { self.submitLoading = true; return updateCountPlan({ planId: self.currentRow.planId, planStatus: 1 }); }).then(function() { self.$modal.msgSuccess('已提交'); self.loadDetail(self.currentRow.planId); self.getList(); }).finally(function() { self.submitLoading = false; }); },
handleAddWarehouse() { this.whForm = { warehouseId: undefined, actualWarehouseId: undefined, warehouseName: '', actualWarehouseName: '', ioStartTime: undefined, ioEndTime: undefined }; this.whDialogOpen = true; },
onWhChange(val) { if (val && this.$refs.wsRef) { var o = this.$refs.wsRef.warehouseOptions; var f = o.find(function(x) { return x.warehouseId === val; }); this.whForm.warehouseName = f ? f.warehouseName : ''; } else this.whForm.warehouseName = ''; },
onAwhChange(val) { if (val && this.$refs.awsRef) { var o = this.$refs.awsRef.options; var f = o.find(function(x) { return x.actualWarehouseId === val; }); this.whForm.actualWarehouseName = f ? f.actualWarehouseName : ''; } else this.whForm.actualWarehouseName = ''; },
submitWh() { var self = this; if (!this.whForm.warehouseId && !this.whForm.actualWarehouseId) { this.$modal.msgWarning('请至少选择一个库区'); return; } this.whBtnLoading = true; addCountPlanWarehouse({ planId: this.currentRow.planId, warehouseId: this.whForm.warehouseId || undefined, actualWarehouseId: this.whForm.actualWarehouseId || undefined, warehouseName: this.whForm.warehouseName || undefined, actualWarehouseName: this.whForm.actualWarehouseName || undefined, ioStartTime: this.whForm.ioStartTime, ioEndTime: this.whForm.ioEndTime }).then(function() { self.$modal.msgSuccess('绑定成功'); self.whDialogOpen = false; self.$refs.whPanel.refreshAll(); }).finally(function() { self.whBtnLoading = false; }); }
}
};
</script>
<style scoped>
.count-container { height: calc(100vh - 84px); }
.left-panel { display: flex; flex-direction: column; height: 100%; background: #f5f7fa; border-right: 1px solid #e4e7ed; }
.panel-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 14px 8px; background: #f5f7fa; }
.header-title { display: flex; align-items: center; gap: 6px; font-size: 14px; font-weight: 600; color: #303133; }
.header-title i { color: #409eff; font-size: 16px; }
.header-filter { width: 100px; }
.search-row { display: flex; align-items: center; gap: 6px; padding: 0 14px 10px; background: #f5f7fa; }
.list-body { flex: 1; overflow-y: auto; padding: 0 6px; }
.list-item { display: flex; align-items: center; padding: 10px 12px; margin-bottom: 2px; cursor: pointer; border-radius: 6px; transition: all 0.15s; }
.list-item:hover { background: #ebeef5; }
.list-item.active { background: #d9ecff; }
.list-item.active .item-title { color: #409eff; font-weight: 600; }
.item-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.item-title { font-size: 13px; font-weight: 500; color: #303133; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.item-sub { font-size: 12px; color: #909399; }
.item-meta { flex-shrink: 0; margin: 0 8px; }
.list-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 0; color: #c0c4cc; font-size: 13px; gap: 8px; }
.list-footer { border-top: 1px solid #e4e7ed; padding: 2px 8px 0; background: #f5f7fa; }
.right-panel { height: 100%; overflow-y: auto; padding: 12px 16px; background: #faf8f5; }
.detail-content { margin: 0 auto; background: #fff; padding: 28px 32px 36px; box-shadow: 0 1px 4px rgba(0,0,0,0.06), 0 2px 12px rgba(0,0,0,0.04); min-height: 100%; }
.empty-tip { display: flex; align-items: center; justify-content: center; height: 100%; color: #909399; font-size: 14px; gap: 8px; }
.doc-header { margin-bottom: 18px; padding-bottom: 14px; border-bottom: 2px solid #1a3c6e; }
.doc-header-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.doc-title-group { flex: 1; min-width: 0; }
.doc-title { font-size: 24px; font-weight: 700; color: #1a1a1a; line-height: 1.3; letter-spacing: 0.5px; }
.doc-subtitle { font-size: 12px; color: #8c8c8c; font-style: italic; letter-spacing: 0.8px; margin-top: 2px; }
.doc-header-right { flex-shrink: 0; }
.doc-status-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
.doc-status-label { font-size: 11px; color: #8c8c8c; letter-spacing: 0.3px; }
.detail-meta { display: flex; flex-wrap: wrap; gap: 16px; font-size: 12px; color: #909399; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid #e0dcd6; }
.detail-meta span { display: inline-flex; align-items: center; gap: 4px; }
.detail-meta i { font-size: 13px; }
.section-title { font-size: 15px; font-weight: 700; color: #1a1a1a; margin: 22px 0 12px; padding: 0 0 10px; border-bottom: 1px solid #d4d0c8; display: flex; align-items: center; gap: 10px; }
.section-title .en-sub { font-size: 11px; font-weight: 400; color: #8c8c8c; font-style: italic; }
.remark-content { padding: 12px 16px; background: #faf8f5; border: 1px solid #e8e4de; border-radius: 2px; font-size: 13px; line-height: 1.8; color: #1a1a1a; }
.section-gap { height: 16px; }
</style>

View File

@@ -1,139 +1,49 @@
<template>
<div class="app-approval-wrapper">
<div class="app-container count-container">
<div class="app-container count-container">
<DragResizePanel :initialSize="280" :minSize="280" :maxSize="600">
<template #panelA>
<div class="left-panel">
<div class="panel-header">
<div class="header-title">
<i class="el-icon-s-check"></i>
<span>盘库审批</span>
<el-button size="mini" type="text" icon="el-icon-refresh" @click="getList" style="margin-left:4px;" title="刷新列表"></el-button>
</div>
<div class="header-title"><i class="el-icon-s-check"></i><span>盘库审批</span><el-button size="mini" type="text" icon="el-icon-refresh" @click="getList" style="margin-left:4px;" title="刷新列表" /></div>
</div>
<div class="search-row">
<el-input v-model="queryParams.planCode" placeholder="搜索计划编号..." clearable prefix-icon="el-icon-search"
size="small" @keyup.enter.native="handleQuery" @clear="handleQuery" />
</div>
<div class="search-row"><el-input v-model="queryParams.planCode" placeholder="搜索计划编号..." clearable prefix-icon="el-icon-search" size="small" @keyup.enter.native="handleQuery" @clear="handleQuery" /></div>
<div v-loading="loading" class="list-body">
<div v-for="item in dataList" :key="item.planId" class="list-item"
:class="{ active: currentRow && currentRow.planId === item.planId }" @click="handleRowClick(item)">
<div class="item-main">
<span class="item-title">{{ item.planCode }}</span>
<span class="item-sub">{{ item.planName }}</span>
</div>
<div class="item-meta">
<el-tag size="mini">待审批</el-tag>
</div>
<div class="item-actions">
<el-button size="mini" type="text" icon="el-icon-view" @click.stop="handleRowClick(item)"></el-button>
</div>
</div>
<div v-if="dataList.length === 0 && !loading" class="list-empty">
<i class="el-icon-folder-opened"></i>
<span>暂无待审批盘库计划</span>
<div v-for="item in dataList" :key="item.planId" class="list-item" :class="{ active: currentRow && currentRow.planId === item.planId }" @click="handleRowClick(item)">
<div class="item-main"><span class="item-title">{{ item.planCode }}</span><span class="item-sub">{{ item.planName }}</span></div>
<div class="item-meta"><el-tag size="mini">待审批</el-tag></div>
</div>
<div v-if="dataList.length === 0 && !loading" class="list-empty"><i class="el-icon-folder-opened"></i><span>暂无待审批盘库计划</span></div>
</div>
<div class="list-footer">
<pagination :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize"
@pagination="getList" />
</div>
<div class="list-footer"><pagination :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" /></div>
</div>
</template>
<template #panelB>
<div class="right-panel">
<div v-if="!currentRow" class="empty-tip">
<i class="el-icon-info"></i>
<span>请在左侧列表中选择一条盘库计划进行审批</span>
</div>
<div v-if="!currentRow" class="empty-tip"><i class="el-icon-info"></i><span>请在左侧列表中选择一条盘库计划进行审批</span></div>
<div v-else v-loading="detailLoading" class="detail-content">
<div class="doc-header">
<div class="doc-header-top">
<div class="doc-title-group">
<div class="doc-title">{{ currentRow.planCode }}</div>
<div class="doc-subtitle">Inventory Count Plan · Approval</div>
</div>
<div class="doc-header-right">
<el-button size="mini" type="text" icon="el-icon-refresh" @click="handleRefreshDetail" title="刷新详情">刷新</el-button>
</div>
</div>
<div class="doc-status-row">
<span class="doc-status-label">Status / 状态</span>
<el-tag size="small">待审批</el-tag>
<div class="doc-title-group"><div class="doc-title">{{ currentRow.planCode }}</div><div class="doc-subtitle">Approval</div></div>
<div class="doc-header-right"><el-button size="mini" type="text" icon="el-icon-refresh" @click="handleRefreshDetail">刷新</el-button></div>
</div>
<div class="doc-status-row"><span class="doc-status-label">Status</span><el-tag size="small">待审批</el-tag></div>
</div>
<div class="detail-meta">
<span><i class="el-icon-document"></i>{{ currentRow.planName }}</span>
<span v-if="currentRow.countDate"><i class="el-icon-date"></i>盘库日期: {{ parseTime(currentRow.countDate, '{y}-{m}-{d}') }}</span>
<span v-if="currentRow.deadlineTime"><i class="el-icon-time"></i>截止: {{ parseTime(currentRow.deadlineTime, '{y}-{m}-{d} {h}:{i}') }}</span>
<span v-if="currentRow.countUserName"><i class="el-icon-user-solid"></i>盘点人: {{ currentRow.countUserName }}</span>
<span v-if="currentRow.principalUserName"><i class="el-icon-s-custom"></i>负责人: {{ currentRow.principalUserName }}</span>
<span v-if="currentRow.participantNames"><i class="el-icon-user"></i>参与人: {{ currentRow.participantNames }}</span>
<span v-if="currentRow.countDate"><i class="el-icon-date"></i>{{ parseTime(currentRow.countDate, '{y}-{m}-{d}') }}</span>
<span v-if="currentRow.countUserName"><i class="el-icon-user-solid"></i>{{ currentRow.countUserName }}</span>
</div>
<CountFlowSection :planStatus="currentRow.planStatus" />
<CountFlowSection :planStatus="1" />
<el-divider />
<div class="section-title">
<span>审批操作 <span class="en-sub">· Approval Actions</span></span>
</div>
<div class="flow-actions">
<el-button type="danger" size="small" icon="el-icon-close" :loading="rejectLoading" @click="handleReject">驳回</el-button>
<el-button type="primary" size="small" icon="el-icon-check" :loading="approveLoading" @click="handleApprove">审批通过</el-button>
</div>
<el-divider />
<div class="section-title">
<span>库区盘点明细 <span class="en-sub">· Warehouse Count Details</span></span>
</div>
<el-table v-loading="warehouseLoading" :data="warehouseList" border size="small" style="width:100%" row-key="relId">
<el-table-column label="逻辑库区" align="center" prop="warehouseName" />
<el-table-column label="实际库区" align="center" prop="actualWarehouseName" />
<el-table-column label="出入库查询起始" align="center" width="160">
<template slot-scope="scope">{{ parseTime(scope.row.ioStartTime, '{y}-{m}-{d} {h}:{i}') || '-' }}</template>
</el-table-column>
<el-table-column label="出入库查询截止" align="center" width="160">
<template slot-scope="scope">{{ parseTime(scope.row.ioEndTime, '{y}-{m}-{d} {h}:{i}') || '-' }}</template>
</el-table-column>
</el-table>
<div class="section-gap" />
<!-- 差异列表审批人逐条标记需处理 -->
<div class="section-title">
<span>差异项标记 <span class="en-sub">· Mark Items for Resolution</span></span>
</div>
<el-table v-loading="discLoading" :data="discList" border size="small" style="width:100%">
<el-table-column label="差异类型" align="center" width="100">
<template slot-scope="scope">
<el-tag v-if="scope.row.discrepancyType === 1" type="success" size="mini">盘盈</el-tag>
<el-tag v-else-if="scope.row.discrepancyType === 2" type="danger" size="mini">盘亏</el-tag>
<el-tag v-else-if="scope.row.discrepancyType === 3" type="warning" size="mini">状态不符</el-tag>
<el-tag v-else-if="scope.row.discrepancyType === 4" size="mini">重量偏差</el-tag>
</template>
</el-table-column>
<el-table-column label="钢卷号" align="center" prop="enterCoilNo" width="160" />
<el-table-column label="差异详情" align="center" prop="discrepancyDetail" min-width="200" show-overflow-tooltip />
<el-table-column label="需处理" align="center" width="80">
<template slot-scope="scope">
<el-tag v-if="scope.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="100">
<template slot-scope="scope">
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleEditDiscMark(scope.row)">标记</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="discList.length === 0 && !discLoading" style="padding:12px 0;color:#909399;font-size:13px;">暂无差异记录</div>
<WarehouseDetailPanel ref="whPanel"
:planId="currentRow.planId"
:planStatus="1"
@approve="handleApprove"
@reject="handleReject"
@mark-disc="handleMarkDisc"
@preview="handlePreview"
/>
<div class="section-gap" />
<div class="section-title">备注 <span class="en-sub">· Remarks</span></div>
@@ -142,520 +52,127 @@
</div>
</template>
</DragResizePanel>
</div>
<!-- 标记需处理对话框 -->
<el-dialog title="标记差异项" :visible.sync="markDialogVisible" width="500px" append-to-body>
<el-form ref="markForm" :model="markForm" label-width="100px">
<el-form-item label="差异类型">
<el-tag v-if="markForm.discrepancyType === 1" type="success"></el-tag>
<el-tag v-else-if="markForm.discrepancyType === 2" type="danger">盘亏</el-tag>
<el-tag v-else-if="markForm.discrepancyType === 3" type="warning">状态不符</el-tag>
<el-tag v-else-if="markForm.discrepancyType === 4">重量偏差</el-tag>
</el-form-item>
<el-form-item label="钢卷号">{{ markForm.enterCoilNo }}</el-form-item>
<el-form-item label="差异详情">{{ markForm.discrepancyDetail }}</el-form-item>
<el-form-item label="需要处理">
<el-switch v-model="markForm.needResolve" active-text="是" inactive-text="否" />
</el-form-item>
<el-form-item label="审批意见">
<el-input v-model="markForm.approveRemark" type="textarea" :rows="3" placeholder="请输入审批意见" />
</el-form-item>
</el-form>
<div slot="footer">
<el-button type="primary" @click="submitDiscMark"> </el-button>
<el-button @click="markDialogVisible = false"> </el-button>
</div>
</el-dialog>
<el-dialog title="标记差异项" :visible.sync="markDialogVisible" width="500px" append-to-body>
<el-form :model="markForm" label-width="100px">
<el-form-item label="差异类型">
<el-tag v-if="markForm.discrepancyType === 1" type="success">盘盈</el-tag>
<el-tag v-else-if="markForm.discrepancyType === 2" type="danger"></el-tag>
<el-tag v-else-if="markForm.discrepancyType === 3" type="warning">不符</el-tag>
<el-tag v-else>偏差</el-tag>
</el-form-item>
<el-form-item label="钢卷号">{{ markForm.enterCoilNo }}</el-form-item>
<el-form-item label="差异详情">{{ markForm.discrepancyDetail }}</el-form-item>
<el-form-item label="须处理"><el-switch v-model="markForm.needResolve" active-text="是" inactive-text="否" /></el-form-item>
<el-form-item label="审批意见"><el-input v-model="markForm.approveRemark" type="textarea" :rows="3" placeholder="审批意见" /></el-form-item>
</el-form>
<div slot="footer"><el-button type="primary" @click="submitMark">确定</el-button><el-button @click="markDialogVisible = false">取消</el-button></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(){ $modal.msgError('预览失败'); }" /><span v-else>加载中...</span></div>
</el-dialog>
</div>
</template>
<script>
import { listCountPlan, getCountPlan, updateCountPlan } from "@/api/flow/countPlan";
import { listCountPlanWarehouse } from "@/api/flow/countPlanWarehouse";
import { listCountDiscrepancy, updateCountDiscrepancy } from "@/api/flow/countDiscrepancy";
import { updateCountDiscrepancy } from "@/api/flow/countDiscrepancy";
import DragResizePanel from "@/components/DragResizePanel/index.vue";
import CountFlowSection from "./components/CountFlowSection.vue";
import WarehouseDetailPanel from "./components/WarehouseDetailPanel.vue";
import VueOfficeExcel from '@vue-office/excel';
import '@vue-office/excel/lib/index.css';
import { listByIds } from '@/api/system/oss';
import { parseTime } from '@/utils/klp'
export default {
name: "InvCountApproval",
components: { DragResizePanel, CountFlowSection },
components: { DragResizePanel, CountFlowSection, WarehouseDetailPanel, VueOfficeExcel },
data() {
return {
loading: false,
detailLoading: false,
warehouseLoading: false,
approveLoading: false,
rejectLoading: false,
discLoading: false,
total: 0,
queryParams: {
pageNum: 1,
pageSize: 10,
planCode: undefined,
planStatus: 1
},
dataList: [],
currentRow: null,
warehouseList: [],
discList: [],
markDialogVisible: false,
markForm: {
discrepancyId: null,
discrepancyType: null,
enterCoilNo: '',
discrepancyDetail: '',
needResolve: false,
approveRemark: ''
}
loading: false, detailLoading: false, total: 0,
queryParams: { pageNum: 1, pageSize: 10, planCode: undefined, planStatus: 1 },
dataList: [], currentRow: null,
markDialogVisible: false, markForm: { discrepancyId: null, discrepancyType: null, enterCoilNo: '', discrepancyDetail: '', needResolve: false, approveRemark: '' },
previewOpen: false, previewUrl: ''
};
},
created() {
this.getList();
},
created() { this.getList(); },
methods: {
parseTime,
getList() {
this.loading = true;
var self = this;
listCountPlan(this.queryParams).then(function(response) {
self.dataList = response.rows;
self.total = response.total;
self.loading = false;
});
},
handleQuery() {
this.queryParams.pageNum = 1;
this.getList();
},
handleRowClick(row) {
this.currentRow = row;
this.loadDetail(row.planId);
},
loadDetail(planId) {
this.detailLoading = true;
var self = this;
getCountPlan(planId).then(function(response) {
self.currentRow = response.data;
self.loadWarehouseList(planId);
}).finally(function() { self.detailLoading = false; });
},
loadWarehouseList(planId) {
this.warehouseLoading = true;
var self = this;
listCountPlanWarehouse({ planId: planId, pageNum: 1, pageSize: 999 }).then(function(r) {
self.warehouseList = r.rows || [];
self.loadDiscList(); // 仓库加载完成后加载差异
}).finally(function() { self.warehouseLoading = false; });
},
loadDiscList() {
this.discLoading = true;
this.discList = [];
var self = this;
if (this.warehouseList.length === 0) {
this.discLoading = false;
return;
}
var promises = this.warehouseList.map(function(wh) {
return listCountDiscrepancy({ relId: wh.relId, pageNum: 1, pageSize: 999 });
});
Promise.all(promises).then(function(results) {
var all = [];
results.forEach(function(r) { all = all.concat(r.rows || []); });
self.discList = all;
}).finally(function() { self.discLoading = false; });
},
handleRefreshDetail() {
if (this.currentRow && this.currentRow.planId) {
this.loadDetail(this.currentRow.planId);
}
},
handleEditDiscMark(row) {
this.markForm = {
discrepancyId: row.discrepancyId,
discrepancyType: row.discrepancyType,
enterCoilNo: row.enterCoilNo,
discrepancyDetail: row.discrepancyDetail,
needResolve: row.processStatus === 1,
approveRemark: row.remark || ''
};
this.markDialogVisible = true;
},
submitDiscMark() {
var self = this;
var newStatus = this.markForm.needResolve ? 1 : 0;
updateCountDiscrepancy({
discrepancyId: this.markForm.discrepancyId,
processStatus: newStatus,
remark: this.markForm.approveRemark
}).then(function() {
self.$modal.msgSuccess('标记已保存');
self.markDialogVisible = false;
self.loadDiscList();
}).catch(function() {
self.$modal.msgError('保存失败');
});
},
getList() { var self = this; this.loading = true; listCountPlan(this.queryParams).then(function(r) { self.dataList = r.rows; self.total = r.total; }).finally(function() { self.loading = false; }); },
handleQuery() { this.queryParams.pageNum = 1; this.getList(); },
handleRowClick(row) { this.currentRow = row; this.loadDetail(row.planId); },
loadDetail(planId) { var self = this; this.detailLoading = true; getCountPlan(planId).then(function(r) { self.currentRow = r.data; }).finally(function() { self.detailLoading = false; }); },
handleRefreshDetail() { if (this.currentRow) this.loadDetail(this.currentRow.planId); },
handleApprove() {
var self = this;
this.$modal.confirm('确认通过"' + this.currentRow.planCode + '"的审批?通过后盘库计划将进入差异处理阶段。').then(function() {
self.approveLoading = true;
this.$modal.confirm('确认通过"' + this.currentRow.planCode + '"的审批?').then(function() {
return updateCountPlan({ planId: self.currentRow.planId, planStatus: 3 });
}).then(function() {
self.$modal.msgSuccess("审批通过,盘库计划已进入差异处理阶段");
self.approveLoading = false;
self.currentRow = null;
self.getList();
}).catch(function() { self.approveLoading = false; });
}).then(function() { self.$modal.msgSuccess("审批通过"); self.currentRow = null; self.getList(); });
},
handleReject() {
var self = this;
this.$prompt('请输入驳回原因', '驳回审批', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputType: 'textarea',
inputValidator: function(val) { return val ? true : '驳回原因不能为空'; }
}).then(function({ value }) {
self.rejectLoading = true;
this.$prompt('请输入驳回原因', '驳回审批', { inputType: 'textarea', inputValidator: function(v) { return v ? true : '驳回原因不能为空'; } }).then(function({ value }) {
return updateCountPlan({ planId: self.currentRow.planId, planStatus: 0, remark: value });
}).then(function() {
self.$modal.msgSuccess("已驳回,盘库计划返回草稿状态");
self.rejectLoading = false;
self.currentRow = null;
self.getList();
}).catch(function() { self.rejectLoading = false; });
}).then(function() { self.$modal.msgSuccess("已驳回"); self.currentRow = null; self.getList(); });
},
handleMarkDisc(row) {
this.markForm = { discrepancyId: row.discrepancyId, discrepancyType: row.discrepancyType, enterCoilNo: row.enterCoilNo, discrepancyDetail: row.discrepancyDetail, needResolve: row.processStatus === 1, approveRemark: row.remark || '' };
this.markDialogVisible = true;
},
submitMark() {
var self = this;
updateCountDiscrepancy({ discrepancyId: this.markForm.discrepancyId, processStatus: this.markForm.needResolve ? 1 : 0, remark: this.markForm.approveRemark }).then(function() {
self.$modal.msgSuccess('标记已保存'); self.markDialogVisible = false;
var wh = self.$refs.whPanel.getActiveWarehouse();
if (wh) self.$refs.whPanel.refreshOneDisc(wh.relId);
}).catch(function() { self.$modal.msgError('保存失败'); });
},
handlePreview(ossId, label) {
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.$modal.msgError('未找到'); self.previewOpen = false; } }).catch(function() { self.$modal.msgError('失败'); self.previewOpen = false; });
}
}
};
</script>
<style scoped>
.count-container {
height: calc(100vh - 84px);
}
/* ========== 左侧面板 ========== */
.left-panel {
display: flex;
flex-direction: column;
height: 100%;
background: #f5f7fa;
border-right: 1px solid #e4e7ed;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px 8px;
background: #f5f7fa;
}
.header-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: #303133;
}
.header-title i {
color: #409eff;
font-size: 16px;
}
.search-row {
display: flex;
align-items: center;
gap: 6px;
padding: 0 14px 10px;
background: #f5f7fa;
}
.list-body {
flex: 1;
overflow-y: auto;
padding: 0 6px;
}
.list-item {
display: flex;
align-items: center;
padding: 10px 12px;
margin-bottom: 2px;
cursor: pointer;
border-radius: 6px;
transition: all 0.15s;
}
.list-item:hover {
background: #ebeef5;
}
.list-item.active {
background: #d9ecff;
}
.list-item.active .item-title {
color: #409eff;
font-weight: 600;
}
.item-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.item-title {
font-size: 13px;
font-weight: 500;
color: #303133;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-sub {
font-size: 12px;
color: #909399;
}
.item-meta {
flex-shrink: 0;
margin: 0 8px;
}
.item-actions {
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s;
}
.list-item:hover .item-actions {
opacity: 1;
}
.list-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 0;
color: #c0c4cc;
font-size: 13px;
gap: 8px;
}
.list-empty i {
font-size: 32px;
}
.list-footer {
border-top: 1px solid #e4e7ed;
padding: 2px 8px 0;
background: #f5f7fa;
}
/* ========== 右侧面板 — Word 文档风格 ========== */
.right-panel {
height: 100%;
overflow-y: auto;
padding: 12px 16px;
background: #faf8f5;
}
.right-panel .detail-content {
margin: 0 auto;
background: #ffffff;
padding: 28px 32px 36px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06), 0 2px 12px rgba(0,0,0,0.04);
min-height: 100%;
}
.empty-tip {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #909399;
font-size: 14px;
gap: 8px;
}
.doc-header {
margin-bottom: 18px;
padding-bottom: 14px;
border-bottom: 2px solid #1a3c6e;
}
.doc-header-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.doc-title-group {
flex: 1;
min-width: 0;
}
.doc-title {
font-family: 'Georgia', 'Times New Roman', 'Noto Serif SC', 'SimSun', serif;
font-size: 24px;
font-weight: 700;
color: #1a1a1a;
line-height: 1.3;
letter-spacing: 0.5px;
}
.doc-subtitle {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 12px;
font-weight: 400;
color: #8c8c8c;
font-style: italic;
letter-spacing: 0.8px;
margin-top: 2px;
}
.doc-header-right {
flex-shrink: 0;
}
.doc-status-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
}
.doc-status-label {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 11px;
color: #8c8c8c;
letter-spacing: 0.3px;
}
.detail-meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
font-size: 12px;
color: #909399;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #e0dcd6;
}
.detail-meta span {
display: inline-flex;
align-items: center;
gap: 4px;
}
.detail-meta i {
font-size: 13px;
}
.section-title {
font-family: 'Georgia', 'Times New Roman', 'Noto Serif SC', 'SimSun', serif;
width: 100%;
font-size: 15px;
font-weight: 700;
color: #1a1a1a;
margin: 22px 0 12px 0;
padding: 0 0 10px 0;
border-bottom: 1px solid #d4d0c8;
display: flex;
align-items: center;
gap: 10px;
letter-spacing: 0.3px;
}
.section-title:first-child {
margin-top: 0;
}
.section-title .en-sub {
font-size: 11px;
font-weight: 400;
color: #8c8c8c;
letter-spacing: 0.5px;
font-family: 'Georgia', 'Times New Roman', serif;
font-style: italic;
}
.flow-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.remark-content {
padding: 12px 16px;
background: #faf8f5;
border: 1px solid #e8e4de;
border-radius: 2px;
font-size: 13px;
line-height: 1.8;
color: #1a1a1a;
}
.section-gap {
height: 16px;
}
.right-panel .el-table {
border: 1px solid #e8e4de !important;
border-radius: 2px !important;
font-size: 12px !important;
}
.right-panel .el-table thead th {
background-color: #2c3e50 !important;
color: #ffffff !important;
font-weight: 600 !important;
font-size: 11px !important;
letter-spacing: 0.5px !important;
border-bottom: none !important;
font-family: 'Georgia', 'Times New Roman', serif;
}
.right-panel .el-table thead th .cell {
color: #ffffff !important;
}
.right-panel .el-table__body tr:hover > td {
background-color: #f7f5f0 !important;
}
.right-panel .el-table--border td {
border-right: 1px solid #f0ece6 !important;
}
.right-panel .el-table--border th {
border-right: 1px solid #3a5166 !important;
}
.right-panel .el-table td {
padding: 6px 4px !important;
color: #3a3a3a !important;
}
.right-panel .el-divider--horizontal {
margin: 8px 0 4px;
background-color: #e0dcd6;
}
.right-panel .el-tag {
border-radius: 2px;
font-family: 'Georgia', 'Times New Roman', serif;
letter-spacing: 0.3px;
}
.count-container { height: calc(100vh - 84px); }
.left-panel { display: flex; flex-direction: column; height: 100%; background: #f5f7fa; border-right: 1px solid #e4e7ed; }
.panel-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 14px 8px; background: #f5f7fa; }
.header-title { display: flex; align-items: center; gap: 6px; font-size: 14px; font-weight: 600; color: #303133; }
.header-title i { color: #409eff; font-size: 16px; }
.search-row { display: flex; align-items: center; gap: 6px; padding: 0 14px 10px; background: #f5f7fa; }
.list-body { flex: 1; overflow-y: auto; padding: 0 6px; }
.list-item { display: flex; align-items: center; padding: 10px 12px; margin-bottom: 2px; cursor: pointer; border-radius: 6px; transition: all 0.15s; }
.list-item:hover { background: #ebeef5; }
.list-item.active { background: #d9ecff; }
.list-item.active .item-title { color: #409eff; font-weight: 600; }
.item-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.item-title { font-size: 13px; font-weight: 500; color: #303133; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.item-sub { font-size: 12px; color: #909399; }
.item-meta { flex-shrink: 0; margin: 0 8px; }
.list-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 0; color: #c0c4cc; font-size: 13px; gap: 8px; }
.list-empty i { font-size: 32px; }
.list-footer { border-top: 1px solid #e4e7ed; padding: 2px 8px 0; background: #f5f7fa; }
.right-panel { height: 100%; overflow-y: auto; padding: 12px 16px; background: #faf8f5; }
.detail-content { margin: 0 auto; background: #fff; padding: 28px 32px 36px; box-shadow: 0 1px 4px rgba(0,0,0,0.06), 0 2px 12px rgba(0,0,0,0.04); min-height: 100%; }
.empty-tip { display: flex; align-items: center; justify-content: center; height: 100%; color: #909399; font-size: 14px; gap: 8px; }
.doc-header { margin-bottom: 18px; padding-bottom: 14px; border-bottom: 2px solid #1a3c6e; }
.doc-header-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.doc-title-group { flex: 1; min-width: 0; }
.doc-title { font-size: 24px; font-weight: 700; color: #1a1a1a; line-height: 1.3; letter-spacing: 0.5px; }
.doc-subtitle { font-size: 12px; color: #8c8c8c; font-style: italic; letter-spacing: 0.8px; margin-top: 2px; }
.doc-header-right { flex-shrink: 0; }
.doc-status-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
.doc-status-label { font-size: 11px; color: #8c8c8c; letter-spacing: 0.3px; }
.detail-meta { display: flex; flex-wrap: wrap; gap: 16px; font-size: 12px; color: #909399; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid #e0dcd6; }
.detail-meta span { display: inline-flex; align-items: center; gap: 4px; }
.detail-meta i { font-size: 13px; }
.section-title { font-size: 15px; font-weight: 700; color: #1a1a1a; margin: 22px 0 12px; padding: 0 0 10px; border-bottom: 1px solid #d4d0c8; display: flex; align-items: center; gap: 10px; }
.section-title .en-sub { font-size: 11px; font-weight: 400; color: #8c8c8c; letter-spacing: 0.5px; font-style: italic; }
.remark-content { padding: 12px 16px; background: #faf8f5; border: 1px solid #e8e4de; border-radius: 2px; font-size: 13px; line-height: 1.8; color: #1a1a1a; }
.section-gap { height: 16px; }
</style>

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>

View File

@@ -6,162 +6,47 @@
<div class="panel-header">
<div class="header-title">
<i class="el-icon-s-data"></i>
<span>盘库执行</span>
<el-button size="mini" type="text" icon="el-icon-refresh" @click="getList" style="margin-left:4px;" title="刷新列表"></el-button>
<span>差异处理</span>
<el-button size="mini" type="text" icon="el-icon-refresh" @click="getList" title="刷新列表" />
</div>
<el-select v-model="queryParams.planStatus" placeholder="执行状态" clearable size="mini" @change="handleQuery" class="header-filter">
<el-option label="执行中" :value="2" />
<el-option label="差异处理中" :value="3" />
</el-select>
</div>
<div class="search-row">
<el-input v-model="queryParams.planCode" placeholder="搜索计划编号..." clearable prefix-icon="el-icon-search"
size="small" @keyup.enter.native="handleQuery" @clear="handleQuery" />
<el-input v-model="queryParams.planCode" placeholder="搜索计划编号..." clearable prefix-icon="el-icon-search" size="small" @keyup.enter.native="handleQuery" @clear="handleQuery" />
</div>
<div v-loading="loading" class="list-body">
<div v-for="item in dataList" :key="item.planId" class="list-item"
:class="{ active: currentRow && currentRow.planId === item.planId }" @click="handleRowClick(item)">
<div class="item-main">
<span class="item-title">{{ item.planCode }}</span>
<span class="item-sub">{{ item.planName }}</span>
</div>
<div class="item-meta">
<el-tag v-if="item.planStatus === 2" type="warning" size="mini">执行中</el-tag>
<el-tag v-else-if="item.planStatus === 3" type="danger" size="mini">差异处理中</el-tag>
</div>
<div class="item-actions">
<el-button size="mini" type="text" icon="el-icon-view" @click.stop="handleRowClick(item)"></el-button>
</div>
</div>
<div v-if="dataList.length === 0 && !loading" class="list-empty">
<i class="el-icon-folder-opened"></i>
<span>暂无执行中的盘库计划</span>
<div class="item-main"><span class="item-title">{{ item.planCode }}</span><span class="item-sub">{{ item.planName }}</span></div>
<div class="item-meta"><el-tag type="danger" size="mini">差异处理中</el-tag></div>
</div>
<div v-if="dataList.length === 0 && !loading" class="list-empty"><i class="el-icon-folder-opened"></i><span>暂无差异处理中的计划</span></div>
</div>
<div class="list-footer">
<pagination :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize"
@pagination="getList" />
</div>
<div class="list-footer"><pagination :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" /></div>
</div>
</template>
<template #panelB>
<div class="right-panel">
<div v-if="!currentRow" class="empty-tip">
<i class="el-icon-info"></i>
<span>请在左侧列表中选择一条盘库计划执行操作</span>
</div>
<div v-if="!currentRow" class="empty-tip"><i class="el-icon-info"></i><span>请在左侧列表中选择一条盘库计划</span></div>
<div v-else v-loading="detailLoading" class="detail-content">
<div class="doc-header">
<div class="doc-header-top">
<div class="doc-title-group">
<div class="doc-title">{{ currentRow.planCode }}</div>
<div class="doc-subtitle">Inventory Count Plan · Execution</div>
</div>
<div class="doc-header-right">
<el-button size="mini" type="text" icon="el-icon-refresh" @click="handleRefreshDetail" title="刷新详情">刷新</el-button>
</div>
</div>
<div class="doc-status-row">
<span class="doc-status-label">Status / 状态</span>
<el-tag v-if="currentRow.planStatus === 2" type="warning" size="small">执行中</el-tag>
<el-tag v-else-if="currentRow.planStatus === 3" type="danger" size="small">差异处理中</el-tag>
<div class="doc-title-group"><div class="doc-title">{{ currentRow.planCode }}</div><div class="doc-subtitle">Discrepancy Processing</div></div>
<div class="doc-header-right"><el-button size="mini" type="text" icon="el-icon-refresh" @click="handleRefreshDetail">刷新</el-button></div>
</div>
<div class="doc-status-row"><span class="doc-status-label">Status / 状态</span><el-tag type="danger" size="small">差异处理中</el-tag></div>
</div>
<div class="detail-meta">
<span><i class="el-icon-document"></i>{{ currentRow.planName }}</span>
<span v-if="currentRow.countDate"><i class="el-icon-date"></i>盘库日期: {{ parseTime(currentRow.countDate, '{y}-{m}-{d}') }}</span>
<span v-if="currentRow.deadlineTime"><i class="el-icon-time"></i>截止: {{ parseTime(currentRow.deadlineTime, '{y}-{m}-{d} {h}:{i}') }}</span>
<span v-if="currentRow.countUserName"><i class="el-icon-user-solid"></i>盘点人: {{ currentRow.countUserName }}</span>
<span v-if="currentRow.principalUserName"><i class="el-icon-s-custom"></i>负责人: {{ currentRow.principalUserName }}</span>
<span v-if="currentRow.participantNames"><i class="el-icon-user"></i>参与人: {{ currentRow.participantNames }}</span>
<span v-if="currentRow.countUserName"><i class="el-icon-user-solid"></i>{{ currentRow.countUserName }}</span>
</div>
<CountFlowSection :planStatus="currentRow.planStatus" />
<CountFlowSection :planStatus="3" />
<el-divider />
<div class="section-title">
<span>执行操作 <span class="en-sub">· Execution Actions</span></span>
</div>
<div class="flow-actions">
<el-button v-if="currentRow.planStatus === 2" type="primary" size="small" icon="el-icon-upload2" @click="handleUploadForPlan">上传实盘Excel</el-button>
<el-button v-if="currentRow.planStatus === 2" type="warning" size="small" icon="el-icon-s-data" :loading="generateLoading" @click="handleGenerateDiscrepancy">核对并生成差异报告</el-button>
<el-button v-if="currentRow.planStatus === 3" type="success" size="small" icon="el-icon-circle-check" :loading="archiveLoading" @click="handleArchive">归档封存</el-button>
</div>
<el-divider />
<div class="section-title">
<span>库区盘点明细 <span class="en-sub">· Warehouse Count Details</span></span>
</div>
<el-table v-loading="warehouseLoading" :data="warehouseList" border size="small" style="width:100%" row-key="relId">
<el-table-column type="expand" width="40">
<template slot-scope="scope">
<div v-loading="discrepancyLoadingMap[scope.row.relId]" style="padding: 8px 16px;">
<el-table v-if="discrepancyMap[scope.row.relId] && discrepancyMap[scope.row.relId].length > 0"
:data="discrepancyMap[scope.row.relId]" border size="small">
<el-table-column label="差异类型" align="center" width="100">
<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-if="ds.row.discrepancyType === 4" size="mini">重量偏差</el-tag>
</template>
</el-table-column>
<el-table-column label="钢卷号" align="center" prop="enterCoilNo" width="160" />
<el-table-column label="差异详情" align="center" prop="discrepancyDetail" min-width="200" show-overflow-tooltip />
<el-table-column label="原因分析" align="center" prop="reasonAnalysis" min-width="150" show-overflow-tooltip />
<el-table-column label="处理建议" align="center" prop="processSuggestion" min-width="150" show-overflow-tooltip />
<el-table-column label="处理结果" align="center" prop="processResult" min-width="150" show-overflow-tooltip />
<el-table-column label="处理状态" align="center" width="100">
<template slot-scope="ds">
<el-tag v-if="ds.row.processStatus === 0" type="info" size="mini">待处理</el-tag>
<el-tag v-else-if="ds.row.processStatus === 1" type="warning" size="mini">处理中</el-tag>
<el-tag v-else-if="ds.row.processStatus === 2" type="success" size="mini">已处理</el-tag>
</template>
</el-table-column>
<el-table-column label="处理人" align="center" prop="processUserName" width="100" />
<el-table-column label="操作" align="center" width="100" v-if="currentRow.planStatus === 3">
<template slot-scope="ds">
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleEditDiscrepancy(ds.row)">处理</el-button>
</template>
</el-table-column>
</el-table>
<span v-else style="color:#909399;font-size:13px;">暂无差异记录</span>
</div>
</template>
</el-table-column>
<el-table-column label="逻辑库区" align="center" prop="warehouseName" />
<el-table-column label="实际库区" align="center" prop="actualWarehouseName" />
<el-table-column label="出入库查询起始" align="center" width="140">
<template slot-scope="scope">{{ parseTime(scope.row.ioStartTime, '{y}-{m}-{d} {h}:{i}') || '-' }}</template>
</el-table-column>
<el-table-column label="出入库查询截止" align="center" width="140">
<template slot-scope="scope">{{ parseTime(scope.row.ioEndTime, '{y}-{m}-{d} {h}:{i}') || '-' }}</template>
</el-table-column>
<el-table-column label="系统钢卷数量" align="center" prop="systemCoilCount" width="100" />
<el-table-column label="系统总重量(kg)" align="center" prop="systemTotalWeight" width="110" />
<el-table-column label="实盘钢卷数量" align="center" prop="actualCoilCount" width="100" />
<el-table-column label="实盘总重量(kg)" align="center" prop="actualTotalWeight" width="110" />
<el-table-column label="账实一致" align="center" width="80">
<template slot-scope="scope">
<el-tag v-if="scope.row.isConsistent === 1" type="success" size="mini">一致</el-tag>
<el-tag v-else-if="scope.row.isConsistent === 0" type="danger" size="mini">不一致</el-tag>
<span v-else style="color:#909399">未盘点</span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="120" fixed="right">
<template slot-scope="scope">
<el-button v-if="currentRow.planStatus === 2" size="mini" type="text" icon="el-icon-upload2" @click="handleUploadExcel(scope.row)">上传Excel</el-button>
<el-button size="mini" type="text" icon="el-icon-view" @click="handleViewDiscrepancy(scope.row)">差异</el-button>
</template>
</el-table-column>
</el-table>
<WarehouseDetailPanel ref="whPanel"
:planId="currentRow.planId"
:planStatus="3"
@process-disc="handleProcessDisc"
/>
<div class="section-gap" />
<div class="section-title">备注 <span class="en-sub">· Remarks</span></div>
@@ -170,590 +55,89 @@
</div>
</template>
</DragResizePanel>
<el-dialog title="上传实盘Excel" :visible.sync="uploadDialogVisible" width="500px" append-to-body>
<el-upload
ref="upload"
class="upload-demo"
drag
:action="uploadAction"
:headers="uploadHeaders"
:before-upload="beforeUpload"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
:data="{ relId: uploadingRelId }"
:file-list="uploadFileList"
:auto-upload="false"
accept=".xlsx,.xls">
<i class="el-icon-upload"></i>
<div class="el-upload__text">将实盘Excel文件拖到此处<em>点击上传</em></div>
<div class="el-upload__tip" slot="tip">仅支持 .xlsx / .xls 格式</div>
</el-upload>
<div slot="footer" class="dialog-footer">
<el-button @click="uploadDialogVisible = false"> </el-button>
<el-button type="primary" @click="submitUpload"> </el-button>
</div>
</el-dialog>
<el-dialog title="差异处理" :visible.sync="discDialogVisible" width="600px" append-to-body>
<el-form ref="discForm" :model="discForm" label-width="100px">
<el-form-item label="差异类型">
<el-tag v-if="discForm.discrepancyType === 1" type="success">盘盈</el-tag>
<el-tag v-else-if="discForm.discrepancyType === 2" type="danger">盘亏</el-tag>
<el-tag v-else-if="discForm.discrepancyType === 3" type="warning">状态不符</el-tag>
<el-tag v-else-if="discForm.discrepancyType === 4">重量偏差</el-tag>
</el-form-item>
<el-form-item label="钢卷号">
<span>{{ discForm.enterCoilNo }}</span>
</el-form-item>
<el-form-item label="差异详情">
<span>{{ discForm.discrepancyDetail }}</span>
</el-form-item>
<el-form-item label="原因分析" prop="reasonAnalysis">
<el-input v-model="discForm.reasonAnalysis" type="textarea" :rows="2" placeholder="请输入原因分析" />
</el-form-item>
<el-form-item label="处理建议" prop="processSuggestion">
<el-input v-model="discForm.processSuggestion" type="textarea" :rows="2" placeholder="请输入处理建议" />
</el-form-item>
<el-form-item label="处理结果" prop="processResult">
<el-input v-model="discForm.processResult" type="textarea" :rows="2" placeholder="请输入处理结果" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="discDialogVisible = false"> </el-button>
<el-button :loading="discButtonLoading" type="primary" @click="submitDiscrepancyForm"> </el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listCountPlan, getCountPlan, updateCountPlan } from "@/api/flow/countPlan";
import { listCountPlanWarehouse } from "@/api/flow/countPlanWarehouse";
import { listCountDiscrepancy, updateCountDiscrepancy } from "@/api/flow/countDiscrepancy";
import { updateCountDiscrepancy } from "@/api/flow/countDiscrepancy";
import DragResizePanel from "@/components/DragResizePanel/index.vue";
import CountFlowSection from "./components/CountFlowSection.vue";
import { getToken } from '@/utils/auth'
import WarehouseDetailPanel from "./components/WarehouseDetailPanel.vue";
import { parseTime } from '@/utils/klp'
export default {
name: "InvCountExecute",
components: { DragResizePanel, CountFlowSection },
components: { DragResizePanel, CountFlowSection, WarehouseDetailPanel },
data() {
return {
loading: false,
detailLoading: false,
warehouseLoading: false,
generateLoading: false,
archiveLoading: false,
discButtonLoading: false,
total: 0,
queryParams: {
pageNum: 1,
pageSize: 10,
planCode: undefined,
planStatus: undefined
},
dataList: [],
currentRow: null,
warehouseList: [],
discrepancyMap: {},
discrepancyLoadingMap: {},
// upload
uploadDialogVisible: false,
uploadFileList: [],
uploadingRelId: null,
// discrepancy dialog
discDialogVisible: false,
discForm: {}
loading: false, detailLoading: false, total: 0,
queryParams: { pageNum: 1, pageSize: 10, planCode: undefined, planStatus: 3 },
dataList: [], currentRow: null
};
},
computed: {
uploadAction() {
return process.env.VUE_APP_BASE_API + "/flow/countPlan/uploadExcel";
},
uploadHeaders() {
return { Authorization: "Bearer " + getToken() };
}
},
created() {
this.getList();
},
created() { this.getList(); },
methods: {
parseTime,
getList() {
this.loading = true;
getList() { var self = this; this.loading = true; listCountPlan(this.queryParams).then(function(r) { self.dataList = r.rows; self.total = r.total; }).finally(function() { self.loading = false; }); },
handleQuery() { this.queryParams.pageNum = 1; this.getList(); },
handleRowClick(row) { this.currentRow = row; this.detailLoading = true; getCountPlan(row.planId).then(function(r) { self.currentRow = r.data; }).finally(function() { self.detailLoading = false; }); },
handleRefreshDetail() { if (this.currentRow) { var self = this; this.detailLoading = true; getCountPlan(this.currentRow.planId).then(function(r) { self.currentRow = r.data; }).finally(function() { self.detailLoading = false; }); } },
handleProcessDisc({ row, relId }) {
var self = this;
listCountPlan(this.queryParams).then(function(response) {
self.dataList = response.rows;
self.total = response.total;
self.loading = false;
});
},
handleQuery() {
this.queryParams.pageNum = 1;
this.getList();
},
handleRowClick(row) {
this.currentRow = row;
this.loadDetail(row.planId);
},
loadDetail(planId) {
this.detailLoading = true;
var self = this;
getCountPlan(planId).then(function(response) {
self.currentRow = response.data;
self.loadWarehouseList(planId);
}).finally(function() { self.detailLoading = false; });
},
loadWarehouseList(planId) {
this.warehouseLoading = true;
var self = this;
listCountPlanWarehouse({ planId: planId, pageNum: 1, pageSize: 999 }).then(function(r) {
self.warehouseList = r.rows || [];
}).finally(function() { self.warehouseLoading = false; });
},
handleRefreshDetail() {
if (this.currentRow && this.currentRow.planId) {
this.loadDetail(this.currentRow.planId);
}
},
handleViewDiscrepancy(row) {
var relId = row.relId;
if (this.discrepancyMap[relId]) {
return;
}
this.$set(this.discrepancyLoadingMap, relId, true);
var self = this;
listCountDiscrepancy({ relId: relId, pageNum: 1, pageSize: 999 }).then(function(r) {
self.$set(self.discrepancyMap, relId, r.rows || []);
}).finally(function() {
self.$set(self.discrepancyLoadingMap, relId, false);
});
},
// ---- Upload ----
handleUploadExcel(row) {
this.uploadingRelId = row.relId;
this.uploadFileList = [];
this.uploadDialogVisible = true;
},
handleUploadForPlan() {
if (this.warehouseList.length === 0) {
this.$modal.msgWarning("无关联库区,无需上传");
return;
}
this.handleUploadExcel(this.warehouseList[0]);
},
beforeUpload(file) {
var isExcel = file.name.endsWith('.xlsx') || file.name.endsWith('.xls');
if (!isExcel) {
this.$modal.msgError("仅支持 .xlsx / .xls 格式文件");
return false;
}
return true;
},
submitUpload() {
this.$refs.upload.submit();
},
handleUploadSuccess(response, file, fileList) {
if (response.code === 200) {
this.$modal.msgSuccess("Excel上传成功");
this.uploadDialogVisible = false;
this.loadWarehouseList(this.currentRow.planId);
} else {
this.$modal.msgError(response.msg || "上传失败");
}
},
handleUploadError(err, file, fileList) {
this.$modal.msgError("文件上传失败,请重试");
},
// ---- Workflow actions ----
handleGenerateDiscrepancy() {
var self = this;
this.$modal.confirm('确认将系统库存快照与实盘Excel数据进行核对系统将自动逐项比对并找出差异。').then(function() {
self.generateLoading = true;
return updateCountPlan({ planId: self.currentRow.planId, planStatus: 3 });
this.$modal.confirm('确认将该差异项标记为已处理?', '处理确认', { confirmButtonText: '确认处理', cancelButtonText: '取消', type: 'warning' }).then(function() {
var now = new Date();
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
return updateCountDiscrepancy({
discrepancyId: row.discrepancyId,
processResult: row._processResult || '',
processStatus: 2,
processTime: now.getFullYear() + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate()) + ' ' + pad(now.getHours()) + ':' + pad(now.getMinutes()) + ':' + pad(now.getSeconds())
});
}).then(function() {
self.$modal.msgSuccess("差异报告已生成");
self.generateLoading = false;
self.loadDetail(self.currentRow.planId);
self.getList();
}).catch(function() { self.generateLoading = false; });
},
handleArchive() {
var self = this;
this.$modal.confirm('确认将盘库计划"' + this.currentRow.planCode + '"归档封存?归档后数据将不可修改。').then(function() {
self.archiveLoading = true;
return updateCountPlan({ planId: self.currentRow.planId, planStatus: 4 });
}).then(function() {
self.$modal.msgSuccess("盘库计划已归档");
self.archiveLoading = false;
self.currentRow = null;
self.getList();
}).catch(function() { self.archiveLoading = false; });
},
// ---- Discrepancy handling ----
handleEditDiscrepancy(row) {
this.discForm = Object.assign({}, row);
this.discDialogVisible = true;
},
submitDiscrepancyForm() {
var self = this;
this.discButtonLoading = true;
updateCountDiscrepancy({
discrepancyId: this.discForm.discrepancyId,
reasonAnalysis: this.discForm.reasonAnalysis,
processSuggestion: this.discForm.processSuggestion,
processResult: this.discForm.processResult,
processStatus: this.discForm.processResult ? 2 : this.discForm.processStatus
}).then(function() {
self.$modal.msgSuccess("差异处理保存成功");
self.discDialogVisible = false;
var relId = self.discForm.relId;
if (relId) {
self.$set(self.discrepancyMap, relId, undefined);
self.handleViewDiscrepancy({ relId: relId });
}
}).finally(function() { self.discButtonLoading = false; });
self.$modal.msgSuccess('已处理');
self.$refs.whPanel.refreshOneDisc(relId);
}).catch(function(err) { if (err !== 'cancel') self.$modal.msgError('处理失败'); });
}
}
};
</script>
<style scoped>
.count-container {
height: calc(100vh - 84px);
}
/* ========== 左侧面板 ========== */
.left-panel {
display: flex;
flex-direction: column;
height: 100%;
background: #f5f7fa;
border-right: 1px solid #e4e7ed;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px 8px;
background: #f5f7fa;
}
.header-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: #303133;
}
.header-title i {
color: #409eff;
font-size: 16px;
}
.header-filter {
width: 130px;
}
.search-row {
display: flex;
align-items: center;
gap: 6px;
padding: 0 14px 10px;
background: #f5f7fa;
}
.list-body {
flex: 1;
overflow-y: auto;
padding: 0 6px;
}
.list-item {
display: flex;
align-items: center;
padding: 10px 12px;
margin-bottom: 2px;
cursor: pointer;
border-radius: 6px;
transition: all 0.15s;
}
.list-item:hover {
background: #ebeef5;
}
.list-item.active {
background: #d9ecff;
}
.list-item.active .item-title {
color: #409eff;
font-weight: 600;
}
.item-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.item-title {
font-size: 13px;
font-weight: 500;
color: #303133;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-sub {
font-size: 12px;
color: #909399;
}
.item-meta {
flex-shrink: 0;
margin: 0 8px;
}
.item-actions {
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s;
}
.list-item:hover .item-actions {
opacity: 1;
}
.list-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 0;
color: #c0c4cc;
font-size: 13px;
gap: 8px;
}
.list-empty i {
font-size: 32px;
}
.list-footer {
border-top: 1px solid #e4e7ed;
padding: 2px 8px 0;
background: #f5f7fa;
}
/* ========== 右侧面板 — Word 文档风格 ========== */
.right-panel {
height: 100%;
overflow-y: auto;
padding: 12px 16px;
background: #faf8f5;
}
.right-panel .detail-content {
margin: 0 auto;
background: #ffffff;
padding: 28px 32px 36px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06), 0 2px 12px rgba(0,0,0,0.04);
min-height: 100%;
}
.empty-tip {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #909399;
font-size: 14px;
gap: 8px;
}
.doc-header {
margin-bottom: 18px;
padding-bottom: 14px;
border-bottom: 2px solid #1a3c6e;
}
.doc-header-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.doc-title-group {
flex: 1;
min-width: 0;
}
.doc-title {
font-family: 'Georgia', 'Times New Roman', 'Noto Serif SC', 'SimSun', serif;
font-size: 24px;
font-weight: 700;
color: #1a1a1a;
line-height: 1.3;
letter-spacing: 0.5px;
}
.doc-subtitle {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 12px;
font-weight: 400;
color: #8c8c8c;
font-style: italic;
letter-spacing: 0.8px;
margin-top: 2px;
}
.doc-header-right {
flex-shrink: 0;
}
.doc-status-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
}
.doc-status-label {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 11px;
color: #8c8c8c;
letter-spacing: 0.3px;
}
.detail-meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
font-size: 12px;
color: #909399;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #e0dcd6;
}
.detail-meta span {
display: inline-flex;
align-items: center;
gap: 4px;
}
.detail-meta i {
font-size: 13px;
}
.section-title {
font-family: 'Georgia', 'Times New Roman', 'Noto Serif SC', 'SimSun', serif;
width: 100%;
font-size: 15px;
font-weight: 700;
color: #1a1a1a;
margin: 22px 0 12px 0;
padding: 0 0 10px 0;
border-bottom: 1px solid #d4d0c8;
display: flex;
align-items: center;
gap: 10px;
letter-spacing: 0.3px;
}
.section-title:first-child {
margin-top: 0;
}
.section-title .en-sub {
font-size: 11px;
font-weight: 400;
color: #8c8c8c;
letter-spacing: 0.5px;
font-family: 'Georgia', 'Times New Roman', serif;
font-style: italic;
}
.flow-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.remark-content {
padding: 12px 16px;
background: #faf8f5;
border: 1px solid #e8e4de;
border-radius: 2px;
font-size: 13px;
line-height: 1.8;
color: #1a1a1a;
}
.section-gap {
height: 16px;
}
.right-panel .el-table {
border: 1px solid #e8e4de !important;
border-radius: 2px !important;
font-size: 12px !important;
}
.right-panel .el-table thead th {
background-color: #2c3e50 !important;
color: #ffffff !important;
font-weight: 600 !important;
font-size: 11px !important;
letter-spacing: 0.5px !important;
border-bottom: none !important;
font-family: 'Georgia', 'Times New Roman', serif;
}
.right-panel .el-table thead th .cell {
color: #ffffff !important;
}
.right-panel .el-table__body tr:hover > td {
background-color: #f7f5f0 !important;
}
.right-panel .el-table--border td {
border-right: 1px solid #f0ece6 !important;
}
.right-panel .el-table--border th {
border-right: 1px solid #3a5166 !important;
}
.right-panel .el-table td {
padding: 6px 4px !important;
color: #3a3a3a !important;
}
.right-panel .el-divider--horizontal {
margin: 8px 0 4px;
background-color: #e0dcd6;
}
.right-panel .el-tag {
border-radius: 2px;
font-family: 'Georgia', 'Times New Roman', serif;
letter-spacing: 0.3px;
}
.upload-demo {
text-align: center;
}
.count-container { height: calc(100vh - 84px); }
.left-panel { display: flex; flex-direction: column; height: 100%; background: #f5f7fa; border-right: 1px solid #e4e7ed; }
.panel-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 14px 8px; background: #f5f7fa; }
.header-title { display: flex; align-items: center; gap: 6px; font-size: 14px; font-weight: 600; color: #303133; }
.header-title i { color: #409eff; font-size: 16px; }
.search-row { display: flex; align-items: center; gap: 6px; padding: 0 14px 10px; background: #f5f7fa; }
.list-body { flex: 1; overflow-y: auto; padding: 0 6px; }
.list-item { display: flex; align-items: center; padding: 10px 12px; margin-bottom: 2px; cursor: pointer; border-radius: 6px; transition: all 0.15s; }
.list-item:hover { background: #ebeef5; }
.list-item.active { background: #d9ecff; }
.list-item.active .item-title { color: #409eff; font-weight: 600; }
.item-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.item-title { font-size: 13px; font-weight: 500; color: #303133; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.item-sub { font-size: 12px; color: #909399; }
.item-meta { flex-shrink: 0; margin: 0 8px; }
.list-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 0; color: #c0c4cc; font-size: 13px; gap: 8px; }
.list-empty i { font-size: 32px; }
.list-footer { border-top: 1px solid #e4e7ed; padding: 2px 8px 0; background: #f5f7fa; }
.right-panel { height: 100%; overflow-y: auto; padding: 12px 16px; background: #faf8f5; }
.detail-content { margin: 0 auto; background: #fff; padding: 28px 32px 36px; box-shadow: 0 1px 4px rgba(0,0,0,0.06), 0 2px 12px rgba(0,0,0,0.04); min-height: 100%; }
.empty-tip { display: flex; align-items: center; justify-content: center; height: 100%; color: #909399; font-size: 14px; gap: 8px; }
.doc-header { margin-bottom: 18px; padding-bottom: 14px; border-bottom: 2px solid #1a3c6e; }
.doc-header-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.doc-title-group { flex: 1; min-width: 0; }
.doc-title { font-size: 24px; font-weight: 700; color: #1a1a1a; line-height: 1.3; letter-spacing: 0.5px; }
.doc-subtitle { font-size: 12px; color: #8c8c8c; font-style: italic; letter-spacing: 0.8px; margin-top: 2px; }
.doc-header-right { flex-shrink: 0; }
.doc-status-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
.doc-status-label { font-size: 11px; color: #8c8c8c; letter-spacing: 0.3px; }
.detail-meta { display: flex; flex-wrap: wrap; gap: 16px; font-size: 12px; color: #909399; margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid #e0dcd6; }
.detail-meta span { display: inline-flex; align-items: center; gap: 4px; }
.detail-meta i { font-size: 13px; }
.section-title { font-size: 15px; font-weight: 700; color: #1a1a1a; margin: 22px 0 12px; padding: 0 0 10px; border-bottom: 1px solid #d4d0c8; display: flex; align-items: center; gap: 10px; }
.section-title .en-sub { font-size: 11px; font-weight: 400; color: #8c8c8c; letter-spacing: 0.5px; font-style: italic; }
.remark-content { padding: 12px 16px; background: #faf8f5; border: 1px solid #e8e4de; border-radius: 2px; font-size: 13px; line-height: 1.8; color: #1a1a1a; }
.section-gap { height: 16px; }
</style>

View File

@@ -12,7 +12,7 @@
<el-select v-model="queryParams.planStatus" placeholder="全部状态" clearable size="mini" @change="handleQuery" class="header-filter">
<el-option label="草稿" :value="0" />
<el-option label="待审批" :value="1" />
<el-option label="执行中" :value="2" />
<el-option label="差异处理中" :value="2" />
<el-option label="差异处理中" :value="3" />
<el-option label="已归档" :value="4" />
</el-select>
@@ -36,7 +36,7 @@
<div class="item-meta">
<el-tag v-if="item.planStatus === 0" type="info" size="mini">草稿</el-tag>
<el-tag v-else-if="item.planStatus === 1" size="mini">待审批</el-tag>
<el-tag v-else-if="item.planStatus === 2" type="warning" size="mini">执行</el-tag>
<el-tag v-else-if="item.planStatus === 2" type="warning" size="mini">差异处理</el-tag>
<el-tag v-else-if="item.planStatus === 3" type="danger" size="mini">差异处理中</el-tag>
<el-tag v-else-if="item.planStatus === 4" type="success" size="mini">已归档</el-tag>
</div>
@@ -81,7 +81,7 @@
<span class="doc-status-label">Status / 状态</span>
<el-tag v-if="currentRow.planStatus === 0" type="info" size="small">草稿</el-tag>
<el-tag v-else-if="currentRow.planStatus === 1" size="small">待审批</el-tag>
<el-tag v-else-if="currentRow.planStatus === 2" type="warning" size="small">执行</el-tag>
<el-tag v-else-if="currentRow.planStatus === 2" type="warning" size="small">差异处理</el-tag>
<el-tag v-else-if="currentRow.planStatus === 3" type="danger" size="small">差异处理中</el-tag>
<el-tag v-else-if="currentRow.planStatus === 4" type="success" size="small">已归档</el-tag>
</div>
@@ -106,7 +106,6 @@
<div class="flow-actions">
<el-button v-if="currentRow.planStatus === 0" type="primary" size="small" icon="el-icon-s-promotion" @click="handleSubmitApproval">提交审批</el-button>
<el-button v-if="currentRow.planStatus === 1" type="primary" size="small" icon="el-icon-s-promotion" @click="handleStartCount">审批通过开始盘库</el-button>
<el-button v-if="currentRow.planStatus === 2" type="warning" size="small" icon="el-icon-s-data" @click="handleGenerateDiscrepancy">核对并生成差异报告</el-button>
<el-button v-if="currentRow.planStatus === 3" type="success" size="small" icon="el-icon-circle-check" @click="handleArchive">归档封存</el-button>
</div>
@@ -115,270 +114,17 @@
<div class="section-title">
库区盘点明细 <span class="en-sub">· Warehouse Count Details</span>
<el-button v-if="currentRow.planStatus === 0" size="mini" type="primary" plain icon="el-icon-plus" style="margin-left: 8px;" @click="handleAddWarehouse">绑定库区</el-button>
<el-button v-if="warehouseList.length > 0 && currentRow.planStatus !== 2 && currentRow.planStatus !== 3 && currentRow.planStatus !== 4" size="mini" type="primary" plain icon="el-icon-camera" :loading="snapshotLoading" @click="handleGenerateAllSnapshots">全部生成快照</el-button>
</div>
<!-- ===== 自定义主 Tab每个仓库一个 ===== -->
<div class="doc-tabs" v-loading="warehouseLoading">
<div class="doc-tabs-header">
<div v-for="(wh, idx) in warehouseList" :key="wh.relId"
class="doc-tab-item"
:class="{ active: activeWarehouseIndex === String(idx) }"
@click="activeWarehouseIndex = String(idx); handleWarehouseTabChange(String(idx))">
<span class="doc-tab-label">{{ wh.warehouseName || wh.actualWarehouseName || '库区' + (idx+1) }}</span>
<span class="doc-tab-badge">{{ wh.systemCoilCount || 0 }}</span>
</div>
<div v-if="warehouseList.length === 0" class="doc-tabs-empty">暂无绑定库区</div>
</div>
<div class="doc-tabs-content">
<div v-for="(wh, idx) in warehouseList" :key="wh.relId"
v-show="activeWarehouseIndex === String(idx)">
<WarehouseDetailPanel ref="whPanel"
:planId="currentRow.planId"
:planStatus="currentRow.planStatus"
@submit-approval="handleSubmitApproval"
@approve="handleStartCount"
@archive="handleArchive"
@process-disc="handleProcessFromPanel"
/>
<!-- ===== 自定义子 Tab概览 / 快照 / 差异明细 ===== -->
<div class="doc-sub-tabs">
<div class="doc-sub-tabs-header">
<div class="doc-sub-tab-item" :class="{ active: activeSubTab === 'console' }" @click="activeSubTab = 'console'">操作台</div>
<div class="doc-sub-tab-item" :class="{ active: activeSubTab === 'overview' }" @click="activeSubTab = 'overview'">概览</div>
<div class="doc-sub-tab-item" :class="{ active: activeSubTab === 'snapshot' }" @click="activeSubTab = 'snapshot'">快照</div>
<div v-if="currentRow.planStatus === 2" class="doc-sub-tab-item" :class="{ active: activeSubTab === 'diff' }" @click="activeSubTab = 'diff'; handleCompareDiff(wh)">差异对比</div>
<div class="doc-sub-tab-item" :class="{ active: activeSubTab === 'discrepancy' }" @click="activeSubTab = 'discrepancy'">差异明细</div>
</div>
<div class="doc-sub-tabs-content">
<!-- ====== 操作台 ====== -->
<div v-show="activeSubTab === 'console'">
<div class="console-section">
<!-- 步骤进度 -->
<div class="console-steps">
<div class="console-step" :class="{ done: wh.snapshotCoilLogic || wh.snapshotCoilActual }">
<div class="step-icon"><i class="el-icon-camera"></i></div>
<div class="step-info">
<div class="step-title">生成快照</div>
<div class="step-desc">{{ (wh.snapshotCoilLogic || wh.snapshotCoilActual) ? '已生成' : '未生成' }}</div>
</div>
<el-button size="mini" type="primary" plain :loading="snapshotLoading" @click="handleGenerateSnapshot(wh)">生成</el-button>
</div>
<div class="console-step" :class="{ done: wh.snapshotCoilStats }">
<div class="step-icon"><i class="el-icon-upload2"></i></div>
<div class="step-info">
<div class="step-title">上传实盘</div>
<div class="step-desc">{{ wh.snapshotCoilStats ? '已上传' : '未上传' }}</div>
</div>
<el-button size="mini" type="primary" plain @click="handleUploadExcel(wh)">上传</el-button>
</div>
<div class="console-step" :class="{ done: _diffDone[wh.relId] }">
<div class="step-icon"><i class="el-icon-s-data"></i></div>
<div class="step-info">
<div class="step-title">执行对比</div>
<div class="step-desc">{{ _diffDone[wh.relId] ? '已完成' : '未对比' }}</div>
</div>
<el-button size="mini" type="primary" plain :loading="diffLoading" @click="handleCompareDiff(wh)">对比</el-button>
</div>
<div class="console-step">
<div class="step-icon"><i class="el-icon-document-checked"></i></div>
<div class="step-info">
<div class="step-title">保存结果</div>
<div class="step-desc">对比后可保存</div>
</div>
<el-button size="mini" type="success" plain :disabled="!_diffDone[wh.relId]">保存</el-button>
</div>
</div>
<!-- 对比结果摘要 -->
<div v-if="_diffDone[wh.relId]" class="console-result">
<div class="result-header">对比结果摘要</div>
<div class="result-grid">
<div class="result-item miss">盘亏{{ diffMissing.length }}</div>
<div class="result-item extra">盘盈{{ diffExtra.length }}</div>
<div class="result-item diff">不一致{{ diffMismatch.length }}</div>
</div>
</div>
</div>
</div>
<!-- ====== 概览 ====== -->
<div v-show="activeSubTab === 'overview'">
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="逻辑库区">{{ wh.warehouseName || '-' }}</el-descriptions-item>
<el-descriptions-item label="实际库区">{{ wh.actualWarehouseName || '-' }}</el-descriptions-item>
<el-descriptions-item label="账实一致">
<el-tag v-if="wh.isConsistent === 1" type="success" size="mini">一致</el-tag>
<el-tag v-else-if="wh.isConsistent === 0" type="danger" size="mini">不一致</el-tag>
<span v-else style="color:#909399">未盘点</span>
</el-descriptions-item>
<el-descriptions-item label="出入库查询起始">{{ parseTime(wh.ioStartTime, '{y}-{m}-{d} {h}:{i}') || '-' }}</el-descriptions-item>
<el-descriptions-item label="出入库查询截止">{{ parseTime(wh.ioEndTime, '{y}-{m}-{d} {h}:{i}') || '-' }}</el-descriptions-item>
<el-descriptions-item label="差异记录">
<el-button type="text" size="mini" icon="el-icon-view" @click="handleViewDiscrepancy(wh)">查看差异 ({{ (discrepancyMap[wh.relId] || []).length }})</el-button>
</el-descriptions-item>
<el-descriptions-item label="系统钢卷数量">{{ wh.systemCoilCount || 0 }}</el-descriptions-item>
<el-descriptions-item label="系统总重量(kg)">{{ wh.systemTotalWeight || 0 }}</el-descriptions-item>
<el-descriptions-item label="操作">
<el-button v-if="currentRow.planStatus !== 2 && currentRow.planStatus !== 3 && currentRow.planStatus !== 4" size="mini" type="primary" icon="el-icon-camera" :loading="snapshotLoading" @click="handleGenerateSnapshot(wh)">生成快照</el-button>
<el-button v-if="currentRow.planStatus === 2" size="mini" type="primary" icon="el-icon-upload2" @click="handleUploadExcel(wh)">上传Excel</el-button>
<el-button v-if="currentRow.planStatus === 0" size="mini" type="danger" plain icon="el-icon-delete" @click="handleRemoveWarehouse(wh)">移除</el-button>
</el-descriptions-item>
<el-descriptions-item label="实盘钢卷数量">{{ wh.actualCoilCount || 0 }}</el-descriptions-item>
<el-descriptions-item label="实盘总重量(kg)">{{ wh.actualTotalWeight || 0 }}</el-descriptions-item>
<el-descriptions-item />
</el-descriptions>
</div>
<!-- ====== 快照 ====== -->
<div v-show="activeSubTab === 'snapshot'">
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="逻辑库区快照 (snapshotCoilLogic)">
<span v-if="wh.snapshotCoilLogic">
<el-tag type="success" size="mini" style="margin-right:8px;">已生成</el-tag>
<el-button type="text" size="mini" icon="el-icon-view" @click="handlePreview(wh.snapshotCoilLogic, wh.warehouseName || '逻辑库区')">预览</el-button>
</span>
<span v-else style="color:#909399;">未生成</span>
</el-descriptions-item>
<el-descriptions-item label="实际库区快照 (snapshotCoilActual)">
<span v-if="wh.snapshotCoilActual">
<el-tag type="success" size="mini" style="margin-right:8px;">已生成</el-tag>
<el-button type="text" size="mini" icon="el-icon-view" @click="handlePreview(wh.snapshotCoilActual, wh.actualWarehouseName || '实际库区')">预览</el-button>
</span>
<span v-else style="color:#909399;">未生成</span>
</el-descriptions-item>
<el-descriptions-item label="出入库记录快照 (snapshotIoRecord)">
<span v-if="wh.snapshotIoRecord">
<el-tag type="success" size="mini" style="margin-right:8px;">已生成</el-tag>
<el-button type="text" size="mini" icon="el-icon-view" @click="handlePreview(wh.snapshotIoRecord, 'IO记录')">预览</el-button>
</span>
<span v-else style="color:#909399;">未生成</span>
</el-descriptions-item>
</el-descriptions>
</div>
<!-- ====== 差异对比仅执行中阶段 ====== -->
<div v-show="activeSubTab === 'diff'">
<div v-loading="diffLoading" style="padding: 8px 0;">
<div class="diff-config" v-if="!diffLoading && (diffMissing.length || diffExtra.length || diffMismatch.length)">
<el-form :model="{ weightThreshold: diffWeightThreshold }" inline>
<el-form-item label="重量差异阈值(kg)">
<el-input-number v-model="diffWeightThreshold" :precision="2" :step="0.1" :min="0" style="width:120px;" />
</el-form-item>
<el-form-item>
<el-button type="primary" size="mini" @click="handleCompareDiff(wh)">重新对比</el-button>
</el-form-item>
</el-form>
<span style="font-size:12px;color:#909399;">重量差异在此阈值内视为相同</span>
</div>
<el-row :gutter="16" class="diff-row">
<el-col :span="12">
<div class="diff-section">
<div class="section-header">
<span class="section-icon missing-icon"></span>
<span class="section-title">盘亏快照有·实盘无</span>
<span class="section-count">{{ diffMissing.length }}</span>
</div>
<el-table height="250" :data="diffMissing" border size="small" class="diff-table">
<el-table-column prop="enterCoilNo" label="入场钢卷号" />
<el-table-column prop="currentCoilNo" label="当前钢卷号" width="120" />
<el-table-column prop="netWeight" label="重量(kg)" width="90" />
<el-table-column prop="specification" label="规格" width="100" />
<el-table-column prop="material" label="材质" width="80" />
</el-table>
<div v-if="diffMissing.length === 0 && !diffLoading" class="empty-tip">无盘亏卷</div>
</div>
</el-col>
<el-col :span="12">
<div class="diff-section">
<div class="section-header">
<span class="section-icon extra-icon"></span>
<span class="section-title">盘盈实盘有·快照无</span>
<span class="section-count">{{ diffExtra.length }}</span>
</div>
<el-table height="250" :data="diffExtra" border size="small" class="diff-table">
<el-table-column prop="enterCoilNo" label="入场钢卷号" />
<el-table-column prop="currentCoilNo" label="当前钢卷号" width="120" />
<el-table-column prop="netWeight" label="重量(kg)" width="90" />
<el-table-column prop="specification" label="规格" width="100" />
<el-table-column prop="material" label="材质" width="80" />
</el-table>
<div v-if="diffExtra.length === 0 && !diffLoading" class="empty-tip">无盘盈卷</div>
</div>
</el-col>
</el-row>
<div class="diff-section">
<div class="section-header">
<span class="section-icon diff-icon"></span>
<span class="section-title">字段不一致</span>
<span class="section-count">{{ diffMismatch.length }}</span>
</div>
<el-table height="300" :data="diffMismatch" border size="small" class="diff-table">
<el-table-column prop="enterCoilNo" label="入场钢卷号" width="130" />
<el-table-column prop="currentCoilNo" label="当前钢卷号" width="130" />
<el-table-column label="字段差异" min-width="500">
<template #default="scope">
<div class="diff-content">
<div v-for="(d, idx) in scope.row.diffs" :key="idx" class="diff-item">
<span class="field-label">{{ d.label }}</span>
<div class="value-row">
<span class="value-label">快照:</span>
<span class="plan-value">{{ d.snapshotValue }}</span>
</div>
<div class="value-row">
<span class="value-label">实盘:</span>
<span class="actual-value">{{ d.physicalValue }}</span>
</div>
</div>
</div>
</template>
</el-table-column>
</el-table>
<div v-if="diffMismatch.length === 0 && !diffLoading" class="empty-tip">无不一致卷</div>
</div>
<div v-if="!diffLoading && diffMissing.length === 0 && diffExtra.length === 0 && diffMismatch.length === 0" style="text-align:center;padding:40px;color:#909399;">
请先生成快照并导入实盘Excel后进行对比
</div>
</div>
</div>
<!-- ====== 差异明细 ====== -->
<div v-show="activeSubTab === 'discrepancy'">
<div v-loading="discrepancyLoadingMap[wh.relId]" style="padding: 8px 0;">
<el-table v-if="discrepancyMap[wh.relId] && discrepancyMap[wh.relId].length > 0"
:data="discrepancyMap[wh.relId]" border size="small" style="width:100%">
<el-table-column label="差异类型" align="center" width="100">
<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-if="ds.row.discrepancyType === 4" size="mini">重量偏差</el-tag>
</template>
</el-table-column>
<el-table-column label="钢卷号" align="center" prop="enterCoilNo" width="160" />
<el-table-column label="差异详情" align="center" prop="discrepancyDetail" min-width="200" show-overflow-tooltip />
<el-table-column label="原因分析" align="center" prop="reasonAnalysis" min-width="150" show-overflow-tooltip />
<el-table-column label="处理建议" align="center" prop="processSuggestion" min-width="150" show-overflow-tooltip />
<el-table-column label="处理结果" align="center" prop="processResult" min-width="150" show-overflow-tooltip />
<el-table-column label="处理状态" align="center" width="100">
<template slot-scope="ds">
<el-tag v-if="ds.row.processStatus === 0" type="info" size="mini">待处理</el-tag>
<el-tag v-else-if="ds.row.processStatus === 1" type="warning" size="mini">处理中</el-tag>
<el-tag v-else-if="ds.row.processStatus === 2" type="success" size="mini">已处理</el-tag>
</template>
</el-table-column>
<el-table-column label="处理人" align="center" prop="processUserName" width="100" />
<el-table-column label="操作" align="center" width="100" v-if="currentRow.planStatus === 3">
<template slot-scope="ds">
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleEditDiscrepancy(ds.row)">处理</el-button>
</template>
</el-table-column>
</el-table>
<span v-else style="color:#909399;font-size:13px;">暂无差异记录</span>
</div>
</div>
</div>
</div>
<!-- ===== end 子Tab ===== -->
</div>
</div>
</div>
<!-- ===== end 自定义主Tab ===== -->
<!-- Excel 快照预览弹窗 -->
<el-dialog title="快照预览" :visible.sync="previewDialogVisible" width="80%" top="5vh" append-to-body
@@ -535,6 +281,7 @@ import { exportCoilWithAll } from "@/api/wms/coil";
import { uploadFile } from "@/api/system/oss";
import DragResizePanel from "@/components/DragResizePanel/index.vue";
import CountFlowSection from "./components/CountFlowSection.vue";
import WarehouseDetailPanel from "./components/WarehouseDetailPanel.vue";
import WarehouseSelect from "@/components/KLPService/WarehouseSelect/index.vue";
import ActualWarehouseL1L2Select from "@/components/KLPService/ActualWarehouseL1L2Select/index.vue";
import { parseTime } from '@/utils/klp'
@@ -610,7 +357,7 @@ const SNAPSHOT_COL_MAP = {
export default {
name: "CountPlan",
components: { DragResizePanel, WarehouseSelect, ActualWarehouseL1L2Select, CountFlowSection, VueOfficeExcel },
components: { DragResizePanel, WarehouseSelect, ActualWarehouseL1L2Select, CountFlowSection, VueOfficeExcel, WarehouseDetailPanel },
data() {
return {
buttonLoading: false,
@@ -631,8 +378,8 @@ export default {
previewDialogVisible: false,
previewUrl: '',
previewTitle: '',
_physicalDataCache: [],
_diffDone: {},
physicalDataCache: [],
diffDoneMap: {},
queryParams: {
pageNum: 1,
pageSize: 10,
@@ -732,6 +479,14 @@ export default {
this.loadDetail(this.currentRow.planId);
}
},
// 处理阶段差异处理(来自组件事件)
handleProcessFromPanel({ row, relId }) {
var self = this;
this.$modal.confirm('确认将该差异项标记为已处理?', '处理确认', { confirmButtonText: '确认处理', cancelButtonText: '取消', type: 'warning' }).then(function() {
var now = new Date(); var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
return updateCountDiscrepancy({ discrepancyId: row.discrepancyId, processResult: row._processResult || '', processStatus: 2, processTime: now.getFullYear() + '-' + pad(now.getMonth()+1) + '-' + pad(now.getDate()) + ' ' + pad(now.getHours()) + ':' + pad(now.getMinutes()) + ':' + pad(now.getSeconds()) });
}).then(function() { self.$message.success('已处理'); self.$refs.whPanel.refreshOneDisc(relId); }).catch(function(err) { if (err !== 'cancel') self.$message.error('处理失败'); });
},
handleAdd() {
this.reset();
this.open = true;
@@ -827,7 +582,7 @@ export default {
handleStartCount() {
var self = this;
this.$modal.confirm('确认开始执行盘库计划"' + this.currentRow.planCode + '"?系统将自动保存当前时间节点的库存快照并统计系统总重量和总数量。').then(function() {
return updateCountPlan({ planId: self.currentRow.planId, planStatus: 2 });
return updateCountPlan({ planId: self.currentRow.planId, planStatus: 3 });
}).then(function() {
self.$modal.msgSuccess("盘库已开始执行,系统已生成库存快照");
self.loadDetail(self.currentRow.planId);
@@ -966,7 +721,7 @@ export default {
actualTotalWeight: Math.round(actualWeight * 100) / 100
});
// 保存实盘数据备份(对话框关闭时 resetImportState 会清空 importTableData
this._physicalDataCache = this.importTableData.slice();
this.physicalDataCache = this.importTableData.slice();
this.importDialogVisible = false;
this.activeSubTab = 'diff';
await self.handleCompareDiff(self.importWarehouse);
@@ -1038,7 +793,7 @@ export default {
return newRow;
});
// 3) 解析实盘Excel数据已转为驼峰字段名
var physicalData = this._physicalDataCache || this.importTableData.slice();
var physicalData = this.physicalDataCache || this.importTableData.slice();
// 4) 以入场钢卷号+当前钢卷号做复合键(两个都一致才视为同一卷)
var snapMap = {};
snapData.forEach(function(item) {
@@ -1097,7 +852,7 @@ export default {
this.diffLoading = false;
// 标记对比完成
if (wh && wh.relId) {
this.$set(this._diffDone, wh.relId, true);
this.$set(this.diffDoneMap, wh.relId, true);
}
}
},
@@ -1194,7 +949,6 @@ export default {
});
}
},
// 全部生成快照(可用于批量操作)
async createSnapshot() {
if (!this.warehouseList || this.warehouseList.length === 0) {
throw new Error('无可用的库区条件');

View File

@@ -0,0 +1,215 @@
// 盘库差异对比工具函数
import { listByIds } from '@/api/system/oss';
import * as XLSX from 'xlsx';
// ===== 列映射常量 =====
/** 实盘Excel模板表头 → 驼峰字段名 */
export const PHYSICAL_HEADER_MAP = {
'入场钢卷号': 'enterCoilNo',
'当前钢卷号': 'currentCoilNo',
'重量(kg)': 'netWeight',
'规格': 'specification',
'材质': 'material',
'生产厂家': 'manufacturer',
'品质': 'qualityStatus',
'实际库区': 'actualWarehouseName',
'长度': 'length',
'备注': 'remark'
};
/** 快照Excel中文表头 → 驼峰字段名 */
export const SNAPSHOT_COL_MAP = {
'入场卷号': 'enterCoilNo',
'成品卷号': 'currentCoilNo',
'重量': 'netWeight',
'规格': 'specification',
'材质': 'material',
'厂家': 'manufacturer',
'产品质量': 'qualityStatus',
'长度': 'length',
'逻辑库区': 'warehouseName',
'实际库区': 'actualWarehouseName',
'名称': 'itemName',
'班组': 'team',
'类型': 'itemTypeDesc',
'厂家卷号': 'supplierCoilNo',
'日期': 'createTime',
'备注': 'remark',
'锌层': 'zincLayer',
'表面处理': 'surfaceTreatmentDesc',
'调制度': 'temperGrade',
'镀层种类': 'coatingType',
'销售人员': 'saleName',
'合同编号': 'contractNo',
'库存状态': 'statusDesc',
'实测厚度': 'actualThickness',
'原料厚度': 'rawMaterialThickness',
'是否与订单相关': 'isRelatedToOrderText',
'发货时间': 'exportTime',
'发货人': 'exportBy',
'用途': 'purpose',
'切边要求': 'trimmingRequirement',
'包装种类': 'packagingRequirement',
'原料材质': 'packingStatus',
'物品ID': 'itemId',
'数据类型': 'dataTypeText',
'业务用途': 'businessPurpose',
'调拨类型': 'transferType',
};
/** 对比字段定义 */
export const COMPARE_FIELDS = [
{ label: '重量(kg)', snapshotField: 'netWeight', physicalField: 'netWeight', isNumber: true },
{ label: '规格', snapshotField: 'specification', physicalField: 'specification' },
{ label: '材质', snapshotField: 'material', physicalField: 'material' },
{ label: '生产厂家', snapshotField: 'manufacturer', physicalField: 'manufacturer' },
{ label: '品质', snapshotField: 'qualityStatus', physicalField: 'qualityStatus' },
{ label: '长度', snapshotField: 'length', physicalField: 'length', isNumber: true },
];
// ===== 解析函数 =====
/**
* 将快照Excel的原始行数据中文表头转为标准驼峰字段名对象数组
* @param {Array<Object>} rawSheetRows - XLSX.utils.sheet_to_json 直接解析的结果
* @returns {Array<Object>} 驼峰字段名数组
*/
export function parseSnapshotExcel(rawSheetRows) {
return rawSheetRows.map(function(row) {
var newRow = {};
Object.keys(SNAPSHOT_COL_MAP).forEach(function(zhKey) {
var camelKey = SNAPSHOT_COL_MAP[zhKey];
if (row[zhKey] !== undefined) newRow[camelKey] = row[zhKey];
});
return newRow;
});
}
/**
* 将实盘Excel的原始行数据中文表头转为标准驼峰字段名对象数组
* @param {Array<Object>} rawSheetRows - XLSX.utils.sheet_to_json 直接解析的结果
* @returns {Array<Object>} 驼峰字段名数组
*/
export function parsePhysicalExcel(rawSheetRows) {
return rawSheetRows.map(function(row) {
var newRow = {};
Object.keys(PHYSICAL_HEADER_MAP).forEach(function(zhKey) {
var camelKey = PHYSICAL_HEADER_MAP[zhKey];
if (row[zhKey] !== undefined) newRow[camelKey] = row[zhKey];
});
return newRow;
});
}
// ===== 下载函数 =====
/**
* 根据ossId从MinIO下载Excel并解析为标准驼峰字段名对象数组
* @param {string} ossId - OSS文件ID
* @param {Object|null} colMap - 中文表头→驼峰映射表,传 null 则不转换
* @returns {Promise<Array<Object>>}
*/
export async function downloadAndParseExcel(ossId, colMap) {
var ossRes = await listByIds(ossId);
var ossList = ossRes.data || ossRes.rows || [];
if (ossList.length === 0) throw new Error('未找到文件');
var url = ossList[0].url;
var resp = await fetch(url);
var blob = await resp.blob();
var arrayBuf = await blob.arrayBuffer();
var workbook = XLSX.read(new Uint8Array(arrayBuf), { type: 'array' });
var sheet = workbook.Sheets[workbook.SheetNames[0]];
var rows = XLSX.utils.sheet_to_json(sheet);
if (colMap) {
rows = rows.map(function(row) {
var newRow = {};
Object.keys(colMap).forEach(function(zhKey) {
var camelKey = colMap[zhKey];
if (row[zhKey] !== undefined) newRow[camelKey] = row[zhKey];
});
return newRow;
});
}
return rows;
}
// ===== 对比函数 =====
/** 构建复合键 enterCoilNo|currentCoilNo */
function makeKey(item) {
return (item.enterCoilNo || '') + '|' + (item.currentCoilNo || '');
}
/**
* 对比快照数据与实盘数据,返回四种差异结果
* @param {Array<Object>} snapRows - 快照标准化数据
* @param {Array<Object>} physicalRows - 实盘标准化数据
* @param {number} weightThreshold - 重量差异阈值(kg)
* @returns {{ missing: Array, extra: Array, mismatch: Array, weightSimilar: Array }}
*/
export function compareCoils(snapRows, physicalRows, weightThreshold) {
var snapMap = {};
snapRows.forEach(function(item) {
var key = makeKey(item);
if (key !== '||') snapMap[key] = item;
});
var physicalMap = {};
physicalRows.forEach(function(item) {
var key = makeKey(item);
if (key !== '||') physicalMap[key] = item;
});
var result = { missing: [], extra: [], mismatch: [], weightSimilar: [] };
// 盘亏:快照有、实盘无
Object.keys(snapMap).forEach(function(key) {
if (!physicalMap[key]) result.missing.push(snapMap[key]);
});
// 盘盈:实盘有、快照无
Object.keys(physicalMap).forEach(function(key) {
if (!snapMap[key]) result.extra.push(physicalMap[key]);
});
// 字段不一致 & 重量不同
Object.keys(snapMap).forEach(function(key) {
var snapItem = snapMap[key];
var physItem = physicalMap[key];
if (!snapItem || !physItem) return;
var realDiffs = [];
var weightDiffs = [];
COMPARE_FIELDS.forEach(function(f) {
var snapVal = snapItem[f.snapshotField];
var physVal = physItem[f.physicalField];
if (f.isNumber) {
var sn = Number(snapVal) || 0;
var pn = Number(physVal) || 0;
var absDiff = Math.abs(sn - pn);
if (absDiff > 0 && absDiff <= weightThreshold && f.label.indexOf('重量') >= 0) {
weightDiffs.push({ label: f.label, snapshotValue: snapVal, physicalValue: physVal });
} else if (absDiff > weightThreshold) {
realDiffs.push({ label: f.label, snapshotValue: snapVal, physicalValue: physVal });
}
} else {
if (String(snapVal || '').trim() !== String(physVal || '').trim()) {
realDiffs.push({ label: f.label, snapshotValue: snapVal, physicalValue: physVal });
}
}
});
if (weightDiffs.length > 0) {
result.weightSimilar.push({
enterCoilNo: snapItem.enterCoilNo || physItem.enterCoilNo,
currentCoilNo: snapItem.currentCoilNo || physItem.currentCoilNo,
diffs: weightDiffs
});
}
if (realDiffs.length > 0) {
result.mismatch.push({
enterCoilNo: snapItem.enterCoilNo || physItem.enterCoilNo,
currentCoilNo: snapItem.currentCoilNo || physItem.currentCoilNo,
diffs: realDiffs
});
}
});
return result;
}

View File

@@ -13,6 +13,19 @@
</span>
</div>
<div class="flow-toolbar">
<el-dropdown trigger="click" @command="handleDownload">
<el-button size="small" type="primary" plain>
<i class="el-icon-download"></i> 下载流程图
<i class="el-icon-arrow-down el-icon--right"></i>
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="svg">下载 SVG</el-dropdown-item>
<el-dropdown-item command="png">下载 PNG</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
<div v-loading="loading" class="flow-content">
<div ref="diagram" class="flow-diagram" v-html="currentSvg" @click="onNodeClick"></div>
</div>
@@ -110,80 +123,105 @@ graph TD
inventoryCheck: `
graph TD
A["<b>创建盘库</b><br/>填写盘库单基本信息<br/>添加盘库计划"]:::c1
A["<b>创建盘库计划</b><br/>填写基本信息"]:::c1
A --> B["<b>盘库计划</b><br/>设定起止时间<br/>选择库区类型"]:::c2
B --> C{"<b>库区类型?</b>"}:::cdec
A --> B["<b>创建计划明细</b><br/>可创建多个明细"]:::c2
C -->|逻辑库| D["<b>获取库存快照</b><br/>记录当前时间节点<br/>库存情况"]:::c3
C -->|物理库| E["<b>获取库存快照</b><br/>记录当前时间节点<br/>库存情况"]:::c3
E --> F["<b>记录吞吐记录</b><br/>额外记录物理库<br/>出入库流水明细"]:::c4
B --> C["<b>选择库区</b><br/>逻辑库 / 实际库<br/>至少选一个"]:::c3
C --> D["<b>生成系统库存快照</b>"]:::c4
D --> E["<b>上传实盘库存Excel</b>"]:::c5
E --> F["<b>执行对比</b><br/>快照 vs 实盘<br/>自动计算差异"]:::c6
D --> G["<b>人工实地盘库</b><br/>按盘库计划执行<br/>录入实际库存数据"]:::c5
F --> G
F --> G["<b>查看差异明细</b><br/>保存差异并填写处理方式"]:::c7
G --> H["<b>提交送审</b>"]:::c8
G --> H["<b>系统自动对照</b><br/>快照库存 vs 实际库存<br/>逐项比对查找差异"]:::c6
H --> I{"审批"}:::dec
I -->|不通过| J["<b>退回修改</b>"]:::c7
J --> G
I -->|通过| K["<b>开始处理差异</b><br/>逐项执行处理方式"]:::c9
H --> I["<b>盘亏明细</b><br/>系统有 实际无<br/>库存缺失项"]:::loss
H --> J["<b>盘盈明细</b><br/>实际有 系统无<br/>库存多出项"]:::gain
H --> K["<b>明细差异</b><br/>数据不一致<br/>数量/规格偏差"]:::diff
I --> L["<b>生成盘库差异报告</b><br/>汇总盘亏/盘盈/差异<br/>存储差异记录"]:::c7
J --> L
K --> L
L --> M(["<b>盘库单封存</b><br/>流程结束"]):::cend
K --> L{"所有差异<br/>处理完成?"}:::dec
L -->|否| K
L -->|是| M(["<b>完结流程</b><br/>盘库结束"]):::cend
classDef c1 fill:#409eff,stroke:#337ecc,color:#fff,stroke-width:2px
classDef c2 fill:#e6fffa,stroke:#13c2c2,color:#303133,stroke-width:2px
classDef cdec fill:#f9f0ff,stroke:#722ed1,color:#303133,stroke-width:2px
classDef c3 fill:#f0f5ff,stroke:#597ef7,color:#303133,stroke-width:2px
classDef c4 fill:#fff7e6,stroke:#fa8c16,color:#303133,stroke-width:2px
classDef c3 fill:#fff7e6,stroke:#fa8c16,color:#303133,stroke-width:2px
classDef c4 fill:#f0f5ff,stroke:#597ef7,color:#303133,stroke-width:2px
classDef c5 fill:#e6f7ff,stroke:#1890ff,color:#303133,stroke-width:2px
classDef c6 fill:#fffbe6,stroke:#fadb14,color:#303133,stroke-width:2px
classDef loss fill:#fff1f0,stroke:#f5222d,color:#303133,stroke-width:2px
classDef gain fill:#f6ffed,stroke:#52c41a,color:#303133,stroke-width:2px
classDef diff fill:#fff0f6,stroke:#eb2f96,color:#303133,stroke-width:2px
classDef c6 fill:#fffbe6,stroke:#fadb14,color:#606266,stroke-width:2px
classDef c7 fill:#f0f5ff,stroke:#597ef7,color:#303133,stroke-width:2px
classDef c8 fill:#fff0f6,stroke:#eb2f96,color:#303133,stroke-width:2px
classDef c9 fill:#f6ffed,stroke:#52c41a,color:#303133,stroke-width:2px
classDef dec fill:#f9f0ff,stroke:#722ed1,color:#303133,stroke-width:2px
classDef cend fill:#dcf7e8,stroke:#52c41a,color:#303133,stroke-width:2px,rx:10,ry:10
linkStyle default stroke:#bfbfbf,stroke-width:2px
`,
productionSchedule: `
stateDiagram-v2
[*] --> 创建排产单: 填写基本信息
创建排产单 --> 关联合同获取需求: 选择合同
关联合同获取需求 --> 选择需求合并明细: 选取需求<br/>合并相同条目
选择需求合并明细 --> 提交审批
graph TD
A["<b>创建需求单</b><br/>填写基本信息"]:::p1
提交审批 --> 审批驳回: 审批不通过
提交审批 --> 审批通过: 审批通过
A --> B["<b>选择合同</b><br/>可选一个或多个合同"]:::p2
B --> C["<b>自动获取需求明细</b><br/>从所选合同提取"]:::p3
C --> D["<b>调整需求明细</b><br/>可编辑/修改/补充"]:::p4
审批驳回 --> 选择需求合并明细: 退回修改后重新提交
D --> E["<b>提交审批</b>"]:::p5
E --> F{"审批"}:::dec
F -->|不通过| G["<b>退回修改</b>"]:::p4
G --> D
F -->|通过| H["<b>转化为排产单</b>"]:::p6
审批通过 --> 车间接收执行单: 转为执行单推送车间
H --> I["<b>排产单</b><br/>可再次编辑"]:::p7
I --> J["<b>再次提交审批</b>"]:::p5
J --> K{"审批"}:::dec
K -->|不通过| L["<b>退回修改</b>"]:::p7
L --> I
K -->|通过| M["<b>提交给车间</b>"]:::p8
车间接收执行单 --> 执行生产: 车间接收
车间接收执行单 --> 审批驳回: 车间打回拒绝
M --> N["<b>车间绑定钢卷</b><br/>每个排产计划<br/>绑定一个或多个钢卷"]:::p9
N --> O["<b>执行生产</b>"]:::p10
O --> P(["<b>排产完结</b>"]):::pend
执行生产 --> [*]: 排产完结
classDef p1 fill:#409eff,stroke:#337ecc,color:#fff,stroke-width:2px
classDef p2 fill:#e6fffa,stroke:#13c2c2,color:#303133,stroke-width:2px
classDef p3 fill:#fff7e6,stroke:#fa8c16,color:#303133,stroke-width:2px
classDef p4 fill:#f0f5ff,stroke:#597ef7,color:#303133,stroke-width:2px
classDef p5 fill:#e6f7ff,stroke:#1890ff,color:#303133,stroke-width:2px
classDef p6 fill:#fffbe6,stroke:#fadb14,color:#606266,stroke-width:2px
classDef p7 fill:#fff0f6,stroke:#eb2f96,color:#303133,stroke-width:2px
classDef p8 fill:#f6ffed,stroke:#52c41a,color:#303133,stroke-width:2px
classDef p9 fill:#f0f5ff,stroke:#597ef7,color:#303133,stroke-width:2px
classDef p10 fill:#fffbe6,stroke:#fadb14,color:#303133,stroke-width:2px
classDef dec fill:#f9f0ff,stroke:#722ed1,color:#303133,stroke-width:2px
classDef pend fill:#dcf7e8,stroke:#52c41a,color:#303133,stroke-width:2px,rx:10,ry:10
linkStyle default stroke:#bfbfbf,stroke-width:2px
`,
equipmentRepair: `
stateDiagram-v2
[*] --> 创建维修计划: 点选异常巡检记录<br/>绑定记录与异常设备
创建维修计划 --> 审批维修计划
graph TD
A["<b>创建维修计划</b>"]:::e1
A1["点选异常巡检记录<br/>绑定记录与异常设备"]:::e1sub
A --> A1
审批维修计划 --> 审批驳回: 审批不通过
审批维修计划 --> 审批通过: 审批通过
A1 --> B["<b>提交审批</b>"]:::e2
B --> C{"审批"}:::edec
C -->|不通过| D["<b>退回修改</b>"]:::e1
D --> A1
C -->|通过| E["<b>逐设备维修记录</b><br/>逐一执行设备维修<br/>记录维修过程与结果"]:::e3
审批驳回 --> 创建维修计划: 退回修改后重新提交
E --> F{"全部设备<br/>维修完成?"}:::edec
F -->|否| E
F -->|是| G(["<b>流程结束</b>"]):::eend
审批通过 --> 逐设备维修记录: 逐一执行设备维修<br/>记录维修过程与结果
逐设备维修记录 --> 逐设备维修记录: 存在未维修设备
逐设备维修记录 --> [*]: 全部设备维修完成<br/>流程结束
`
classDef e1 fill:#e6fffa,stroke:#13c2c2,color:#303133,stroke-width:2px
classDef e1sub fill:#e6fffa,stroke:#13c2c2,color:#606266,stroke-width:1px,stroke-dasharray:3 3
classDef e2 fill:#409eff,stroke:#337ecc,color:#fff,stroke-width:2px
classDef e3 fill:#f0f5ff,stroke:#597ef7,color:#303133,stroke-width:2px
classDef edec fill:#f9f0ff,stroke:#722ed1,color:#303133,stroke-width:2px
classDef eend fill:#dcf7e8,stroke:#52c41a,color:#303133,stroke-width:2px,rx:10,ry:10
linkStyle default stroke:#bfbfbf,stroke-width:2px
`,
}
export default {
@@ -201,6 +239,7 @@ export default {
],
svgCache: {},
selectedNode: null,
downloadLoading: false,
}
},
watch: {
@@ -226,6 +265,76 @@ export default {
},
},
methods: {
handleDownload(format) {
if (format === 'svg') {
this.downloadSvg()
} else if (format === 'png') {
this.downloadPng()
}
},
downloadSvg() {
const svgEl = this.$refs.diagram?.querySelector('svg')
if (!svgEl) {
this.$message.warning('流程图尚未渲染完成')
return
}
const clone = svgEl.cloneNode(true)
const serializer = new XMLSerializer()
const source = serializer.serializeToString(clone)
const blob = new Blob([source], { type: 'image/svg+xml;charset=utf-8' })
this.triggerDownload(URL.createObjectURL(blob), `${this.activeTab}.svg`)
this.$message.success('SVG 已下载')
},
downloadPng() {
const svgEl = this.$refs.diagram?.querySelector('svg')
if (!svgEl) {
this.$message.warning('流程图尚未渲染完成')
return
}
this.downloadLoading = true
const clone = svgEl.cloneNode(true)
const serializer = new XMLSerializer()
let source = serializer.serializeToString(clone)
source = source.replace(/<\/?foreignObject[^>]*>/gi, '').replace(/<\/?style[^>]*>/gi, '')
const svgBlob = new Blob([source], { type: 'image/svg+xml;charset=utf-8' })
const url = URL.createObjectURL(svgBlob)
const img = new Image()
img.onload = () => {
const canvas = document.createElement('canvas')
const rect = svgEl.getBoundingClientRect()
const scale = 2
canvas.width = rect.width * scale
canvas.height = rect.height * scale
const ctx = canvas.getContext('2d')
ctx.scale(scale, scale)
ctx.drawImage(img, 0, 0, rect.width, rect.height)
URL.revokeObjectURL(url)
canvas.toBlob(blob => {
this.downloadLoading = false
if (blob) {
this.triggerDownload(URL.createObjectURL(blob), `${this.activeTab}.png`)
this.$message.success('PNG 已下载')
}
}, 'image/png')
}
img.onerror = () => {
this.downloadLoading = false
this.$message.error('PNG 导出失败')
}
img.src = url
},
triggerDownload(url, filename) {
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
},
switchTab(key) {
this.activeTab = key
this.selectedNode = null
@@ -331,4 +440,12 @@ export default {
.flow-diagram :deep(svg g:hover) {
opacity: 0.8;
}
.flow-toolbar {
display: flex;
justify-content: flex-end;
padding: 6px 12px;
background: #fafafa;
border-bottom: 1px solid #e8e8e8;
}
</style>