成本模块

This commit is contained in:
2025-12-02 17:58:16 +08:00
parent be91905508
commit 4b9cce2777
22 changed files with 4808 additions and 3 deletions

View File

@@ -0,0 +1,388 @@
<template>
<div class="app-container cost-dashboard">
<!-- 成本概览 -->
<div class="overview-section">
<div class="overview-item">
<div class="overview-label">今日总成本</div>
<div class="overview-value primary">{{ formatMoney(todayCost) }}</div>
<div class="overview-desc"></div>
</div>
<div class="overview-item">
<div class="overview-label">在库钢卷数</div>
<div class="overview-value warning">{{ totalCoils }}</div>
<div class="overview-desc"></div>
</div>
<div class="overview-item">
<div class="overview-label">总净重</div>
<div class="overview-value success">{{ formatWeight(totalNetWeight) }}</div>
<div class="overview-desc"></div>
</div>
<div class="overview-item">
<div class="overview-label">当前成本标准</div>
<div class="overview-value info">{{ formatMoney(currentUnitCost) }}</div>
<div class="overview-desc">//</div>
</div>
</div>
<!-- 快速入口 -->
<div class="action-section">
<div class="section-header">
<span>快速入口</span>
<el-tooltip content="批量日计算改为后端定时任务,这里仅提供配置与明细入口" placement="top">
<i class="el-icon-question"></i>
</el-tooltip>
</div>
<div class="action-buttons">
<el-button type="warning" icon="el-icon-setting" @click="goToStandardConfig">
成本标准配置
</el-button>
<el-button type="info" icon="el-icon-view" @click="goToDetail">
查看成本明细
</el-button>
</div>
</div>
<!-- 成本趋势图表 -->
<div class="chart-section">
<div class="section-header">
<span>成本趋势近30天</span>
<el-date-picker
v-model="trendDateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
size="small"
style="width: 300px;"
value-format="yyyy-MM-dd"
@change="loadTrendData"
/>
</div>
<div id="costTrendChart" style="height: 300px;"></div>
</div>
<!-- 成本分布统计 -->
<div class="stat-section">
<div class="stat-item">
<div class="section-header">
<span>按库区统计</span>
</div>
<el-table :data="warehouseStats" stripe style="width: 100%">
<el-table-column prop="warehouseName" label="库区名称" />
<el-table-column prop="coilCount" label="钢卷数" align="right" />
<el-table-column prop="totalCost" label="总成本" align="right">
<template slot-scope="scope">
{{ formatMoney(scope.row.totalCost) }}
</template>
</el-table-column>
</el-table>
</div>
<div class="stat-item">
<div class="section-header">
<span>按物品类型统计</span>
</div>
<el-table :data="itemTypeStats" stripe style="width: 100%">
<el-table-column prop="itemType" label="物品类型">
<template slot-scope="scope">
{{ scope.row.itemType === 'raw_material' ? '原料' : '成品' }}
</template>
</el-table-column>
<el-table-column prop="coilCount" label="钢卷数" align="right" />
<el-table-column prop="totalCost" label="总成本" align="right">
<template slot-scope="scope">
{{ formatMoney(scope.row.totalCost) }}
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</template>
<script>
import {
queryCostSummary,
queryCostTrend,
getCurrentCostStandard,
getCostOverview
} from '@/api/wms/cost'
import * as echarts from 'echarts'
export default {
name: 'CostDashboard',
data() {
return {
todayCost: 0,
totalCoils: 0,
totalNetWeight: 0,
totalGrossWeight: 0,
avgStorageDays: 0,
currentUnitCost: 10000,
trendDateRange: [],
trendChart: null,
warehouseStats: [],
itemTypeStats: []
}
},
mounted() {
this.initTrendDateRange()
this.loadOverviewData()
this.loadTrendData()
this.loadStatsData()
this.loadCurrentStandard()
},
beforeDestroy() {
if (this.trendChart) {
this.trendChart.dispose()
}
},
methods: {
initTrendDateRange() {
const end = new Date()
const start = new Date()
start.setTime(start.getTime() - 30 * 24 * 60 * 60 * 1000)
this.trendDateRange = [
start.toISOString().split('T')[0],
end.toISOString().split('T')[0]
]
},
async loadOverviewData() {
try {
const res = await getCostOverview()
if (res.code === 200 && res.data) {
this.todayCost = res.data.totalCost || 0
this.totalCoils = res.data.totalCoils || 0
this.totalNetWeight = res.data.totalNetWeight || 0
this.totalGrossWeight = res.data.totalGrossWeight || 0
this.avgStorageDays = res.data.avgStorageDays || 0
}
} catch (error) {
console.error('加载概览数据失败:', error)
}
},
async loadTrendData() {
if (!this.trendDateRange || this.trendDateRange.length !== 2) return
try {
const startDate = this.trendDateRange[0] instanceof Date
? this.trendDateRange[0].toISOString().split('T')[0]
: this.trendDateRange[0]
const endDate = this.trendDateRange[1] instanceof Date
? this.trendDateRange[1].toISOString().split('T')[0]
: this.trendDateRange[1]
const res = await queryCostTrend(startDate, endDate)
if (res.code === 200 && res.data) {
this.renderTrendChart(res.data)
}
} catch (error) {
console.error('加载趋势数据失败:', error)
}
},
renderTrendChart(data) {
this.$nextTick(() => {
if (!this.trendChart) {
this.trendChart = echarts.init(document.getElementById('costTrendChart'))
}
const dates = data.map(item => item.date)
const costs = data.map(item => item.totalCost)
const option = {
tooltip: {
trigger: 'axis',
formatter: (params) => {
return `${params[0].name}<br/>成本: ${this.formatMoney(params[0].value)}`
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: dates,
axisLine: { lineStyle: { color: '#C0C4CC' } }
},
yAxis: {
type: 'value',
axisLine: { lineStyle: { color: '#C0C4CC' } },
axisLabel: {
formatter: (value) => {
if (value >= 10000) {
return (value / 10000).toFixed(1) + '万'
}
return value
}
}
},
series: [{
data: costs,
type: 'line',
smooth: true,
itemStyle: { color: '#409EFF' },
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(64, 158, 255, 0.3)' },
{ offset: 1, color: 'rgba(64, 158, 255, 0.1)' }
]
}
}
}]
}
this.trendChart.setOption(option)
})
},
async loadStatsData() {
try {
const today = new Date().toISOString().split('T')[0]
const res = await queryCostSummary(today, today, 'warehouse', null)
if (res.code === 200) {
this.warehouseStats = res.data.details || []
const itemTypeRes = await queryCostSummary(today, today, 'itemType', null)
if (itemTypeRes.code === 200) {
this.itemTypeStats = itemTypeRes.data.details || []
}
}
} catch (error) {
console.error('加载统计数据失败:', error)
}
},
async loadCurrentStandard() {
try {
const res = await getCurrentCostStandard()
if (res.code === 200 && res.data) {
this.currentUnitCost = res.data.unitCost || 10000
}
} catch (error) {
console.error('加载成本标准失败:', error)
}
},
goToStandardConfig() {
this.$router.push('/wms/cost/standard')
},
goToDetail() {
this.$router.push('/wms/cost/detail')
},
formatMoney(value) {
if (!value) return '0.00'
return Number(value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
},
formatWeight(value) {
if (!value) return '0.000'
return Number(value).toFixed(3)
}
}
}
</script>
<style lang="scss" scoped>
.cost-dashboard {
padding: 20px;
background: #ffffff;
}
.overview-section {
display: flex;
gap: 20px;
margin-bottom: 20px;
padding: 20px;
background: #fafafa;
border-radius: 4px;
border: 1px solid #ebeef5;
.overview-item {
flex: 1;
text-align: center;
.overview-label {
font-size: 14px;
color: #606266;
margin-bottom: 10px;
}
.overview-value {
font-size: 32px;
font-weight: bold;
margin-bottom: 5px;
&.primary {
color: #409eff;
}
&.warning {
color: #e6a23c;
}
&.success {
color: #67c23a;
}
&.info {
color: #909399;
}
}
.overview-desc {
font-size: 12px;
color: #909399;
}
}
}
.action-section {
margin-bottom: 20px;
padding: 20px;
background: #fafafa;
border-radius: 4px;
border: 1px solid #ebeef5;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 500;
margin-bottom: 15px;
font-size: 16px;
color: #303133;
.el-icon-question {
color: #909399;
cursor: help;
margin-left: 5px;
}
}
.action-buttons {
display: flex;
gap: 15px;
}
.chart-section {
margin-bottom: 20px;
padding: 20px;
background: #fafafa;
border-radius: 4px;
border: 1px solid #ebeef5;
}
.stat-section {
display: flex;
gap: 20px;
margin-bottom: 20px;
}
.stat-item {
flex: 1;
padding: 20px;
background: #fafafa;
border-radius: 4px;
border: 1px solid #ebeef5;
}
</style>

View File

@@ -0,0 +1,568 @@
<template>
<div class="cost-search-page">
<div class="hero">
<div class="hero-content">
<h1>成本检索中心</h1>
<p>基于 wms_material_coil 实时计算输入入场钢卷号即可查看成本</p>
<div class="search-bar">
<el-input
v-model.trim="searchForm.enterCoilNo"
placeholder="请输入入场钢卷号(支持前缀)"
clearable
class="search-input"
@input="handleInputChange"
@keyup.enter.native="handleSearchClick"
/>
<el-date-picker
v-model="searchForm.calcDate"
type="date"
placeholder="可选:指定计算日期"
value-format="yyyy-MM-dd"
class="search-date"
:picker-options="datePickerOptions"
@change="handleCalcDateChange"
/>
<el-button
type="primary"
icon="el-icon-search"
:loading="loading"
@click="handleSearchClick"
>
检索成本
</el-button>
</div>
<div class="hero-meta">
<span>未发货计算到选择日期默认今日</span>
<span>已发货计算到发货前一天</span>
</div>
</div>
</div>
<div class="result-wrapper">
<div class="result-actions">
<el-button type="text" icon="el-icon-setting" @click="goToStandardConfig">成本标准配置</el-button>
<el-divider direction="vertical"></el-divider>
<el-button type="text" icon="el-icon-tickets" @click="goToDetail">查看历史明细</el-button>
</div>
<el-empty
v-if="!searchExecuted && !loading"
description="请输入入场钢卷号后开始检索"
/>
<div v-else>
<el-card v-loading="loading" shadow="never" class="summary-card">
<div class="summary-header">
<div>
<div class="summary-label">入场钢卷号</div>
<div class="summary-value">{{ summary.enterCoilNo || '-' }}</div>
</div>
<el-tag type="info" effect="plain">
统计日期{{ summary.calcDate || '-' }}
</el-tag>
</div>
<div class="summary-grid">
<div class="grid-item">
<div class="grid-label">总子钢卷数</div>
<div class="grid-value primary">{{ summary.totalCoils }}</div>
<div class="grid-desc">
<span>在库 {{ summary.unshippedCount }}</span>
<span>已发货 {{ summary.shippedCount }}</span>
</div>
</div>
<div class="grid-item">
<div class="grid-label">总毛重</div>
<div class="grid-value">{{ formatWeight(summary.totalGrossWeight) }} </div>
<div class="grid-desc">毛重优先缺失时用净重</div>
</div>
<div class="grid-item">
<div class="grid-label">总净重</div>
<div class="grid-value">{{ formatWeight(summary.totalNetWeight) }} </div>
<div class="grid-desc">实时换算为吨</div>
</div>
<div class="grid-item">
<div class="grid-label">累计成本</div>
<div class="grid-value accent">{{ formatMoney(summary.totalCost) }} </div>
<div class="grid-desc">所有子钢卷累计值</div>
</div>
<div class="grid-item">
<div class="grid-label">平均在库天数</div>
<div class="grid-value">{{ formatNumber(summary.avgStorageDays, 2) }} </div>
<div class="grid-desc">含在库与已发货</div>
</div>
</div>
</el-card>
<div v-if="cards.length" class="card-section">
<div class="card-grid">
<div class="coil-card" v-for="card in cards" :key="card.coilId">
<div class="card-header">
<div>
<div class="coil-no">{{ card.currentCoilNo }}</div>
<div class="coil-meta">入库{{ formatDate(card.startDate) }}</div>
</div>
<el-tag
size="mini"
:type="card.isShipped ? 'info' : 'success'"
effect="plain"
>
{{ card.isShipped ? '已发货' : '在库' }}
</el-tag>
</div>
<div class="card-body">
<div class="metric-row">
<span class="metric-label">毛重()</span>
<span class="metric-value">{{ formatWeight(card.grossWeightTon) }}</span>
</div>
<div class="metric-row">
<span class="metric-label">净重()</span>
<span class="metric-value">{{ formatWeight(card.netWeightTon) }}</span>
</div>
<div class="metric-row">
<span class="metric-label">计费基准</span>
<span class="metric-value">
<el-tag size="mini" :type="card.weightBasis === 'gross' ? 'success' : 'info'" effect="plain">
{{ card.weightBasis === 'gross' ? '毛重' : '净重' }}
</el-tag>
</span>
</div>
<div class="metric-row">
<span class="metric-label">在库天数</span>
<span :class="['metric-value', getStorageDaysClass(card.storageDays)]">
{{ card.storageDays || '-' }}
</span>
</div>
<div class="metric-row">
<span class="metric-label">单位成本</span>
<span class="metric-value">{{ formatMoney(card.unitCost) }} //</span>
</div>
<div class="metric-row">
<span class="metric-label">累计成本</span>
<span class="metric-value accent">{{ formatMoney(card.totalCost) }} </span>
</div>
<div class="metric-row">
<span class="metric-label">计费截至</span>
<span class="metric-value">{{ formatDate(card.endDate) }}</span>
</div>
<div class="metric-row">
<span class="metric-label">所在库区</span>
<span class="metric-value">{{ card.warehouseName || '-' }}</span>
</div>
</div>
</div>
</div>
<div class="pagination" v-if="pagination.total > pagination.pageSize">
<el-pagination
background
layout="prev, pager, next"
:page-size="pagination.pageSize"
:total="pagination.total"
:current-page.sync="pagination.pageNum"
@current-change="handlePageChange"
/>
</div>
</div>
<el-empty
v-else-if="!loading"
description="暂无匹配钢卷"
/>
</div>
</div>
</div>
</template>
<script>
import { searchMaterialCost } from '@/api/wms/cost'
export default {
name: 'CostSearchDashboard',
data() {
return {
loading: false,
searchExecuted: false,
searchForm: {
enterCoilNo: '',
calcDate: null
},
pagination: {
pageNum: 1,
pageSize: 20,
total: 0
},
summary: {
enterCoilNo: '',
calcDate: '',
totalCoils: 0,
shippedCount: 0,
unshippedCount: 0,
totalGrossWeight: 0,
totalNetWeight: 0,
totalCost: 0,
avgStorageDays: 0
},
cards: [],
debounceTimer: null,
datePickerOptions: {
disabledDate(time) {
return time.getTime() > Date.now()
}
}
}
},
beforeDestroy() {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
this.debounceTimer = null
}
},
methods: {
handleInputChange() {
this.pagination.pageNum = 1
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
}
this.debounceTimer = setTimeout(() => {
this.fetchResults()
}, 400)
},
handleCalcDateChange() {
if (!this.searchForm.enterCoilNo) return
this.pagination.pageNum = 1
this.fetchResults()
},
handleSearchClick() {
this.pagination.pageNum = 1
this.fetchResults()
},
async fetchResults() {
if (!this.searchForm.enterCoilNo) {
this.resetResults(false)
return
}
this.loading = true
const params = {
enterCoilNo: this.searchForm.enterCoilNo.trim(),
calcDate: this.searchForm.calcDate,
pageNum: this.pagination.pageNum,
pageSize: this.pagination.pageSize
}
try {
const res = await searchMaterialCost(params)
this.searchExecuted = true
if (res.code === 200 && res.data) {
const data = res.data
const summaryData = data.summary || {}
this.summary = {
enterCoilNo: summaryData.enterCoilNo || params.enterCoilNo,
calcDate: summaryData.calcDate || params.calcDate || this.formatDate(new Date()),
totalCoils: summaryData.totalCoils || 0,
shippedCount: summaryData.shippedCount || 0,
unshippedCount: summaryData.unshippedCount || 0,
totalGrossWeight: summaryData.totalGrossWeight || 0,
totalNetWeight: summaryData.totalNetWeight || 0,
totalCost: summaryData.totalCost || 0,
avgStorageDays: summaryData.avgStorageDays || 0
}
this.cards = (data.records || []).map(item => ({
...item,
isShipped: item.isShipped === 1 || item.isShipped === true
}))
this.pagination.total = data.total || 0
this.pagination.pageNum = data.pageNum || params.pageNum
} else {
this.resetResults(true)
this.$message.warning(res.msg || '未查询到成本数据')
}
} catch (error) {
this.$message.error('检索失败,请稍后再试')
} finally {
this.loading = false
}
},
handlePageChange(page) {
this.pagination.pageNum = page
this.fetchResults()
},
resetResults(keepExecuted) {
if (!keepExecuted) {
this.searchExecuted = false
}
this.cards = []
this.pagination.total = 0
this.summary = {
enterCoilNo: this.searchForm.enterCoilNo || '',
calcDate: this.searchForm.calcDate || '',
totalCoils: 0,
shippedCount: 0,
unshippedCount: 0,
totalGrossWeight: 0,
totalNetWeight: 0,
totalCost: 0,
avgStorageDays: 0
}
},
goToStandardConfig() {
this.$router.push('/wms/cost/standard')
},
goToDetail() {
this.$router.push('/wms/cost/detail')
},
formatMoney(value) {
if (!value) return '0.00'
return Number(value).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
},
formatWeight(value) {
if (!value) return '0.000'
return Number(value).toFixed(3)
},
formatNumber(value, fraction = 2) {
if (!value) return Number(0).toFixed(fraction)
return Number(value).toFixed(fraction)
},
formatDate(value) {
if (!value) return '-'
if (value instanceof Date) {
const yyyy = value.getFullYear()
const mm = `${value.getMonth() + 1}`.padStart(2, '0')
const dd = `${value.getDate()}`.padStart(2, '0')
return `${yyyy}-${mm}-${dd}`
}
return value
},
getStorageDaysClass(days) {
if (!days) return ''
if (days >= 60) return 'storage-days-high'
if (days >= 30) return 'storage-days-medium'
return 'storage-days-normal'
}
}
}
</script>
<style lang="scss" scoped>
.cost-search-page {
min-height: 100%;
background: #eef1f4;
padding-bottom: 40px;
}
.hero {
background: linear-gradient(120deg, #f7f8fa 0%, #e2e5ec 100%);
border-bottom: 1px solid #d5d9e0;
padding: 48px 20px 36px;
color: #2f3135;
}
.hero-content {
max-width: 900px;
margin: 0 auto;
text-align: center;
h1 {
font-size: 34px;
font-weight: 600;
margin-bottom: 8px;
letter-spacing: 1px;
}
p {
margin-bottom: 24px;
color: #5f646e;
}
}
.search-bar {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
flex-wrap: wrap;
.search-input {
width: 320px;
}
.search-date {
width: 220px;
}
}
.hero-meta {
margin-top: 14px;
font-size: 13px;
color: #757a85;
display: flex;
justify-content: center;
gap: 20px;
}
.result-wrapper {
max-width: 1100px;
margin: -25px auto 0;
padding: 0 20px 40px;
}
.result-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
margin-bottom: 14px;
::v-deep .el-button--text {
color: #5f6470;
}
}
.summary-card {
border: 1px solid #d7dbe2;
border-radius: 10px;
background: #fefefe;
margin-bottom: 20px;
}
.summary-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.summary-label {
font-size: 13px;
color: #80848f;
}
.summary-value {
font-size: 24px;
font-weight: 600;
color: #2f3135;
}
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 14px;
.grid-item {
padding: 14px;
border-radius: 8px;
border: 1px solid #e2e5ea;
background: linear-gradient(180deg, #f9fafc 0%, #f0f2f5 100%);
.grid-label {
font-size: 13px;
color: #7b808a;
margin-bottom: 4px;
}
.grid-value {
font-size: 22px;
font-weight: 600;
color: #2f3135;
&.primary {
color: #435d7a;
}
&.accent {
color: #aa7728;
}
}
.grid-desc {
margin-top: 6px;
font-size: 12px;
color: #9a9fac;
display: flex;
justify-content: space-between;
}
}
}
.card-section {
border: 1px solid #d7dbe2;
border-radius: 10px;
padding: 20px;
background: #fdfdfd;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
}
.coil-card {
border: 1px solid #d0d4dc;
border-radius: 10px;
padding: 16px;
background: #ffffff;
box-shadow: 0 6px 18px rgba(32, 41, 58, 0.08);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.coil-no {
font-size: 18px;
font-weight: 600;
color: #2e3240;
}
.coil-meta {
font-size: 12px;
color: #8a8f99;
}
}
.card-body {
display: flex;
flex-direction: column;
gap: 6px;
}
.metric-row {
display: flex;
justify-content: space-between;
font-size: 13px;
color: #5a5f6b;
.metric-value {
font-weight: 600;
color: #30343c;
}
.metric-value.accent {
color: #b4792b;
}
}
.pagination {
margin-top: 20px;
text-align: right;
}
.storage-days-normal {
color: #4c805c;
}
.storage-days-medium {
color: #b78b38;
}
.storage-days-high {
color: #b3473f;
}
</style>

View File

@@ -0,0 +1,363 @@
<template>
<div class="app-container cost-standard">
<!-- 当前有效标准提示 -->
<div class="alert-section" v-if="currentStandard">
<div class="alert-content">
<i class="el-icon-info"></i>
<span>当前有效标准<strong>{{ formatMoney(currentStandard.unitCost) }}</strong> //生效日期{{ currentStandard.effectiveDate }}</span>
</div>
</div>
<!-- 查询条件 -->
<div class="query-section">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" label-width="100px">
<el-form-item>
<el-button type="primary" icon="el-icon-plus" @click="handleAdd">新增成本标准</el-button>
<el-button type="success" icon="el-icon-refresh" @click="loadCurrentStandard">刷新当前标准</el-button>
</el-form-item>
</el-form>
</div>
<!-- 成本标准列表 -->
<div class="table-section">
<div class="section-header">
<span>成本标准配置列表</span>
<el-tooltip content="成本标准按生效日期自动应用,历史标准不会影响已计算的成本记录" placement="top">
<i class="el-icon-question"></i>
</el-tooltip>
</div>
<el-table
v-loading="loading"
:data="standardList"
stripe
style="width: 100%"
>
<el-table-column prop="unitCost" label="单位成本" align="right">
<template slot-scope="scope">
<span class="cost-value">{{ formatMoney(scope.row.unitCost) }}</span> //
</template>
</el-table-column>
<el-table-column prop="effectiveDate" label="生效日期" />
<el-table-column prop="expireDate" label="失效日期">
<template slot-scope="scope">
{{ scope.row.expireDate || '当前有效' }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态">
<template slot-scope="scope">
<el-tag :type="scope.row.status === 1 ? 'success' : 'info'">
{{ scope.row.status === 1 ? '有效' : '失效' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" />
<el-table-column prop="createBy" label="创建人" />
<el-table-column prop="createTime" label="创建时间" />
<el-table-column label="操作" fixed="right">
<template slot-scope="scope">
<el-button type="text" size="small" @click="handleUpdate(scope.row)">修改</el-button>
<el-button
type="text"
size="small"
@click="handleDelete(scope.row)"
:disabled="scope.row.status === 1"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
</div>
<!-- 新增/修改对话框 -->
<el-dialog
:title="dialogTitle"
:visible.sync="dialogVisible"
width="600px"
>
<el-form ref="form" :model="form" :rules="rules" label-width="120px">
<el-form-item label="单位成本" prop="unitCost">
<el-input-number
v-model="form.unitCost"
:precision="2"
:step="1000"
:min="0"
style="width: 100%;"
/>
<div style="color: #909399; font-size: 12px; margin-top: 5px;">
单位//
</div>
</el-form-item>
<el-form-item label="生效日期" prop="effectiveDate">
<el-date-picker
v-model="form.effectiveDate"
type="date"
placeholder="选择生效日期"
style="width: 100%;"
value-format="yyyy-MM-dd"
:picker-options="{
disabledDate(time) {
return time.getTime() < Date.now() - 8.64e7
}
}"
/>
<div style="color: #909399; font-size: 12px; margin-top: 5px;">
生效日期不能早于今天
</div>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="form.remark"
type="textarea"
:rows="3"
placeholder="请输入备注说明"
/>
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm" :loading="submitting">确定</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import {
listCostStandardConfig,
getCostStandardConfig,
addCostStandardConfig,
updateCostStandardConfig,
delCostStandardConfig,
getCurrentCostStandard
} from '@/api/wms/cost'
export default {
name: 'CostStandard',
data() {
return {
loading: false,
submitting: false,
total: 0,
standardList: [],
currentStandard: null,
dialogVisible: false,
dialogTitle: '新增成本标准',
queryParams: {
pageNum: 1,
pageSize: 20
},
form: {
configId: null,
unitCost: 10000,
effectiveDate: null,
remark: null
},
rules: {
unitCost: [
{ required: true, message: '请输入单位成本', trigger: 'blur' },
{ type: 'number', min: 0, message: '单位成本必须大于0', trigger: 'blur' }
],
effectiveDate: [
{ required: true, message: '请选择生效日期', trigger: 'change' }
]
}
}
},
created() {
this.getList()
this.loadCurrentStandard()
},
methods: {
async getList() {
this.loading = true
try {
const res = await listCostStandardConfig(this.queryParams)
if (res.code === 200) {
this.standardList = res.rows || []
this.total = res.total || 0
}
} catch (error) {
this.$message.error('加载成本标准列表失败')
} finally {
this.loading = false
}
},
async loadCurrentStandard() {
try {
const res = await getCurrentCostStandard()
if (res.code === 200) {
this.currentStandard = res.data
}
} catch (error) {
console.error('加载当前标准失败:', error)
}
},
handleAdd() {
this.dialogTitle = '新增成本标准'
this.form = {
configId: null,
unitCost: 10000,
effectiveDate: null,
remark: null
}
this.dialogVisible = true
this.$nextTick(() => {
this.$refs.form && this.$refs.form.clearValidate()
})
},
async handleUpdate(row) {
this.dialogTitle = '修改成本标准'
try {
const res = await getCostStandardConfig(row.configId)
if (res.code === 200) {
this.form = {
configId: res.data.configId,
unitCost: res.data.unitCost,
effectiveDate: res.data.effectiveDate,
remark: res.data.remark
}
this.dialogVisible = true
this.$nextTick(() => {
this.$refs.form && this.$refs.form.clearValidate()
})
}
} catch (error) {
this.$message.error('加载成本标准详情失败')
}
},
async handleDelete(row) {
this.$confirm('确定要删除该成本标准吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await delCostStandardConfig([row.configId])
if (res.code === 200) {
this.$message.success('删除成功')
this.getList()
this.loadCurrentStandard()
}
} catch (error) {
this.$message.error('删除失败')
}
})
},
submitForm() {
this.$refs.form.validate(async (valid) => {
if (valid) {
this.submitting = true
try {
if (this.form.configId) {
// 修改
const res = await updateCostStandardConfig(this.form)
if (res.code === 200) {
this.$message.success('修改成功')
this.dialogVisible = false
this.getList()
this.loadCurrentStandard()
}
} else {
// 新增
const res = await addCostStandardConfig(this.form)
if (res.code === 200) {
this.$message.success('新增成功')
this.dialogVisible = false
this.getList()
this.loadCurrentStandard()
}
}
} catch (error) {
this.$message.error(this.form.configId ? '修改失败' : '新增失败')
} finally {
this.submitting = false
}
}
})
},
formatMoney(value) {
if (!value) return '0.00'
return Number(value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
}
}
</script>
<style lang="scss" scoped>
.cost-standard {
padding: 20px;
}
.alert-section {
margin-bottom: 20px;
padding: 15px 20px;
background: #ECF5FF;
border-radius: 4px;
border: 1px solid #B3D8FF;
.alert-content {
display: flex;
align-items: center;
color: #409EFF;
font-size: 14px;
i {
margin-right: 8px;
font-size: 16px;
}
strong {
font-size: 16px;
margin: 0 5px;
}
}
}
.query-section {
margin-bottom: 20px;
padding: 20px;
background: #FAFAFA;
border-radius: 4px;
border: 1px solid #EBEEF5;
}
.table-section {
padding: 20px;
background: #FAFAFA;
border-radius: 4px;
border: 1px solid #EBEEF5;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 500;
margin-bottom: 15px;
font-size: 16px;
color: #303133;
.el-icon-question {
color: #909399;
cursor: help;
margin-left: 5px;
}
}
.cost-value {
color: #409EFF;
font-weight: bold;
font-size: 16px;
}
</style>

View File

@@ -1,5 +1,422 @@
<template>
<div>
囤积成本页面
<div class="app-container cost-stockpile">
<!-- 查询条件 -->
<div class="query-section">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" label-width="120px">
<el-form-item label="入场钢卷号">
<el-input
v-model="queryParams.enterCoilNo"
placeholder="请输入入场钢卷号"
clearable
style="width: 200px;"
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="当前钢卷号">
<el-input
v-model="queryParams.currentCoilNo"
placeholder="请输入当前钢卷号"
clearable
style="width: 200px;"
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" @click="handleQuery">查询</el-button>
<el-button icon="el-icon-refresh" @click="resetQuery">重置</el-button>
<el-button type="success" icon="el-icon-refresh-left" @click="refreshCosts" :loading="refreshing">
刷新成本
</el-button>
</el-form-item>
</el-form>
</div>
<!-- 汇总信息 -->
<div class="summary-section">
<div class="summary-item">
<div class="summary-label">在库钢卷数</div>
<div class="summary-value warning">{{ total }}</div>
<div class="summary-desc"></div>
</div>
<div class="summary-item">
<div class="summary-label">总净重</div>
<div class="summary-value success">{{ formatWeight(totalNetWeight) }}</div>
<div class="summary-desc"></div>
</div>
<div class="summary-item">
<div class="summary-label">总毛重</div>
<div class="summary-value success">{{ formatWeight(totalGrossWeight) }}</div>
<div class="summary-desc"></div>
</div>
<div class="summary-item">
<div class="summary-label">总囤积成本</div>
<div class="summary-value primary">{{ formatMoney(totalCost) }}</div>
<div class="summary-desc"></div>
</div>
<div class="summary-item">
<div class="summary-label">平均在库天数</div>
<div class="summary-value info">{{ avgStorageDays }}</div>
<div class="summary-desc"></div>
</div>
</div>
<!-- 钢卷列表 -->
<div class="table-section">
<div class="section-header">
<span>囤积成本明细</span>
<el-tooltip content="按入场钢卷号聚类展示囤积成本,点击查看详情可查看该入场卷下每个子钢卷的成本" placement="top">
<i class="el-icon-question"></i>
</el-tooltip>
</div>
<el-table
v-loading="loading"
:data="coilList"
stripe
style="width: 100%"
@sort-change="handleSortChange"
>
<el-table-column prop="enterCoilNo" label="入场钢卷号" />
<el-table-column prop="coilCount" label="子钢卷数" align="right" />
<el-table-column prop="totalGrossWeight" label="总毛重(吨)" align="right">
<template slot-scope="scope">
{{ formatWeight(scope.row.totalGrossWeight) }}
</template>
</el-table-column>
<el-table-column prop="totalNetWeight" label="总净重(吨)" align="right">
<template slot-scope="scope">
{{ formatWeight(scope.row.totalNetWeight) }}
</template>
</el-table-column>
<el-table-column prop="avgStorageDays" label="平均在库天数" align="right" sortable="custom">
<template slot-scope="scope">
<span :class="getStorageDaysClass(scope.row.avgStorageDays)">
{{ scope.row.avgStorageDays || '-' }}
</span>
</template>
</el-table-column>
<el-table-column prop="maxStorageDays" label="最大在库天数" align="right" sortable="custom">
<template slot-scope="scope">
<span :class="getStorageDaysClass(scope.row.maxStorageDays)">
{{ scope.row.maxStorageDays || '-' }}
</span>
</template>
</el-table-column>
<el-table-column prop="totalCost" label="累计成本(元)" align="right" sortable="custom">
<template slot-scope="scope">
<span class="cost-total">{{ formatMoney(scope.row.totalCost) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right">
<template slot-scope="scope">
<el-button type="text" size="small" @click="viewCoilDetail(scope.row)">查看子钢卷成本</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
</div>
<!-- 入场钢卷号下子钢卷详情对话框 -->
<el-dialog
title="入场钢卷号下子钢卷成本详情"
:visible.sync="showDetailDialog"
width="900px"
>
<div v-if="detailEnterCoilNo" style="margin-bottom: 10px;">
入场钢卷号<strong>{{ detailEnterCoilNo }}</strong>
</div>
<el-table :data="detailList" stripe style="width: 100%">
<el-table-column prop="currentCoilNo" label="当前钢卷号" />
<el-table-column prop="grossWeightTon" label="毛重(吨)" align="right">
<template slot-scope="scope">
{{ formatWeight(scope.row.grossWeightTon) }}
</template>
</el-table-column>
<el-table-column prop="netWeightTon" label="净重(吨)" align="right">
<template slot-scope="scope">
{{ formatWeight(scope.row.netWeightTon) }}
</template>
</el-table-column>
<el-table-column prop="storageDays" label="在库天数" align="right">
<template slot-scope="scope">
<span :class="getStorageDaysClass(scope.row.storageDays)">
{{ scope.row.storageDays || '-' }}
</span>
</template>
</el-table-column>
<el-table-column prop="unitCost" label="单位成本(元/吨/天)" align="right">
<template slot-scope="scope">
{{ formatMoney(scope.row.unitCost) }}
</template>
</el-table-column>
<el-table-column prop="dailyCost" label="日成本(元)" align="right">
<template slot-scope="scope">
{{ formatMoney(scope.row.dailyCost) }}
</template>
</el-table-column>
<el-table-column prop="totalCost" label="累计成本(元)" align="right">
<template slot-scope="scope">
<span class="cost-total">{{ formatMoney(scope.row.totalCost) }}</span>
</template>
</el-table-column>
</el-table>
</el-dialog>
</div>
</template>
</template>
<script>
import { calculateCostByEnterCoilNo, getStockpileCostList } from '@/api/wms/cost'
export default {
name: 'CostStockpile',
data() {
return {
loading: false,
refreshing: false,
total: 0,
totalNetWeight: 0,
totalGrossWeight: 0,
totalCost: 0,
avgStorageDays: 0,
coilList: [],
queryParams: {
pageNum: 1,
pageSize: 50,
enterCoilNo: null,
currentCoilNo: null
},
showDetailDialog: false,
detailEnterCoilNo: null,
detailList: []
}
},
created() {
this.getList()
},
methods: {
async getList() {
this.loading = true
try {
const params = {
enterCoilNo: this.queryParams.enterCoilNo,
currentCoilNo: this.queryParams.currentCoilNo,
pageNum: this.queryParams.pageNum,
pageSize: this.queryParams.pageSize
}
const res = await getStockpileCostList(params)
if (res.code === 200 && res.data) {
this.coilList = res.data.rows || []
this.total = res.data.total || 0
const summary = res.data.summary || {}
this.totalNetWeight = summary.totalNetWeight || 0
this.totalGrossWeight = summary.totalGrossWeight || 0
this.totalCost = summary.totalCost || 0
this.avgStorageDays = summary.avgStorageDays || 0
}
} catch (error) {
this.$message.error('加载钢卷列表失败')
} finally {
this.loading = false
}
},
async refreshCosts() {
this.refreshing = true
try {
await this.getList()
this.$message.success('已重新计算并刷新列表')
} catch (error) {
this.$message.error('刷新成本失败')
} finally {
this.refreshing = false
}
},
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
resetQuery() {
this.queryParams = {
pageNum: 1,
pageSize: 50,
enterCoilNo: null,
currentCoilNo: null
}
this.getList()
},
handleSortChange({ prop, order }) {
if (order === 'ascending') {
this.coilList.sort((a, b) => {
const valA = a[prop] || 0
const valB = b[prop] || 0
return valA - valB
})
} else if (order === 'descending') {
this.coilList.sort((a, b) => {
const valA = a[prop] || 0
const valB = b[prop] || 0
return valB - valA
})
}
},
async viewCoilDetail(row) {
try {
const res = await calculateCostByEnterCoilNo(row.enterCoilNo, null)
if (res.code === 200 && res.data && !res.data.error) {
this.detailEnterCoilNo = row.enterCoilNo
this.detailList = res.data.coilDetails || []
this.showDetailDialog = true
} else {
this.$message.error(res.data?.error || '加载详情失败')
}
} catch (error) {
this.$message.error('加载详情失败')
}
},
getStorageDaysClass(days) {
if (!days) return ''
if (days >= 30) return 'storage-days-high'
if (days >= 15) return 'storage-days-medium'
return 'storage-days-normal'
},
formatMoney(value) {
if (!value) return '0.00'
return Number(value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
},
formatWeight(value) {
if (!value) return '0.000'
// 如果值大于1000可能是kg单位需要转换
const weight = Number(value) > 1000 ? Number(value) / 1000 : Number(value)
return weight.toFixed(3)
},
formatDateTime(value) {
if (!value) return '-'
const date = new Date(value)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
}
}
</script>
<style lang="scss" scoped>
.cost-stockpile {
padding: 20px;
background: #FFFFFF;
}
.query-section {
margin-bottom: 20px;
padding: 20px;
background: #FAFAFA;
border-radius: 4px;
border: 1px solid #EBEEF5;
}
.summary-section {
display: flex;
gap: 20px;
margin-bottom: 20px;
padding: 20px;
background: #FAFAFA;
border-radius: 4px;
border: 1px solid #EBEEF5;
.summary-item {
flex: 1;
text-align: center;
.summary-label {
font-size: 14px;
color: #606266;
margin-bottom: 10px;
}
.summary-value {
font-size: 28px;
font-weight: bold;
margin-bottom: 5px;
&.primary {
color: #409EFF;
}
&.warning {
color: #E6A23C;
}
&.success {
color: #67C23A;
}
&.info {
color: #909399;
}
}
.summary-desc {
font-size: 12px;
color: #909399;
}
}
}
.table-section {
padding: 20px;
background: #FAFAFA;
border-radius: 4px;
border: 1px solid #EBEEF5;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 500;
margin-bottom: 15px;
font-size: 16px;
color: #303133;
.el-icon-question {
color: #909399;
cursor: help;
margin-left: 5px;
}
}
.cost-value {
color: #409EFF;
font-weight: 500;
}
.cost-total {
color: #E6A23C;
font-weight: bold;
font-size: 16px;
}
.storage-days-normal {
color: #67C23A;
}
.storage-days-medium {
color: #E6A23C;
font-weight: 500;
}
.storage-days-high {
color: #F56C6C;
font-weight: bold;
}
</style>