Files
KCGL/inventory-web/src/views/purchase/index.vue
yueli 8fd4a2ae53 style: 采购申请'完结本批'按钮样式统一为出库审批的 plain 实心样式
- 去掉 link 文字样式,改用 type=danger plain size=small,与出库审批完结按钮一致
2026-09-01 16:23:44 +08:00

1354 lines
52 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="app-container">
<!-- 顶部工具栏 -->
<div class="filter-container">
<span style="font-weight: bold; font-size: 15px; margin-right: 8px;">审批状态:</span>
<el-radio-group v-model="filterStatus" size="default" @change="handleStatusChange">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button :label="0">待审批</el-radio-button>
<el-radio-button :label="1">已通过</el-radio-button>
<el-radio-button :label="2">已驳回</el-radio-button>
<el-radio-button :label="3">已完成</el-radio-button>
<el-radio-button :label="4">已完结</el-radio-button>
</el-radio-group>
<el-button type="primary" :icon="Refresh" @click="fetchData">刷新</el-button>
<el-button type="success" :icon="Plus" @click="openCreateDialog">
新建采购申请
</el-button>
</div>
<!-- 按批次分组的列表 -->
<el-table v-loading="loading" :data="list" border stripe fit style="width: 100%; margin-top: 16px;" row-key="batchKey">
<!-- 展开/收起 + 批次信息 -->
<el-table-column type="expand" width="50" align="center">
<template #default="{ row }">
<div style="padding: 12px 24px; background: #f5f7fa;">
<div style="font-weight: bold; margin-bottom: 8px;">批次明细{{ row.itemCount }} </div>
<el-table :data="row.items" border fit size="small" style="width: 100%;">
<el-table-column prop="request_no" label="申请单号" min-width="200" show-overflow-tooltip />
<!-- 采购物品显示 名称+货号 -->
<el-table-column label="采购物品" min-width="180" show-overflow-tooltip>
<template #default="{ row: it }">
<span>{{ it.name }}</span>
<span v-if="it.spec_model" style="color:#909399; font-size:12px; margin-left:6px;">{{ it.spec_model }}</span>
</template>
</el-table-column>
<el-table-column prop="quantity" label="数量" width="70" align="center" />
<el-table-column prop="purchase_date" label="采购日期" width="110" />
<el-table-column v-if="userStore.hasPermission('inbound_purchase:unit_price')" label="单价" width="90" align="right">
<template #default="{ row: it }">
{{ it.unit_price ? '¥' + Number(it.unit_price).toFixed(2) : '-' }}
</template>
</el-table-column>
<el-table-column v-if="userStore.hasPermission('inbound_purchase:total_price')" label="总价" width="100" align="right">
<template #default="{ row: it }">
{{ it.total_price ? '¥' + Number(it.total_price).toFixed(2) : '-' }}
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row: it }">
<el-tag :type="statusTagType(it.status)" size="small">{{ it.status_text }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="110" align="center">
<template #default="{ row: it }">
<el-button type="primary" link size="small" @click="openDetailDialog(it)">详情</el-button>
<template v-if="it.status === 0 && canApprove">
<el-button type="success" link size="small" @click="handleApprove(it)">通过</el-button>
<el-button type="danger" link size="small" @click="openRejectDialog(it)">驳回</el-button>
</template>
</template>
</el-table-column>
</el-table>
</div>
</template>
</el-table-column>
<!-- 批次主行 -->
<el-table-column label="批次号" min-width="200">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="openBatchDialog(row.batchKey)">
{{ row.batchKey }}
</el-link>
</template>
</el-table-column>
<!-- 物品列单个显示 名称+货号多个显示数量 -->
<el-table-column label="采购物品" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span style="white-space: nowrap;">
<template v-if="row.itemCount === 1">
<span>{{ row.items[0].name }}</span>
<span v-if="row.items[0].spec_model" style="color:#909399; font-size:12px; margin-left:6px;">{{ row.items[0].spec_model }}</span>
</template>
<template v-else>
<el-tag size="small" type="info">{{ row.itemCount }} 种物料</el-tag>
</template>
</span>
</template>
</el-table-column>
<el-table-column v-if="userStore.hasPermission('inbound_purchase:total_price')" label="批次总价" width="120" align="right">
<template #default="{ row }">
<span style="font-weight: bold; color: #303133;">¥{{ Number(row.totalAmount).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column label="申请人" width="100" :formatter="(row: any) => formatUserName(row.requesterName)" />
<el-table-column prop="purchaseDate" label="采购日期" width="110" />
<el-table-column label="状态" width="140" align="center">
<template #default="{ row }">
<template v-for="(text, i) in row.statusTexts" :key="i">
<el-tag v-if="row.statusTexts.length === 1"
:type="statusTagType(row.summaryStatus)" size="small" style="margin-right:2px;">
{{ text }}
</el-tag>
</template>
<el-tag v-if="row.statusTexts.length > 1" type="warning" size="small">混合</el-tag>
<span v-if="row.pendingCount > 0" style="font-size: 11px; color: #F56C6C; margin-left: 2px;">
待审{{ row.pendingCount }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right" align="center">
<template #default="{ row }">
<el-button type="info" link size="small" @click="openBatchDialog(row.batchKey)">查看整批</el-button>
<!-- 一键审批本批 -->
<template v-if="row.pendingCount > 0 && canApprove">
<el-button type="success" link size="small" :loading="batchLoading" @click="handleApproveBatch(row.batchKey)">审批本批</el-button>
<el-button type="danger" link size="small" :loading="batchLoading" @click="openRejectBatchDialog(row.batchKey)">驳回本批</el-button>
</template>
<!-- 完结本批批次内已通过的单库管可直接完结仿出库审批主行可见样式一致 -->
<template v-if="batchHasClosable(row) && canClose">
<el-button type="danger" plain size="small" :loading="batchLoading" @click="handleCloseBatch(row.batchKey)">完结本批</el-button>
</template>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
background
style="margin-top: 16px; justify-content: flex-end; display: flex;"
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, prev, pager, next, sizes"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
<!-- ========== 新建/编辑弹窗 ========== -->
<el-dialog v-model="formDialogVisible" :title="dialogTitle" width="1350px" top="4vh" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false" class="purchase-dialog">
<el-form ref="formRef" :model="form" label-width="100px">
<!-- 物料明细表动态多行 -->
<div class="form-section">
<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>
<el-tooltip content="把第一行备注复制到所有行" placement="top">
<el-button type="warning" plain size="small" :icon="CopyDocument" style="margin-left: 8px;" @click="syncRemarkToAll">备注同步所有</el-button>
</el-tooltip>
<el-tooltip content="把第一行照片复制到所有行" placement="top">
<el-button type="success" plain size="small" :icon="Picture" style="margin-left: 8px;" @click="syncImagesToAll">照片同步所有</el-button>
</el-tooltip>
</div>
<el-table :data="formItems" border size="small" style="width: 100%;" :max-height="400">
<el-table-column type="index" label="#" width="50" align="center" />
<el-table-column label="采购物品" min-width="200">
<template #default="{ row }">
<el-select
: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"
>
<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;">
<span>{{ item.name }}</span>
<span style="color: #999; font-size: 12px;">{{ item.spec_model || '-' }}</span>
</div>
</el-option>
</el-select>
</template>
</el-table-column>
<el-table-column label="规格型号" min-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="100">
<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="3%" :value="3" />
<el-option label="5%" :value="5" />
<el-option label="6%" :value="6" />
<el-option label="9%" :value="9" />
<el-option label="13%" :value="13" />
</el-select>
</template>
</el-table-column>
<el-table-column label="商家链接" min-width="160">
<template #default="{ row }">
<el-input v-model="row.supplier_link" placeholder="https://" clearable />
</template>
</el-table-column>
<el-table-column label="备注" min-width="150">
<template #default="{ row }">
<el-input v-model="row.remark" type="textarea" :rows="1" placeholder="行备注" resize="none" />
</template>
</el-table-column>
<el-table-column width="90" align="center">
<!-- 表头加红星照片必填 -->
<template #header>
<span style="color: #F56C6C; margin-right: 3px;">*</span>照片
</template>
<template #default="{ row }">
<el-upload
class="table-mini-uploader"
:http-request="(opt: any) => customUploadRow(opt, row)"
:on-remove="(f: any) => handleRemoveRowImage(f, row)"
:before-upload="beforeAvatarUpload"
accept="image/jpeg,image/png,image/gif,image/webp"
list-type="picture-card"
:file-list="(row.fileList || [])"
:limit="3"
>
<el-icon><Plus /></el-icon>
</el-upload>
</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>
<el-option v-for="u in approvers" :key="u.id" :label="u.username" :value="u.id" />
</el-select>
</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="24">
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="公共备注未填写行备注时使用" />
</el-form-item>
</el-col>
</el-row>
</div>
</el-form>
<template #footer>
<el-button @click="formDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitForm">
{{ submitLoading ? '提交中...' : `确认提交 (${formItems.length} 项)` }}
</el-button>
</template>
</el-dialog>
<!-- ========== 详情弹窗 ========== -->
<el-dialog v-model="detailDialogVisible" title="采购申请详情" width="700px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<el-descriptions :column="2" border>
<el-descriptions-item label="申请单号">{{ detail.request_no }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusTagType(detail.status)" size="small">{{ detail.status_text }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="采购物品">{{ detail.name }}</el-descriptions-item>
<el-descriptions-item label="规格型号">{{ detail.spec_model || '-' }}</el-descriptions-item>
<el-descriptions-item label="采购数量">{{ detail.quantity }}</el-descriptions-item>
<el-descriptions-item label="采购日期">{{ detail.purchase_date }}</el-descriptions-item>
<el-descriptions-item v-if="userStore.hasPermission('inbound_purchase:unit_price')" label="单价">{{ detail.unit_price || '-' }}</el-descriptions-item>
<el-descriptions-item v-if="userStore.hasPermission('inbound_purchase:total_price')" label="总价">{{ detail.total_price || '-' }}</el-descriptions-item>
<el-descriptions-item label="申请人">{{ formatUserName(detail.requester_name) }}</el-descriptions-item>
<el-descriptions-item label="审批人">{{ detail.approver_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="审批时间">{{ detail.approved_at || '-' }}</el-descriptions-item>
<el-descriptions-item label="商家链接">
<a v-if="detail.supplier_link" :href="detail.supplier_link" target="_blank" style="color: #409EFF; word-break: break-all;">
{{ detail.supplier_link }}
</a>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ detail.remark || '-' }}</el-descriptions-item>
<el-descriptions-item label="驳回原因" :span="2" v-if="detail.reject_reason">
<span style="color: #F56C6C;">{{ detail.reject_reason }}</span>
</el-descriptions-item>
</el-descriptions>
<!-- 图片展示 -->
<div v-if="detail.images && detail.images.length > 0" style="margin-top: 16px;">
<div style="font-weight: bold; margin-bottom: 8px;">图片</div>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<el-image
v-for="(img, idx) in detail.images"
:key="idx"
:src="getImageUrl(img)"
:preview-src-list="detail.images.map(u => getImageUrl(u))"
fit="cover"
style="width: 100px; height: 100px; border-radius: 4px; cursor: pointer;"
/>
</div>
</div>
</el-dialog>
<!-- ========== 整批查看弹窗(同一批提交的所有记录) ========== -->
<el-dialog v-model="batchDialogVisible" title="采购批次详情" width="900px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<div style="margin-bottom: 12px;">
<span style="font-weight: 600;">批次号:</span>
<el-tag style="margin-left: 6px;">{{ currentBatchKey }}</el-tag>
<span style="margin-left: 12px; color: #909399;">共 {{ batchItems.length }} 条采购记录</span>
<el-button
v-if="canApprove && batchItems.some(i => i.status === 0)"
type="success" size="small" style="float: right;"
:loading="batchLoading" @click="handleBatchApproveByNo"
>批量通过本批</el-button>
</div>
<el-table :data="batchItems" border size="small" max-height="450">
<el-table-column type="index" label="#" width="40" align="center" />
<el-table-column prop="request_no" label="单号" width="170" />
<el-table-column prop="name" label="物品" min-width="130" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格" min-width="100" show-overflow-tooltip />
<el-table-column prop="quantity" label="数量" width="60" align="center" />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ row.status_text }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="purchase_date" label="采购日期" width="110" />
<el-table-column label="操作" width="70" align="center">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="openDetailDialog(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="batchDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- ========== 驳回原因弹窗 ========== -->
<el-dialog v-model="rejectDialogVisible" title="驳回申请" width="480px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<el-form label-width="80px">
<el-form-item label="申请单号">
<span style="font-weight: bold; color: #409EFF;">{{ currentRejectRow?.request_no }}</span>
</el-form-item>
<el-form-item label="驳回原因" required>
<el-input v-model="rejectReason" type="textarea" :rows="4" placeholder="请填写驳回原因必填" maxlength="200" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rejectDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="rejectLoading" @click="confirmReject">确认驳回</el-button>
</template>
</el-dialog>
<!-- ========== 批量驳回原因弹窗 ========== -->
<el-dialog v-model="batchRejectDialogVisible" title="批量驳回" width="480px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<el-form label-width="80px">
<el-form-item label="驳回数量">
<span style="font-weight: bold; color: #F56C6C;">{{ selectedRows.length }}</span> 条
</el-form-item>
<el-form-item label="驳回原因" required>
<el-input v-model="batchRejectReason" type="textarea" :rows="4" placeholder="请填写统一驳回原因必填" maxlength="200" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="batchRejectDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="batchLoading" @click="confirmBatchReject">确认批量驳回</el-button>
</template>
</el-dialog>
<!-- ========== 驳回本批弹窗 ========== -->
<el-dialog v-model="rejectBatchDialogVisible" title="驳回本批" width="480px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<el-form label-width="80px">
<el-form-item label="批次号">
<el-tag>{{ rejectBatchKey }}</el-tag>
</el-form-item>
<el-form-item label="驳回原因" required>
<el-input v-model="rejectBatchReason" type="textarea" :rows="4" placeholder="请填写统一驳回原因必填" maxlength="200" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rejectBatchDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="batchLoading" @click="confirmRejectBatch">确认驳回本批</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { Refresh, Plus, Delete, CopyDocument, Picture } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { searchMaterialPurchase } from '@/api/purchase'
import {
getPurchaseList, createPurchase, getPurchaseDetail,
approvePurchase, batchApprovePurchase, getPurchaseApprovers, autoFillPurchase,
getPurchaseByBatch, getNextBatchSeq
} from '@/api/purchase'
import { uploadFile, deleteFile } from '@/api/common/upload'
import type { FormInstance } from 'element-plus'
const userStore = useUserStore()
// --- 状态 ---
const list = ref<any[]>([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filterStatus = ref<number | ''>(0) // 默认筛选待审批
const submitLoading = ref(false)
const rejectLoading = ref(false)
// 驳回
const rejectDialogVisible = ref(false)
const currentRejectRow = ref<any>(null)
const rejectReason = ref('')
// ★ 批量审批
const selectedRows = ref<any[]>([])
const batchLoading = ref(false)
const batchRejectDialogVisible = ref(false)
const batchRejectReason = ref('')
// 多选变化
const handleSelectionChange = (rows: any[]) => {
selectedRows.value = rows
}
// 清除选择
const clearSelection = () => {
selectedRows.value = []
}
// ★ 审批本批:一键通过批次内所有待审批记录
const handleApproveBatch = async (batchKey: string) => {
const batch = list.value.find(b => b.batchKey === batchKey)
if (!batch || batch.pendingCount === 0) return ElMessage.warning('本批没有待审批记录')
try {
await ElMessageBox.confirm(
`确定通过批次【${batchKey}】的 ${batch.pendingCount} 条待审批记录吗?`,
'审批本批',
{ confirmButtonText: '全部通过', cancelButtonText: '取消', type: 'info' }
)
} catch { return }
batchLoading.value = true
try {
const res: any = await batchApprovePurchase({ batch_key: batchKey, action: 'approve' })
ElMessage.success(res?.msg || '本批已全部通过')
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || '审批失败')
} finally {
batchLoading.value = false
}
}
// ★ 批次内是否存在「已通过」的单(可完结)
const batchHasClosable = (row: any) => (row.items || []).some((it: any) => it.status === 1)
// ★ 完结本批:一键完结批次内所有「已通过」记录(仿出库审批)
const handleCloseBatch = async (batchKey: string) => {
const batch = list.value.find(b => b.batchKey === batchKey)
const closable = batch ? (batch.items || []).filter((it: any) => it.status === 1) : []
if (!closable.length) return ElMessage.warning('本批没有已通过记录可完结')
try {
await ElMessageBox.confirm(
`确定强制完结批次【${batchKey}】中 ${closable.length} 条已通过的记录吗?\n\n此操作不可恢复。`,
'⚠️ 完结本批确认',
{ confirmButtonText: '确认完结', cancelButtonText: '取消', type: 'warning' }
)
} catch { return }
batchLoading.value = true
try {
const res: any = await batchApprovePurchase({ batch_key: batchKey, action: 'close' })
ElMessage.success(res?.msg || '本批已完结')
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || err?.message || '完结失败')
} finally {
batchLoading.value = false
}
}
// ★ 驳回本批:批次内所有待审批记录统一驳回
const rejectBatchKey = ref('')
const rejectBatchDialogVisible = ref(false)
const rejectBatchReason = ref('')
const openRejectBatchDialog = (batchKey: string) => {
rejectBatchKey.value = batchKey
rejectBatchReason.value = ''
rejectBatchDialogVisible.value = true
}
const confirmRejectBatch = async () => {
if (!rejectBatchReason.value.trim()) { ElMessage.warning('请填写驳回原因'); return }
batchLoading.value = true
try {
const res: any = await batchApprovePurchase({
batch_key: rejectBatchKey.value,
action: 'reject',
reject_reason: rejectBatchReason.value
})
ElMessage.success(res?.msg || '本批已驳回')
rejectBatchDialogVisible.value = false
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || '驳回失败')
} finally {
batchLoading.value = false
}
}
// 批量通过
const handleBatchApprove = async () => {
if (selectedRows.value.length === 0) return
const ids = selectedRows.value.map(r => r.id)
try {
await ElMessageBox.confirm(
`确定批量通过 ${ids.length} 条采购申请吗?`,
'批量审批确认',
{ confirmButtonText: '批量通过', cancelButtonText: '取消', type: 'info' }
)
} catch { return }
batchLoading.value = true
try {
const res: any = await batchApprovePurchase({ ids, action: 'approve' })
ElMessage.success(res?.msg || `已通过 ${ids.length}`)
selectedRows.value = []
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || '批量通过失败')
} finally {
batchLoading.value = false
}
}
// 批量驳回弹窗
const openBatchRejectDialog = () => {
batchRejectReason.value = ''
batchRejectDialogVisible.value = true
}
// 确认批量驳回
const confirmBatchReject = async () => {
if (selectedRows.value.length === 0) return
if (!batchRejectReason.value.trim()) { ElMessage.warning('请填写驳回原因'); return }
const ids = selectedRows.value.map(r => r.id)
batchLoading.value = true
try {
const res: any = await batchApprovePurchase({ ids, action: 'reject', reject_reason: batchRejectReason.value })
ElMessage.success(res?.msg || `已驳回 ${ids.length}`)
batchRejectDialogVisible.value = false
selectedRows.value = []
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || '批量驳回失败')
} finally {
batchLoading.value = false
}
}
// 详情
const detailDialogVisible = ref(false)
const detail = ref<any>({})
// ★ 整批查看
const batchDialogVisible = ref(false)
const currentBatchKey = ref('')
const batchItems = ref<any[]>([])
// 打开整批查看弹窗(传入任一 request_no推导批次前缀
const openBatchDialog = async (requestNo: string) => {
const batchKey = getBatchKey(requestNo)
currentBatchKey.value = batchKey
batchItems.value = []
batchDialogVisible.value = true
try {
const res: any = await getPurchaseByBatch(batchKey)
batchItems.value = res.data?.items || []
} catch (err: any) {
ElMessage.error(err?.msg || '加载批次记录失败')
}
}
// 批量通过本批(按批次前缀)
const handleBatchApproveByNo = async () => {
if (!currentBatchKey.value) return
const pendingCount = batchItems.value.filter(i => i.status === 0).length
if (pendingCount === 0) return ElMessage.warning('本批没有待审批记录')
try {
await ElMessageBox.confirm(
`确定批量通过本批 ${pendingCount} 条采购申请吗?`,
'批量审批确认',
{ confirmButtonText: '批量通过', cancelButtonText: '取消', type: 'info' }
)
} catch { return }
batchLoading.value = true
try {
const res: any = await batchApprovePurchase({ batch_key: currentBatchKey.value, action: 'approve' })
ElMessage.success(res?.msg || '本批已全部通过')
await openBatchDialog(batchItems.value[0]?.request_no || currentBatchKey.value) // 刷新整批数据
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || '批量通过失败')
} finally {
batchLoading.value = false
}
}
// 创建弹窗
const formDialogVisible = ref(false)
const dialogTitle = ref('新建采购申请')
const formRef = ref<FormInstance>()
const isUploading = ref(false)
// 审批人
const approvers = ref<any[]>([])
// 物料搜索
const materialOptions = ref<any[]>([])
const searchLoading = ref(false)
const searchPage = ref(1)
const searchKeyword = ref('')
const hasNextPage = ref(true)
const loadingMore = ref(false)
// v-loadmore 指令:监听 el-select 下拉滚动,滚动到底部时触发回调
const vLoadmore = {
mounted(el: any, binding: any) {
const dropdown = el.querySelector?.('.el-select-dropdown__wrap')
if (!dropdown) return
dropdown.addEventListener('scroll', function (this: any) {
const condition = this.scrollHeight - this.scrollTop <= this.clientHeight + 2
if (condition) binding.value()
})
}
}
// 表单(公共字段)
const form = ref({
approver_id: undefined as number | undefined,
remark: ''
})
// ★ 动态明细行
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
supplier_link: string
remark: string
images: string[]
fileList: any[]
}
const formItems = ref<FormItemRow[]>([])
// 生成一行空的采购明细
const createEmptyFormItem = (): FormItemRow => ({
rowKey: Date.now() + Math.random(),
materialBaseId: null,
name: '',
spec_model: '',
quantity: 1,
purchase_date: new Date().toISOString().split('T')[0],
unit_price: undefined,
total_price: undefined,
tax_rate: 0,
totalPriceManuallyEdited: false,
supplier_link: '',
remark: '',
images: [],
fileList: []
})
// 添加一行
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(() => {
return userStore.role === 'SUPER_ADMIN'
|| userStore.hasPermission('inbound_purchase:operation')
})
// ★ 完结权限:超级管理员 / 库管(采购操作权限)
const canClose = computed(() => {
return userStore.role === 'SUPER_ADMIN'
|| userStore.hasPermission('inbound_purchase:operation')
})
// --- 工具函数 ---
const statusTagType = (status: number) => {
const map: Record<number, string> = {
0: 'warning', 1: 'success', 2: 'danger', 3: 'info', 4: 'info'
}
return map[status] ?? 'info'
}
const getImageUrl = (url: string) => {
if (!url) return ''
if (url.startsWith('http')) return url
if (url.startsWith('/api/v1/common/files/')) return url
return `/api/v1/common/files/${url}`
}
const formatDate = (d: string) => d ? d.split(' ')[0] : ''
// ★ 格式化用户名:韩善龙/hanshanlong → 韩善龙(只取斜杠前的中文部分)
const formatUserName = (name: string): string => {
if (!name) return '-'
return name.split('/')[0].trim()
}
// --- 数据获取 ---
const fetchData = async () => {
loading.value = true
try {
const params: any = { page: page.value, limit: pageSize.value }
if (filterStatus.value !== '') params.status = filterStatus.value
const res: any = await getPurchaseList(params)
const items = res.data?.items || []
total.value = res.data?.total || 0
// ★ 按批次分组:同批次(单号去末尾-批内序号)聚合为一行
const groupMap = new Map<string, any[]>()
items.forEach(item => {
const key = getBatchKey(item.request_no) || '独立'
if (!groupMap.has(key)) groupMap.set(key, [])
groupMap.get(key)!.push(item)
})
list.value = Array.from(groupMap.entries()).map(([batchKey, batchItems]) => {
const totalAmount = batchItems.reduce((sum, it) => sum + (Number(it.total_price) || 0), 0)
const pendingCount = batchItems.filter(it => it.status === 0).length
// 状态汇总:全通过=1全驳回=2混合/待审=0
const statuses = new Set(batchItems.map(it => it.status))
let summaryStatus = batchItems[0]?.status ?? 0
if (statuses.size > 1) summaryStatus = 0 // 混合状态按待审处理
return {
batchKey,
items: batchItems,
itemCount: batchItems.length,
totalAmount,
pendingCount,
summaryStatus,
requesterName: batchItems[0]?.requester_name || '',
purchaseDate: batchItems[0]?.purchase_date || '',
statusTexts: batchItems.map(it => it.status_text).filter((v, i, a) => a.indexOf(v) === i)
}
})
} catch (err: any) {
ElMessage.error(err?.msg || '加载失败')
} finally {
loading.value = false
}
}
const fetchApprovers = async () => {
try {
const res: any = await getPurchaseApprovers()
approvers.value = res.data || []
} catch (e) {
console.error(e)
}
}
// --- 筛选 & 分页 ---
const handleStatusChange = () => {
page.value = 1
fetchData()
}
const handlePageChange = (p: number) => { page.value = p; fetchData() }
const handleSizeChange = (s: number) => { pageSize.value = s; page.value = 1; fetchData() }
// --- 新建 ---
const openCreateDialog = (prefill?: { materialId?: number; name?: string; spec?: string; unit?: string }) => {
dialogTitle.value = '新建采购申请'
form.value = {
approver_id: undefined,
remark: ''
}
// ★ 初始化明细行:若有预填物料则第一行带入,否则给一行空行
const firstRow = createEmptyFormItem()
if (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 || ''
}]
} else {
materialOptions.value = []
}
formItems.value = [firstRow]
formDialogVisible.value = true
// 异步加载默认审批人(取当前用户上一次采购申请的审批人)
fetchDefaultApprover()
}
// 获取当前用户上一次采购申请的审批人作为默认值
const fetchDefaultApprover = async () => {
try {
const res: any = await getPurchaseList({ page: 1, limit: 1, status: undefined })
const items = res?.data?.items || []
if (items.length > 0 && items[0].approver_id) {
form.value.approver_id = items[0].approver_id
}
} catch (e) {
// 静默失败,用户可手动选择
}
}
// --- 物料搜索(分页) ---
let searchTimer: any = null
const handleSearchMaterialDebounced = (query: string, _row?: any) => {
clearTimeout(searchTimer)
searchTimer = setTimeout(() => handleSearchMaterial(query), 300)
}
const handleSearchMaterial = async (query: string) => {
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
searchLoading.value = true
searchKeyword.value = safeQuery
searchPage.value = 1
materialOptions.value = []
hasNextPage.value = true
try {
const res: any = await searchMaterialPurchase(safeQuery, 1)
materialOptions.value = res.data || []
hasNextPage.value = res.has_next !== false
} finally {
searchLoading.value = false
}
}
const handleLoadMoreMaterials = async () => {
if (searchLoading.value || loadingMore.value || !hasNextPage.value) return
loadingMore.value = true
searchPage.value += 1
try {
const res: any = await searchMaterialPurchase(searchKeyword.value, searchPage.value)
if (res.data && res.data.length > 0) {
materialOptions.value = [...materialOptions.value, ...res.data]
hasNextPage.value = res.has_next !== false
} else {
hasNextPage.value = false
}
} catch {
searchPage.value -= 1
} finally {
loadingMore.value = false
}
}
const onMaterialDropdownVisibleChange = (visible: boolean) => {
if (!visible) return
// 防御性拦截:竞态条件守卫
// 如果当前已经有搜索关键字例如用户刚刚粘贴了内容、remote-method 已经设置了 searchKeyword
// 绝对不要去请求默认列表,否则会清空 searchKeyword、覆盖正确结果。
if (searchKeyword.value || materialOptions.value.length > 0) return
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
handleSearchMaterial('')
}
const onMaterialSelected = (id: number | null, row: any) => {
if (!id) {
row.materialBaseId = null
return
}
const item = materialOptions.value.find(i => i.id === id)
if (item) {
row.name = item.name || item.material_name || ''
row.spec_model = item.spec_model || item.spec || ''
row.materialBaseId = id
}
}
// --- 行内价格半联动逻辑 ---
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 onRowTotalPriceChange = (row: any) => {
row.totalPriceManuallyEdited = true
}
const onRowTaxRateChange = (row: any) => {
// 税率变化时:重算不含税总价 = 单价×数量
if (row.unit_price && row.quantity) {
row.total_price = Number((row.unit_price * row.quantity).toFixed(2))
}
}
// --- 上传 ---
const beforeAvatarUpload = (rawFile: any) => {
const isTypeValid = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'].includes(rawFile.type)
if (!isTypeValid) {
ElMessage.error('仅支持 JPG/PNG/GIF/WEBP 图片')
return false
}
if (rawFile.size / 1024 / 1024 > 10) {
ElMessage.error('图片不能超过 10MB')
return false
}
return true
}
// ★ 行内照片上传
const customUploadRow = async (options: any, row: any) => {
const { file, onSuccess, onError } = options
const formData = new FormData()
formData.append('file', file)
isUploading.value = true
try {
const res: any = await uploadFile(formData)
if (res.code === 200) {
const newUrl = res.data.url
row.images = row.images || []
row.images.push(newUrl)
row.fileList = row.fileList || []
row.fileList.push({ name: file.name || newUrl.split('/').pop(), url: getImageUrl(newUrl) })
onSuccess({ url: getImageUrl(newUrl) })
} else {
ElMessage.error(res.msg || '上传失败')
onError(new Error(res.msg))
}
} catch (e) {
ElMessage.error('网络错误')
onError(e)
} finally {
isUploading.value = false
}
}
// ★ 统计某张照片被多少行引用(用于判断同步后的跨行删除)
const countImageRefs = (urlToRemove: string): number => {
let count = 0
formItems.value.forEach(r => {
if ((r.images || []).includes(urlToRemove)) count++
})
return count
}
// ★ 行内照片删除(带确认 + 跨行引用保护)
const handleRemoveRowImage = async (uploadFile: any, row: any) => {
const urlToRemove = (row.images || []).find(u => getImageUrl(u) === uploadFile.url) || uploadFile.url
const filename = urlToRemove.split('/').pop()
const isInternal = filename && !urlToRemove.startsWith('http')
// 检查该照片是否也被其他行引用(同步照片场景)
const refCount = countImageRefs(urlToRemove)
const sharedByOthers = refCount > 1 // 包含当前行
if (sharedByOthers) {
// ★ 同步照片:询问是否全部删除
const otherCount = refCount - 1
try {
const action = await ElMessageBox.confirm(
`此照片当前被 ${refCount} 行使用(含本行)。\n\n` +
`• 【全部删除】将移除所有行中的该照片,并删除服务器文件\n` +
`• 【仅移出此行】只从当前行移除引用,其他行保留(不删服务器文件)`,
'删除照片',
{
confirmButtonText: '全部删除',
cancelButtonText: '仅移出此行',
distinguishCancelAndClose: true,
type: 'warning'
}
)
// 用户点【全部删除】
formItems.value.forEach(r => {
r.images = (r.images || []).filter(u => u !== urlToRemove)
r.fileList = (r.fileList || []).filter(f => f.url !== uploadFile.url && f.url !== urlToRemove)
})
if (isInternal) await deleteFile(filename).catch(() => {})
ElMessage.success(`已从 ${refCount} 行删除该照片`)
} catch (action) {
if (action === 'cancel') {
// 用户点【仅移出此行】
row.images = (row.images || []).filter(u => u !== urlToRemove)
row.fileList = (row.fileList || []).filter(f => f.url !== uploadFile.url && f.url !== urlToRemove)
ElMessage.success('已从当前行移除该照片,其他行保留')
}
// close 或直接关闭 → 不操作
}
} else {
// 普通删除:仅当前行引用,确认后删除
try {
await ElMessageBox.confirm(
`确定删除这张照片吗?删除后不可恢复。`,
'删除照片',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }
)
row.images = (row.images || []).filter(u => u !== urlToRemove)
row.fileList = (row.fileList || []).filter(f => f.url !== uploadFile.url && f.url !== urlToRemove)
if (isInternal) await deleteFile(filename).catch(() => {})
ElMessage.success('照片已删除')
} catch (e) {
// 用户取消,不操作
}
}
}
// ★ 备注一键同步所有行:取第一行的备注复制到所有行
const syncRemarkToAll = () => {
if (formItems.value.length === 0) return
const source = formItems.value[0].remark || ''
formItems.value.forEach((row, idx) => {
if (idx > 0) row.remark = source
})
ElMessage.success('已将第一行备注同步到所有行')
}
// ★ 推导批次前缀:取单号前 3 段PUR-日期-时间)
// 例: PUR-20260625-1345-0001-0001 → PUR-20260625-1345
// 同一天不同时间(分钟)的单子不会合并;同一分钟内的多条视为同一批
const getBatchKey = (requestNo: string): string => {
if (!requestNo) return ''
const parts = requestNo.split('-')
return parts.length >= 3 ? parts.slice(0, 3).join('-') : requestNo
}
// ★ 照片一键同步所有行:取第一行的照片深拷贝到所有行
const syncImagesToAll = () => {
if (formItems.value.length <= 1) {
ElMessage.warning('当前只有一行,无需同步')
return
}
const firstRow = formItems.value[0]
const firstFiles = firstRow.fileList || []
const firstImages = firstRow.images || []
if (firstImages.length === 0) {
ElMessage.warning('第一行没有上传照片,无法同步')
return
}
// 深拷贝,避免某行删除照片时影响其他行
const clonedFiles = JSON.parse(JSON.stringify(firstFiles))
const clonedImages = JSON.parse(JSON.stringify(firstImages))
formItems.value.forEach((row, idx) => {
if (idx > 0) {
row.fileList = JSON.parse(JSON.stringify(clonedFiles))
row.images = JSON.parse(JSON.stringify(clonedImages))
}
})
ElMessage.success('照片已同步至所有明细行')
}
// --- 提交(循环逐行调用单条接口) ---
const submitForm = async () => {
// 1. 公共信息校验
if (!form.value.approver_id) { 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 }
// ★ 每行照片必填
if (!row.images || row.images.length === 0) {
ElMessage.warning(`请为第 ${idx + 1} 行【${row.name || '未命名物品'}】上传至少一张照片凭证!`)
return
}
}
// 3. 逐行提交(每行携带公共信息 + 同一批次号)
submitLoading.value = true
let successCount = 0
// ★ 获取今日批次号:同一批所有行共享,单号体现 PUR-日期-时间-批次-批内序号
let batchSeq: number | undefined
try {
const seqRes: any = await getNextBatchSeq()
batchSeq = seqRes.data?.batch_seq
} catch (e) {
// 获取批次号失败不阻断,回退旧格式
}
try {
for (const row of validItems) {
const payload: any = {
batch_seq: batchSeq,
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: row.supplier_link || '',
// 行备注优先;若空则用公共备注
remark: row.remark || form.value.remark,
// ★ 每行必填照片
images: row.images || []
}
await createPurchase(payload)
successCount++
}
ElMessage.success(`成功提交 ${successCount} 条采购申请,审批人将收到邮件通知`)
formDialogVisible.value = false
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || `提交失败(已完成 ${successCount} 条)`)
} finally {
submitLoading.value = false
}
}
// --- 详情 ---
const openDetailDialog = async (row: any) => {
try {
const res: any = await getPurchaseDetail(row.id)
detail.value = res.data || {}
detailDialogVisible.value = true
} catch (e) {
ElMessage.error('获取详情失败')
}
}
// --- 审批操作 ---
const handleApprove = async (row: any) => {
try {
await ElMessageBox.confirm(`确定要通过采购申请「${row.request_no}」吗?`, '审批确认', {
confirmButtonText: '确定通过', cancelButtonText: '取消', type: 'info'
})
} catch { return }
try {
await approvePurchase(row.id, { action: 'approve' })
ElMessage.success('已通过')
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || '操作失败')
}
}
// ★ 完结已通过的采购申请(库管)
const handleCloseRequest = async (row: any) => {
try {
await ElMessageBox.confirm(
`确定强制完结采购申请【${row.request_no}】吗?\n\n` +
`此操作将使该单从「已通过」列表中移除,且不可恢复。`,
'⚠️ 强制完结确认',
{ confirmButtonText: '确认完结', cancelButtonText: '取消', type: 'warning' }
)
} catch { return }
try {
await approvePurchase(row.id, { action: 'close' })
ElMessage.success(`采购申请 ${row.request_no} 已完结`)
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || err?.message || '完结失败')
}
}
const openRejectDialog = (row: any) => {
currentRejectRow.value = row
rejectReason.value = ''
rejectDialogVisible.value = true
}
const confirmReject = async () => {
if (!rejectReason.value.trim()) { ElMessage.warning('请填写驳回原因'); return }
rejectLoading.value = true
try {
await approvePurchase(currentRejectRow.value.id, {
action: 'reject', reject_reason: rejectReason.value
})
ElMessage.success('已驳回')
rejectDialogVisible.value = false
await fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || '操作失败')
} finally {
rejectLoading.value = false
}
}
// --- 初始化 ---
const route = useRoute()
onMounted(() => {
fetchData()
fetchApprovers()
// 从基础信息跳转过来的参数
const query = route.query
if (query.material_id) {
openCreateDialog({
materialId: Number(query.material_id),
name: (query.name as string) || '',
spec: (query.spec as string) || '',
unit: (query.unit as string) || ''
})
}
})
</script>
<style scoped>
.app-container { padding: 20px; }
.filter-container { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
/* 批量审批操作栏 */
.batch-bar {
display: flex;
align-items: center;
gap: 10px;
margin-top: 12px;
padding: 8px 12px;
background: #ecf5ff;
border: 1px solid #d9ecff;
border-radius: 4px;
font-size: 13px;
}
/* 表单分组 */
.form-section {
background: #fafbfc;
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 12px 16px 4px;
margin-bottom: 14px;
}
.form-section .section-title {
font-size: 13px;
font-weight: 600;
color: #303133;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid #ebeef5;
}
.form-section .section-title span {
position: relative;
padding-left: 10px;
}
.form-section .section-title span::before {
content: '';
position: absolute;
left: 0;
top: 2px;
bottom: 2px;
width: 3px;
background: #409EFF;
border-radius: 2px;
}
/* ★ 表格内迷你上传框:缩小到 60x60避免撑爆表格 */
:deep(.table-mini-uploader) .el-upload--picture-card {
width: 60px;
height: 60px;
line-height: 66px;
}
:deep(.table-mini-uploader) .el-upload--picture-card .el-icon {
font-size: 20px;
}
:deep(.table-mini-uploader) .el-upload-list--picture-card .el-upload-list__item {
width: 60px;
height: 60px;
}
:deep(.table-mini-uploader) .el-upload-list--picture-card .el-upload-list__item img {
width: 60px;
height: 60px;
object-fit: cover;
}
:deep(.table-mini-uploader) .el-upload-list--picture-card .el-upload-list__item-actions {
font-size: 14px;
}
/* ★ 统一列表行高:防止单/多物品、状态差异导致行高不一致 */
:deep(.el-table .el-table__row > td) {
height: 52px;
line-height: 20px;
}
:deep(.el-table .el-table__row > td .cell) {
line-height: 20px;
}
</style>