# 间接支付 - 关联合同选择带回逻辑整理
## 概述
在间接支付页面(`/payment/indirect-payment`)中,"关联合同"功能出现在两个位置:
1. **第三步(事前流程分类)**:当所选分类的 `need_contract` 为 `true` 时
2. **第六步(支付分类)**:当所选支付分类的 `need_contract` 为 `true` 时
## 一、合同选择位置
### 1.1 第三步 - 支出合同选择
**位置**:步骤3(选择事前流程分类并填写信息)
**显示条件**:`selectedCategory?.need_contract === true`
**相关变量**:
- `selectedExpenditureContract`:存储选中的支出合同
- `showExpenditureContractSelector`:控制合同选择器对话框显示
**UI 结构**:
```vue
选择合同
```
### 1.2 第六步 - 支付合同选择
**位置**:步骤5(填写支付信息)
**显示条件**:`selectedPaymentCategory?.need_contract === true`
**相关变量**:
- `selectedPaymentContract`:存储选中的支付合同
- `showPaymentContractSelector`:控制合同选择器对话框显示
- `isPaymentContractReused`:判断是否复用第三步的合同
- `reusedPaymentContract`:复用的合同(第三步的合同)
**UI 结构**:
```vue
已选择合同(复用第三步)
选择合同
```
## 二、合同选择器组件
### 2.1 组件位置
`czemc-budget-execution-frontend/src/components/ContractSelector.vue`
### 2.2 组件功能
1. **搜索功能**:
- 支持按合同编号(`contract_no`)搜索
- 支持按合同名称(`title`)搜索
- 支持回车键触发查询
2. **列表展示**:
- 显示合同编号、合同名称、合同类型、合同金额、金额类型、签订日期、合同履行期
- 支持分页(10/20/50 条每页)
- 支持行点击选择
3. **选择确认**:
- 点击"确定"按钮时,会调用 `contractAPI.detail(contractId)` 获取合同详情
- 合同详情包含支付统计信息(`payment_stats`)
- 通过 `@confirm` 事件将完整的合同详情返回给父组件
### 2.3 组件使用
```vue
```
## 三、合同选择带回逻辑
### 3.1 支出合同选择处理(第三步)
**函数**:`handleExpenditureContractSelected(contract)`
**位置**:`IndirectPayment.vue` 第 5885-5911 行
**逻辑流程**:
1. **验证闭口合同**:
```javascript
if (contract.amount_type === 'fixed') {
// 检查是否已关联过非直接支付
const paymentCount = contract.payment_stats?.payment_count || 0
const paidAmount = Number(contract.payment_stats?.paid_amount || 0)
// 如果有已支付记录,不允许选择
if (paidAmount > 0 || paymentCount > 0) {
// 显示警告提示,阻止选择
return
}
}
```
2. **保存合同**:
```javascript
selectedExpenditureContract.value = contract
ElMessage.success('合同选择成功')
```
**验证规则**:
- ✅ **闭口合同**(`amount_type === 'fixed'`):
- 如果 `payment_stats.paid_amount > 0` 或 `payment_stats.payment_count > 0`,说明已关联过非直接支付,**不允许选择**
- 否则允许选择
- ✅ **开口合同**(`amount_type === 'open'`):
- **允许选择**,不检查是否已关联过非直接支付
### 3.2 支付合同选择处理(第六步)
**函数**:`handlePaymentContractSelected(contract)`
**位置**:`IndirectPayment.vue` 第 5930-5956 行
**逻辑流程**:
与支出合同选择处理逻辑**完全相同**:
1. 验证闭口合同是否已关联过非直接支付
2. 如果验证通过,保存合同并提示成功
**验证规则**:与支出合同选择相同
### 3.3 合同复用逻辑
**触发时机**:当进入第六步(选择支付分类后)时
**位置**:`IndirectPayment.vue` 第 4992-4994 行
**逻辑**:
```javascript
// 如果第三步已选择合同,且第六步也需要合同,则自动复用第三步的合同
if (selectedCategory.value?.need_contract &&
selectedExpenditureContract.value &&
selectedPaymentCategory.value?.need_contract) {
selectedPaymentContract.value = selectedExpenditureContract.value
}
```
**判断条件**:
- `isPaymentContractReused` 计算属性:
```javascript
const isPaymentContractReused = computed(() => {
return selectedCategory.value?.need_contract &&
selectedExpenditureContract.value &&
selectedPaymentCategory.value?.need_contract
})
```
**复用效果**:
- 第六步的合同选择区域显示"已选择合同(复用第三步)"
- 合同信息不可更改(不显示"重新选择"和"清除"按钮)
- 使用第三步选择的合同数据
## 四、合同数据使用
### 4.1 合同信息展示
已选择的合同会显示以下信息:
- 合同名称(`title`)
- 合同总额(`amount_total`)
- 已支付次数(`payment_stats.payment_count`)
- 已支付金额(`payment_stats.paid_amount`)
- 剩余金额(仅闭口合同显示,计算公式:`总额 - 已支付金额`)
### 4.1.1 合同总额的使用场景
**重要说明**:合同选择后,**不会自动填充**合同总额到表单字段 `contract_total_amount`。用户需要**手动输入**合同总金额。
合同总额(`contract.amount_total`)的使用场景如下:
#### 场景1:合同信息展示
- **位置**:第三步和第六步的合同选择卡片
- **用途**:显示选中合同的总额信息
- **代码位置**:
- 第三步:`IndirectPayment.vue` 第 631-632 行
- 第六步:`IndirectPayment.vue` 第 901-902 行、941-942 行
- **显示格式**:使用 `formatAmount()` 格式化,显示为带千分位的数字
#### 场景2:计算剩余金额(闭口合同)
- **位置**:合同信息卡片中的"剩余金额"字段
- **用途**:计算闭口合同的剩余可支付金额
- **计算公式**:`剩余金额 = 合同总额 - 已支付金额`
- **代码位置**:
- 支出合同:`IndirectPayment.vue` 第 5919-5926 行
- 支付合同:`IndirectPayment.vue` 第 5964-5971 行
- 复用合同:`IndirectPayment.vue` 第 5986-5993 行
- **注意**:仅闭口合同(`amount_type === 'fixed'`)才显示剩余金额
#### 场景3:预览页面显示
- **位置**:步骤6(总体预览)的合同信息部分
- **用途**:在预览页面显示合同总额
- **代码位置**:`IndirectPayment.vue` 第 773-774 行
- **数据来源**:通过 `getPreviewContract()` 函数获取合同对象
#### 场景4:字段显示条件判断(支付分类)
- **位置**:第六步(填写支付信息)的动态字段显示
- **用途**:根据合同总额判断某些字段是否显示
- **代码位置**:`IndirectPayment.vue` 第 5221-5270 行
- **重要说明**:
- 在支付分类的字段显示条件中,使用的是**表单字段** `formData.contract_total_amount`(用户手动输入的)
- **不是**合同的 `amount_total`(合同对象自带的)
- 代码逻辑:
```javascript
// 非直接支付场景,合同总金额使用事前流程的合同总金额
const contractTotalAmount = formData.value.contract_total_amount || 0
```
- 这意味着:**用户需要手动输入合同总金额,该金额会用于判断支付分类的字段是否显示**
#### 场景5:金额限制条件验证(事前流程分类)
- **位置**:第三步(填写事前流程信息)的金额限制验证
- **用途**:验证用户输入的合同总金额是否满足分类的金额限制条件
- **代码位置**:
- 金额限制条件获取:`IndirectPayment.vue` 第 6070 行
- 金额限制验证:`IndirectPayment.vue` 第 6072-6074 行
- 验证函数:`IndirectPayment.vue` 第 6024-6068 行
- **验证字段**:`formData.contract_total_amount`(用户手动输入的)
- **验证规则**:根据分类配置的 `amount_limit_conditions` 进行验证
- 支持的操作符:`lt`(小于)、`lte`(小于等于)、`gt`(大于)、`gte`(大于等于)、`eq`(等于)
- **UI 显示**:
- 实时显示验证结果(成功/失败)
- 显示金额限制条件的提示文本
#### 场景6:表单验证
- **位置**:第三步进入下一步的验证
- **用途**:验证合同总金额是否已填写且大于0
- **代码位置**:`IndirectPayment.vue` 第 4281-4284 行
- **验证规则**:
```javascript
const contractAmount = Number(formData.value.contract_total_amount)
if (!Number.isFinite(contractAmount) || contractAmount <= 0) {
return false // 不能进入下一步
}
```
#### 场景7:金额大写显示
- **位置**:第三步的合同总金额输入框下方
- **用途**:将合同总金额转换为中文大写
- **代码位置**:
- 计算属性:`IndirectPayment.vue` 第 5880 行
- 格式化函数:`IndirectPayment.vue` 第 5874-5878 行
- **显示格式**:例如 "壹佰万元整"
### 4.2 剩余金额计算
**支出合同剩余金额**:
```javascript
const expenditureContractRemainingAmount = computed(() => {
if (!selectedExpenditureContract.value ||
selectedExpenditureContract.value.amount_type !== 'fixed') {
return 0
}
const total = Number(selectedExpenditureContract.value.amount_total) || 0
const paid = Number(selectedExpenditureContract.value.payment_stats?.paid_amount) || 0
return Math.max(0, total - paid)
})
```
**支付合同剩余金额**:
```javascript
const paymentContractRemainingAmount = computed(() => {
if (!selectedPaymentContract.value ||
selectedPaymentContract.value.amount_type !== 'fixed') {
return 0
}
const total = Number(selectedPaymentContract.value.amount_total) || 0
const paid = Number(selectedPaymentContract.value.payment_stats?.paid_amount) || 0
return Math.max(0, total - paid)
})
```
### 4.3 预览页面合同显示
**函数**:`getPreviewContract()`
**位置**:`IndirectPayment.vue` 第 5819-5835 行
**逻辑**:
```javascript
const getPreviewContract = () => {
// 优先使用第六步的合同(如果支付分类需要合同)
if (selectedPaymentCategory.value?.need_contract) {
// 如果复用第三步的合同,使用第三步的合同
if (isPaymentContractReused.value && reusedPaymentContract.value) {
return reusedPaymentContract.value
}
// 否则使用第六步选择的合同
if (selectedPaymentContract.value) {
return selectedPaymentContract.value
}
}
// 如果第六步没有合同,但第三步有合同,显示第三步的合同
if (selectedCategory.value?.need_contract && selectedExpenditureContract.value) {
return selectedExpenditureContract.value
}
return null
}
```
**优先级**:
1. 第六步复用的合同(第三步的合同)
2. 第六步独立选择的合同
3. 第三步的合同(如果第六步不需要合同)
### 4.4 提交时的合同ID
**位置**:`IndirectPayment.vue` 第 5620-5628 行
**逻辑**:
```javascript
// 支出合同ID(第三步)
const expenditureContractId = selectedCategory.value?.need_contract && selectedExpenditureContract.value
? selectedExpenditureContract.value.id
: null
// 支付合同ID(第六步)
const paymentContractId = selectedPaymentCategory.value?.need_contract
? (isPaymentContractReused.value
? reusedPaymentContract.value?.id
: selectedPaymentContract.value?.id)
: null
```
## 五、清除合同
### 5.1 清除支出合同
**函数**:`clearExpenditureContract()`
```javascript
const clearExpenditureContract = () => {
selectedExpenditureContract.value = null
}
```
### 5.2 清除支付合同
**函数**:`clearPaymentContract()`
```javascript
const clearPaymentContract = () => {
selectedPaymentContract.value = null
}
```
**注意**:如果支付合同是复用的第三步合同,清除操作会清除 `selectedPaymentContract`,但不会影响 `selectedExpenditureContract`。
## 六、关键代码位置
| 功能 | 代码位置 | 行号范围 |
|------|---------|---------|
| 第三步合同选择UI | `IndirectPayment.vue` | 596-647 |
| 第六步合同选择UI | `IndirectPayment.vue` | 884-956 |
| 合同选择器组件使用 | `IndirectPayment.vue` | 1162-1173 |
| 支出合同选择处理 | `IndirectPayment.vue` | 5885-5911 |
| 支付合同选择处理 | `IndirectPayment.vue` | 5930-5956 |
| 合同复用逻辑 | `IndirectPayment.vue` | 4992-4994 |
| 合同复用判断 | `IndirectPayment.vue` | 5974-5978 |
| 预览合同获取 | `IndirectPayment.vue` | 5819-5835 |
| 合同选择器组件 | `components/ContractSelector.vue` | 全部 |
## 七、数据流图
```
用户点击"选择合同"
↓
打开 ContractSelector 对话框
↓
用户搜索/浏览合同列表
↓
用户选择合同并点击"确定"
↓
ContractSelector 调用 contractAPI.detail(contractId)
↓
获取合同详情(包含 payment_stats)
↓
触发 @confirm 事件,传递合同详情
↓
父组件处理函数(handleExpenditureContractSelected / handlePaymentContractSelected)
↓
验证合同(闭口合同检查是否已关联过非直接支付)
↓
验证通过:保存到 selectedExpenditureContract / selectedPaymentContract
验证失败:显示警告,阻止选择
↓
进入第六步时,如果满足条件,自动复用第三步的合同
↓
预览和提交时使用合同数据
```
## 八、注意事项
1. **闭口合同限制**:
- 闭口合同一旦关联过非直接支付(`paid_amount > 0` 或 `payment_count > 0`),就不能再次关联
- 这个限制是为了保证数据一致性
2. **开口合同(框架协议)**:
- 开口合同不受"已关联过非直接支付"的限制
- 可以多次关联非直接支付
3. **合同复用**:
- 只有在第三步和第六步都需要合同时,才会自动复用
- 复用的合同在第六步不可更改
- 如果用户想使用不同的合同,需要在第三步清除合同,然后在第六步重新选择
4. **合同详情获取**:
- 选择器组件在确认选择时会调用 `contractAPI.detail()` 获取完整的合同详情
- 这确保了返回的合同数据包含最新的支付统计信息
5. **数据同步**:
- 合同选择后,相关的金额计算、预览显示等会自动更新
- 通过 Vue 的响应式系统实现数据同步
## 九、相关API
### 9.1 合同列表API
**接口**:`contractAPI.list(params)`
**参数**:
- `page`: 页码
- `page_size`: 每页数量
- `contract_no`: 合同编号(可选)
- `title`: 合同名称(可选)
**返回**:
```javascript
{
code: 0,
data: {
data: [/* 合同列表 */],
total: 100
}
}
```
### 9.2 合同详情API
**接口**:`contractAPI.detail(contractId)`
**返回**:
```javascript
{
code: 0,
data: {
id: 1,
contract_no: "HT2024001",
title: "合同名称",
amount_total: 1000000,
amount_type: "fixed", // "fixed" | "open"
payment_stats: {
payment_count: 0,
paid_amount: 0
},
// ... 其他合同字段
}
}
```
## 十、总结
"关联合同"选择带回的逻辑核心要点:
1. **两个选择位置**:第三步(支出合同)和第六步(支付合同)
2. **统一的选择器组件**:`ContractSelector` 提供搜索、列表、选择功能
3. **严格的验证规则**:闭口合同不能重复关联非直接支付
4. **智能的复用机制**:满足条件时自动复用第三步的合同
5. **完整的数据流**:从选择到验证到保存到使用的完整链路