feat:库存总览页面

main
黄伟杰 5 days ago
parent b7890af647
commit d44dbe3034

@ -345,6 +345,69 @@
validatorAllowBatchMixRequired: 'Allow batch mix is required',
validatorStatusRequired: 'Status is required'
},
Overview: {
materialCategory: 'Material Category',
location: 'Location',
dateRange: 'Date Range',
keyword: 'Keyword',
placeholderKeyword: 'Material code/name/specification',
stockDetail: 'Stock Detail',
recentRecord: 'Recent Stock Records',
more: 'More',
time: 'Time',
material: 'Material',
latestChangeTime: 'Latest Change Time',
stockIn: 'Inbound',
stockOut: 'Outbound',
cards: {
finishedGoods: {
title: 'Finished Goods Stock',
line1: 'SKU: 36',
line2: 'Stock Summary: 128 pallets 42 packs 560 pcs',
line3: 'Completed Inbound Today: 12 pallets 8 packs',
tip: 'Converted by product packaging scheme'
},
rawMaterial: {
title: 'Raw Material Stock',
line1: 'Materials: 24 types',
line2: 'Weight: 18.6 tons',
line3: 'Liquid: 1,250 L',
line4: 'Pack/Box: 86 drums / 320 packs',
tip: 'Summarized by material measurement mode'
},
sparePart: {
title: 'Spare Parts Stock',
line1: 'Spare Parts: 128 types',
line2: 'Low Stock: 8 items',
line3: 'Common Parts: 36 items',
line4: 'Sample Stock: cable ties 9 packs 70 pcs',
tip: 'Stock is stored by minimum unit with auxiliary conversion'
},
todayIn: {
title: 'Inbound Today',
line1: 'Inbound Orders: 18',
line2: 'Detail Lines: 46',
line3: 'Finished Goods: 12 pallets 8 packs',
line4: 'Raw Materials: 3 pallets / 1.2 tons',
line5: 'Spare Parts: 6 packs / 120 pcs'
},
todayOut: {
title: 'Outbound Today',
line1: 'Outbound Orders: 15',
line2: 'Detail Lines: 38',
line3: 'Raw Material Issue: 820 kg',
line4: 'Spare Parts Issue: 45 pcs',
line5: 'Finished Goods Shipment: 6 pallets 20 packs'
},
warning: {
title: 'Stock Alerts',
line1: 'Alerts: 18',
line2: 'Raw Materials: 5',
line3: 'Spare Parts: 10',
line4: 'Finished Goods: 3'
}
}
},
Stock: {
product: 'Product',
warehouse: 'Warehouse',

@ -345,6 +345,69 @@
validatorAllowBatchMixRequired: '是否允许批次混放不能为空',
validatorStatusRequired: '开启状态不能为空'
},
Overview: {
materialCategory: '物料大类',
location: '库位',
dateRange: '日期范围',
keyword: '关键字',
placeholderKeyword: '物料编码/名称/规格型号',
stockDetail: '库存明细',
recentRecord: '最近库存流水',
more: '更多',
time: '时间',
material: '物料',
latestChangeTime: '最近变动时间',
stockIn: '入库',
stockOut: '出库',
cards: {
finishedGoods: {
title: '产成品库存',
line1: 'SKU36',
line2: '库存摘要128托 42包 560个',
line3: '今日完工入库12托 8包',
tip: '按产品包装方案换算展示'
},
rawMaterial: {
title: '原材料库存',
line1: '物料24种',
line2: '重量类18.6吨',
line3: '液体类1,250L',
line4: '包/箱类86桶 / 320包',
tip: '按物料档案计量方式分别汇总'
},
sparePart: {
title: '备件库存',
line1: '备件128种',
line2: '低库存8项',
line3: '常用件36项',
line4: '示例库存:扎带 9包70根',
tip: '库存按最小单位保存,页面辅助换算展示'
},
todayIn: {
title: '今日入库',
line1: '入库单18张',
line2: '明细行46行',
line3: '产成品12托8包',
line4: '原材料3托 / 1.2吨',
line5: '备件6包 / 120个'
},
todayOut: {
title: '今日出库',
line1: '出库单15张',
line2: '明细行38行',
line3: '原材料领用820kg',
line4: '备件领用45个',
line5: '产成品发货6托20包'
},
warning: {
title: '库存预警',
line1: '预警18条',
line2: '原材料5',
line3: '备件10',
line4: '产成品3'
}
}
},
Stock: {
product: '产品',
warehouse: '仓库',

@ -0,0 +1,585 @@
<template>
<div class="stock-overview">
<ContentWrap>
<el-form
ref="queryFormRef" :inline="true" :model="queryParams" class="-mb-15px stock-overview__query"
label-width="auto">
<el-form-item :label="t('ErpStock.Overview.materialCategory')" prop="categoryType">
<el-select
v-model="queryParams.categoryType" clearable :placeholder="t('common.selectText')"
class="!w-220px">
<el-option v-for="item in categoryTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item :label="t('ErpStock.Stock.warehouse')" prop="warehouseId">
<el-select
v-model="queryParams.warehouseId" clearable filterable
:placeholder="t('ErpStock.Stock.placeholderWarehouse')" class="!w-220px">
<el-option v-for="item in warehouseList" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item :label="t('ErpStock.Overview.location')" prop="areaId">
<el-select
v-model="queryParams.areaId" clearable filterable :placeholder="t('common.selectText')"
class="!w-220px">
<el-option v-for="item in areaOptions" :key="item.id" :label="item.areaName" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item :label="t('ErpStock.Overview.dateRange')" prop="dateRange">
<el-date-picker
v-model="queryParams.dateRange" value-format="YYYY-MM-DD HH:mm:ss" type="daterange"
:start-placeholder="t('ErpStock.Record.placeholderCreateTimeStart')"
:end-placeholder="t('ErpStock.Record.placeholderCreateTimeEnd')"
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" class="!w-280px" />
</el-form-item>
<el-form-item :label="t('ErpStock.Overview.keyword')" prop="keyword">
<el-input
v-model="queryParams.keyword" clearable :placeholder="t('ErpStock.Overview.placeholderKeyword')"
class="!w-240px" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">
<Icon icon="ep:search" class="mr-5px" /> {{ t('common.query') }}
</el-button>
<el-button @click="resetQuery">
<Icon icon="ep:refresh" class="mr-5px" /> {{ t('common.reset') }}
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<div class="stock-overview__cards">
<ContentWrap
v-for="item in summaryCards" :key="item.title" class="stock-overview__card-wrap"
:body-style="{ padding: '22px 24px' }">
<div class="stock-overview__card">
<div class="stock-overview__card-main">
<div class="stock-overview__card-title">{{ item.title }}</div>
<div v-for="line in item.lines" :key="line" class="stock-overview__card-line">
{{ line }}
</div>
</div>
<Icon :icon="item.icon" :class="['stock-overview__card-icon', `is-${item.color}`]" :size="34" />
</div>
<div v-if="item.tip" class="stock-overview__card-tip">{{ item.tip }}</div>
</ContentWrap>
</div>
<div class="stock-overview__tables">
<ContentWrap :title="t('ErpStock.Overview.stockDetail')" class="stock-overview__stock">
<el-table
v-loading="stockLoading" :data="stockList" :stripe="true" :show-overflow-tooltip="true" row-key="id"
height="520">
<el-table-column :label="t('ErpStock.Stock.code')" align="center" sortable prop="barCode" min-width="130" />
<el-table-column :label="t('ErpStock.Stock.name')" align="center" sortable prop="name" min-width="150">
<template #default="{ row }">
{{ row.name || row.productName || '-' }}
</template>
</el-table-column>
<el-table-column
:label="t('ErpStock.Overview.materialCategory')" align="center" prop="categoryType"
min-width="100">
<template #default="{ row }">
<el-tag :type="getCategoryTagType(row.categoryType)">
{{ getCategoryLabel(row.categoryType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
:label="t('ErpStock.Stock.subCategory')" align="center" prop="categoryName"
min-width="110" />
<el-table-column :label="t('ErpStock.Stock.warehouse')" align="center" prop="warehouseName" min-width="110" />
<el-table-column :label="t('ErpStock.Overview.location')" align="center" prop="areaName" min-width="110" />
<el-table-column :label="t('ErpStock.Stock.stockDisplay')" align="center" prop="stockDisplay" min-width="180">
<template #default="{ row }">
<span v-if="row.stockDisplay" class="stock-overview__display">{{ row.stockDisplay }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column :label="t('ErpStock.Stock.count')" align="center" sortable prop="count" min-width="110">
<template #default="{ row }">
{{ formatNumber(row.count) }}
</template>
</el-table-column>
<el-table-column :label="t('ErpStock.Stock.unit')" align="center" prop="unitName" min-width="80" />
<el-table-column :label="t('ErpStock.Overview.latestChangeTime')" align="center" min-width="170">
<template #default="{ row }">
{{ formatStockTime(getLatestChangeTime(row)) }}
</template>
</el-table-column>
</el-table>
<Pagination
:total="stockTotal" v-model:page="stockQuery.pageNo" v-model:limit="stockQuery.pageSize"
@pagination="getStockList" />
</ContentWrap>
<ContentWrap
:title="t('ErpStock.Overview.recentRecord')" class="stock-overview__record"
:body-style="{ padding: '10px 14px 16px' }">
<template #header>
<el-button link type="primary" class="stock-overview__more" @click="goRecordPage">
{{ t('ErpStock.Overview.more') }}
<Icon icon="ep:arrow-right" class="ml-2px" />
</el-button>
</template>
<template #default>
<el-table
v-loading="recordLoading" :data="recordList" :stripe="true" :show-overflow-tooltip="true"
row-key="id" height="562">
<el-table-column :label="t('ErpStock.Overview.time')" align="center" min-width="160">
<template #default="{ row }">
{{ formatStockTime(row.recordTime || row.createTime) }}
</template>
</el-table-column>
<el-table-column :label="t('ErpStock.Record.bizType')" align="center" prop="bizDirection" min-width="90">
<template #default="{ row }">
<el-tag v-if="row.bizDirection" :type="getDirectionTagType(row.bizDirection)">
{{ formatDirection(row.bizDirection) }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column :label="t('ErpStock.Overview.material')" align="center" min-width="150">
<template #default="{ row }">
<div class="stock-overview__material">
<span>{{ row.productName || '-' }}</span>
<span v-if="row.bizNo">{{ row.bizNo }}</span>
</div>
</template>
</el-table-column>
<el-table-column :label="t('ErpStock.Record.count')" align="center" min-width="120">
<template #default="{ row }">
<span :class="getDirectionClass(row.bizDirection)">
{{ formatRecordCount(row) }}
</span>
</template>
</el-table-column>
<el-table-column
:label="t('ErpStock.Record.creatorName')" align="center" prop="creatorName"
min-width="90" />
</el-table>
</template>
</ContentWrap>
</div>
</div>
</template>
<script setup lang="ts">
import { DICT_TYPE, getDictObj, getIntDictOptions } from '@/utils/dict'
import { formatDate } from '@/utils/formatTime'
import { StockApi, StockVO } from '@/api/erp/stock/stock'
import { StockRecordApi, StockRecordVO } from '@/api/erp/stock/record'
import { WarehouseApi, WarehouseVO } from '@/api/erp/stock/warehouse'
import { useDictStoreWithOut } from '@/store/modules/dict'
defineOptions({ name: 'ErpStockOverview' })
const router = useRouter()
const { t } = useI18n()
const dictStore = useDictStoreWithOut()
const queryFormRef = ref()
const warehouseList = ref<WarehouseVO[]>([])
const categoryTypeOptions = computed(() => getIntDictOptions(DICT_TYPE.MATERIAL_CLASSIFICATION_TYPE))
const queryParams = reactive<{
categoryType?: number
warehouseId?: number
areaId?: number
dateRange: string[]
keyword?: string
}>({
categoryType: undefined,
warehouseId: undefined,
areaId: undefined,
dateRange: [],
keyword: undefined
})
const stockLoading = ref(false)
const recordLoading = ref(false)
const stockList = ref<StockVO[]>([])
const recordList = ref<StockRecordVO[]>([])
const stockTotal = ref(0)
const stockQuery = reactive({
pageNo: 1,
pageSize: 10
})
const recordQuery = reactive({
pageNo: 1,
pageSize: 9
})
const summaryCards = computed(() => [
{
title: t('ErpStock.Overview.cards.finishedGoods.title'),
icon: 'ep:box',
color: 'blue',
lines: [
t('ErpStock.Overview.cards.finishedGoods.line1'),
t('ErpStock.Overview.cards.finishedGoods.line2'),
t('ErpStock.Overview.cards.finishedGoods.line3')
],
tip: t('ErpStock.Overview.cards.finishedGoods.tip')
},
{
title: t('ErpStock.Overview.cards.rawMaterial.title'),
icon: 'ep:collection',
color: 'green',
lines: [
t('ErpStock.Overview.cards.rawMaterial.line1'),
t('ErpStock.Overview.cards.rawMaterial.line2'),
t('ErpStock.Overview.cards.rawMaterial.line3'),
t('ErpStock.Overview.cards.rawMaterial.line4')
],
tip: t('ErpStock.Overview.cards.rawMaterial.tip')
},
{
title: t('ErpStock.Overview.cards.sparePart.title'),
icon: 'ep:setting',
color: 'orange',
lines: [
t('ErpStock.Overview.cards.sparePart.line1'),
t('ErpStock.Overview.cards.sparePart.line2'),
t('ErpStock.Overview.cards.sparePart.line3'),
t('ErpStock.Overview.cards.sparePart.line4')
],
tip: t('ErpStock.Overview.cards.sparePart.tip')
},
{
title: t('ErpStock.Overview.cards.todayIn.title'),
icon: 'ep:download',
color: 'blue',
lines: [
t('ErpStock.Overview.cards.todayIn.line1'),
t('ErpStock.Overview.cards.todayIn.line2'),
t('ErpStock.Overview.cards.todayIn.line3'),
t('ErpStock.Overview.cards.todayIn.line4'),
t('ErpStock.Overview.cards.todayIn.line5')
]
},
{
title: t('ErpStock.Overview.cards.todayOut.title'),
icon: 'ep:upload',
color: 'green',
lines: [
t('ErpStock.Overview.cards.todayOut.line1'),
t('ErpStock.Overview.cards.todayOut.line2'),
t('ErpStock.Overview.cards.todayOut.line3'),
t('ErpStock.Overview.cards.todayOut.line4'),
t('ErpStock.Overview.cards.todayOut.line5')
]
},
{
title: t('ErpStock.Overview.cards.warning.title'),
icon: 'ep:warning-filled',
color: 'red',
lines: [
t('ErpStock.Overview.cards.warning.line1'),
t('ErpStock.Overview.cards.warning.line2'),
t('ErpStock.Overview.cards.warning.line3'),
t('ErpStock.Overview.cards.warning.line4')
]
}
])
const areaOptions = computed(() => {
const areaMap = new Map<number, { id: number; areaName: string }>()
warehouseList.value.forEach((warehouse) => {
warehouse.areaList?.forEach((area) => {
areaMap.set(area.id, {
id: area.id,
areaName: `${warehouse.name || ''}${warehouse.name ? ' / ' : ''}${area.areaName}`
})
})
})
return Array.from(areaMap.values())
})
const filterEmptyParams = (params: Record<string, any>) => {
return Object.fromEntries(
Object.entries(params).filter(([, value]) => {
if (Array.isArray(value)) return value.length > 0
return value !== undefined && value !== null && value !== ''
})
)
}
const buildBaseQueryParams = () => {
const [beginTime, endTime] = queryParams.dateRange || []
return filterEmptyParams({
categoryType: queryParams.categoryType,
warehouseId: queryParams.warehouseId,
areaId: queryParams.areaId,
keyword: queryParams.keyword,
createTime: beginTime && endTime ? [beginTime, endTime] : undefined
})
}
const getStockList = async () => {
stockLoading.value = true
try {
const data = await StockApi.getStockPage(
filterEmptyParams({
...buildBaseQueryParams(),
pageNo: stockQuery.pageNo,
pageSize: stockQuery.pageSize
})
)
stockList.value = data.list || []
stockTotal.value = data.total || 0
} finally {
stockLoading.value = false
}
}
const getRecordList = async () => {
recordLoading.value = true
try {
const data = await StockRecordApi.getStockRecordPage(
filterEmptyParams({
...buildBaseQueryParams(),
pageNo: recordQuery.pageNo,
pageSize: recordQuery.pageSize
})
)
recordList.value = data.list || []
} finally {
recordLoading.value = false
}
}
const getList = async () => {
await Promise.all([getStockList(), getRecordList()])
}
const handleQuery = () => {
stockQuery.pageNo = 1
recordQuery.pageNo = 1
getList()
}
const resetQuery = () => {
queryFormRef.value?.resetFields()
handleQuery()
}
const goRecordPage = () => {
router.push({ path: '/warehouse/record' })
}
const getCategoryLabel = (value?: number | string) => {
return getDictObj(DICT_TYPE.MATERIAL_CLASSIFICATION_TYPE, value)?.label || '-'
}
const getCategoryTagType = (value?: number | string) => {
const dict = getDictObj(DICT_TYPE.MATERIAL_CLASSIFICATION_TYPE, value)
const colorType = String(dict?.colorType || '')
return ['success', 'info', 'warning', 'danger'].includes(colorType) ? colorType : 'primary'
}
const formatNumber = (value: number | string | undefined) => {
if (value === undefined || value === null || value === '') return '-'
const num = Number(value)
return Number.isFinite(num) ? num.toLocaleString() : String(value)
}
const formatStockTime = (value: any) => {
return value ? formatDate(value, 'YYYY-MM-DD HH:mm:ss') : '-'
}
const getLatestChangeTime = (row: any) => {
return (
row.latestChangeTime ??
row.latestInTime ??
row.lastInTime ??
row.recentInTime ??
row.latestStockInTime ??
row.lastStockInTime ??
row.latestOutTime ??
row.lastOutTime ??
row.recentOutTime ??
row.latestStockOutTime ??
row.lastStockOutTime
)
}
const isStockIn = (direction?: string) => ['入库', 'Inbound'].includes(String(direction || ''))
const isStockOut = (direction?: string) => ['出库', 'Outbound'].includes(String(direction || ''))
const formatDirection = (direction?: string) => {
if (isStockIn(direction)) return t('ErpStock.Overview.stockIn')
if (isStockOut(direction)) return t('ErpStock.Overview.stockOut')
return direction || '-'
}
const getDirectionClass = (direction?: string) => {
if (isStockIn(direction)) return 'stock-overview__count-in'
if (isStockOut(direction)) return 'stock-overview__count-out'
return ''
}
const getDirectionTagType = (direction?: string) => {
if (isStockIn(direction)) return 'success'
if (isStockOut(direction)) return 'danger'
return 'info'
}
const formatRecordCount = (row: StockRecordVO) => {
const unit = row.unitName ? ` ${row.unitName}` : ''
const num = Number(row.count)
if (!Number.isFinite(num)) return `${row.count ?? '-'}${unit}`
const value = Math.abs(num).toLocaleString()
if (isStockIn(row.bizDirection)) return `+${value}${unit}`
if (isStockOut(row.bizDirection)) return `-${value}${unit}`
return `${num.toLocaleString()}${unit}`
}
onMounted(async () => {
await dictStore.setDictMap()
warehouseList.value = await WarehouseApi.getWarehouseSimpleList()
await getList()
})
</script>
<style scoped lang="scss">
.stock-overview {
.stock-overview__query {
display: flex;
align-items: flex-start;
}
.stock-overview__cards {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 14px;
margin-bottom: 15px;
}
.stock-overview__card-wrap {
margin-bottom: 0 !important;
}
.stock-overview__card {
display: flex;
justify-content: space-between;
min-height: 174px;
}
.stock-overview__card-main {
min-width: 0;
}
.stock-overview__card-title {
margin-bottom: 16px;
color: var(--el-text-color-primary);
font-size: 16px;
font-weight: 700;
}
.stock-overview__card-line {
margin-bottom: 13px;
color: var(--el-text-color-regular);
font-size: 14px;
font-weight: 600;
line-height: 1.25;
}
.stock-overview__card-tip {
color: var(--el-text-color-placeholder);
font-size: 12px;
font-weight: 600;
}
.stock-overview__card-icon {
flex: none;
&.is-blue {
color: var(--el-color-primary);
}
&.is-green {
color: var(--el-color-success);
}
&.is-orange {
color: var(--el-color-warning);
}
&.is-red {
color: var(--el-color-danger);
}
}
.stock-overview__tables {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(460px, 1fr);
gap: 15px;
align-items: start;
}
.stock-overview__stock,
.stock-overview__record {
margin-bottom: 0 !important;
}
.stock-overview__more {
margin-left: auto;
font-weight: 600;
}
.stock-overview__display {
color: var(--el-color-primary);
font-weight: 600;
}
.stock-overview__material {
display: flex;
flex-direction: column;
gap: 4px;
line-height: 1.25;
span:last-child {
color: var(--el-text-color-secondary);
font-size: 12px;
}
}
.stock-overview__count-in {
color: var(--el-color-success);
font-weight: 700;
}
.stock-overview__count-out {
color: var(--el-color-danger);
font-weight: 700;
}
}
@media (max-width: 1600px) {
.stock-overview {
.stock-overview__cards {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
}
@media (max-width: 1200px) {
.stock-overview {
.stock-overview__tables {
grid-template-columns: 1fr;
}
}
}
@media (max-width: 768px) {
.stock-overview {
.stock-overview__query {
display: block;
}
.stock-overview__cards {
grid-template-columns: 1fr;
}
}
}
</style>
Loading…
Cancel
Save