采购文件编制审核

This commit is contained in:
吴红兵
2026-02-08 23:30:23 +08:00
parent 9397da2ba3
commit 48f07afc40
6 changed files with 555 additions and 7 deletions

View File

@@ -124,3 +124,53 @@ export function getContracts(params?: any) {
}); });
} }
/**
* 实施采购:上传采购文件并关联到申请单(可同时保存采购代表人方式与人员)
* @param id 采购申请ID
* @param fileIds 已上传的采购文件ID列表fileType=130
* @param implementType 实施采购方式 1:自行组织采购 2:委托代理采购
* @param representorTeacherNo 需求部门初审-指定采购代表人(单人)
* @param representors 需求部门初审-部门多人逗号分隔
*/
export function implementApply(
id: number,
fileIds: string[],
implementType?: string,
representorTeacherNo?: string,
representors?: string
) {
return request({
url: '/purchase/purchasingapply/implement',
method: 'get',
params: { id, fileIds, implementType, representorTeacherNo, representors }
});
}
/**
* 发起采购文件审批流程(需已实施采购并上传采购文件)
* @param id 采购申请ID
* @param representorTeacherNo 需求部门初审-指定采购代表人单人用户ID或工号
* @param representors 需求部门初审-部门多人由系统抽取多人用户ID或工号逗号分隔
*/
export function startFileFlow(
id: number,
representorTeacherNo?: string,
representors?: string
) {
return request({
url: '/purchase/purchasingapply/startFileFlow',
method: 'post',
data: { id, representorTeacherNo, representors }
});
}
/**
* 获取部门下人员(用于选采购代表人)
*/
export function getDeptMembers() {
return request({
url: '/purchase/purchasingapply/getDeptMembers',
method: 'get'
});
}

View File

@@ -17,7 +17,8 @@ import SignInput from "./sign/index.vue";
// vite glob导入 // vite glob导入
const modules: Record<string, () => Promise<unknown>> = import.meta.glob( const modules: Record<string, () => Promise<unknown>> = import.meta.glob(
['../../views/jsonflow/*/*.vue', '../../views/order/*/*.vue', ['../../views/jsonflow/*/*.vue', '../../views/order/*/*.vue',
'../../views/purchase/*/*.vue','../../views/finance/purchasingrequisition/add.vue'] '../../views/purchase/*/*.vue', '../../views/finance/purchasingrequisition/add.vue',
'../../views/finance/purchasingrequisition/implement.vue']
) )
/** /**

View File

@@ -107,6 +107,14 @@ export const staticRoutes: Array<RouteRecordRaw> = [
isAuth: false, // 不需要认证,纯页面展示 isAuth: false, // 不需要认证,纯页面展示
}, },
}, },
{
path: '/finance/purchasingrequisition/implement',
name: 'purchasingrequisition.implement',
component: () => import('/@/views/finance/purchasingrequisition/implement.vue'),
meta: {
isAuth: false, // 供流程 iframe 嵌入
},
},
...staticRoutesFlow ...staticRoutesFlow
]; ];

View File

@@ -0,0 +1,313 @@
<template>
<div class="implement-page">
<div class="implement-form">
<el-form-item label="实施采购方式" required>
<el-radio-group v-model="implementType">
<el-radio label="1">自行组织采购</el-radio>
<el-radio label="2">委托代理采购</el-radio>
</el-radio-group>
</el-form-item>
<!-- 采购文件版本列表保留原文件多版本分别显示 -->
<el-divider content-position="left">采购文件版本</el-divider>
<div v-if="purchaseFileVersions.length" class="file-versions mb-2">
<el-table :data="purchaseFileVersions" border size="small" max-height="280">
<el-table-column type="index" label="版本" width="70" align="center">
<template #default="{ $index }">V{{ $index + 1 }}</template>
</el-table-column>
<el-table-column prop="fileTitle" label="文件名" min-width="180" show-overflow-tooltip />
<el-table-column prop="createBy" label="上传人" width="100" align="center" show-overflow-tooltip />
<el-table-column prop="createTime" label="上传时间" width="165" align="center">
<template #default="{ row }">{{ formatCreateTime(row.createTime) }}</template>
</el-table-column>
<el-table-column label="操作" width="90" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleDownloadVersion(row)">下载</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="mb-2">不满意可继续上传新版本保留原文件格式 doc/docx/pdf单文件不超过 5MB</div>
<upload-file
v-model="implementFileIds"
:limit="5"
:file-type="['doc', 'docx', 'pdf']"
:data="{ fileType: PURCHASE_FILE_TYPE }"
upload-file-url="/purchase/purchasingfiles/upload"
/>
<el-divider content-position="left">发起采购文件审批</el-divider>
<div class="mb-2">需求部门初审需指定采购代表人请选择一种方式</div>
<el-radio-group v-model="representorMode" class="mb-2">
<el-radio label="single">指定采购代表人单人</el-radio>
<el-radio label="multi">部门多人由系统自动抽取</el-radio>
</el-radio-group>
<el-form-item v-if="representorMode === 'single'" label="采购代表人">
<el-select v-model="representorTeacherNo" placeholder="请选择" clearable filterable style="width: 100%">
<el-option v-for="m in deptMembers" :key="m.userId || m.teacherNo || m.id" :label="m.realName || m.name || m.teacherNo" :value="m.userId || m.teacherNo || m.id" />
</el-select>
</el-form-item>
<el-form-item v-else label="部门多人">
<el-select v-model="representorsMulti" placeholder="请选择多人,系统将自动抽取一人" clearable filterable multiple style="width: 100%">
<el-option v-for="m in deptMembers" :key="m.userId || m.teacherNo || m.id" :label="m.realName || m.name || m.teacherNo" :value="m.userId || m.teacherNo || m.id" />
</el-select>
</el-form-item>
</div>
<div class="implement-footer">
<el-button @click="handleClose">取消</el-button>
<template v-if="implementHasPurchaseFiles && !applyRow?.fileFlowInstId">
<el-button type="primary" :loading="implementSubmitting" @click="handleImplementSubmit">保存实施采购</el-button>
<el-button type="success" :loading="startFileFlowSubmitting" @click="handleStartFileFlow">发起采购文件审批</el-button>
</template>
<template v-else>
<el-button type="primary" :loading="implementSubmitting" @click="handleImplementSubmit">确定</el-button>
</template>
</div>
</div>
</template>
<script setup lang="ts" name="PurchasingImplement">
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { implementApply, getApplyFiles, startFileFlow, getDeptMembers, getObj } from '/@/api/finance/purchasingrequisition'
import { useMessage } from '/@/hooks/message'
import other from '/@/utils/other'
import UploadFile from '/@/components/Upload/index.vue'
// 与编辑界面一致:支持流程 dynamic-link 传入 currJob/currElTab申请单 ID 优先取 currJob.orderId
const props = defineProps({
currJob: { type: Object, default: null },
currElTab: { type: Object, default: null }
})
const route = useRoute()
const PURCHASE_FILE_TYPE = '130'
/** 申请单 ID数值用于 getObj 等):与 add 一致,优先流程 currJob.orderId否则 route.query.id */
const applyId = computed(() => {
const raw = applyIdRaw.value
if (raw == null || raw === '') return null
const n = Number(raw)
return Number.isNaN(n) ? null : n
})
/** 申请单 ID 原始字符串(用于 getApplyFiles 的 purchaseId与编辑页一致避免类型/精度问题) */
const applyIdRaw = computed(() => {
if (props.currJob?.orderId != null && props.currJob?.orderId !== '') {
return String(props.currJob.orderId)
}
const id = route.query.id
return id != null && id !== '' ? String(id) : ''
})
const applyRow = ref<any>(null)
/** 已有采购文件版本列表(按 createTime 排序,用于展示与提交时一并关联) */
const purchaseFileVersions = ref<{ id: string; fileTitle?: string; createBy?: string; createTime?: string; remark?: string }[]>([])
/** 本次新上传的采购文件(仅新版本,不与已有版本混在一起) */
const implementFileIds = ref<string | string[]>([])
const implementType = ref<string>('1')
const implementSubmitting = ref(false)
const representorMode = ref<'single' | 'multi'>('single')
const representorTeacherNo = ref<string>('')
const representorsMulti = ref<string[]>([])
const deptMembers = ref<any[]>([])
const startFileFlowSubmitting = ref(false)
const implementHasPurchaseFiles = computed(() => {
if (purchaseFileVersions.value.length > 0) return true
const raw = implementFileIds.value
if (Array.isArray(raw)) return raw.length > 0
return !!raw
})
function formatCreateTime(t?: string) {
if (!t) return '-'
const d = new Date(t)
return isNaN(d.getTime()) ? t : d.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
function handleDownloadVersion(file: { remark?: string; fileTitle?: string }) {
if (!file?.remark) {
useMessage().warning('无法获取文件路径')
return
}
const url = `/purchase/purchasingfiles/download?fileName=${encodeURIComponent(file.remark)}&fileTitle=${encodeURIComponent(file.fileTitle || '采购文件')}`
other.downBlobFile(url, {}, file.fileTitle || '采购文件')
}
const isInIframe = () => typeof window !== 'undefined' && window.self !== window.top
const postMessage = (type: string, payload?: any) => {
if (typeof window !== 'undefined' && window.parent) {
window.parent.postMessage({ type, ...payload }, '*')
}
}
const loadData = async () => {
const id = applyId.value
if (!id) {
useMessage().warning('缺少申请单ID')
return
}
try {
const idStr = applyIdRaw.value || String(id)
const [detailRes, filesRes, membersRes] = await Promise.all([
getObj(id),
getApplyFiles(idStr),
getDeptMembers()
])
applyRow.value = detailRes?.data ? { ...detailRes.data, id: detailRes.data.id ?? id } : { id }
const row = applyRow.value
if (row?.implementType) implementType.value = row.implementType
// 回显需求部门初审-采购代表人方式与人员(与发起采购文件审批时保存的一致)
if (row?.representorTeacherNo) {
representorMode.value = 'single'
representorTeacherNo.value = row.representorTeacherNo ?? ''
representorsMulti.value = []
} else if (row?.representors) {
representorMode.value = 'multi'
representorTeacherNo.value = ''
const parts = typeof row.representors === 'string' ? row.representors.split(',') : []
representorsMulti.value = parts.map((s: string) => s.trim()).filter(Boolean)
} else {
representorTeacherNo.value = ''
representorsMulti.value = []
}
const list = filesRes?.data || []
const purchaseFiles = list.filter((f: any) => f.fileType === PURCHASE_FILE_TYPE)
purchaseFileVersions.value = purchaseFiles.map((f: any) => ({
id: String(f.id),
fileTitle: f.fileTitle || f.file_title || '采购文件',
createBy: f.createBy ?? f.create_by ?? '-',
createTime: f.createTime || f.create_time,
remark: f.remark
}))
deptMembers.value = membersRes?.data || []
} catch (_) {
applyRow.value = { id }
purchaseFileVersions.value = []
deptMembers.value = []
}
}
const handleClose = () => {
postMessage('purchasingimplement:close')
if (!isInIframe()) {
window.history.back()
}
}
const handleImplementSubmit = async () => {
const row = applyRow.value
if (!row?.id && !applyId.value) return
const id = row?.id ?? applyId.value
if (!id) return
if (!implementType.value) {
useMessage().warning('请选择实施采购方式')
return
}
const existingIds = purchaseFileVersions.value.map((f) => f.id)
const raw = implementFileIds.value
const newIds: string[] = Array.isArray(raw)
? raw.map((x: any) => (typeof x === 'object' && x?.id ? x.id : x)).filter(Boolean)
: raw ? [String(raw)] : []
const fileIds = [...existingIds, ...newIds]
if (fileIds.length === 0) {
useMessage().warning('请至少上传一个采购文件')
return
}
const single = representorMode.value === 'single' ? representorTeacherNo.value : undefined
const multi = representorMode.value === 'multi' && representorsMulti.value?.length ? representorsMulti.value.join(',') : undefined
implementSubmitting.value = true
try {
await implementApply(id, fileIds, implementType.value, single, multi)
useMessage().success('实施采购已保存')
implementFileIds.value = []
await loadData()
postMessage('purchasingimplement:saved')
} catch (err: any) {
useMessage().error(err?.msg || '实施采购失败')
} finally {
implementSubmitting.value = false
}
}
const handleStartFileFlow = async () => {
const row = applyRow.value
const id = row?.id ?? applyId.value
if (!id) return
if (representorMode.value === 'single') {
if (!representorTeacherNo.value) {
useMessage().warning('请选择采购代表人')
return
}
} else {
if (!representorsMulti.value?.length) {
useMessage().warning('请选择部门多人')
return
}
}
startFileFlowSubmitting.value = true
try {
const single = representorMode.value === 'single' ? representorTeacherNo.value : undefined
const multi = representorMode.value === 'multi' ? representorsMulti.value.join(',') : undefined
await startFileFlow(id, single, multi)
useMessage().success('已发起采购文件审批流程')
postMessage('purchasingimplement:submitSuccess')
await loadData()
} catch (err: any) {
useMessage().error(err?.msg || '发起失败')
} finally {
startFileFlowSubmitting.value = false
}
}
// 流程切换工单时重新加载数据(与 add 编辑页一致)
watch(
() => props.currJob?.orderId ?? props.currJob?.id,
(newVal, oldVal) => {
if (newVal !== oldVal && applyId.value) {
loadData()
}
}
)
onMounted(() => {
loadData()
if (isInIframe()) {
document.documentElement.classList.add('iframe-mode')
document.body.classList.add('iframe-mode')
}
})
</script>
<style scoped lang="scss">
.implement-page {
padding: 20px;
min-height: 100%;
display: flex;
flex-direction: column;
}
.implement-form {
flex: 1;
.mb-2 {
margin-bottom: 8px;
}
}
.implement-form-tip {
margin-top: 12px;
padding: 8px 0;
}
.implement-footer {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--el-border-color-lighter);
display: flex;
gap: 12px;
flex-wrap: wrap;
}
</style>

View File

@@ -0,0 +1,95 @@
<template>
<el-dialog
v-model="visible"
title="实施采购"
width="560px"
:close-on-click-modal="false"
destroy-on-close
class="implement-iframe-dialog"
@close="handleClose"
>
<div class="implement-iframe-content">
<iframe
ref="iframeRef"
:src="iframeSrc"
frameborder="0"
class="implement-iframe"
/>
</div>
</el-dialog>
</template>
<script setup lang="ts" name="ImplementForm">
import { ref, computed, watch } from 'vue'
const emit = defineEmits<{
(e: 'refresh'): void
}>()
const visible = ref(false)
const iframeRef = ref<HTMLIFrameElement>()
const applyId = ref<string | number>('')
const iframeSrc = computed(() => {
const baseUrl = window.location.origin + window.location.pathname
return `${baseUrl}#/finance/purchasingrequisition/implement?id=${applyId.value}`
})
const handleClose = () => {
visible.value = false
window.removeEventListener('message', handleMessage)
}
const handleMessage = (event: MessageEvent) => {
if (event.data?.type === 'purchasingimplement:submitSuccess') {
handleClose()
emit('refresh')
} else if (event.data?.type === 'purchasingimplement:close') {
handleClose()
} else if (event.data?.type === 'purchasingimplement:saved') {
emit('refresh')
}
}
const openDialog = (row: { id: string | number }) => {
applyId.value = row?.id ?? ''
visible.value = true
window.addEventListener('message', handleMessage)
}
watch(visible, (val) => {
if (!val) {
window.removeEventListener('message', handleMessage)
}
})
defineExpose({
openDialog,
})
</script>
<style scoped lang="scss">
.implement-iframe-content {
width: 100%;
height: 65vh;
min-height: 480px;
max-height: calc(100vh - 180px);
position: relative;
overflow: hidden;
.implement-iframe {
width: 100%;
height: 100%;
min-height: 480px;
border: none;
display: block;
}
}
</style>
<style>
.implement-iframe-dialog .el-dialog__body {
padding: 16px;
overflow: hidden;
}
</style>

View File

@@ -228,6 +228,52 @@
</template> </template>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="fileFlowStatus" label="文件审批状态" width="110" align="center">
<template #header>
<el-icon><DocumentChecked /></el-icon>
<span style="margin-left: 4px">文件审批状态</span>
</template>
<template #default="scope">
<template v-if="scope.row.fileFlowInstId">
<el-tooltip content="点击查看审批过程" placement="top">
<el-tag
v-if="scope.row.fileFlowStatus === '-2'"
type="info"
class="status-tag-clickable"
@click="handleShowFileFlowComment(scope.row)">撤回</el-tag>
<el-tag
v-else-if="scope.row.fileFlowStatus === '-1'"
type="warning"
class="status-tag-clickable"
@click="handleShowFileFlowComment(scope.row)">暂存</el-tag>
<el-tag
v-else-if="scope.row.fileFlowStatus === '0'"
type="primary"
class="status-tag-clickable"
@click="handleShowFileFlowComment(scope.row)">运行中</el-tag>
<el-tag
v-else-if="scope.row.fileFlowStatus === '1'"
type="success"
class="status-tag-clickable"
@click="handleShowFileFlowComment(scope.row)">完成</el-tag>
<el-tag
v-else-if="scope.row.fileFlowStatus === '2'"
type="danger"
class="status-tag-clickable"
@click="handleShowFileFlowComment(scope.row)">作废</el-tag>
<el-tag
v-else-if="scope.row.fileFlowStatus === '3'"
type="info"
class="status-tag-clickable"
@click="handleShowFileFlowComment(scope.row)">终止</el-tag>
<span v-else class="status-tag-clickable" @click="handleShowFileFlowComment(scope.row)">-</span>
</el-tooltip>
</template>
<template v-else>
<span style="color: #909399;"></span>
</template>
</template>
</el-table-column>
<el-table-column label="操作" align="center" fixed="right" width="150"> <el-table-column label="操作" align="center" fixed="right" width="150">
<template #default="scope"> <template #default="scope">
<div class="op-cell"> <div class="op-cell">
@@ -268,18 +314,21 @@
<!-- 履约验收弹窗 --> <!-- 履约验收弹窗 -->
<PurchasingAcceptModal ref="acceptModalRef" @refresh="getDataList" /> <PurchasingAcceptModal ref="acceptModalRef" @refresh="getDataList" />
<!-- 查看审批过程参考 hi-job 流程弹窗 --> <!-- 查看审批过程申请单审批 / 文件审批 -->
<el-dialog <el-dialog
v-model="showFlowComment" v-model="showFlowComment"
v-if="showFlowComment" v-if="showFlowComment"
title="查看审批过程" :title="currFlowCommentType === 'file' ? '查看文件审批过程' : '查看审批过程'"
top="20px" top="20px"
width="90%" width="90%"
append-to-body append-to-body
destroy-on-close destroy-on-close
@close="currFlowJob = null"> @close="currFlowJob = null; currFlowCommentType = 'apply'">
<FlowCommentTimeline v-if="currFlowJob" :key="currFlowJob.flowInstId" :curr-job="currFlowJob" /> <FlowCommentTimeline v-if="currFlowJob" :key="String(currFlowJob.flowInstId) + currFlowCommentType" :curr-job="currFlowJob" />
</el-dialog> </el-dialog>
<!-- 实施采购iframe 嵌入 implement.vue供列表与流程页面使用 -->
<ImplementForm ref="implementFormRef" @refresh="getDataList" />
</div> </div>
</template> </template>
@@ -295,6 +344,7 @@ import { List, Document, DocumentCopy, Search, Collection, Money, CircleCheck, I
// 引入组件 // 引入组件
const FormDialog = defineAsyncComponent(() => import('./form.vue')); const FormDialog = defineAsyncComponent(() => import('./form.vue'));
const ImplementForm = defineAsyncComponent(() => import('./implementForm.vue'));
const ActionDropdown = defineAsyncComponent(() => import('/@/components/tools/action-dropdown.vue')); const ActionDropdown = defineAsyncComponent(() => import('/@/components/tools/action-dropdown.vue'));
const PurchasingAcceptModal = defineAsyncComponent(() => import('./accept/PurchasingAcceptModal.vue')); const PurchasingAcceptModal = defineAsyncComponent(() => import('./accept/PurchasingAcceptModal.vue'));
const FlowCommentTimeline = defineAsyncComponent(() => import('/@/views/jsonflow/comment/timeline.vue')); const FlowCommentTimeline = defineAsyncComponent(() => import('/@/views/jsonflow/comment/timeline.vue'));
@@ -317,9 +367,12 @@ const formDialogRef = ref()
const acceptModalRef = ref() const acceptModalRef = ref()
const searchFormRef = ref() const searchFormRef = ref()
const showSearch = ref(true) const showSearch = ref(true)
/** 审批过程弹窗:是否显示、当前行对应的流程 job供 Comment 组件用) */ /** 审批过程弹窗:是否显示、当前行对应的流程 job供 Comment 组件用)、类型(申请单/文件) */
const showFlowComment = ref(false) const showFlowComment = ref(false)
const currFlowJob = ref<{ id?: number; flowInstId?: number } | null>(null) const currFlowJob = ref<{ id?: number; flowInstId?: number } | null>(null)
const currFlowCommentType = ref<'apply' | 'file'>('apply')
const implementFormRef = ref()
/** /**
* 定义响应式表格数据 * 定义响应式表格数据
@@ -361,15 +414,29 @@ const handleAdd = () => {
* 点击审核状态:若有流程实例则打开「查看审批过程」弹窗(参考 hi-job.vue * 点击审核状态:若有流程实例则打开「查看审批过程」弹窗(参考 hi-job.vue
* @param row - 当前行数据(需含 flowInstId * @param row - 当前行数据(需含 flowInstId
*/ */
/** 点击审核状态:打开申请单审批过程 */
const handleShowFlowComment = (row: any) => { const handleShowFlowComment = (row: any) => {
if (!row?.flowInstId) { if (!row?.flowInstId) {
useMessage().info('暂存状态无审批过程'); useMessage().info('暂存状态无审批过程');
return; return;
} }
currFlowCommentType.value = 'apply';
currFlowJob.value = { id: row.id, flowInstId: row.flowInstId }; currFlowJob.value = { id: row.id, flowInstId: row.flowInstId };
showFlowComment.value = true; showFlowComment.value = true;
}; };
/** 点击文件审批状态:打开文件审批过程 */
const handleShowFileFlowComment = (row: any) => {
if (!row?.fileFlowInstId) {
useMessage().info('未发起文件审批流程');
return;
}
currFlowCommentType.value = 'file';
const flowInstId = typeof row.fileFlowInstId === 'string' ? parseInt(row.fileFlowInstId, 10) : row.fileFlowInstId;
currFlowJob.value = { id: row.id, flowInstId: Number.isNaN(flowInstId) ? row.fileFlowInstId : flowInstId };
showFlowComment.value = true;
};
/** /**
* 打开查看对话框 * 打开查看对话框
* @param row - 当前行数据 * @param row - 当前行数据
@@ -394,6 +461,11 @@ const handleAccept = (row: any) => {
acceptModalRef.value?.open(row); acceptModalRef.value?.open(row);
}; };
/** 打开实施采购(仅暂存状态可点;通过 iframe 嵌入 implement.vue */
const handleImplement = (row: any) => {
implementFormRef.value?.openDialog(row);
};
/** /**
* 删除当前行 * 删除当前行
* @param row - 当前行数据 * @param row - 当前行数据
@@ -458,6 +530,12 @@ const getActionMenuItems = (row: any) => {
icon: DocumentChecked, icon: DocumentChecked,
visible: () => true, visible: () => true,
}, },
{
command: 'implement',
label: '实施采购',
icon: Upload,
visible: () => isTemp,
},
]; ];
}; };
@@ -476,6 +554,9 @@ const handleMoreCommand = (command: string, row: any) => {
case 'accept': case 'accept':
handleAccept(row); handleAccept(row);
break; break;
case 'implement':
handleImplement(row);
break;
} }
}; };