636 lines
21 KiB
Vue
636 lines
21 KiB
Vue
<!-- 市场物料库 · 变动记录 Tab -->
|
||
<!-- eslint-disable vue/no-v-model-argument -->
|
||
<template>
|
||
<div class="material-usage-tab">
|
||
<div class="filter-block">
|
||
<el-form :model="searchForm" class="filter-form" @submit.prevent>
|
||
<div class="filter-grid">
|
||
<el-form-item label="关键词">
|
||
<el-input
|
||
v-model="searchForm.keyword"
|
||
placeholder="物料编码/名称/入库编号/批号/货号"
|
||
clearable
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="物料筛选">
|
||
<el-select
|
||
v-model="searchForm.materialId"
|
||
placeholder="输入物料名称检索"
|
||
clearable
|
||
filterable
|
||
remote
|
||
reserve-keyword
|
||
:loading="materialOptionsLoading"
|
||
:remote-method="remoteSearchMaterials"
|
||
@visible-change="onMaterialSelectVisible"
|
||
>
|
||
<el-option
|
||
v-for="m in materialOptions"
|
||
:key="m.id"
|
||
:label="m.label"
|
||
:value="m.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="变动类型">
|
||
<el-select v-model="searchForm.changeType" placeholder="全部" clearable>
|
||
<el-option v-if="!mineOnly" label="入库" value="in" />
|
||
<el-option label="出库" value="out" />
|
||
<el-option label="废弃" value="discard" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="时间范围">
|
||
<el-date-picker
|
||
v-model="dateRange"
|
||
type="daterange"
|
||
range-separator="至"
|
||
start-placeholder="开始日期"
|
||
end-placeholder="结束日期"
|
||
value-format="YYYY-MM-DD"
|
||
clearable
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
</div>
|
||
<div class="filter-actions-row">
|
||
<span class="filter-hint">{{
|
||
mineOnly
|
||
? '查看本人物料领用记录,可按物料、批号、类型与时间范围筛选。'
|
||
: '按物料、入库编号、批号、变动类型和时间范围查询变动记录。'
|
||
}}</span>
|
||
<div class="filter-actions-end">
|
||
<el-button class="btn-ghost" @click="handleReset">
|
||
<el-icon><RefreshLeft /></el-icon>重置
|
||
</el-button>
|
||
<el-button class="btn-main" type="primary" @click="handleSearch">
|
||
<el-icon><Search /></el-icon>搜索
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</el-form>
|
||
</div>
|
||
|
||
<div class="list-body list-wrap">
|
||
<el-table
|
||
class="list-table"
|
||
:data="tableData"
|
||
v-loading="loading"
|
||
stripe
|
||
row-key="id"
|
||
@row-dblclick="handleView"
|
||
>
|
||
<el-table-column
|
||
type="index"
|
||
label="序号"
|
||
min-width="68"
|
||
align="center"
|
||
header-align="center"
|
||
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
|
||
/>
|
||
<el-table-column
|
||
prop="materialCode"
|
||
label="物料编码"
|
||
min-width="120"
|
||
align="center"
|
||
header-align="center"
|
||
/>
|
||
<el-table-column
|
||
prop="materialName"
|
||
label="物料名称"
|
||
min-width="140"
|
||
align="center"
|
||
header-align="center"
|
||
/>
|
||
<el-table-column
|
||
prop="stockInNo"
|
||
label="入库编号"
|
||
min-width="120"
|
||
align="center"
|
||
header-align="center"
|
||
>
|
||
<template #default="{ row }">{{ row.stockInNo || '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="入库日期" min-width="110" align="center" header-align="center">
|
||
<template #default="{ row }">{{ row.stockInDate || '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="货号" min-width="100" align="center" header-align="center">
|
||
<template #default="{ row }">{{ row.catalogNo || '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="批号" min-width="100" align="center" header-align="center">
|
||
<template #default="{ row }">{{ row.batchNo || '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="厂家" min-width="120" align="center" header-align="center" show-overflow-tooltip>
|
||
<template #default="{ row }">{{ row.manufacturer || '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="单价(元)" min-width="110" align="center" header-align="center">
|
||
<template #default="{ row }">
|
||
{{ row.unitPrice != null ? formatMoneyDisplay(row.unitPrice) : '—' }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="金额(元)" min-width="110" align="center" header-align="center">
|
||
<template #default="{ row }">
|
||
{{ row.amount != null ? formatMoneyDisplay(row.amount) : '—' }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
prop="changeType"
|
||
label="变动类型"
|
||
min-width="100"
|
||
align="center"
|
||
header-align="center"
|
||
>
|
||
<template #default="{ row }">
|
||
<el-tag :type="getChangeTypeTagType(row)" size="small">
|
||
{{ getChangeTypeLabel(row) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
prop="changeQty"
|
||
label="领用数量"
|
||
min-width="120"
|
||
align="center"
|
||
header-align="center"
|
||
>
|
||
<template #default="{ row }">
|
||
{{ displayUsageQty(row) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="变动前库存" min-width="120" align="center" header-align="center">
|
||
<template #default="{ row }">
|
||
{{ displayStockBefore(row) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
prop="stockAfter"
|
||
label="变动后库存"
|
||
min-width="120"
|
||
align="center"
|
||
header-align="center"
|
||
/>
|
||
<el-table-column
|
||
v-if="mineOnly"
|
||
label="使用状态"
|
||
min-width="100"
|
||
align="center"
|
||
header-align="center"
|
||
>
|
||
<template #default="{ row }">
|
||
<template v-if="isManualOutRow(row)">
|
||
<el-tag v-if="row.requisitionStatus === 'closed'" type="success" size="small">
|
||
已使用完
|
||
</el-tag>
|
||
<el-tag v-else type="info" size="small">
|
||
使用中
|
||
</el-tag>
|
||
</template>
|
||
<span v-else>—</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
prop="sourceType"
|
||
label="来源类型"
|
||
min-width="120"
|
||
align="center"
|
||
header-align="center"
|
||
>
|
||
<template #default="{ row }">
|
||
<el-tag :type="getSourceTagType(row.sourceType)" size="small">
|
||
{{ getSourceLabel(row.sourceType) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
v-if="!mineOnly"
|
||
label="领用人"
|
||
min-width="100"
|
||
align="center"
|
||
header-align="center"
|
||
>
|
||
<template #default="{ row }">
|
||
{{ displayReceiver(row) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
label="用途"
|
||
min-width="120"
|
||
align="center"
|
||
header-align="center"
|
||
show-overflow-tooltip
|
||
>
|
||
<template #default="{ row }">
|
||
{{ displayPurpose(row) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="领用时间" min-width="170" align="center" header-align="center">
|
||
<template #default="{ row }">
|
||
{{ displayUseTime(row) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
prop="remark"
|
||
label="备注"
|
||
min-width="140"
|
||
align="center"
|
||
header-align="center"
|
||
show-overflow-tooltip
|
||
/>
|
||
<el-table-column
|
||
prop="createByName"
|
||
label="操作人"
|
||
min-width="100"
|
||
align="center"
|
||
header-align="center"
|
||
>
|
||
<template #default="{ row }">
|
||
{{ row.createByName || row.createBy || '-' }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column
|
||
prop="createTime"
|
||
label="变动时间"
|
||
min-width="170"
|
||
align="center"
|
||
header-align="center"
|
||
/>
|
||
<el-table-column
|
||
label="操作"
|
||
min-width="260"
|
||
fixed="right"
|
||
align="center"
|
||
header-align="center"
|
||
>
|
||
<template #default="{ row }">
|
||
<div class="table-op-btns">
|
||
<el-button class="btn-op-item" @click="handleView(row)">
|
||
<el-icon><View /></el-icon>查看
|
||
</el-button>
|
||
<el-button
|
||
v-if="canMarkFullyUsed(row)"
|
||
class="btn-op-item"
|
||
:loading="markFullyUsedLoadingId === row.id"
|
||
@click="handleMarkFullyUsed(row)"
|
||
>
|
||
<el-icon><CircleCheck /></el-icon>标记已使用完
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<div class="pagination list-pager-bar">
|
||
<el-pagination
|
||
:current-page="pagination.page"
|
||
:page-size="pagination.pageSize"
|
||
:total="pagination.total"
|
||
:page-sizes="[10, 20, 50, 100]"
|
||
layout="total, sizes, prev, pager, next"
|
||
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
|
||
@current-change="(val) => ((pagination.page = val), loadData())"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 查看弹窗 -->
|
||
<SystemViewDialog
|
||
v-model:visible="viewVisible"
|
||
:title="viewRow?.materialName || '变动记录详情'"
|
||
:icon="Document"
|
||
:meta="viewRow?.createTime || ''"
|
||
:sections="viewSections"
|
||
:loading="viewLoading"
|
||
width="960px"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, computed, onMounted } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { Search, RefreshLeft, View, Document, CircleCheck } from '@element-plus/icons-vue'
|
||
import {
|
||
fetchMaterialUsageList,
|
||
fetchMyMaterialUsageList,
|
||
fetchMarkMaterialUsageFullyUsed
|
||
} from '@/api/material-usage'
|
||
import type { MaterialUsage } from '@/api/material-usage'
|
||
import { fetchMaterialList } from '@/api/material'
|
||
import type { MaterialOption } from '@/api/material'
|
||
import { formatMoneyDisplay } from '@/utils/money-format'
|
||
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
|
||
|
||
defineOptions({ name: 'MaterialUsageTab' })
|
||
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
mineOnly?: boolean
|
||
}>(),
|
||
{
|
||
mineOnly: false
|
||
}
|
||
)
|
||
|
||
const route = useRoute()
|
||
|
||
const searchForm = reactive<{
|
||
keyword: string
|
||
materialId: string
|
||
changeType: string
|
||
}>({
|
||
keyword: '',
|
||
materialId: '',
|
||
changeType: ''
|
||
})
|
||
|
||
const dateRange = ref<[string, string] | null>(null)
|
||
const tableData = ref<MaterialUsage[]>([])
|
||
const loading = ref(false)
|
||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||
const materialOptions = ref<MaterialOption[]>([])
|
||
const materialOptionsLoading = ref(false)
|
||
|
||
function isManualOutRow(row: MaterialUsage) {
|
||
if (row.sourceType === 'manual_out') return true
|
||
const remark = row.remark || ''
|
||
return row.changeType === 'use' && (row.changeQty ?? 0) < 0 && remark.includes('领用人:')
|
||
}
|
||
|
||
function isDiscardRow(row: MaterialUsage) {
|
||
return row.changeType === 'discard' || row.sourceType === 'manual_discard'
|
||
}
|
||
|
||
function parseFromRemark(remark: string, key: 'receiver' | 'purpose') {
|
||
const prefix = key === 'receiver' ? '领用人:' : '用途:'
|
||
for (const part of remark.split(';')) {
|
||
const seg = part.trim()
|
||
if (seg.startsWith(prefix)) return seg.slice(prefix.length).trim()
|
||
}
|
||
return ''
|
||
}
|
||
|
||
function displayReceiver(row: MaterialUsage) {
|
||
if (isDiscardRow(row)) return '-'
|
||
if (!isManualOutRow(row)) return '-'
|
||
return (row.receiver || '').trim() || parseFromRemark(row.remark || '', 'receiver') || '-'
|
||
}
|
||
|
||
function displayPurpose(row: MaterialUsage) {
|
||
if (isDiscardRow(row)) return (row.purpose || '').trim() || '-'
|
||
if (!isManualOutRow(row)) return '-'
|
||
return (row.purpose || '').trim() || parseFromRemark(row.remark || '', 'purpose') || '-'
|
||
}
|
||
|
||
function displayUseTime(row: MaterialUsage) {
|
||
if (isDiscardRow(row)) return (row.createTime || '').trim() || '-'
|
||
if (!isManualOutRow(row)) return '-'
|
||
return (row.useTime || row.createTime || '').trim() || '-'
|
||
}
|
||
|
||
function displayUsageQty(row: MaterialUsage) {
|
||
const qty = Math.abs(Number(row.changeQty) || 0)
|
||
const unit = (row.usageUnit || '').trim()
|
||
return unit ? `${qty}${unit}` : String(qty)
|
||
}
|
||
|
||
function displayStockBefore(row: MaterialUsage) {
|
||
if (row.stockBefore != null) return row.stockBefore
|
||
const after = Number(row.stockAfter ?? 0)
|
||
const qty = Number(row.changeQty ?? 0)
|
||
return after - qty
|
||
}
|
||
|
||
function getChangeTypeLabel(row: MaterialUsage) {
|
||
if (row.changeType === 'in') return '入库'
|
||
if (isDiscardRow(row)) return '废弃'
|
||
if (isManualOutRow(row)) return '出库'
|
||
return '出库'
|
||
}
|
||
|
||
function getChangeTypeTagType(row: MaterialUsage) {
|
||
if (row.changeType === 'in') return 'success'
|
||
if (isDiscardRow(row)) return 'danger'
|
||
if (isManualOutRow(row)) return 'warning'
|
||
return 'warning'
|
||
}
|
||
|
||
function getSourceTagType(sourceType: string) {
|
||
if (sourceType === 'manual_in') return 'success'
|
||
if (sourceType === 'manual_out') return 'warning'
|
||
if (sourceType === 'manual_discard') return 'danger'
|
||
if (sourceType === 'outbound_handover') return 'primary'
|
||
return 'info'
|
||
}
|
||
|
||
function getSourceLabel(sourceType: string) {
|
||
if (sourceType === 'manual_in') return '手动入库'
|
||
if (sourceType === 'manual_out') return '手动出库'
|
||
if (sourceType === 'manual_discard') return '手动废弃'
|
||
if (sourceType === 'outbound_handover') return '出库交接'
|
||
return sourceType || '-'
|
||
}
|
||
|
||
const markFullyUsedLoadingId = ref('')
|
||
|
||
function canMarkFullyUsed(row: MaterialUsage) {
|
||
return isManualOutRow(row) && (row.requisitionStatus || 'open') !== 'closed'
|
||
}
|
||
|
||
async function handleMarkFullyUsed(row: MaterialUsage) {
|
||
try {
|
||
await ElMessageBox.confirm('确认该领用记录已全部使用完?标记后将不再出现在盘点待登记列表。', '标记已使用完', {
|
||
type: 'warning',
|
||
confirmButtonText: '确认',
|
||
cancelButtonText: '取消'
|
||
})
|
||
} catch {
|
||
return
|
||
}
|
||
markFullyUsedLoadingId.value = row.id
|
||
try {
|
||
await fetchMarkMaterialUsageFullyUsed(row.id)
|
||
row.requisitionStatus = 'closed'
|
||
ElMessage.success('已标记为使用完')
|
||
} finally {
|
||
markFullyUsedLoadingId.value = ''
|
||
}
|
||
}
|
||
|
||
function mapMaterialListToOptions(list: Array<Record<string, any>>): MaterialOption[] {
|
||
return (list || []).map((m) => ({
|
||
id: String(m.id || ''),
|
||
label: `${m.materialName || ''}${m.materialCode ? ` (${m.materialCode})` : ''}`.trim(),
|
||
materialCode: String(m.materialCode || ''),
|
||
materialName: String(m.materialName || ''),
|
||
category: String(m.category || ''),
|
||
specModel: String(m.specModel || ''),
|
||
unit: String(m.unit || ''),
|
||
stockQty: Number(m.stockQty ?? 0)
|
||
}))
|
||
}
|
||
|
||
async function remoteSearchMaterials(query: string) {
|
||
materialOptionsLoading.value = true
|
||
try {
|
||
const res = await fetchMaterialList({
|
||
page: 1,
|
||
pageSize: 100,
|
||
keyword: query?.trim() || undefined
|
||
})
|
||
materialOptions.value = mapMaterialListToOptions(res.list || [])
|
||
} catch {
|
||
materialOptions.value = []
|
||
} finally {
|
||
materialOptionsLoading.value = false
|
||
}
|
||
}
|
||
|
||
function onMaterialSelectVisible(visible: boolean) {
|
||
if (visible) void remoteSearchMaterials('')
|
||
}
|
||
|
||
async function loadData() {
|
||
loading.value = true
|
||
try {
|
||
const params: Record<string, any> = {
|
||
page: pagination.page,
|
||
pageSize: pagination.pageSize
|
||
}
|
||
if (searchForm.keyword) params.keyword = searchForm.keyword
|
||
if (searchForm.materialId) params.materialId = searchForm.materialId
|
||
if (searchForm.changeType) params.changeType = searchForm.changeType
|
||
if (dateRange.value && dateRange.value[0]) {
|
||
params.startTime = dateRange.value[0]
|
||
params.endTime = dateRange.value[1]
|
||
}
|
||
const res = props.mineOnly
|
||
? await fetchMyMaterialUsageList(params)
|
||
: await fetchMaterialUsageList(params)
|
||
tableData.value = res.list || []
|
||
pagination.total = res.total ?? 0
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function handleSearch() {
|
||
pagination.page = 1
|
||
loadData()
|
||
}
|
||
|
||
function handleReset() {
|
||
searchForm.keyword = ''
|
||
searchForm.materialId = ''
|
||
searchForm.changeType = ''
|
||
dateRange.value = null
|
||
pagination.page = 1
|
||
loadData()
|
||
}
|
||
|
||
const viewVisible = ref(false)
|
||
const viewLoading = ref(false)
|
||
const viewRow = ref<MaterialUsage | null>(null)
|
||
|
||
const viewSections = computed(() => {
|
||
if (!viewRow.value) return []
|
||
const row = viewRow.value
|
||
return [
|
||
{
|
||
title: '变动信息',
|
||
fields: [
|
||
{ label: '物料编码', value: row.materialCode },
|
||
{ label: '物料名称', value: row.materialName },
|
||
{ label: '入库编号', value: row.stockInNo || '—' },
|
||
{ label: '入库日期', value: row.stockInDate || '—' },
|
||
{ label: '货号', value: row.catalogNo || '—' },
|
||
{ label: '批号', value: row.batchNo || '—' },
|
||
{ label: '厂家', value: row.manufacturer || '—' },
|
||
{
|
||
label: '单价(元)',
|
||
value: row.unitPrice != null ? formatMoneyDisplay(row.unitPrice) : '—'
|
||
},
|
||
{
|
||
label: '金额(元)',
|
||
value: row.amount != null ? formatMoneyDisplay(row.amount) : '—'
|
||
},
|
||
{
|
||
label: '变动类型',
|
||
value: getChangeTypeLabel(row),
|
||
tagType: getChangeTypeTagType(row) as any
|
||
},
|
||
{
|
||
label: isManualOutRow(row) ? '领用数量' : '变动数量',
|
||
value: isManualOutRow(row)
|
||
? displayUsageQty(row)
|
||
: row.changeQty < 0
|
||
? String(row.changeQty)
|
||
: `+${row.changeQty}`
|
||
},
|
||
{ label: '变动前库存', value: String(displayStockBefore(row)) },
|
||
{ label: '变动后库存', value: String(row.stockAfter ?? 0) },
|
||
{
|
||
label: '来源类型',
|
||
value: getSourceLabel(row.sourceType),
|
||
tagType: getSourceTagType(row.sourceType) as any
|
||
},
|
||
{
|
||
label: '领用人',
|
||
value: displayReceiver(row) === '-' ? '—' : displayReceiver(row)
|
||
},
|
||
{
|
||
label: '用途',
|
||
value: displayPurpose(row) === '-' ? '—' : displayPurpose(row)
|
||
},
|
||
{
|
||
label: '领用时间',
|
||
value: displayUseTime(row) === '-' ? '—' : displayUseTime(row)
|
||
},
|
||
{ label: '备注', value: row.remark || '—', span: true },
|
||
{ label: '操作人', value: row.createByName || row.createBy || '—' },
|
||
{ label: '变动时间', value: row.createTime || '—' }
|
||
]
|
||
}
|
||
]
|
||
})
|
||
|
||
function handleView(row: MaterialUsage) {
|
||
viewRow.value = row
|
||
viewVisible.value = true
|
||
}
|
||
// 输入检索条件时自动搜索(防抖 300ms)
|
||
watchDebounced(
|
||
searchForm,
|
||
() => {
|
||
pagination.page = 1
|
||
loadData()
|
||
},
|
||
{ debounce: 300, deep: true }
|
||
)
|
||
|
||
// 日期范围变更时自动搜索
|
||
watch(dateRange, () => {
|
||
pagination.page = 1
|
||
loadData()
|
||
})
|
||
|
||
onMounted(async () => {
|
||
if (route.query.materialId) {
|
||
searchForm.materialId = route.query.materialId as string
|
||
}
|
||
if (route.query.stockInNo) {
|
||
searchForm.keyword = route.query.stockInNo as string
|
||
}
|
||
await remoteSearchMaterials('')
|
||
loadData()
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.table-op-btns {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
}
|
||
</style>
|
||
|