feat(purchase): 采购申请改为动态明细表,支持一行多物料批量提交
- 新建弹窗物料区改为动态明细表格:每行独立选择物料/规格/数量/日期/单价/总价/税率 - 支持 [+ 添加一行] 动态增行、行内删除 - 审批人/商家链接/备注/图片凭证为公共信息 - 提交时 for 循环逐行调用单条 createPurchase 接口,后端无需改动 - 行内价格半联动:改单价自动算总价,手动改总价后不再覆盖
This commit is contained in:
@ -71,21 +71,25 @@
|
||||
/>
|
||||
|
||||
<!-- ========== 新建/编辑弹窗 ========== -->
|
||||
<el-dialog v-model="formDialogVisible" :title="dialogTitle" width="750px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
|
||||
<el-dialog v-model="formDialogVisible" :title="dialogTitle" width="900px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
|
||||
<el-form ref="formRef" :model="form" label-width="100px">
|
||||
|
||||
<!-- ── 物料信息 ── -->
|
||||
<!-- ── 物料明细表(动态多行) ── -->
|
||||
<div class="form-section">
|
||||
<div class="section-title"><span>物料信息</span></div>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="24">
|
||||
<el-form-item label="采购物品" required>
|
||||
<div class="section-title">
|
||||
<span>物料明细({{ formItems.length }} 项)</span>
|
||||
<el-button type="primary" plain size="small" :icon="Plus" style="margin-left: 12px;" @click="addFormItem">添加一行</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="formItems" border size="small" style="width: 100%;">
|
||||
<el-table-column type="index" label="#" width="40" align="center" />
|
||||
<el-table-column label="采购物品" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-model="materialBaseId" filterable remote reserve-keyword="true" clearable
|
||||
placeholder="输入名称或规格搜索..." :remote-method="handleSearchMaterialDebounced"
|
||||
:loading="searchLoading" style="width: 100%" @change="onMaterialSelected"
|
||||
:model-value="row.materialBaseId" filterable remote reserve-keyword="true" clearable
|
||||
placeholder="输入名称或规格搜索..." :remote-method="(q: string) => handleSearchMaterialDebounced(q, row)"
|
||||
:loading="searchLoading" style="width: 100%" @change="(id: any) => onMaterialSelected(id, row)"
|
||||
default-first-option="true" popper-class="long-dropdown"
|
||||
v-loadmore="handleLoadMoreMaterials" @visible-change="onMaterialDropdownVisibleChange"
|
||||
>
|
||||
<el-option v-for="item in materialOptions" :key="item.id" :label="item.name" :value="item.id">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
@ -93,53 +97,56 @@
|
||||
<span style="color: #999; font-size: 12px;">{{ item.spec_model || '-' }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
<li v-if="loadingMore" class="el-select-dropdown__item loading-more" style="text-align:center;color:#999;cursor:default;"><span>加载中...</span></li>
|
||||
</el-select>
|
||||
<div v-if="autoFillHint" style="font-size: 12px; color: #67C23A; margin-top: 4px;">{{ autoFillHint }}</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="规格型号">
|
||||
<el-input v-model="form.spec_model" placeholder="自动填充" clearable />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="采购数量" required>
|
||||
<el-input-number v-model="form.quantity" :min="1" :controls="false" style="width: 100%;" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="采购日期" required>
|
||||
<el-date-picker v-model="form.purchase_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%;" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- ── 价格与审批 ── -->
|
||||
<div class="form-section">
|
||||
<div class="section-title"><span>价格与审批</span></div>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="含税单价">
|
||||
<el-input-number v-model="form.unit_price" :min="0" :precision="2" :controls="false" style="width: 100%;" placeholder="¥" @change="onUnitPriceChange" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="含税总价">
|
||||
<el-input-number v-model="form.total_price" :min="0" :precision="2" :controls="false" style="width: 100%;" placeholder="¥" @change="onTotalPriceChange" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="税率">
|
||||
<el-select v-model="form.tax_rate" style="width: 100%;" @change="onTaxRateChange">
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="规格型号" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.spec_model" placeholder="自动填充" clearable />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数量" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.quantity" :min="1" :controls="false" style="width: 100%;" @change="() => onRowUnitPriceChange(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="采购日期" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-date-picker v-model="row.purchase_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%;" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="含税单价" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.unit_price" :min="0" :precision="2" :controls="false" style="width: 100%;" placeholder="¥" @change="() => onRowUnitPriceChange(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="含税总价" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.total_price" :min="0" :precision="2" :controls="false" style="width: 100%;" placeholder="¥" @change="() => onRowTotalPriceChange(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="税率" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.tax_rate" style="width: 100%;" @change="() => onRowTaxRateChange(row)">
|
||||
<el-option label="0%" :value="0" />
|
||||
<el-option label="1%" :value="1" />
|
||||
<el-option label="6%" :value="6" />
|
||||
<el-option label="13%" :value="13" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="60" align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button type="danger" link :icon="Delete" @click="removeFormItem($index)" :disabled="formItems.length <= 1" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- ── 公共信息:审批人 / 链接 ── -->
|
||||
<div class="form-section">
|
||||
<div class="section-title"><span>公共信息</span></div>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="审批人" required>
|
||||
<el-select v-model="form.approver_id" placeholder="请选择审批人" style="width: 100%;" filterable>
|
||||
@ -185,7 +192,9 @@
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="formDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="submitForm">确认提交</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="submitForm">
|
||||
{{ submitLoading ? '提交中...' : `确认提交 (${formItems.length} 项)` }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@ -255,7 +264,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Refresh, Plus } from '@element-plus/icons-vue'
|
||||
import { Refresh, Plus, Delete } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { searchMaterialPurchase } from '@/api/purchase'
|
||||
@ -294,7 +303,6 @@ const formDialogVisible = ref(false)
|
||||
const dialogTitle = ref('新建采购申请')
|
||||
const formRef = ref<FormInstance>()
|
||||
const isUploading = ref(false)
|
||||
const autoFillHint = ref('')
|
||||
|
||||
// 审批人
|
||||
const approvers = ref<any[]>([])
|
||||
@ -302,7 +310,6 @@ const approvers = ref<any[]>([])
|
||||
// 物料搜索
|
||||
const materialOptions = ref<any[]>([])
|
||||
const searchLoading = ref(false)
|
||||
const materialBaseId = ref<number | null>(null)
|
||||
const searchPage = ref(1)
|
||||
const searchKeyword = ref('')
|
||||
const hasNextPage = ref(true)
|
||||
@ -320,21 +327,57 @@ const vLoadmore = {
|
||||
}
|
||||
}
|
||||
|
||||
// 表单
|
||||
// 表单(公共字段)
|
||||
const form = ref({
|
||||
approver_id: undefined as number | undefined,
|
||||
supplier_link: '',
|
||||
remark: '',
|
||||
images: [] as string[]
|
||||
})
|
||||
|
||||
// ★ 动态明细行
|
||||
interface FormItemRow {
|
||||
rowKey: number
|
||||
materialBaseId: number | null
|
||||
name: string
|
||||
spec_model: string
|
||||
quantity: number
|
||||
purchase_date: string
|
||||
unit_price: number | undefined
|
||||
total_price: number | undefined
|
||||
tax_rate: number
|
||||
totalPriceManuallyEdited: boolean
|
||||
}
|
||||
const formItems = ref<FormItemRow[]>([])
|
||||
|
||||
// 生成一行空的采购明细
|
||||
const createEmptyFormItem = (): FormItemRow => ({
|
||||
rowKey: Date.now() + Math.random(),
|
||||
materialBaseId: null,
|
||||
name: '',
|
||||
spec_model: '',
|
||||
quantity: 1,
|
||||
purchase_date: '',
|
||||
supplier_link: '',
|
||||
remark: '',
|
||||
unit_price: undefined as number | undefined,
|
||||
total_price: undefined as number | undefined,
|
||||
purchase_date: new Date().toISOString().split('T')[0],
|
||||
unit_price: undefined,
|
||||
total_price: undefined,
|
||||
tax_rate: 0,
|
||||
approver_id: undefined as number | undefined,
|
||||
images: [] as string[]
|
||||
totalPriceManuallyEdited: false
|
||||
})
|
||||
|
||||
// 添加一行
|
||||
const addFormItem = () => {
|
||||
formItems.value.push(createEmptyFormItem())
|
||||
}
|
||||
|
||||
// 删除一行
|
||||
const removeFormItem = (index: number) => {
|
||||
if (formItems.value.length <= 1) {
|
||||
ElMessage.warning('至少保留一行')
|
||||
return
|
||||
}
|
||||
formItems.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// --- 计算属性 ---
|
||||
// 审批按钮仅对拥有操作权限的用户可见(菜单权限不等于审批权限)
|
||||
const canApprove = computed(() => {
|
||||
@ -398,35 +441,29 @@ const handleSizeChange = (s: number) => { pageSize.value = s; page.value = 1; fe
|
||||
const openCreateDialog = (prefill?: { materialId?: number; name?: string; spec?: string; unit?: string }) => {
|
||||
dialogTitle.value = '新建采购申请'
|
||||
form.value = {
|
||||
name: prefill?.name || '',
|
||||
spec_model: prefill?.spec || '',
|
||||
quantity: 1,
|
||||
purchase_date: new Date().toISOString().split('T')[0],
|
||||
supplier_link: '', remark: '',
|
||||
unit_price: undefined, total_price: undefined, tax_rate: 0,
|
||||
approver_id: undefined, images: []
|
||||
approver_id: undefined,
|
||||
supplier_link: '',
|
||||
remark: '',
|
||||
images: []
|
||||
}
|
||||
|
||||
// ★ 初始化明细行:若有预填物料则第一行带入,否则给一行空行
|
||||
const firstRow = createEmptyFormItem()
|
||||
if (prefill?.materialId) {
|
||||
materialBaseId.value = prefill.materialId
|
||||
// 预填物料选项以便显示选中状态
|
||||
firstRow.materialBaseId = prefill.materialId
|
||||
firstRow.name = prefill.name || ''
|
||||
firstRow.spec_model = prefill.spec || ''
|
||||
materialOptions.value = [{
|
||||
id: prefill.materialId,
|
||||
name: prefill.name || '',
|
||||
spec_model: prefill.spec || ''
|
||||
}]
|
||||
if (prefill.spec) {
|
||||
form.value.spec_model = prefill.spec
|
||||
}
|
||||
autoFillHint.value = `已从基础信息【${prefill.name || ''}】带入,请补充采购信息`
|
||||
} else {
|
||||
materialBaseId.value = null
|
||||
materialOptions.value = []
|
||||
autoFillHint.value = ''
|
||||
}
|
||||
formItems.value = [firstRow]
|
||||
|
||||
fileList.value = []
|
||||
totalPriceManuallyEdited.value = false
|
||||
formDialogVisible.value = true
|
||||
|
||||
// 异步加载默认审批人(取当前用户上一次采购申请的审批人)
|
||||
@ -448,7 +485,7 @@ const fetchDefaultApprover = async () => {
|
||||
|
||||
// --- 物料搜索(分页) ---
|
||||
let searchTimer: any = null
|
||||
const handleSearchMaterialDebounced = (query: string) => {
|
||||
const handleSearchMaterialDebounced = (query: string, _row?: any) => {
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => handleSearchMaterial(query), 300)
|
||||
}
|
||||
@ -502,39 +539,35 @@ const onMaterialDropdownVisibleChange = (visible: boolean) => {
|
||||
handleSearchMaterial('')
|
||||
}
|
||||
|
||||
const onMaterialSelected = (id: number | null) => {
|
||||
const onMaterialSelected = (id: number | null, row: any) => {
|
||||
if (!id) {
|
||||
materialBaseId.value = null
|
||||
row.materialBaseId = null
|
||||
return
|
||||
}
|
||||
const item = materialOptions.value.find(i => i.id === id)
|
||||
if (item) {
|
||||
form.value.name = item.name || item.material_name || ''
|
||||
form.value.spec_model = item.spec_model || item.spec || ''
|
||||
materialBaseId.value = id
|
||||
autoFillHint.value = ''
|
||||
row.name = item.name || item.material_name || ''
|
||||
row.spec_model = item.spec_model || item.spec || ''
|
||||
row.materialBaseId = id
|
||||
}
|
||||
}
|
||||
|
||||
// --- 价格半联动逻辑 ---
|
||||
// totalPriceManuallyEdited:标记总价是否被用户手动修改过,修改后不再自动覆盖
|
||||
const totalPriceManuallyEdited = ref(false)
|
||||
|
||||
const onUnitPriceChange = (val: number | undefined) => {
|
||||
// 用户修改单价时:如果总价尚未被手动修改,则自动计算总价
|
||||
if (val !== undefined && val > 0 && form.value.quantity > 0 && !totalPriceManuallyEdited.value) {
|
||||
form.value.total_price = +(val * form.value.quantity).toFixed(2)
|
||||
// --- 行内价格半联动逻辑 ---
|
||||
const onRowUnitPriceChange = (row: any) => {
|
||||
// 修改单价时:如果总价未被手动修改,自动重算总价 = 单价×数量
|
||||
if (row.unit_price !== undefined && row.unit_price > 0 && row.quantity > 0 && !row.totalPriceManuallyEdited) {
|
||||
row.total_price = +(row.unit_price * row.quantity).toFixed(2)
|
||||
}
|
||||
}
|
||||
|
||||
const onTotalPriceChange = (_val: number | undefined) => {
|
||||
totalPriceManuallyEdited.value = true
|
||||
const onRowTotalPriceChange = (row: any) => {
|
||||
row.totalPriceManuallyEdited = true
|
||||
}
|
||||
|
||||
const onTaxRateChange = () => {
|
||||
// 税率变化时:保持不含税单价不变,重算不含税总价 = 单价×数量
|
||||
if (form.value.unit_price && form.value.quantity) {
|
||||
form.value.total_price = Number((form.value.unit_price * form.value.quantity).toFixed(2))
|
||||
const onRowTaxRateChange = (row: any) => {
|
||||
// 税率变化时:重算不含税总价 = 单价×数量
|
||||
if (row.unit_price && row.quantity) {
|
||||
row.total_price = Number((row.unit_price * row.quantity).toFixed(2))
|
||||
}
|
||||
}
|
||||
|
||||
@ -592,21 +625,52 @@ const handlePasteLink = (link: string, field: string) => {
|
||||
}
|
||||
usePasteUpload(customUpload, 'images', '#upload-purchase-images', handlePasteLink)
|
||||
|
||||
// --- 提交 ---
|
||||
// --- 提交(循环逐行调用单条接口) ---
|
||||
const submitForm = async () => {
|
||||
if (!form.value.name.trim()) { ElMessage.warning('请选择或填写采购物品'); return }
|
||||
// 1. 公共信息校验
|
||||
if (!form.value.approver_id) { ElMessage.warning('请选择审批人'); return }
|
||||
if (!form.value.purchase_date) { ElMessage.warning('请选择采购日期'); return }
|
||||
if (!form.value.images || form.value.images.length === 0) { ElMessage.warning('请上传至少一张图片'); return }
|
||||
|
||||
// 2. 逐行校验
|
||||
const validItems = formItems.value.filter(i => i.name.trim())
|
||||
if (validItems.length === 0) { ElMessage.warning('请至少填写一行采购物品'); return }
|
||||
if (validItems.length < formItems.value.length) {
|
||||
ElMessage.warning('存在未填写物品名称的行,请填写或删除空行')
|
||||
return
|
||||
}
|
||||
for (const [idx, row] of validItems.entries()) {
|
||||
if (!row.name.trim()) { ElMessage.warning(`第 ${idx + 1} 行请选择采购物品`); return }
|
||||
if (!row.purchase_date) { ElMessage.warning(`第 ${idx + 1} 行请选择采购日期`); return }
|
||||
if (!row.quantity || row.quantity <= 0) { ElMessage.warning(`第 ${idx + 1} 行采购数量必须大于 0`); return }
|
||||
}
|
||||
|
||||
// 3. 逐行提交(每行携带公共信息)
|
||||
submitLoading.value = true
|
||||
let successCount = 0
|
||||
try {
|
||||
await createPurchase(form.value as any)
|
||||
ElMessage.success('提交成功,审批人将收到邮件通知')
|
||||
for (const row of validItems) {
|
||||
const payload: any = {
|
||||
name: row.name,
|
||||
spec_model: row.spec_model,
|
||||
quantity: row.quantity,
|
||||
purchase_date: row.purchase_date,
|
||||
unit_price: row.unit_price,
|
||||
total_price: row.total_price,
|
||||
tax_rate: row.tax_rate,
|
||||
approver_id: form.value.approver_id,
|
||||
supplier_link: form.value.supplier_link,
|
||||
remark: form.value.remark,
|
||||
images: form.value.images
|
||||
}
|
||||
await createPurchase(payload)
|
||||
successCount++
|
||||
}
|
||||
|
||||
ElMessage.success(`成功提交 ${successCount} 条采购申请,审批人将收到邮件通知`)
|
||||
formDialogVisible.value = false
|
||||
await fetchData()
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err?.msg || '提交失败')
|
||||
ElMessage.error(err?.msg || `提交失败(已完成 ${successCount} 条)`)
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user