diff --git a/src/components/payment-print/ContractInfoCard.vue b/src/components/payment-print/ContractInfoCard.vue index d1e1288..f7fa5f9 100644 --- a/src/components/payment-print/ContractInfoCard.vue +++ b/src/components/payment-print/ContractInfoCard.vue @@ -363,6 +363,7 @@ watch( .muted { color: #909399; } + .related-expenditure-block { margin-top: 12px; } @@ -371,4 +372,3 @@ watch( margin-bottom: 20px; } - diff --git a/src/utils/api.js b/src/utils/api.js index ec9e086..70e3df3 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -670,6 +670,11 @@ export const paymentAPI = { getDetail: (id) => { return request.get(`/budget/payments/${id}`) }, + + // 获取支付审计记录 + getAudits: (id, params) => { + return request.get(`/budget/payments/${id}/audits`, params) + }, // 创建支付流程 create: (data) => { diff --git a/src/views/payment/ContractManagement.vue b/src/views/payment/ContractManagement.vue index 27b4bbf..287a265 100644 --- a/src/views/payment/ContractManagement.vue +++ b/src/views/payment/ContractManagement.vue @@ -72,6 +72,19 @@ + + + @@ -208,11 +221,11 @@ - - - - - + + + + + @@ -579,6 +592,43 @@ - + + 关联非直接支付 + + + + + + + + + + + + + + + + + + + 付款计划 @@ -1271,7 +1321,18 @@ const importColumnGuide = ref([ ]) const rules = { - contract_no: [{ required: true, message: '请填写合同编号', trigger: 'blur' }], + contract_no_without_prefix: [ + { + validator: (rule, value, callback) => { + if (!value || !String(value).trim()) { + callback(new Error('请填写合同编号')) + } else { + callback() + } + }, + trigger: ['blur', 'change'] + } + ], title: [{ required: true, message: '请填写合同名称', trigger: 'blur' }], main_content: [{ required: true, message: '请填写合同主要内容', trigger: 'blur' }], party_a: [{ required: true, message: '请填写甲方', trigger: 'blur' }], @@ -2423,6 +2484,36 @@ function formatAmount(amount) { }) } +function getRelatedIndirectPayments(contract) { + return Array.isArray(contract?.related_indirect_payments) + ? contract.related_indirect_payments + : [] +} + +function formatDateTime(value) { + if (!value) return '-' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return String(value) + const pad = (n) => String(n).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` +} + +function getPaymentStatusTagType(status) { + if (status === 'completed') return 'success' + if (status === 'rejected') return 'danger' + if (status === 'processing') return 'warning' + return 'info' +} + +function openRelatedPayment(payment) { + if (!payment?.id) { + ElMessage.warning('缺少支付记录ID') + return + } + const base = `${window.location.origin}${window.location.pathname}` + window.open(`${base}#/payment/payment-detail-print/${payment.id}`, '_blank') +} + // 根据用户ID获取用户名称 function getUserName(userId) { if (!userId) return '-' @@ -3891,6 +3982,12 @@ onMounted(() => { margin-top: 4px; } +.related-payment-more { + margin-left: 6px; + color: #909399; + font-size: 12px; +} + :deep(.error-row) { background-color: #fef0f0; } @@ -3929,4 +4026,3 @@ onMounted(() => { color: #606266; } - diff --git a/src/views/payment/CreatePayment.vue b/src/views/payment/CreatePayment.vue index 748e477..0fee76e 100644 --- a/src/views/payment/CreatePayment.vue +++ b/src/views/payment/CreatePayment.vue @@ -260,7 +260,7 @@
@@ -900,6 +900,19 @@ const submitSuccessPayment = ref(null) // 成功后返回的支付单信息( const selectedContract = ref(null) const showContractSelector = ref(false) +const getContractRequirement = (category) => { + if (!category) return 'none' + return category.contract_requirement || (category.need_contract ? 'required' : 'none') +} + +const allowsContractSelection = (category) => { + return ['optional', 'required'].includes(getContractRequirement(category)) +} + +const requiresContractSelection = (category) => { + return getContractRequirement(category) === 'required' +} + // 格式化金额 const formatAmount = (amount) => { if (amount === null || amount === undefined) return '-' @@ -923,7 +936,7 @@ const checkFieldDisplayConditionsWithContract = (element, contract = null) => { // 如果当前支付分类开启了 need_contract,则取合同带回的 amount_total // 如果没开启,取表单中的 amount 字段的值 let contractTotalAmount - if (selectedCategory.value?.need_contract && contract) { + if (allowsContractSelection(selectedCategory.value) && contract) { contractTotalAmount = contract.amount_total || 0 } else { contractTotalAmount = currentPaymentAmount @@ -984,7 +997,7 @@ const checkFieldDisplayConditionsWithContract = (element, contract = null) => { // 检测字段显示状态是否受合同总金额影响 const checkFieldDisplayChangeByContract = (newContract) => { - if (!selectedCategory.value?.need_contract || !templateElements.value || templateElements.value.length === 0) { + if (!allowsContractSelection(selectedCategory.value) || !templateElements.value || templateElements.value.length === 0) { return { hasChange: false, changedFields: [] } } @@ -1269,7 +1282,7 @@ const checkFieldDisplayConditions = (element) => { display_conditions: element?.display_conditions, formDataAmount: formData.amount, selectedCategory: selectedCategory.value, - need_contract: selectedCategory.value?.need_contract, + contract_requirement: getContractRequirement(selectedCategory.value), selectedContract: selectedContract.value, contractAmountTotal: selectedContract.value?.amount_total }) @@ -1287,7 +1300,7 @@ const checkFieldDisplayConditions = (element) => { // 如果当前支付分类开启了 need_contract,则取合同带回的 amount_total // 如果没开启,取表单中的 amount 字段的值 let contractTotalAmount - if (selectedCategory.value?.need_contract && selectedContract.value) { + if (allowsContractSelection(selectedCategory.value) && selectedContract.value) { contractTotalAmount = selectedContract.value.amount_total || 0 console.log('[checkFieldDisplayConditions] 使用合同总金额:', contractTotalAmount) } else { @@ -2300,12 +2313,12 @@ const handleNextStep = async () => { return } - // 验证合同选择(直接支付且分类需要关联合同) - if (isDirectPayment.value && selectedCategory.value?.need_contract) { - if (!selectedContract.value) { - ElMessage.warning('请选择关联合同') - return - } + // 验证合同选择(直接支付且分类要求必须关联合同) + if (isDirectPayment.value && requiresContractSelection(selectedCategory.value) && !selectedContract.value) { + ElMessage.warning('请选择关联合同') + return + } + if (isDirectPayment.value && selectedContract.value) { // 再次验证合同类型(防止用户通过其他方式绕过) if (selectedContract.value.amount_type === 'open') { ElMessageBox.alert( @@ -2662,13 +2675,13 @@ const autoCreatePayment = async () => { return } - // 验证合同选择(直接支付且分类需要关联合同) - if (isDirectPayment.value && selectedCategory.value?.need_contract) { - if (!selectedContract.value) { - ElMessage.warning('已创建OA流程,但缺少关联合同,请选择合同后手动提交') - autoSubmitFailed.value = true - return - } + // 验证合同选择(直接支付且分类要求必须关联合同) + if (isDirectPayment.value && requiresContractSelection(selectedCategory.value) && !selectedContract.value) { + ElMessage.warning('已创建OA流程,但缺少关联合同,请选择合同后手动提交') + autoSubmitFailed.value = true + return + } + if (isDirectPayment.value && selectedContract.value) { if (selectedContract.value.amount_type === 'open') { ElMessage.warning('已创建OA流程,但合同类型不符合要求,请手动提交') autoSubmitFailed.value = true @@ -2873,12 +2886,12 @@ const handleSubmit = async () => { return } - // 验证合同选择(直接支付且分类需要关联合同) - if (isDirectPayment.value && selectedCategory.value?.need_contract) { - if (!selectedContract.value) { - ElMessage.warning('请选择关联合同') - return - } + // 验证合同选择(直接支付且分类要求必须关联合同) + if (isDirectPayment.value && requiresContractSelection(selectedCategory.value) && !selectedContract.value) { + ElMessage.warning('请选择关联合同') + return + } + if (isDirectPayment.value && selectedContract.value) { // 再次验证合同类型 if (selectedContract.value.amount_type === 'open') { ElMessageBox.alert( @@ -3627,4 +3640,3 @@ onUnmounted(() => { justify-content: flex-end; } - diff --git a/src/views/payment/IndirectPayment.vue b/src/views/payment/IndirectPayment.vue index b703d3f..1b8a4b4 100644 --- a/src/views/payment/IndirectPayment.vue +++ b/src/views/payment/IndirectPayment.vue @@ -594,9 +594,9 @@ - + @@ -886,9 +886,9 @@
- + @@ -1979,6 +1979,19 @@ const loadingCategoryTree = ref(false) const categoryTreeData = ref(null) const selectedCategory = ref(null) +const getContractRequirement = (category) => { + if (!category) return 'none' + return category.contract_requirement || (category.need_contract ? 'required' : 'none') +} + +const allowsContractSelection = (category) => { + return ['optional', 'required'].includes(getContractRequirement(category)) +} + +const requiresContractSelection = (category) => { + return getContractRequirement(category) === 'required' +} + // 合同选择相关(第三步,当 need_contract 为 true 时) const selectedExpenditureContract = ref(null) const showExpenditureContractSelector = ref(false) @@ -4392,20 +4405,20 @@ const nextStep = async () => { return } - // 验证合同选择(如果所选的事前流程分支的need_contract为true) - if (selectedCategory.value?.need_contract) { - if (!selectedExpenditureContract.value) { - ElMessage.warning('请选择关联合同') - return - } - + // 验证合同选择(如果所选的事前流程分支要求必须关联合同) + if (requiresContractSelection(selectedCategory.value) && !selectedExpenditureContract.value) { + ElMessage.warning('请选择关联合同') + return + } + + if (selectedExpenditureContract.value) { const paymentCount = selectedExpenditureContract.value.payment_stats?.payment_count || 0 const paidAmount = Number(selectedExpenditureContract.value.payment_stats?.paid_amount || 0) // 如果有已支付记录,说明已经关联过非直接支付,不允许继续 - if (paidAmount > 0 || paymentCount > 0) { + if (shouldBlockAlreadyLinkedContract() && (paidAmount > 0 || paymentCount > 0)) { await ElMessageBox.alert( - '该合同已关联过非直接支付,不允许再次关联。请选择其他合同。', + buildContractLinkedIndirectPaymentMessage(selectedExpenditureContract.value), '提示', { confirmButtonText: '确定', @@ -4460,20 +4473,20 @@ const nextStep = async () => { return } - // 验证合同选择(如果所选的事前流程分支的need_contract为true) - if (selectedCategory.value?.need_contract) { - if (!selectedExpenditureContract.value) { - ElMessage.warning('请选择关联合同') - return - } - + // 验证合同选择(如果所选的事前流程分支要求必须关联合同) + if (requiresContractSelection(selectedCategory.value) && !selectedExpenditureContract.value) { + ElMessage.warning('请选择关联合同') + return + } + + if (selectedExpenditureContract.value) { const paymentCount = selectedExpenditureContract.value.payment_stats?.payment_count || 0 const paidAmount = Number(selectedExpenditureContract.value.payment_stats?.paid_amount || 0) // 如果有已支付记录,说明已经关联过非直接支付,不允许继续 - if (paidAmount > 0 || paymentCount > 0) { + if (shouldBlockAlreadyLinkedContract() && (paidAmount > 0 || paymentCount > 0)) { await ElMessageBox.alert( - '该合同已关联过非直接支付,不允许再次关联。请选择其他合同。', + buildContractLinkedIndirectPaymentMessage(selectedExpenditureContract.value), '提示', { confirmButtonText: '确定', @@ -4519,7 +4532,7 @@ const nextStep = async () => { currentStep.value++ // 如果支付分类需要关联合同,检查合同是否已关联过非直接支付 - if (selectedPaymentCategory.value?.need_contract) { + if (allowsContractSelection(selectedPaymentCategory.value)) { // 获取实际使用的合同(如果复用第三步的合同,使用第三步的合同;否则使用第五步选择的合同) const actualContract = isPaymentContractReused.value ? reusedPaymentContract.value : selectedPaymentContract.value @@ -4530,9 +4543,9 @@ const nextStep = async () => { const paidAmount = Number(actualContract.payment_stats?.paid_amount || 0) // 如果有已支付记录,说明已经关联过非直接支付,不允许继续 - if (paidAmount > 0 || paymentCount > 0) { + if (shouldBlockAlreadyLinkedContract() && (paidAmount > 0 || paymentCount > 0)) { await ElMessageBox.alert( - '该合同已关联过非直接支付,不允许再次关联。请选择其他合同。', + buildContractLinkedIndirectPaymentMessage(actualContract), '提示', { confirmButtonText: '确定', @@ -4568,21 +4581,23 @@ const nextStep = async () => { } // 验证合同选择(支付分类需要关联合同时) - if (selectedPaymentCategory.value?.need_contract) { + if (requiresContractSelection(selectedPaymentCategory.value) && !(isPaymentContractReused.value ? reusedPaymentContract.value : selectedPaymentContract.value)) { + ElMessage.warning('请选择关联合同') + return + } + if (allowsContractSelection(selectedPaymentCategory.value)) { // 获取实际使用的合同(如果复用第三步的合同,使用第三步的合同;否则使用第六步选择的合同) const actualContract = isPaymentContractReused.value ? reusedPaymentContract.value : selectedPaymentContract.value - if (!actualContract) { - ElMessage.warning('请选择关联合同') return } const paymentCount = actualContract.payment_stats?.payment_count || 0 const paidAmount = Number(actualContract.payment_stats?.paid_amount || 0) // 如果有已支付记录,说明已经关联过非直接支付,不允许继续 - if (paidAmount > 0 || paymentCount > 0) { + if (shouldBlockAlreadyLinkedContract() && (paidAmount > 0 || paymentCount > 0)) { await ElMessageBox.alert( - '该合同已关联过非直接支付,不允许再次关联。请选择其他合同。', + buildContractLinkedIndirectPaymentMessage(actualContract), '提示', { confirmButtonText: '确定', @@ -4672,10 +4687,8 @@ const canNextStep = computed(() => { } // 验证合同选择(如果所选的事前流程分支的need_contract为true) - if (selectedCategory.value?.need_contract) { - if (!selectedExpenditureContract.value) { - return false - } + if (requiresContractSelection(selectedCategory.value) && !selectedExpenditureContract.value) { + return false } return true } @@ -4693,7 +4706,7 @@ const canNextStep = computed(() => { return false } // 验证合同选择(支付分类需要关联合同时) - if (selectedPaymentCategory.value?.need_contract) { + if (requiresContractSelection(selectedPaymentCategory.value)) { // 如果复用第三步的合同,使用第三步的合同;否则使用第五步选择的合同 const actualContract = isPaymentContractReused.value ? reusedPaymentContract.value : selectedPaymentContract.value if (!actualContract) { @@ -5369,8 +5382,8 @@ const loadPaymentTemplateElements = async () => { if (response.code === 0) { paymentTemplateElements.value = response.data || [] - // 如果第三步已选择合同,且第六步也需要合同,则自动复用第三步的合同 - if (selectedCategory.value?.need_contract && selectedExpenditureContract.value && selectedPaymentCategory.value?.need_contract) { + // 如果第三步已选择合同,且第六步也允许合同,则自动复用第三步的合同 + if (allowsContractSelection(selectedCategory.value) && selectedExpenditureContract.value && allowsContractSelection(selectedPaymentCategory.value)) { selectedPaymentContract.value = selectedExpenditureContract.value } @@ -5435,33 +5448,36 @@ const checkAndAutoProceedFromStep5 = async () => { return // 金额验证不通过,不自动进入下一步 } - // 验证合同选择(支付分类需要关联合同时) - if (selectedPaymentCategory.value?.need_contract) { + // 验证合同选择(支付分类允许关联合同时) + if (allowsContractSelection(selectedPaymentCategory.value)) { // 获取实际使用的合同(如果复用第三步的合同,使用第三步的合同;否则使用第五步选择的合同) const actualContract = isPaymentContractReused.value ? reusedPaymentContract.value : selectedPaymentContract.value if (!actualContract) { + if (requiresContractSelection(selectedPaymentCategory.value)) { + console.log('checkAndAutoProceedFromStep5: 合同未选择,跳过') + return + } console.log('checkAndAutoProceedFromStep5: 合同未选择,跳过') - return // 合同未选择,不自动进入下一步 - } - - const paymentCount = actualContract.payment_stats?.payment_count || 0 - const paidAmount = Number(actualContract.payment_stats?.paid_amount || 0) - - // 如果有已支付记录,说明已经关联过非直接支付,不允许继续 - if (paidAmount > 0 || paymentCount > 0) { - console.log('checkAndAutoProceedFromStep5: 合同已关联过,跳过') - return // 合同已关联过,不自动进入下一步 - } - - // 验证本次支付金额不能超过剩余金额 - const remainingAmount = isPaymentContractReused.value - ? reusedPaymentContractRemainingAmount.value - : paymentContractRemainingAmount.value - const paymentAmount = Number(paymentFormData.amount) || 0 - if (paymentAmount > remainingAmount) { - console.log('checkAndAutoProceedFromStep5: 金额超过剩余金额,跳过', paymentAmount, remainingAmount) - return // 金额超过剩余金额,不自动进入下一步 + } else { + const paymentCount = actualContract.payment_stats?.payment_count || 0 + const paidAmount = Number(actualContract.payment_stats?.paid_amount || 0) + + // 如果有已支付记录,说明已经关联过非直接支付,不允许继续 + if (shouldBlockAlreadyLinkedContract() && (paidAmount > 0 || paymentCount > 0)) { + console.log('checkAndAutoProceedFromStep5: 合同已关联过,跳过') + return // 合同已关联过,不自动进入下一步 + } + + // 验证本次支付金额不能超过剩余金额 + const remainingAmount = isPaymentContractReused.value + ? reusedPaymentContractRemainingAmount.value + : paymentContractRemainingAmount.value + const paymentAmount = Number(paymentFormData.amount) || 0 + if (paymentAmount > remainingAmount) { + console.log('checkAndAutoProceedFromStep5: 金额超过剩余金额,跳过', paymentAmount, remainingAmount) + return // 金额超过剩余金额,不自动进入下一步 + } } } @@ -6036,15 +6052,17 @@ const autoFinalSubmit = async () => { return } - // 验证合同选择(支付分类需要关联合同时) - if (selectedPaymentCategory.value?.need_contract) { + // 验证合同选择(支付分类允许关联合同时) + if (requiresContractSelection(selectedPaymentCategory.value) && !(isPaymentContractReused.value ? reusedPaymentContract.value : selectedPaymentContract.value)) { + ElMessage.warning('已创建OA流程,但缺少关联合同,请选择合同后手动提交') + autoSubmitFailed.value = true + return + } + if (allowsContractSelection(selectedPaymentCategory.value)) { const actualContract = isPaymentContractReused.value ? reusedPaymentContract.value : selectedPaymentContract.value if (!actualContract) { - ElMessage.warning('已创建OA流程,但缺少关联合同,请选择合同后手动提交') - autoSubmitFailed.value = true - return - } - if (actualContract.amount_type === 'fixed') { + // 可选场景下允许不选 + } else if (actualContract.amount_type === 'fixed') { const remainingAmount = isPaymentContractReused.value ? reusedPaymentContractRemainingAmount.value : paymentContractRemainingAmount.value @@ -6065,12 +6083,12 @@ const autoFinalSubmit = async () => { const amountNumber = Number(paymentFormData.amount) // 获取第三步选择的合同ID(如果need_contract为true) - const expenditureContractId = selectedCategory.value?.need_contract && selectedExpenditureContract.value + const expenditureContractId = allowsContractSelection(selectedCategory.value) && selectedExpenditureContract.value ? selectedExpenditureContract.value.id : null // 获取支付明细的合同ID(如果need_contract为true) - const paymentDetailContractId = selectedPaymentCategory.value?.need_contract + const paymentDetailContractId = allowsContractSelection(selectedPaymentCategory.value) ? (isPaymentContractReused.value ? reusedPaymentContract.value?.id : selectedPaymentContract.value?.id) @@ -6198,24 +6216,27 @@ const handleFinalSubmit = async () => { return } - // 验证合同选择(支付分类需要关联合同时) - if (selectedPaymentCategory.value?.need_contract) { + // 验证合同选择(支付分类允许关联合同时) + if (requiresContractSelection(selectedPaymentCategory.value) && !(isPaymentContractReused.value ? reusedPaymentContract.value : selectedPaymentContract.value)) { + ElMessage.warning('请选择关联合同') + return + } + if (allowsContractSelection(selectedPaymentCategory.value)) { // 获取实际使用的合同(如果复用第三步的合同,使用第三步的合同;否则使用第六步选择的合同) const actualContract = isPaymentContractReused.value ? reusedPaymentContract.value : selectedPaymentContract.value - if (!actualContract) { - ElMessage.warning('请选择关联合同') - return - } + // 可选场景下允许不选 + } else { // 闭口合同:验证本次支付金额不能超过剩余金额 - if (actualContract.amount_type === 'fixed') { - const remainingAmount = isPaymentContractReused.value - ? reusedPaymentContractRemainingAmount.value - : paymentContractRemainingAmount.value - const paymentAmount = Number(paymentFormData.amount) || 0 - if (paymentAmount > remainingAmount) { - ElMessage.warning(`本次支付金额不能超过合同剩余金额 ${formatAmount(remainingAmount)}`) - return + if (actualContract.amount_type === 'fixed') { + const remainingAmount = isPaymentContractReused.value + ? reusedPaymentContractRemainingAmount.value + : paymentContractRemainingAmount.value + const paymentAmount = Number(paymentFormData.amount) || 0 + if (paymentAmount > remainingAmount) { + ElMessage.warning(`本次支付金额不能超过合同剩余金额 ${formatAmount(remainingAmount)}`) + return + } } } } @@ -6243,12 +6264,12 @@ const handleFinalSubmit = async () => { const amountNumber = Number(paymentFormData.amount) // 获取第三步选择的合同ID(如果need_contract为true) - const expenditureContractId = selectedCategory.value?.need_contract && selectedExpenditureContract.value + const expenditureContractId = allowsContractSelection(selectedCategory.value) && selectedExpenditureContract.value ? selectedExpenditureContract.value.id : null // 获取支付明细的合同ID(如果need_contract为true) - const paymentDetailContractId = selectedPaymentCategory.value?.need_contract + const paymentDetailContractId = allowsContractSelection(selectedPaymentCategory.value) ? (isPaymentContractReused.value ? reusedPaymentContract.value?.id : selectedPaymentContract.value?.id) @@ -6445,7 +6466,7 @@ const getBreadcrumbPath = () => { // 获取预览时应该显示的合同信息 const getPreviewContract = () => { // 优先显示第六步的合同(如果第六步需要合同且已选择) - if (selectedPaymentCategory.value?.need_contract) { + if (allowsContractSelection(selectedPaymentCategory.value)) { // 如果复用第三步的合同,使用第三步的合同 if (isPaymentContractReused.value && reusedPaymentContract.value) { return reusedPaymentContract.value @@ -6456,7 +6477,7 @@ const getPreviewContract = () => { } } // 如果第六步没有合同,但第三步有合同,显示第三步的合同 - if (selectedCategory.value?.need_contract && selectedExpenditureContract.value) { + if (allowsContractSelection(selectedCategory.value) && selectedExpenditureContract.value) { return selectedExpenditureContract.value } return null @@ -6499,6 +6520,27 @@ const formatAmount = (amount) => { return '¥' + numAmount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } +const buildContractLinkedIndirectPaymentMessage = (contract) => { + const payments = Array.isArray(contract?.related_indirect_payments) + ? contract.related_indirect_payments + : [] + if (payments.length === 0) { + return '该合同已关联过非直接支付,不允许再次关联。请选择其他合同。' + } + + const names = payments + .slice(0, 3) + .map((payment) => { + const name = payment.name || payment.oa_flow_title || payment.planned_expenditure_title || payment.serial_number || `非直接支付#${payment.id}` + const serial = payment.serial_number ? `(${payment.serial_number})` : '' + return `${name}${serial}` + }) + const more = payments.length > names.length ? `等 ${payments.length} 条` : '' + return `该合同已关联过非直接支付:${names.join('、')}${more},不允许再次关联。请选择其他合同。` +} + +const shouldBlockAlreadyLinkedContract = () => false + // 金额转中文大写(与直接支付页面一致) const formatUppercaseAmount = (amount) => { const n = Number(amount) @@ -6515,9 +6557,9 @@ const handleExpenditureContractSelected = async (contract) => { // 统一按闭口合同逻辑处理:检查是否已关联过非直接支付 const paymentCount = contract.payment_stats?.payment_count || 0 const paidAmount = Number(contract.payment_stats?.paid_amount || 0) - if (paidAmount > 0 || paymentCount > 0) { + if (shouldBlockAlreadyLinkedContract() && (paidAmount > 0 || paymentCount > 0)) { await ElMessageBox.alert( - '该合同已关联过非直接支付,不允许再次关联。请选择其他合同。', + buildContractLinkedIndirectPaymentMessage(contract), '提示', { confirmButtonText: '确定', @@ -6574,9 +6616,9 @@ const handlePaymentContractSelected = async (contract) => { // 统一按闭口合同逻辑处理:检查是否已关联过非直接支付 const paymentCount = contract.payment_stats?.payment_count || 0 const paidAmount = Number(contract.payment_stats?.paid_amount || 0) - if (paidAmount > 0 || paymentCount > 0) { + if (shouldBlockAlreadyLinkedContract() && (paidAmount > 0 || paymentCount > 0)) { await ElMessageBox.alert( - '该合同已关联过非直接支付,不允许再次关联。请选择其他合同。', + buildContractLinkedIndirectPaymentMessage(contract), '提示', { confirmButtonText: '确定', @@ -6607,9 +6649,9 @@ const paymentContractRemainingAmount = computed(() => { // 判断是否复用第三步的合同 const isPaymentContractReused = computed(() => { - return selectedCategory.value?.need_contract && + return allowsContractSelection(selectedCategory.value) && selectedExpenditureContract.value && - selectedPaymentCategory.value?.need_contract + allowsContractSelection(selectedPaymentCategory.value) }) // 获取复用的合同(第三步的合同) diff --git a/src/views/payment/PaymentDetailPrint.vue b/src/views/payment/PaymentDetailPrint.vue index 95e5323..67f3d17 100644 --- a/src/views/payment/PaymentDetailPrint.vue +++ b/src/views/payment/PaymentDetailPrint.vue @@ -1,8 +1,82 @@ @@ -309,7 +574,9 @@ import { ref, onMounted, computed, nextTick, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ElMessage } from 'element-plus' import { Printer } from '@element-plus/icons-vue' -import { paymentAPI, paymentCategoryAPI, plannedExpenditureAPI, plannedExpenditureTemplateAPI, detailTableFieldAPI, departmentAPI, userAPI, oaFlowAPI } from '@/utils/api' +import { useUserStore } from '@/store/user' +import config from '@/config' +import { paymentAPI, paymentCategoryAPI, plannedExpenditureAPI, plannedExpenditureTemplateAPI, detailTableFieldAPI, departmentAPI, userAPI, oaFlowAPI, templateElementAPI } from '@/utils/api' import { getToken } from '@/utils/auth' import MeetingMinutesField from '@/components/MeetingMinutesField.vue' import ContractInfoCard from '@/components/payment-print/ContractInfoCard.vue' @@ -348,6 +615,7 @@ console.log('[PDF.js] CMap URL (本地,中文支持):', cMapUrl) const route = useRoute() const router = useRouter() +const userStore = useUserStore() const loading = ref(false) const payment = ref(null) @@ -370,11 +638,20 @@ const flowIframeUrl = ref('') const flowIframeLoading = ref(false) const flowIframeRef = ref(null) let flowIframeLoadTimeout = null +const editDialogVisible = ref(false) +const editContext = ref(null) +const editDraft = ref({}) +const savingElementEdit = ref(false) +const auditLoading = ref(false) +const auditRecords = ref([]) +const relatedChecklistOptionsMap = ref({}) // 收集所有相关流程ID(用于打印模版渲染) const collectedFlowIds = ref(new Set()) const renderedPrintTemplates = ref([]) // 存储渲染后的打印模版HTML const REPEATED_MEETING_MINUTES_MESSAGE = '会议纪要再次使用,仅展示标题,不重复打印' +const printInProgress = ref(false) +const renderingPrintTemplates = ref(false) let meetingMinutesUsageCache = {} let meetingMinutesSeenMap = new Map() @@ -477,8 +754,10 @@ const resolveMeetingMinutesUsage = (occurrenceKey, rawVal) => { // 收集流程ID(去重) const collectFlowId = (flowId) => { - if (flowId && typeof flowId === 'number') { - collectedFlowIds.value.add(flowId) + if (flowId === null || flowId === undefined || flowId === '') return + const normalized = Number(flowId) + if (Number.isFinite(normalized) && normalized > 0) { + collectedFlowIds.value.add(normalized) } } @@ -505,6 +784,613 @@ const relatedTypeCase = computed(() => { } }) +const hasGlobalFlowSupervisionRole = computed(() => { + return Array.isArray(userStore.roles) && userStore.roles.includes('全局流程监管') +}) + +const editableBaseFieldConfig = { + payment_date: { label: '支付日期', editorType: 'date' }, + payment_method: { label: '支付方式', editorType: 'payment_method' }, + voucher_number: { label: '凭证号', editorType: 'single_line_text' }, + voucher_date: { label: '凭证日期', editorType: 'date' }, + description: { label: '说明', editorType: 'multi_line_text' }, + remarks: { label: '备注', editorType: 'multi_line_text' }, + contract_id: { label: '关联合同ID', editorType: 'contract_id' } +} + +const editDialogTitle = computed(() => { + if (!editContext.value) return '编辑' + return `编辑:${editContext.value.label}` +}) + +const paymentBasicChildren = computed(() => { + const p = payment.value + if (!p) return [] + const children = [] + Object.entries(editableBaseFieldConfig).forEach(([key, fieldConfig]) => { + const raw = p[key] + children.push({ + key, + label: fieldConfig.label, + source: 'payment_base', + action: 'edit', + editorType: fieldConfig.editorType, + value: raw + }) + }) + return children +}) + +const paymentTemplateChildren = computed(() => { + const elements = paymentTemplateElements.value || [] + return elements.map(el => ({ + key: String(el.id), + label: el.name || `元素 ${el.id}`, + source: 'payment_template', + element: el, + action: el.type === 'approval_flow' ? 'redirect_oa' : 'edit', + editorType: resolveTemplateEditorType(el), + value: payment.value?.fields?.[el.id] + })) +}) + +const relatedDataChildren = computed(() => { + const p = payment.value + if (!p) return [] + const children = [] + if (relatedTypeCase.value === 'planned_expenditure' && p.related_id) { + children.push({ key: 'related_planned_expenditure', label: `关联事前审批 #${p.related_id}`, action: 'none' }) + const relatedEditableItems = getRelatedPreApprovalEditableItems() + relatedEditableItems.forEach(item => { + children.push(item) + }) + } + if (relatedTypeCase.value === 'contract' && p.related_id) { + children.push({ key: 'related_contract', label: `关联合同 #${p.related_id}`, action: 'none' }) + } + if (p.contract_id) { + children.push({ key: 'payment_contract', label: `付款关联合同 #${p.contract_id}`, action: 'none' }) + } + return children +}) + +const relatedFlowChildren = computed(() => { + const list = renderedPrintTemplates.value || [] + return list.map(item => ({ + key: String(item.flow_id || item.flow_info?.id || ''), + label: item.flow_info?.no + ? `${item.flow_info.no} - ${item.flow_info?.title || '-'}` + : `流程 #${item.flow_id}`, + action: 'none' + })) +}) + +const majorDataChainItems = computed(() => { + return [ + { + key: 'payment_basic', + label: '付款基本信息', + children: paymentBasicChildren.value + }, + { + key: 'payment_template', + label: '支付模板字段', + children: paymentTemplateChildren.value + }, + { + key: 'related_data', + label: '关联数据', + children: relatedDataChildren.value + }, + { + key: 'related_flows', + label: '相关流程', + children: relatedFlowChildren.value + } + ].filter(item => item.children.length > 0) +}) + +const totalElementCount = computed(() => { + return majorDataChainItems.value.reduce((sum, item) => sum + item.children.length, 0) +}) + +const paymentBaseFieldLabelMap = Object.entries(editableBaseFieldConfig).reduce((acc, [key, cfg]) => { + acc[key] = cfg.label + return acc +}, {}) + +const formatAuditTime = (value) => { + if (!value) return '-' + const d = new Date(value) + if (Number.isNaN(d.getTime())) return '-' + return d.toLocaleString('zh-CN') +} + +const safePreviewText = (value) => { + if (value === null || value === undefined || value === '') return '-' + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'boolean') return String(value) + try { + return JSON.stringify(value) + } catch (error) { + return String(value) + } +} + +const getAuditEventLabel = (record) => { + if (record?.display_title) return record.display_title + + const eventMap = { + payment_base_update: '基础字段调整', + payment_template_update: '模板字段调整', + payment_template_checklist_update: '清单调整', + payment_template_detail_table_update: '明细表调整' + } + const meta = record?.tag_meta || {} + if (meta.scope === 'payment_base' && meta.field_key) { + const fieldLabel = paymentBaseFieldLabelMap[meta.field_key] || meta.field_key + return `基础字段调整:${fieldLabel}` + } + if (meta.scope === 'payment_template' && meta.element_name) { + return `${eventMap[record?.event] || '模板字段调整'}:${meta.element_name}` + } + return eventMap[record?.event] || (record?.event || '变更') +} + +const getAuditOperator = (record) => { + const meta = record?.tag_meta || {} + const name = meta.operator_name || '' + if (name) return `操作人:${name}` + if (record?.user_id) return `操作人ID:${record.user_id}` + return '操作人:-' +} + +const getAuditSummaryText = (record) => { + if (record?.display_summary) return record.display_summary + + const event = record?.event + const oldValues = record?.old_values || {} + const newValues = record?.new_values || {} + + if (event === 'payment_base_update') { + return `${safePreviewText(oldValues.value)} -> ${safePreviewText(newValues.value)}` + } + + if (event === 'payment_template_checklist_update') { + const summary = newValues.change_summary || {} + const added = Array.isArray(summary.added_options) ? summary.added_options.length : 0 + const removed = Array.isArray(summary.removed_options) ? summary.removed_options.length : 0 + const remarkChanges = Array.isArray(summary.remark_changes) ? summary.remark_changes.length : 0 + return `勾选新增 ${added},移除 ${removed},备注变更 ${remarkChanges}` + } + + if (event === 'payment_template_detail_table_update') { + const summary = newValues.change_summary || {} + const added = summary.rows_added_total || 0 + const removed = summary.rows_removed_total || 0 + const updated = summary.rows_updated_total || 0 + return `新增行 ${added},删除行 ${removed},更新行 ${updated}` + } + + return `${safePreviewText(oldValues.value)} -> ${safePreviewText(newValues.value)}` +} + +const loadPaymentAudits = async () => { + if (!hasGlobalFlowSupervisionRole.value) { + auditRecords.value = [] + return + } + + if (!payment.value?.id) { + auditRecords.value = [] + return + } + + auditLoading.value = true + try { + const res = await paymentAPI.getAudits(payment.value.id, { page_size: 30 }) + if (res.code === 0) { + const rows = Array.isArray(res.data?.data) ? res.data.data : (Array.isArray(res.data) ? res.data : []) + auditRecords.value = rows + } else { + auditRecords.value = [] + } + } catch (error) { + console.error('加载审计记录失败:', error) + auditRecords.value = [] + } finally { + auditLoading.value = false + } +} + +const uploadAction = computed(() => `${config.api.baseURL}/upload-file`) +const uploadHeaders = computed(() => { + const token = getToken() + return token ? { Authorization: `Bearer ${token}` } : {} +}) + +const ensureUserRoleContext = async () => { + const token = getToken() + if (!token) return + if (Array.isArray(userStore.roles) && userStore.roles.length > 0) return + try { + await userStore.getInfo() + } catch (error) { + console.warn('[PaymentDetailPrint] 获取用户角色信息失败:', error) + } +} + +const cloneDeep = (value) => { + if (value === null || value === undefined) return value + try { + return JSON.parse(JSON.stringify(value)) + } catch (error) { + return value + } +} + +const resolveTemplateEditorType = (element) => { + if (!element) return 'single_line_text' + if (element.type === 'approval_flow') return 'approval_flow' + if (element.type === 'meeting_minutes') return 'meeting_minutes' + if (element.type === 'checklist') return 'checklist' + if (element.type === 'detail_table') return 'detail_table' + if (element.type === 'form_element') { + if (element.field_type === 'multi_line_text') return 'multi_line_text' + if (element.field_type === 'attachment') return 'attachment' + return 'single_line_text' + } + return 'single_line_text' +} + +const normalizeFileList = (rawValue) => { + const arr = Array.isArray(rawValue) ? rawValue : (rawValue ? [rawValue] : []) + return arr.map((item, index) => { + if (!item) return null + if (typeof item === 'string') { + const url = buildFileUrl(item) + return { + uid: `file_${index}_${Date.now()}`, + name: item.split('/').pop() || `附件${index + 1}`, + status: 'success', + url, + response: { url, original_name: item.split('/').pop() || `附件${index + 1}` } + } + } + const url = buildFileUrl(item.url || item.path || item.file_url || '') + return { + uid: item.uid || item.id || `file_${index}_${Date.now()}`, + name: item.name || item.original_name || item.file_name || `附件${index + 1}`, + status: 'success', + url: url || item.url || '', + response: item.response || item + } + }).filter(Boolean) +} + +const buildEditContext = (item) => { + if (!item) return null + if (item.source === 'payment_base') { + const cfg = editableBaseFieldConfig[item.key] + if (!cfg) return null + return { + source: 'payment_base', + key: item.key, + label: cfg.label, + editorType: cfg.editorType + } + } + if (item.source === 'payment_template' && item.element) { + return { + source: 'payment_template', + key: String(item.element.id), + label: item.element.name || `元素 ${item.element.id}`, + editorType: resolveTemplateEditorType(item.element), + element: item.element, + options: Array.isArray(item.element.options) ? item.element.options : [], + detailFields: getDetailTableFields(item.element.id) + } + } + if (item.source === 'related_pre_approval' && item.field) { + return { + source: 'related_pre_approval', + key: item.key, + label: item.label, + editorType: item.editorType, + elementId: item.elementId, + fieldKey: item.fieldKey, + field: item.field, + options: Array.isArray(item.options) ? item.options : [], + detailFields: getDetailTableFields(item.elementId) + } + } + return null +} + +const openElementEditor = (item) => { + const context = buildEditContext(item) + if (!context) { + ElMessage.warning('当前元素不支持编辑') + return + } + editContext.value = context + editDraft.value = {} + + if (context.source === 'payment_base') { + editDraft.value = { value: cloneDeep(payment.value?.[context.key] ?? null) } + } else { + const raw = context.source === 'related_pre_approval' + ? getRelatedPreApprovalFieldValue(context.fieldKey, context.elementId) + : payment.value?.fields?.[context.key] + if (context.editorType === 'checklist') { + const selected = Array.isArray(raw) ? raw.map(v => String(v)) : [] + const remarks = {} + ;(context.options || []).forEach(opt => { + const remarkKey = context.source === 'related_pre_approval' + ? `${context.fieldKey}_remark_${opt.value}` + : `${context.key}_remark_${opt.value}` + const remarkVal = context.source === 'related_pre_approval' + ? getRelatedPreApprovalFieldValue(remarkKey) + : payment.value?.fields?.[remarkKey] + remarks[String(opt.value)] = remarkVal ? String(remarkVal) : '' + }) + editDraft.value = { selected, remarks } + } else if (context.editorType === 'detail_table') { + const rows = Array.isArray(raw) ? cloneDeep(raw) : [] + rows.forEach((row, index) => { + if (row && typeof row === 'object' && !row._row_key) { + row._row_key = `row_${Date.now()}_${index}` + } + }) + editDraft.value = { rows } + } else if (context.editorType === 'attachment') { + editDraft.value = { files: normalizeFileList(raw) } + } else { + editDraft.value = { value: cloneDeep(raw) } + } + } + + editDialogVisible.value = true +} + +const extractFlowIdFromValue = (value) => { + if (value === null || value === undefined || value === '') return null + + if (typeof value === 'number') { + return Number.isFinite(value) && value > 0 ? value : null + } + + if (typeof value === 'string') { + const s = value.trim() + if (!s) return null + const n = Number(s) + if (Number.isFinite(n) && n > 0) return n + try { + const parsed = JSON.parse(s) + return extractFlowIdFromValue(parsed) + } catch { + return null + } + } + + if (typeof value === 'object') { + const candidates = [ + value.flow_id, + value.flowId, + value.id, + value.value + ] + for (const candidate of candidates) { + const id = extractFlowIdFromValue(candidate) + if (id) return id + } + } + + return null +} + +const redirectApprovalFlowToOa = (item) => { + const flowId = + extractFlowIdFromValue(item?.value) || + extractFlowIdFromValue(payment.value?.flow_info?.id) || + extractFlowIdFromValue(payment.value?.oa_flow_id) + if (!flowId) { + ElMessage.warning('流程实例不存在,无法跳转OA编辑') + return + } + const token = getToken() + const params = new URLSearchParams({ + flow_id: String(flowId) + }) + if (token) params.set('auth_token', token) + const fullUrl = `/oa/#/flow/edit?${params.toString()}` + window.open(fullUrl, '_blank') +} + +const isEditChecklistChecked = (optionValue) => { + const selected = Array.isArray(editDraft.value?.selected) ? editDraft.value.selected : [] + return selected.includes(String(optionValue)) +} + +const toggleEditChecklistOption = (optionValue, checked) => { + const value = String(optionValue) + const selected = new Set(Array.isArray(editDraft.value?.selected) ? editDraft.value.selected : []) + if (checked) { + selected.add(value) + } else { + selected.delete(value) + } + editDraft.value.selected = Array.from(selected) +} + +const addEditDetailTableRow = () => { + if (!editContext.value || editContext.value.editorType !== 'detail_table') return + const row = { _row_key: `row_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` } + ;(editContext.value.detailFields || []).forEach(field => { + row[field.field_key] = field.field_type === 'number' ? null : '' + }) + if (!Array.isArray(editDraft.value.rows)) editDraft.value.rows = [] + editDraft.value.rows.push(row) +} + +const getEditDetailTableRowKey = (row) => { + return row?._row_key || row?.id || JSON.stringify(row) +} + +const removeEditDetailTableRow = (index) => { + if (!Array.isArray(editDraft.value?.rows)) return + editDraft.value.rows.splice(index, 1) +} + +const beforeEditAttachmentUpload = (file) => { + const sizeOk = file.size / 1024 / 1024 < 10 + if (!sizeOk) { + ElMessage.error('单个文件不能超过 10MB') + } + return sizeOk +} + +const handleEditAttachmentUploadSuccess = (response, uploadFile) => { + if (!response || response.code !== 0 || !response.data) { + ElMessage.error(response?.msg || '上传失败') + return + } + const fileObj = { + uid: uploadFile.uid, + name: response.data.original_name || uploadFile.name, + status: 'success', + url: response.data.url || uploadFile.url || '', + response: response.data + } + const files = Array.isArray(editDraft.value.files) ? editDraft.value.files : [] + const idx = files.findIndex(f => f.uid === uploadFile.uid) + if (idx >= 0) { + files[idx] = fileObj + } else { + files.push(fileObj) + } + editDraft.value.files = files +} + +const handleEditAttachmentRemove = (file) => { + const files = Array.isArray(editDraft.value.files) ? editDraft.value.files : [] + editDraft.value.files = files.filter(f => f.uid !== file.uid) +} + +const handleEditAttachmentUploadError = (error) => { + ElMessage.error(`上传失败:${error?.message || '未知错误'}`) +} + +const serializeEditContextToPayload = () => { + if (!editContext.value || !payment.value?.id) return null + if (editContext.value.source === 'payment_base') { + return { + type: 'payment_base', + payload: { + [editContext.value.key]: editDraft.value?.value ?? null + } + } + } + + if (editContext.value.source === 'related_pre_approval') { + const relatedId = relatedPlannedExpenditure.value?.id + const baseMap = cloneDeep(relatedPlannedExpenditure.value?.element_values || {}) + if (!relatedId || !editContext.value.fieldKey) return null + + const fieldKey = editContext.value.fieldKey + + if (editContext.value.editorType === 'checklist') { + const selected = Array.isArray(editDraft.value?.selected) ? editDraft.value.selected : [] + baseMap[fieldKey] = selected + const options = editContext.value.options || [] + options.forEach(opt => { + const remarkKey = `${fieldKey}_remark_${opt.value}` + const remarkVal = editDraft.value?.remarks?.[String(opt.value)] + if (remarkVal && String(remarkVal).trim() !== '') { + baseMap[remarkKey] = String(remarkVal).trim() + } else { + delete baseMap[remarkKey] + } + }) + } else if (editContext.value.editorType === 'detail_table') { + const rows = Array.isArray(editDraft.value?.rows) ? editDraft.value.rows : [] + baseMap[fieldKey] = rows.map(row => cloneDeep(row) || {}) + } else { + return null + } + + return { + type: 'related_pre_approval', + payload: { + id: relatedId, + save_action: 'draft', + form_data: baseMap + } + } + } + + const fields = cloneDeep(payment.value?.fields || {}) + const elementId = editContext.value.key + + if (editContext.value.editorType === 'checklist') { + const selected = Array.isArray(editDraft.value?.selected) ? editDraft.value.selected : [] + fields[elementId] = selected + const options = editContext.value.options || [] + options.forEach(opt => { + const remarkKey = `${elementId}_remark_${opt.value}` + const remarkVal = editDraft.value?.remarks?.[String(opt.value)] + if (remarkVal && String(remarkVal).trim() !== '') { + fields[remarkKey] = String(remarkVal).trim() + } else { + delete fields[remarkKey] + } + }) + } else if (editContext.value.editorType === 'detail_table') { + const rows = Array.isArray(editDraft.value?.rows) ? editDraft.value.rows : [] + fields[elementId] = rows.map(row => { + const cleanRow = cloneDeep(row) || {} + return cleanRow + }) + } else if (editContext.value.editorType === 'attachment') { + const files = Array.isArray(editDraft.value?.files) ? editDraft.value.files : [] + fields[elementId] = files.map(file => file.response || { + name: file.name, + url: file.url + }) + } else { + fields[elementId] = editDraft.value?.value ?? null + } + + return { + type: 'payment_template', + payload: { fields } + } +} + +const saveElementEdit = async () => { + if (!editContext.value || !payment.value?.id) return + const serialized = serializeEditContextToPayload() + if (!serialized) return + savingElementEdit.value = true + try { + if (serialized.type === 'related_pre_approval') { + await plannedExpenditureAPI.update(serialized.payload.id, { + save_action: serialized.payload.save_action, + form_data: serialized.payload.form_data + }) + } else { + await paymentAPI.update(payment.value.id, serialized.payload) + } + ElMessage.success('保存成功') + editDialogVisible.value = false + await loadPaymentDetail() + } catch (error) { + ElMessage.error(error?.message || '保存失败') + } finally { + savingElementEdit.value = false + } +} + const isEmptyValue = (val) => { if (val === null || val === undefined) return true if (typeof val === 'string') return val.trim() === '' @@ -779,12 +1665,112 @@ const loadRelatedPlannedExpenditure = async () => { await loadTemplates([categoryId]) relatedPlannedExpenditureTemplate.value = templatesCache.value[categoryId] || null } + await preloadRelatedPreApprovalEditDependencies() } } catch (e) { // ignore(打印页不阻塞主信息展示) } } +const resolveRelatedFieldKey = (field) => { + if (!field) return null + return field.key || field.field_key || (field.element_id ? `element_${field.element_id}` : null) +} + +const getRelatedPreApprovalFieldValue = (fieldKey, elementId = null) => { + const map = relatedPlannedExpenditure.value?.element_values || {} + if (fieldKey && Object.prototype.hasOwnProperty.call(map, fieldKey)) { + return map[fieldKey] + } + if (elementId) { + const fallbackKey = `element_${elementId}` + if (Object.prototype.hasOwnProperty.call(map, fallbackKey)) { + return map[fallbackKey] + } + } + return null +} + +const getRelatedChecklistOptions = (field) => { + const elementId = Number(field?.element_id || 0) + if (elementId > 0 && Array.isArray(relatedChecklistOptionsMap.value[elementId]) && relatedChecklistOptionsMap.value[elementId].length) { + return relatedChecklistOptionsMap.value[elementId] + } + if (Array.isArray(field?.options)) return field.options + return [] +} + +const getRelatedPreApprovalEditableItems = () => { + const template = relatedPlannedExpenditureTemplate.value + if (!template?.config || typeof template.config !== 'object') return [] + const items = [] + + const tryPushField = (field, sectionKey, idx) => { + if (!field) return + const elementType = field.element_type + if (!['checklist', 'detail_table'].includes(elementType)) return + const elementId = Number(field.element_id || 0) + if (!elementId) return + const fieldKey = resolveRelatedFieldKey(field) + if (!fieldKey) return + items.push({ + key: `related_${sectionKey}_${elementId}_${idx}`, + label: `事前-${field.label || field.name || `元素${elementId}`}`, + source: 'related_pre_approval', + action: 'edit', + editorType: elementType, + elementId, + fieldKey, + field, + options: elementType === 'checklist' ? getRelatedChecklistOptions(field) : [], + value: getRelatedPreApprovalFieldValue(fieldKey, elementId) + }) + } + + Object.entries(template.config).forEach(([sectionKey, section]) => { + ;(section?.fields || []).forEach((field, idx) => tryPushField(field, sectionKey, idx)) + ;(section?.rounds || []).forEach((round, roundIdx) => { + ;(round?.fields || []).forEach((field, idx) => tryPushField(field, `${sectionKey}_round_${roundIdx}`, idx)) + }) + }) + + return items +} + +const preloadRelatedPreApprovalEditDependencies = async () => { + const template = relatedPlannedExpenditureTemplate.value + if (!template?.config || typeof template.config !== 'object') return + const checklistElementIds = new Set() + const detailElementIds = new Set() + + const collect = (field) => { + if (!field?.element_id) return + if (field.element_type === 'checklist') checklistElementIds.add(Number(field.element_id)) + if (field.element_type === 'detail_table') detailElementIds.add(Number(field.element_id)) + } + + Object.values(template.config).forEach((section) => { + ;(section?.fields || []).forEach(collect) + ;(section?.rounds || []).forEach(round => (round?.fields || []).forEach(collect)) + }) + + for (const elementId of detailElementIds) { + await loadDetailTableFields(elementId) + } + for (const elementId of checklistElementIds) { + if (relatedChecklistOptionsMap.value[elementId]?.length) continue + try { + const res = await templateElementAPI.getDetail(elementId) + if (res.code === 0 && res.data) { + const options = Array.isArray(res.data.options) ? res.data.options : [] + relatedChecklistOptionsMap.value = { ...relatedChecklistOptionsMap.value, [elementId]: options } + } + } catch (error) { + // ignore + } + } +} + // 获取付款详情 const loadPaymentDetail = async () => { const paymentId = route.params.id @@ -802,6 +1788,7 @@ const loadPaymentDetail = async () => { const response = await paymentAPI.getDetail(paymentId) if (response.code === 0 && response.data) { payment.value = response.data + await loadPaymentAudits() // 收集付款基本信息里的流程信息 if (payment.value?.flow_info?.id) { @@ -909,6 +1896,37 @@ const getPaymentStatusText = (status) => { return statusText || status || '-' } +const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)) + +const waitForFlowCollectionStability = async (quietMs = 350, timeoutMs = 3000) => { + const startedAt = Date.now() + let lastSize = collectedFlowIds.value.size + let stableSince = Date.now() + + while (Date.now() - startedAt < timeoutMs) { + await sleep(80) + const currentSize = collectedFlowIds.value.size + if (currentSize !== lastSize) { + lastSize = currentSize + stableSince = Date.now() + renderPrintTemplates() + } + if (Date.now() - stableSince >= quietMs) { + break + } + } +} + +const waitForPrintContentReady = async () => { + await nextTick() + await waitForFlowCollectionStability() + if (renderingPrintTemplates.value || renderPrintTemplatesTimer) { + await renderPrintTemplatesPendingPromise + } + await nextTick() + await renderPdfAsImages() +} + // 打印 const handlePrint = async (e) => { // 阻止事件冒泡 @@ -916,21 +1934,23 @@ const handlePrint = async (e) => { e.preventDefault() e.stopPropagation() } + + if (printInProgress.value) return + printInProgress.value = true try { - // 确保所有 PDF 已渲染为图片 - console.log('[打印] 确保 PDF 已渲染为图片...') - await renderPdfAsImages() - - // 等待图片渲染完成 - await new Promise(resolve => setTimeout(resolve, 300)) - console.log('[打印] PDF 渲染完成,开始打印...') + console.log('[打印] 等待内容渲染稳定...') + await waitForPrintContentReady() + await sleep(200) + console.log('[打印] 内容已就绪,开始打印...') window.print() } catch (error) { console.error('[打印] 处理失败:', error) // 即使失败也尝试打印 - window.print() + window.print() + } finally { + printInProgress.value = false } } @@ -1083,17 +2103,41 @@ const formatFlowCreatedAt = (createdAt) => { // 渲染打印模版(带防抖) let renderPrintTemplatesTimer = null +let renderPrintTemplatesPendingPromise = Promise.resolve() +let resolveRenderPrintTemplatesPending = null + +const beginRenderPrintTemplatesPending = () => { + renderPrintTemplatesPendingPromise = new Promise((resolve) => { + resolveRenderPrintTemplatesPending = resolve + }) +} + +const finishRenderPrintTemplatesPending = () => { + if (typeof resolveRenderPrintTemplatesPending === 'function') { + resolveRenderPrintTemplatesPending() + } + resolveRenderPrintTemplatesPending = null +} + const renderPrintTemplates = async () => { // 清除之前的定时器 if (renderPrintTemplatesTimer) { clearTimeout(renderPrintTemplatesTimer) + renderPrintTemplatesTimer = null + finishRenderPrintTemplatesPending() } + + beginRenderPrintTemplatesPending() // 延迟执行,等待所有子组件完成流程ID收集 renderPrintTemplatesTimer = setTimeout(async () => { + renderingPrintTemplates.value = true const flowIds = Array.from(collectedFlowIds.value) if (flowIds.length === 0) { renderedPrintTemplates.value = [] + renderingPrintTemplates.value = false + renderPrintTemplatesTimer = null + finishRenderPrintTemplatesPending() return } @@ -1107,6 +2151,10 @@ const renderPrintTemplates = async () => { } catch (error) { console.error('渲染打印模版失败:', error) renderedPrintTemplates.value = [] + } finally { + renderingPrintTemplates.value = false + renderPrintTemplatesTimer = null + finishRenderPrintTemplatesPending() } }, 500) // 延迟500ms,确保所有子组件都完成渲染和emit } @@ -1138,8 +2186,12 @@ const convertToProxyUrl = (url) => { } } +let pdfRenderingPromise = Promise.resolve() +let pdfRenderingInProgress = false +let pdfRenderQueued = false + // 使用 PDF.js 将 PDF 渲染为图片(跨浏览器一致) -const renderPdfAsImages = async () => { +const runPdfRenderPass = async () => { await nextTick() // 查找所有 PDF 占位容器 @@ -1147,7 +2199,11 @@ const renderPdfAsImages = async () => { for (const container of pdfContainers) { const pdfUrl = container.getAttribute('data-pdf-url') - if (!pdfUrl || container.hasAttribute('data-rendered')) continue + const renderedUrl = container.getAttribute('data-rendered-url') + if (!pdfUrl) continue + if (container.hasAttribute('data-rendering')) continue + if (container.getAttribute('data-rendered') === 'true' && renderedUrl === pdfUrl) continue + container.setAttribute('data-rendering', 'true') try { // 获取容器宽度 @@ -1259,6 +2315,7 @@ const renderPdfAsImages = async () => { } container.setAttribute('data-rendered', 'true') + container.setAttribute('data-rendered-url', pdfUrl) console.log('[PDF] 渲染完成:', { pages: pdf.numPages, url: pdfUrl }) } catch (error) { console.error('[PDF] 渲染失败:', error) @@ -1278,10 +2335,37 @@ const renderPdfAsImages = async () => { } pagesContainer.innerHTML = `
${errorMsg}
` } + container.removeAttribute('data-rendered') + container.removeAttribute('data-rendered-url') + } finally { + container.removeAttribute('data-rendering') } } } +const renderPdfAsImages = async () => { + if (pdfRenderingInProgress) { + pdfRenderQueued = true + await pdfRenderingPromise + return + } + + pdfRenderingInProgress = true + pdfRenderQueued = false + pdfRenderingPromise = runPdfRenderPass() + + try { + await pdfRenderingPromise + } finally { + pdfRenderingInProgress = false + } + + if (pdfRenderQueued) { + pdfRenderQueued = false + await renderPdfAsImages() + } +} + // 监听渲染模板加载完成,处理 PDF 渲染 watch( () => renderedPrintTemplates.value, @@ -1304,6 +2388,7 @@ const setFlowIframeLoadTimeout = () => { } onMounted(async () => { + await ensureUserRoleContext() await loadPaymentDetail() // 初始处理 PDF 渲染(防止首次加载时 watch 未触发的情况) await renderPdfAsImages() @@ -1319,6 +2404,188 @@ onMounted(async () => { font-size: 13px; } +.edit-exposure-panel { + position: fixed; + top: 80px; + left: 16px; + z-index: 998; + width: 280px; + max-height: calc(100vh - 96px); + overflow: auto; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; + box-shadow: 0 6px 22px rgba(15, 23, 42, 0.12); + padding: 10px 12px; +} + +.edit-exposure-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.edit-exposure-title { + font-size: 13px; + font-weight: 700; + color: #111827; +} + +.edit-exposure-tree { + font-size: 12px; + color: #374151; +} + +.tree-root-line, +.tree-group-line { + display: flex; + align-items: center; + justify-content: space-between; + line-height: 20px; +} + +.tree-root-line { + font-weight: 700; + margin-bottom: 6px; +} + +.tree-group { + padding-left: 10px; + border-left: 1px solid #e5e7eb; + margin-bottom: 8px; +} + +.tree-group-name { + font-weight: 600; +} + +.tree-count { + color: #6b7280; + font-size: 12px; +} + +.tree-sub-list { + margin: 4px 0 0; + padding-left: 12px; +} + +.tree-sub-item { + color: #4b5563; + line-height: 18px; + word-break: break-word; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.tree-sub-item-label { + flex: 1; + min-width: 0; +} + +.tree-sub-item-actions { + flex-shrink: 0; +} + +.audit-records-panel { + margin-top: 10px; + padding-top: 8px; + border-top: 1px dashed #d1d5db; +} + +.audit-records-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + font-size: 12px; + color: #374151; + font-weight: 600; +} + +.audit-records-list { + max-height: 280px; + overflow: auto; +} + +.audit-record-empty { + color: #9ca3af; + font-size: 12px; + text-align: center; + padding: 8px 0; +} + +.audit-record-item { + padding: 6px 0; + border-bottom: 1px solid #f3f4f6; +} + +.audit-record-item:last-child { + border-bottom: none; +} + +.audit-record-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + margin-bottom: 2px; +} + +.audit-record-event { + font-size: 12px; + color: #111827; + font-weight: 600; +} + +.audit-record-time { + font-size: 11px; + color: #6b7280; + white-space: nowrap; +} + +.audit-record-operator { + font-size: 11px; + color: #6b7280; + margin-bottom: 2px; +} + +.audit-record-summary { + font-size: 12px; + color: #374151; + line-height: 1.4; + word-break: break-word; +} + +.element-edit-dialog-body { + max-height: 62vh; + overflow: auto; +} + +.edit-checklist-container { + width: 100%; +} + +.edit-checklist-item { + display: grid; + grid-template-columns: 180px 1fr; + gap: 8px; + align-items: center; + margin-bottom: 8px; +} + +.edit-detail-table-container { + width: 100%; +} + +.edit-detail-table-toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: 8px; +} + .print-toolbar { position: fixed; top: 80px; /* 考虑 header 高度(约 60px)+ 间距 */ @@ -1756,6 +3023,14 @@ onMounted(async () => { display: none; } + .edit-exposure-panel { + display: none !important; + } + + :deep(.el-dialog) { + display: none !important; + } + .a4-page { box-shadow: none; margin: 0; @@ -1894,6 +3169,12 @@ onMounted(async () => { } } +@media (max-width: 1480px) { + .edit-exposure-panel { + display: none; + } +} + /* 流程信息抽屉样式 */ :deep(.flow-drawer .el-drawer) { width: 72% !important; diff --git a/src/views/pre-approval/ProcessQuery.vue b/src/views/pre-approval/ProcessQuery.vue index d429d43..1013d75 100644 --- a/src/views/pre-approval/ProcessQuery.vue +++ b/src/views/pre-approval/ProcessQuery.vue @@ -69,10 +69,10 @@ - + diff --git a/src/views/settings/PaymentCategory.vue b/src/views/settings/PaymentCategory.vue index 21dc314..2a6c0a1 100644 --- a/src/views/settings/PaymentCategory.vue +++ b/src/views/settings/PaymentCategory.vue @@ -99,8 +99,14 @@ 叶子节点 - - 需关联合同 + + {{ getContractRequirementTag(data).label }} 已禁用 @@ -221,10 +227,14 @@ - + + + 必须 + 可选 + (仅叶子节点可设置) @@ -424,6 +434,7 @@ const formData = reactive({ sort_order: 0, is_active: true, need_contract: false, + contract_requirement: 'none', amount_limit_conditions: [] }) @@ -522,6 +533,22 @@ const formRules = { ] } +const getContractRequirement = (category) => { + if (!category) return 'none' + return category.contract_requirement || (category.need_contract ? 'required' : 'none') +} + +const getContractRequirementTag = (category) => { + const requirement = getContractRequirement(category) + if (requirement === 'required') { + return { label: '必须关联合同', type: 'danger' } + } + if (requirement === 'optional') { + return { label: '可关联合同', type: 'warning' } + } + return null +} + // 方法:加载分类树 const loadCategoryTree = async () => { try { @@ -592,6 +619,7 @@ const handleCreate = () => { sort_order: 0, is_active: true, need_contract: false, + contract_requirement: 'none', amount_limit_conditions: [] }) dialogVisible.value = true @@ -610,6 +638,7 @@ const handleCreateChild = (row) => { sort_order: 0, is_active: true, need_contract: false, + contract_requirement: 'none', amount_limit_conditions: [] }) dialogVisible.value = true @@ -636,6 +665,7 @@ const handleEdit = async (row) => { sort_order: response.data.sort_order, is_active: response.data.is_active, need_contract: response.data.need_contract ?? false, + contract_requirement: getContractRequirement(response.data), amount_limit_conditions: Array.isArray(response.data.amount_limit_conditions) ? response.data.amount_limit_conditions.map(c => ({ field: c.field || 'current_payment_amount', @@ -877,6 +907,7 @@ const handleSubmit = async () => { sort_order: formData.sort_order, is_active: formData.is_active, need_contract: isEditingLeafNode.value ? (formData.need_contract ?? false) : false, + contract_requirement: isEditingLeafNode.value ? getContractRequirement(formData) : 'none', amount_limit_conditions: isEditingLeafNode.value ? normalizedAmountLimitConditions : null } diff --git a/src/views/settings/PlannedExpenditureCategory.vue b/src/views/settings/PlannedExpenditureCategory.vue index cfbcf3a..14f237e 100644 --- a/src/views/settings/PlannedExpenditureCategory.vue +++ b/src/views/settings/PlannedExpenditureCategory.vue @@ -94,8 +94,14 @@ 叶子节点 - - 需关联合同 + + {{ getContractRequirementTag(data).label }} 已禁用 @@ -202,10 +208,14 @@ - + + + 必须 + 可选 + (仅叶子节点可设置) @@ -325,6 +335,7 @@ const formData = reactive({ sort_order: 0, is_active: true, need_contract: false, + contract_requirement: 'none', amount_limit_conditions: [] // level 和 is_leaf 由后台自动计算,不需要前端传递 }) @@ -348,6 +359,22 @@ const formRules = { ] } +const getContractRequirement = (category) => { + if (!category) return 'none' + return category.contract_requirement || (category.need_contract ? 'required' : 'none') +} + +const getContractRequirementTag = (category) => { + const requirement = getContractRequirement(category) + if (requirement === 'required') { + return { label: '必须关联合同', type: 'danger' } + } + if (requirement === 'optional') { + return { label: '可关联合同', type: 'warning' } + } + return null +} + // 父级分类选项 const parentOptions = ref([]) @@ -564,6 +591,7 @@ const handleCreate = () => { sort_order: 0, is_active: true, need_contract: false, + contract_requirement: 'none', amount_limit_conditions: [] }) dialogVisible.value = true @@ -581,6 +609,7 @@ const handleCreateChild = (row) => { sort_order: 0, is_active: true, need_contract: false, + contract_requirement: 'none', amount_limit_conditions: [] }) dialogVisible.value = true @@ -606,6 +635,7 @@ const handleEdit = async (row) => { sort_order: response.data.sort_order, is_active: response.data.is_active, need_contract: response.data.need_contract ?? false, + contract_requirement: getContractRequirement(response.data), amount_limit_conditions: Array.isArray(response.data.amount_limit_conditions) ? response.data.amount_limit_conditions.map(c => ({ field: c.field || '', @@ -691,6 +721,7 @@ const handleSubmit = async () => { sort_order: formData.sort_order, is_active: formData.is_active, need_contract: isEditingLeafNode.value ? (formData.need_contract ?? false) : false, + contract_requirement: isEditingLeafNode.value ? getContractRequirement(formData) : 'none', amount_limit_conditions: isEditingLeafNode.value ? normalizedAmountLimitConditions : null // level 和 is_leaf 由后台自动计算,不需要前端传递 }