合同管理以及与OA的合同签订组件的配合优化

master
weizong song 1 month ago
parent 1630a4b39b
commit 650036ef7f

@ -773,6 +773,11 @@ export const contractAPI = {
flow_instance_id: flowInstanceId,
...data
}),
simulateFromFlow: (flowInstanceId, settings) =>
request.post('/budget/contracts/simulate-from-oa', {
flow_instance_id: flowInstanceId,
settings
}),
getPendingConversions: (params) => request.get('/budget/contracts/pending-conversions', params),
batchImport: (data) => request.post('/budget/contracts/batch-import', data),
previewAttachmentMatch: (data) => request.post('/budget/contracts/preview-attachment-match', data),
@ -835,6 +840,7 @@ export const contractSettingsAPI = {
getOaModels: () => request.get('/budget/contract-settings/oa-models'),
getOaSubModels: () => request.get('/budget/contract-settings/oa-sub-models'),
getOaFields: (params) => request.get('/budget/contract-settings/oa-fields', params),
getOaFlows: (params) => request.get('/budget/contract-settings/oa-flows', params),
getBudgetFields: () => request.get('/budget/contract-settings/budget-fields')
}

@ -182,14 +182,14 @@
<el-table-column prop="remark" label="备注" min-width="150" show-overflow-tooltip />
<el-table-column prop="attachment" label="附件" width="120">
<template #default="{ row }">
<template v-if="row.attachment_id">
<template v-if="getEffectiveAttachment(row)">
<el-link
v-if="getAttachmentUrl(row)"
:href="getAttachmentUrl(row)"
target="_blank"
type="primary"
>
{{ row.attachment?.original_name || row.attachment?.name || '查看' }}
{{ getAttachmentName(row) }}
</el-link>
<span v-else style="color: #909399;">附件加载中...</span>
</template>
@ -449,6 +449,21 @@
<div class="el-upload__tip">只能上传 JPG/PNG/PDF/DOC/DOCX/XLS/XLSX 格式的文件且不超过 10MB</div>
</template>
</el-upload>
<div v-if="inferredAttachmentInfo" class="oa-inferred-attachment">
<div class="oa-inferred-attachment__title">当前附件由 OA 合同流程映射推导合同本身尚未保存附件</div>
<el-link
v-if="getAttachmentUrl({ effective_attachment: inferredAttachmentInfo.attachment })"
:href="getAttachmentUrl({ effective_attachment: inferredAttachmentInfo.attachment })"
target="_blank"
type="primary"
>
{{ getAttachmentName({ effective_attachment: inferredAttachmentInfo.attachment }) }}
</el-link>
<span v-else>{{ getAttachmentName({ effective_attachment: inferredAttachmentInfo.attachment }) }}</span>
<span v-if="inferredAttachmentInfo.meta?.flow_no || inferredAttachmentInfo.meta?.flow_title" class="oa-inferred-attachment__meta">
来源{{ [inferredAttachmentInfo.meta?.flow_no, inferredAttachmentInfo.meta?.flow_title].filter(Boolean).join(' / ') }}
</span>
</div>
</el-form-item>
<el-divider content-position="left">付款计划</el-divider>
@ -578,14 +593,14 @@
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ viewData.remark || '-' }}</el-descriptions-item>
<el-descriptions-item label="附件" :span="2">
<template v-if="viewData.attachment_id">
<template v-if="getEffectiveAttachment(viewData)">
<el-link
v-if="getViewAttachmentUrl(viewData)"
:href="getViewAttachmentUrl(viewData)"
target="_blank"
type="primary"
>
{{ viewData.attachment?.original_name || viewData.attachment?.name || '查看' }}
{{ getAttachmentName(viewData) }}
</el-link>
<span v-else style="color: #909399;">附件加载中...</span>
</template>
@ -1497,6 +1512,7 @@ const rules = {
const userList = ref([])
const departmentList = ref([])
const attachmentFileList = ref([])
const inferredAttachmentInfo = ref(null)
//
const importDialogVisible = ref(false)
@ -1570,6 +1586,7 @@ function handleUploadSuccess(response) {
uid: fileData.id,
id: fileData.id
}]
inferredAttachmentInfo.value = null
ElMessage.success('上传成功')
} else {
ElMessage.error('上传失败:' + (response.msg || '未知错误'))
@ -2195,18 +2212,7 @@ async function loadViewDetail(id) {
}
function getViewAttachmentUrl(row) {
if (!row || !row.attachment_id) return null
if (row.attachment && row.attachment.url) {
return row.attachment.url
}
if (row.attachment && row.attachment.folder && row.attachment.name) {
const baseUrl = config.baseURL || ''
return `${baseUrl}/${row.attachment.folder}/${row.attachment.name}`
}
return null
return getAttachmentUrl(row)
}
function openForm(row) {
@ -2217,6 +2223,7 @@ function openForm(row) {
Object.assign(form, getEmptyForm())
payPlanDeleteIds.value = []
attachmentFileList.value = []
inferredAttachmentInfo.value = null
loadDetail(row.id)
}
@ -2304,8 +2311,15 @@ async function loadDetail(id) {
uid: data.attachment.id,
id: data.attachment.id
}]
inferredAttachmentInfo.value = null
} else {
attachmentFileList.value = []
inferredAttachmentInfo.value = data.oa_inferred_attachment
? {
attachment: data.oa_inferred_attachment,
meta: data.oa_inferred_attachment_meta || {}
}
: null
}
Object.assign(form, data, { pay_plans: data.pay_plans || [] })
@ -2541,23 +2555,31 @@ function getDepartmentName(departmentId) {
}
// URL
function getAttachmentUrl(row) {
if (!row.attachment_id) return null
// attachmenturl使
if (row.attachment && row.attachment.url) {
return row.attachment.url
}
// attachmenturlURL
if (row.attachment && row.attachment.folder && row.attachment.name) {
const baseUrl = import.meta.env.VITE_APP_BASE_API?.replace('/api', '') || window.location.origin
return `${baseUrl}/${row.attachment.folder}/${row.attachment.name}`
function getEffectiveAttachment(row) {
if (!row) return null
return row.effective_attachment || row.attachment || null
}
function getAttachmentName(row) {
const attachment = getEffectiveAttachment(row)
return attachment?.original_name || attachment?.name || '查看'
}
function buildAttachmentUrl(attachment) {
if (!attachment) return null
if (attachment.url) return attachment.url
if (attachment.folder && attachment.name) {
const apiBase = config.api?.baseURL || import.meta.env.VITE_APP_BASE_API || ''
const baseUrl = apiBase ? apiBase.replace(/\/api\/?$/, '') : window.location.origin
return `${baseUrl}/${attachment.folder}/${attachment.name}`
}
return null
}
function getAttachmentUrl(row) {
return buildAttachmentUrl(getEffectiveAttachment(row))
}
async function loadUserList() {
try {
const response = await request.get('/backend/simple-user', { rows: 1000 })
@ -3988,6 +4010,26 @@ onMounted(() => {
font-size: 12px;
}
.oa-inferred-attachment {
margin-top: 8px;
padding: 8px 10px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #fafafa;
color: #606266;
font-size: 12px;
line-height: 1.6;
}
.oa-inferred-attachment__title {
color: #909399;
}
.oa-inferred-attachment__meta {
margin-left: 12px;
color: #909399;
}
:deep(.error-row) {
background-color: #fef0f0;
}

@ -49,6 +49,19 @@
:value="f.name"
/>
</el-select>
<el-checkbox
:model-value="item.value_transform === 'json_field'"
@change="(checked) => toggleJsonFieldTransform(item, checked)"
>
JSON取字段
</el-checkbox>
<el-input
v-if="item.value_transform === 'json_field'"
v-model="item.json_field"
placeholder="id"
class="w-120"
/>
<el-tag v-if="isJsonCandidate(item, oaFieldsMain)" size="small" type="warning" effect="plain">JSON</el-tag>
<el-button type="danger" link @click="removeContractField(idx)"></el-button>
</div>
<el-button type="primary" link @click="addContractField"></el-button>
@ -80,6 +93,19 @@
:value="f.name"
/>
</el-select>
<el-checkbox
:model-value="item.value_transform === 'json_field'"
@change="(checked) => toggleJsonFieldTransform(item, checked)"
>
JSON取字段
</el-checkbox>
<el-input
v-if="item.value_transform === 'json_field'"
v-model="item.json_field"
placeholder="id"
class="w-120"
/>
<el-tag v-if="isJsonCandidate(item, oaFieldsSub)" size="small" type="warning" effect="plain">JSON</el-tag>
<el-button type="danger" link @click="removePayplanField(idx)"></el-button>
</div>
<el-button type="primary" link @click="addPayplanField"></el-button>
@ -89,16 +115,175 @@
<el-form-item>
<el-button type="primary" :loading="saving" @click="handleSave"></el-button>
<el-button @click="loadData"></el-button>
<el-button type="warning" @click="openSimulationDialog"></el-button>
</el-form-item>
</el-form>
</el-card>
<el-dialog
v-model="simulationDialogVisible"
title="合同映射模拟"
width="82%"
:close-on-click-modal="false"
>
<el-alert
title="模拟只读取当前页面设置和所选OA流程生成合同草稿不会创建合同不会写入付款计划。"
type="info"
show-icon
:closable="false"
class="mb-16"
/>
<el-form label-width="160px">
<el-form-item label="合同流程">
<div class="simulation-flow-picker">
<el-select
v-model="simulationFlowId"
placeholder="选择一个合同流程"
filterable
remote
clearable
reserve-keyword
:remote-method="searchSimulationFlows"
:loading="simulationFlowsLoading"
class="simulation-flow-select"
@focus="loadSimulationFlows()"
>
<el-option
v-for="flow in simulationFlows"
:key="flow.id"
:label="flow.label"
:value="flow.id"
>
<div class="flow-option">
<span>{{ flow.label }}</span>
<small>{{ flow.created_at || '' }}</small>
</div>
</el-option>
</el-select>
<el-button type="primary" :loading="simulationLoading" :disabled="!simulationFlowId" @click="runSimulation">
生成模拟
</el-button>
</div>
</el-form-item>
</el-form>
<template v-if="simulationResult">
<el-divider content-position="left">合同签订表单预览</el-divider>
<el-form :model="simulationContract" label-width="150px" disabled class="simulation-contract-form">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="合同编号">
<el-input :model-value="simulationContract.contract_no || ''" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="合同名称">
<el-input :model-value="simulationContract.title || ''" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="合同主要内容">
<el-input :model-value="simulationContract.main_content || ''" type="textarea" :rows="3" />
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="甲方">
<el-input :model-value="simulationContract.party_a || ''" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="乙方">
<el-input :model-value="simulationContract.party_b || ''" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="合同金额(元)">
<el-input :model-value="formatAmount(simulationContract.amount_total)" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="项目预算金额(元)">
<el-input :model-value="formatAmount(simulationContract.budget_amount)" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="金额类型">
<el-input :model-value="getAmountTypeLabel(simulationContract.amount_type)" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="合同类型">
<el-input :model-value="simulationContract.contract_type || simulationContract.contract_type_id || ''" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="签订日期">
<el-input :model-value="simulationContract.sign_date || ''" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="申请日期">
<el-input :model-value="simulationContract.apply_date || ''" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="合同履行期">
<el-input :model-value="simulationContract.perform_period || ''" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="付款方式">
<el-input :model-value="simulationContract.pay_method || ''" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="合同盖章版本附件">
<el-input :model-value="formatAttachmentValue(simulationContract.attachment_id)" />
</el-form-item>
<el-form-item label="备注">
<el-input :model-value="simulationContract.remark || ''" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<el-divider content-position="left">付款计划预览</el-divider>
<el-table :data="simulationPayPlans" border size="small" class="mb-16">
<el-table-column prop="phase_no" label="期次" width="100" />
<el-table-column prop="due_date" label="计划付款日" width="150" />
<el-table-column prop="amount_plan" label="计划金额" width="160">
<template #default="{ row }">
{{ formatAmount(row.amount_plan) }}
</template>
</el-table-column>
</el-table>
<el-divider content-position="left">映射结果明细</el-divider>
<el-table :data="simulationMappingRows" border size="small">
<el-table-column prop="targetLabel" label="合同字段" width="180" />
<el-table-column prop="sourceLabel" label="OA字段" width="180" />
<el-table-column prop="value" label="模拟值" show-overflow-tooltip />
</el-table>
</template>
<template #footer>
<el-button @click="simulationDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { contractSettingsAPI } from '@/utils/api'
import { contractAPI, contractSettingsAPI } from '@/utils/api'
import { Setting } from '@element-plus/icons-vue'
const form = ref(getEmptyForm())
@ -110,6 +295,30 @@ const oaFieldsSub = ref([])
const oaFieldsLoading = ref(false)
const contractFields = ref([])
const payplanFields = ref([])
const simulationDialogVisible = ref(false)
const simulationFlows = ref([])
const simulationFlowsLoading = ref(false)
const simulationFlowId = ref('')
const simulationLoading = ref(false)
const simulationResult = ref(null)
const simulationContract = ref({})
const simulationPayPlans = ref([])
const simulationMappingRows = ref([])
function normalizeNullableId(value) {
if (value === null || value === undefined || value === '') {
return ''
}
const id = Number(value)
return Number.isNaN(id) ? value : id
}
function normalizeModelOptions(list) {
return (Array.isArray(list) ? list : []).map((item) => ({
...item,
id: normalizeNullableId(item.id)
}))
}
function getEmptyForm() {
return {
@ -154,14 +363,142 @@ function removePayplanField(idx) {
form.value.payplan_field_mapping.splice(idx, 1)
}
function toggleJsonFieldTransform(item, checked) {
if (checked) {
item.value_transform = 'json_field'
if (!item.json_field) {
item.json_field = 'id'
}
return
}
item.value_transform = ''
item.json_field = ''
}
function getSelectedOaField(item, fields) {
return fields.find((field) => field.name === item.oa_field)
}
function isJsonCandidate(item, fields) {
const field = getSelectedOaField(item, fields)
return ['file', 'image', 'upload', 'relation'].includes(field?.type)
}
function formatAmount(amount) {
if (amount === null || amount === undefined || amount === '') {
return ''
}
const num = Number(amount)
if (Number.isNaN(num)) {
return String(amount)
}
return num.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
function getAmountTypeLabel(value) {
if (value === 'fixed') return '闭口合同(金额确定)'
if (value === 'open') return '框架协议/开口合同(金额不确定)'
return value || ''
}
function formatAttachmentValue(value) {
if (value === null || value === undefined || value === '') {
return ''
}
if (Array.isArray(value)) {
return value.join(', ')
}
if (typeof value === 'object') {
return value.name || value.original_name || value.id || JSON.stringify(value)
}
return `附件ID${value}`
}
function getFieldLabel(fields, fieldName) {
const field = fields.find((item) => item.field === fieldName || item.name === fieldName)
return field?.label || fieldName || ''
}
function buildSimulationMappingRows(contract) {
return (Array.isArray(form.value.contract_field_mapping) ? form.value.contract_field_mapping : [])
.filter((item) => item.budget_field || item.oa_field)
.map((item) => ({
targetLabel: getFieldLabel(contractFields.value, item.budget_field),
sourceLabel: getFieldLabel(oaFieldsMain.value, item.oa_field),
value: contract?.[item.budget_field] ?? ''
}))
}
async function loadSimulationFlows(keyword = '') {
if (!form.value.oa_custom_model_id) {
simulationFlows.value = []
return
}
simulationFlowsLoading.value = true
try {
const res = await contractSettingsAPI.getOaFlows({
custom_model_id: form.value.oa_custom_model_id,
keyword,
limit: 30
})
simulationFlows.value = res?.data || res || []
} catch (e) {
simulationFlows.value = []
} finally {
simulationFlowsLoading.value = false
}
}
function searchSimulationFlows(keyword) {
loadSimulationFlows(keyword)
}
async function openSimulationDialog() {
if (!form.value.oa_custom_model_id) {
ElMessage.warning('请先选择主表对应OA模型')
return
}
simulationDialogVisible.value = true
simulationResult.value = null
simulationContract.value = {}
simulationPayPlans.value = []
simulationMappingRows.value = []
await loadSimulationFlows()
}
async function runSimulation() {
if (!simulationFlowId.value) {
ElMessage.warning('请选择合同流程')
return
}
simulationLoading.value = true
try {
const res = await contractAPI.simulateFromFlow(simulationFlowId.value, form.value)
const data = res?.data || res || {}
const prepared = data.prepared_contract || {}
simulationResult.value = prepared
simulationContract.value = prepared.contract || {}
simulationPayPlans.value = Array.isArray(prepared.pay_plans) ? prepared.pay_plans : []
simulationMappingRows.value = buildSimulationMappingRows(simulationContract.value)
ElMessage.success('模拟生成完成,未写入任何合同数据')
} catch (e) {
ElMessage.error(e?.message || '模拟失败')
} finally {
simulationLoading.value = false
}
}
async function loadData() {
try {
const res = await contractSettingsAPI.get()
const data = res?.data || res
if (data) {
form.value = {
oa_custom_model_id: data.oa_custom_model_id || '',
oa_custom_model_id_for_payplan: data.oa_custom_model_id_for_payplan || '',
oa_custom_model_id: normalizeNullableId(data.oa_custom_model_id),
oa_custom_model_id_for_payplan: normalizeNullableId(data.oa_custom_model_id_for_payplan),
contract_field_mapping: data.contract_field_mapping?.length ? data.contract_field_mapping : getEmptyForm().contract_field_mapping,
payplan_field_mapping: data.payplan_field_mapping?.length ? data.payplan_field_mapping : getEmptyForm().payplan_field_mapping
}
@ -176,13 +513,13 @@ async function loadData() {
async function loadOptions() {
try {
const modelsRes = await contractSettingsAPI.getOaModels()
oaModels.value = modelsRes?.data || modelsRes || []
oaModels.value = normalizeModelOptions(modelsRes?.data || modelsRes || [])
} catch (e) {
oaModels.value = []
}
try {
const subRes = await contractSettingsAPI.getOaSubModels()
oaSubModels.value = subRes?.data || subRes || []
oaSubModels.value = normalizeModelOptions(subRes?.data || subRes || [])
} catch (e) {
oaSubModels.value = []
}
@ -285,5 +622,29 @@ watch(() => [form.value.oa_custom_model_id, form.value.oa_custom_model_id_for_pa
.w-200 {
width: 200px;
}
.w-120 {
width: 120px;
}
.mb-16 {
margin-bottom: 16px;
}
.simulation-flow-picker {
display: flex;
align-items: center;
gap: 12px;
}
.simulation-flow-select {
width: 520px;
}
.flow-option {
display: flex;
justify-content: space-between;
gap: 16px;
}
.flow-option small {
color: #909399;
}
.simulation-contract-form {
max-width: 1000px;
}
</style>

@ -2413,6 +2413,10 @@ const handleRelationCheckboxChange = (flowId, fieldName, rowId, checked) => {
relationCheckboxStates.value[key] = {}
}
relationCheckboxStates.value[key][rowId] = checked
if (!checked && relationSelectedRows.value[key]?.[rowId]) {
relationSelectedRows.value[key][rowId].quantity = 0
}
}
// relationcheckbox

Loading…
Cancel
Save