material_manage_system/frontend/src/views/Outbound.vue
2026-09-20 10:31:28 +08:00

332 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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="操作人">
<el-input v-model="form.handler" placeholder="请输入操作人" />
</el-form-item>
<el-form-item label="接收方">
<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="出库物料">
<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"
/>
</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({
handler: '',
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, { handler: '', 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.handler = order.handler || ''
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}` : '', 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 当文字显示。
const selected = source.find(m => m.id === item.material_id)
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'
}
async function handleSave() {
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 = {
handler: form.handler,
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>