340 lines
11 KiB
Vue
340 lines
11 KiB
Vue
<template>
|
||
<div class="outbound-page">
|
||
<div class="page-header">
|
||
<h2>出库管理</h2>
|
||
<el-button type="primary" @click="showAddModal">新增出库单</el-button>
|
||
</div>
|
||
|
||
<BaseTable
|
||
:data="outboundStore.outboundList"
|
||
:loading="outboundStore.loading"
|
||
:current-page="outboundStore.page.currentPage"
|
||
:page-size="outboundStore.page.pageSize"
|
||
:total="outboundStore.page.total"
|
||
@page-change="handlePageChange"
|
||
>
|
||
<el-table-column type="index" label="ID" width="60" />
|
||
<el-table-column prop="outbound_no" label="出库单号" />
|
||
<el-table-column prop="status" label="状态">
|
||
<template #default="{ row }">
|
||
<StatusBadge :status="row.status" />
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="receiver" label="接收方" />
|
||
<el-table-column prop="operator" label="操作人" />
|
||
<el-table-column prop="remark" label="备注" />
|
||
<el-table-column prop="created_at" label="创建时间" width="160" class-name="pr-3">
|
||
<template #default="{ row }">
|
||
{{ formatDate(row.created_at) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="240" fixed="right" class-name="border-left">
|
||
<template #default="{ row }">
|
||
<el-button size="small" @click="showDetail(row)">详情</el-button>
|
||
<el-button v-if="authStore.isAdmin" size="small" type="primary" @click="showEditModal(row)">编辑</el-button>
|
||
<el-button v-if="authStore.isAdmin" size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</BaseTable>
|
||
|
||
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑出库单' : '新增出库单'" width="900px">
|
||
<el-form :model="form" label-width="100px">
|
||
<el-form-item label="接收方" required>
|
||
<el-input v-model="form.receiver" placeholder="请输入接收方" />
|
||
</el-form-item>
|
||
<el-form-item label="备注">
|
||
<el-input v-model="form.remark" type="textarea" rows="3" />
|
||
</el-form-item>
|
||
<el-form-item label="出库物料" required>
|
||
<div class="material-items">
|
||
<div v-for="(item, index) in form.items" :key="index" class="item-row">
|
||
<el-select
|
||
v-model="item.material_id"
|
||
placeholder="输入物料编码或名称搜索"
|
||
filterable
|
||
:filter-method="(val) => materialFilter(val, item)"
|
||
style="width: 280px"
|
||
@change="(id) => onMaterialChange(item, id)"
|
||
>
|
||
<el-option
|
||
v-for="m in getFilteredMaterials(item)"
|
||
:key="m.id"
|
||
:label="`${m.material_code} - ${m.name}`"
|
||
:value="m.id"
|
||
:disabled="m.status === 'obsolete'"
|
||
/>
|
||
</el-select>
|
||
<el-input-number v-model="item.quantity" :min="1" />
|
||
<span class="stock-info" :class="{ 'low-stock': isLowStock(item.material_id) }">
|
||
库存: {{ itemStocks[item.material_id] ?? '-' }}
|
||
</span>
|
||
<el-button @click="removeItem(index)" :disabled="form.items.length <= 1">删除</el-button>
|
||
</div>
|
||
<el-button @click="addItem">添加物料</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="dialogVisible = false">取消</el-button>
|
||
<el-button type="primary" @click="handleSave" :loading="saveLoading">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<DeleteConfirm v-model="deleteDialogVisible" @confirm="confirmDelete" />
|
||
|
||
<el-dialog v-model="detailVisible" title="出库单详情" width="600px">
|
||
<div v-if="currentRow">
|
||
<p><strong>出库单号:</strong>{{ currentRow.outbound_no }}</p>
|
||
<p><strong>状态:</strong><StatusBadge :status="currentRow.status" /></p>
|
||
<p><strong>接收方:</strong>{{ currentRow.receiver || '-' }}</p>
|
||
<p><strong>操作人:</strong>{{ currentRow.operator }}</p>
|
||
<p><strong>备注:</strong>{{ currentRow.remark || '-' }}</p>
|
||
<p><strong>创建时间:</strong>{{ formatDate(currentRow.created_at) }}</p>
|
||
<div v-if="currentRow.items && currentRow.items.length > 0" class="mt-3">
|
||
<h5>物料明细</h5>
|
||
<el-table :data="currentRow.items" border size="small">
|
||
<el-table-column prop="material_code" label="物料编码" />
|
||
<el-table-column prop="material_name" label="物料名称" />
|
||
<el-table-column prop="quantity" label="数量" />
|
||
</el-table>
|
||
</div>
|
||
</div>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, onMounted, computed } from 'vue'
|
||
import { useOutboundStore } from '../stores/outbound'
|
||
import { useMaterialsStore } from '../stores/materials'
|
||
import { useStockStore } from '../stores/stock'
|
||
import { useAuthStore } from '../stores/auth'
|
||
import BaseTable from '../components/business/BaseTable.vue'
|
||
import StatusBadge from '../components/business/StatusBadge.vue'
|
||
import DeleteConfirm from '../components/business/DeleteConfirm.vue'
|
||
import { ElMessage } from 'element-plus'
|
||
|
||
const outboundStore = useOutboundStore()
|
||
const materialsStore = useMaterialsStore()
|
||
const stockStore = useStockStore()
|
||
const authStore = useAuthStore()
|
||
|
||
const dialogVisible = ref(false)
|
||
const detailVisible = ref(false)
|
||
const deleteDialogVisible = ref(false)
|
||
const saveLoading = ref(false)
|
||
const deleteId = ref(null)
|
||
const currentRow = ref(null)
|
||
const isEdit = ref(false)
|
||
const editId = ref(null)
|
||
|
||
const form = reactive({
|
||
receiver: '',
|
||
remark: '',
|
||
items: [{ material_id: null, materialSearchText: '', quantity: 1 }]
|
||
})
|
||
|
||
// Track stock for selected materials in form
|
||
const itemStocks = ref({})
|
||
|
||
onMounted(async () => {
|
||
await Promise.all([
|
||
outboundStore.fetchOutboundList(),
|
||
// 用全量物料而不是分页的第一页:编辑旧单据时,它的物料可能不在前 20 条里,
|
||
// 那样下拉框找不到对应选项,只会把物料 ID 当文字显示出来。
|
||
materialsStore.fetchMaterialOptions(),
|
||
stockStore.fetchAllStock()
|
||
])
|
||
})
|
||
|
||
function handlePageChange(page) {
|
||
outboundStore.fetchOutboundList(page)
|
||
}
|
||
|
||
function showAddModal() {
|
||
isEdit.value = false
|
||
editId.value = null
|
||
Object.assign(form, { receiver: '', remark: '', items: [{ material_id: null, materialSearchText: '', quantity: 1 }] })
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
async function showEditModal(row) {
|
||
isEdit.value = true
|
||
editId.value = row.id
|
||
try {
|
||
const res = await outboundStore.fetchOutboundDetail(row.id)
|
||
const order = res.data || res
|
||
form.receiver = order.receiver || ''
|
||
form.remark = order.remark || ''
|
||
form.items = order.items && order.items.length > 0
|
||
? order.items.map(item => {
|
||
const material = materialsStore.materialOptions.find(m => m.id === item.material_id)
|
||
// Fetch stock for existing items
|
||
stockStore.fetchStockByMaterialId(item.material_id).then(stock => {
|
||
itemStocks.value[item.material_id] = stock
|
||
})
|
||
return { material_id: item.material_id, materialSearchText: material ? `${material.material_code} - ${material.name}` : `${item.material_code || ''} - ${item.material_name || ''}`, material_code: item.material_code, material_name: item.material_name, material_status: item.material_status, quantity: item.quantity }
|
||
})
|
||
: [{ material_id: null, materialSearchText: '', quantity: 1 }]
|
||
dialogVisible.value = true
|
||
} catch (error) {
|
||
ElMessage.error('加载出库单详情失败')
|
||
}
|
||
}
|
||
|
||
function addItem() {
|
||
form.items.push({ material_id: null, materialSearchText: '', quantity: 1 })
|
||
}
|
||
|
||
function removeItem(index) {
|
||
form.items.splice(index, 1)
|
||
}
|
||
|
||
function materialFilter(val, item) {
|
||
item.materialSearchText = val
|
||
}
|
||
|
||
function getFilteredMaterials(item) {
|
||
const source = materialsStore.materialOptions
|
||
const keyword = (item.materialSearchText || '').toLowerCase()
|
||
const matched = keyword
|
||
? source.filter(m =>
|
||
m.material_code.toLowerCase().includes(keyword) ||
|
||
m.name.toLowerCase().includes(keyword)
|
||
)
|
||
: source
|
||
// 当前选中的物料必须始终留在选项里:el-select 靠选项列表把 id 映射成名称,
|
||
// 选项里没有它就只能把物料 ID 当文字显示。
|
||
let selected = source.find(m => m.id === item.material_id)
|
||
if (!selected && item.material_status === 'obsolete') {
|
||
selected = { id: item.material_id, material_code: item.material_code, name: item.material_name, status: 'obsolete' }
|
||
}
|
||
if (selected && !matched.some(m => m.id === selected.id)) {
|
||
return [selected, ...matched]
|
||
}
|
||
return matched
|
||
}
|
||
|
||
function onMaterialChange(item, id) {
|
||
const idx = form.items.indexOf(item)
|
||
if (idx !== -1) {
|
||
form.items.splice(idx, 1, { ...item })
|
||
}
|
||
if (id) {
|
||
stockStore.fetchStockByMaterialId(id).then(stock => {
|
||
itemStocks.value[id] = stock
|
||
})
|
||
}
|
||
}
|
||
|
||
// 直接采信后端的 stock_status,不再自己比一遍阈值——否则"没配过规则"和"关闭了预警"
|
||
// 这两类物料会在这里被误判成低库存,和库存页、预警列表的口径对不上。
|
||
function isLowStock(materialId) {
|
||
if (!materialId) return false
|
||
const stock = stockStore.stockList.find(s => s.material_id === materialId)
|
||
return stock?.stock_status === 'low'
|
||
}
|
||
|
||
function firstMissingField() {
|
||
if (!form.receiver.trim()) return '请输入接收方'
|
||
return ''
|
||
}
|
||
|
||
async function handleSave() {
|
||
const missing = firstMissingField()
|
||
if (missing) {
|
||
ElMessage.warning(missing)
|
||
return
|
||
}
|
||
saveLoading.value = true
|
||
try {
|
||
const validItems = form.items.filter(item => item.material_id && item.quantity > 0)
|
||
if (validItems.length === 0) {
|
||
ElMessage.warning('请至少添加一个物料')
|
||
return
|
||
}
|
||
const data = {
|
||
receiver: form.receiver,
|
||
remark: form.remark,
|
||
items: validItems
|
||
}
|
||
if (isEdit.value) {
|
||
await outboundStore.updateOutbound(editId.value, data)
|
||
ElMessage.success('修改成功')
|
||
} else {
|
||
await outboundStore.createOutbound(data)
|
||
ElMessage.success('保存成功')
|
||
}
|
||
dialogVisible.value = false
|
||
outboundStore.fetchOutboundList()
|
||
} finally {
|
||
saveLoading.value = false
|
||
}
|
||
}
|
||
|
||
function handleDelete(id) {
|
||
deleteId.value = id
|
||
deleteDialogVisible.value = true
|
||
}
|
||
|
||
async function confirmDelete() {
|
||
await outboundStore.deleteOutbound(deleteId.value)
|
||
ElMessage.success('删除成功')
|
||
}
|
||
|
||
async function showDetail(row) {
|
||
try {
|
||
const detail = await outboundStore.fetchOutboundDetail(row.id)
|
||
currentRow.value = detail
|
||
} catch (error) {
|
||
console.error('获取详情失败:', error)
|
||
ElMessage.error('获取详情失败')
|
||
currentRow.value = { ...row, items: [] }
|
||
}
|
||
detailVisible.value = true
|
||
}
|
||
|
||
function formatDate(dateStr) {
|
||
if (!dateStr) return '-'
|
||
return new Date(dateStr).toLocaleString('zh-CN')
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.outbound-page {
|
||
padding: 20px;
|
||
}
|
||
.page-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 20px;
|
||
}
|
||
.item-row {
|
||
display: flex;
|
||
gap: 10px;
|
||
margin-bottom: 10px;
|
||
align-items: center;
|
||
}
|
||
.material-items {
|
||
width: 100%;
|
||
}
|
||
.stock-info {
|
||
padding: 0 8px;
|
||
min-width: 80px;
|
||
}
|
||
.stock-info.low-stock {
|
||
color: #f56c6c;
|
||
font-weight: bold;
|
||
}
|
||
.mt-3 {
|
||
margin-top: 16px;
|
||
}
|
||
:deep(.pr-3) {
|
||
padding-right: 12px;
|
||
}
|
||
:deep(.border-left) {
|
||
border-left: 1px solid var(--el-table-border-color);
|
||
}
|
||
</style>
|