452 lines
18 KiB
Markdown
452 lines
18 KiB
Markdown
# 合同管理 — 02 业务逻辑(列表查询)
|
||
|
||
> **步骤 2 产出** | 日期:2026-05-31
|
||
> **权威源码**:`CrmContractServiceImpl.buildQueryWrapper` + `queryPageList`;前端 `views/crm/contract/index.vue`
|
||
> **关联 API**:`04-api-spec.md` C01(`GET /crm/contract/list`)
|
||
|
||
---
|
||
|
||
## 1. 调用链
|
||
|
||
```
|
||
index.vue getList()
|
||
→ listContract(queryParams) // GET /crm/contract/list
|
||
→ CrmContractController.list(bo, pageQuery)
|
||
→ CrmContractServiceImpl.queryPageList(bo, pageQuery)
|
||
① buildQueryWrapper(bo) // 组装 WHERE
|
||
② selectVoPage // 分页查 crm_contract
|
||
③ 逐行 setCanApprove // 审批权限(非 WHERE 条件)
|
||
→ 返回 { rows, total }
|
||
```
|
||
|
||
**Python 重写时必须拆成两层:**
|
||
|
||
1. `_build_contract_filters(...)` — 仅翻译 `buildQueryWrapper` 的 SQL 条件
|
||
2. `list_contracts(...)` — 分页查询后,对每条记录调用 `can_user_approve(contract_id, current_user_id)`
|
||
|
||
---
|
||
|
||
## 2. 列表 Tab 与 Query 参数映射
|
||
|
||
前端 `handleViewChange` 在切换 Tab 时**重置**下列字段,再调用 `getList()`。
|
||
`getList()` 还会根据是否显式选了 `status` 设置 `excludeStatus`(见 §4)。
|
||
|
||
| Tab `activeView` | `ownerUserId` | `pendingApprovalUserId` | `approvedByUserId` | `status` | 说明 |
|
||
|------------------|---------------|---------------------------|--------------------|---------|------|
|
||
| `my` 我的合同 | **当前用户** | — | — | — | 负责人=我 |
|
||
| `all` 全部合同 | — | — | — | — | 不限负责人 |
|
||
| `submitted` 待审批 | — | **当前用户** | — | — | 见 §5.1 |
|
||
| `approved` 已审批 | — | — | **当前用户** | — | 见 §5.2 |
|
||
| `executing` 执行中 | — | — | — | `executing` | 合同状态精确匹配 |
|
||
| `finished` 已完成 | — | — | — | `finished` | |
|
||
| `invalid` 已作废 | — | — | — | `invalid` | 此时不再 exclude invalid |
|
||
|
||
切换 Tab 时均清空:`approvalStatus`(筛选表单项,与 Tab 无关时可单独选)。
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
subgraph tabs [Tab activeView]
|
||
my[my]
|
||
all[all]
|
||
sub[submitted]
|
||
app[approved]
|
||
exe[executing]
|
||
fin[finished]
|
||
inv[invalid]
|
||
end
|
||
subgraph params [queryParams 写入]
|
||
O[ownerUserId=当前用户]
|
||
P[pendingApprovalUserId=当前用户]
|
||
A[approvedByUserId=当前用户]
|
||
S1[status=executing/finished/invalid]
|
||
end
|
||
my --> O
|
||
sub --> P
|
||
app --> A
|
||
exe --> S1
|
||
fin --> S1
|
||
inv --> S1
|
||
```
|
||
|
||
---
|
||
|
||
## 3. `buildQueryWrapper` 条件逐项说明
|
||
|
||
Java 方法位置:`CrmContractServiceImpl` 第 76–152 行。
|
||
所有业务表查询默认带 **租户隔离**(Entity 继承 Tenant)+ **`del_flag=0`**(`@TableLogic`,Wrapper 自动追加)。
|
||
|
||
**排序(固定):** `ORDER BY create_time DESC`
|
||
|
||
### 3.1 文本搜索:联合 OR vs 分别 AND
|
||
|
||
| 场景 | 条件 | SQL 语义 |
|
||
|------|------|----------|
|
||
| **联合搜索** | `contractNo`、`contractName`、`customerName` **三者非空且值完全相同** | `(contract_no LIKE ? OR contract_name LIKE ? OR customer_name LIKE ?)` |
|
||
| **普通筛选** | 否则 | 各字段独立 `LIKE`(有值才加) |
|
||
|
||
**前端触发联合搜索:**
|
||
|
||
- 工具栏 **快速检索框** `handleQuickSearch`:把同一关键字写入三个字段
|
||
```ts
|
||
queryParams.contractNo = queryParams.contractName = queryParams.customerName = quickSearch
|
||
```
|
||
- 签订日期 **快捷按钮**(今日/昨日/明日/本周)在改 `dateRange` 后会调用 `handleQuickSearch()`,若快捷框有字则同样走 OR 逻辑
|
||
|
||
**Python 实现要点:**
|
||
|
||
```python
|
||
if (
|
||
contract_no and contract_name and customer_name
|
||
and contract_no == contract_name == customer_name
|
||
):
|
||
kw = contract_no
|
||
q = q.filter(or_(
|
||
CrmContract.contract_no.like(f"%{kw}%"),
|
||
CrmContract.contract_name.like(f"%{kw}%"),
|
||
CrmContract.customer_name.like(f"%{kw}%"),
|
||
))
|
||
else:
|
||
# 分别 like
|
||
```
|
||
|
||
### 3.2 精确匹配字段
|
||
|
||
| Bo 字段 | 列 | 条件 |
|
||
|---------|-----|------|
|
||
| `customerId` | `customer_id` | `=` |
|
||
| `opportunityId` | `opportunity_id` | `=` |
|
||
| `quoteId` | `quote_id` | `=` |
|
||
| `contractType` | `contract_type` | `=` |
|
||
| `status` | `status` | `=` |
|
||
| `approvalStatus` | `approval_status` | `=` |
|
||
| `ownerUserId` | `owner_user_id` | `=` |
|
||
|
||
### 3.3 排除状态 `excludeStatus`
|
||
|
||
```java
|
||
lqw.ne(StringUtils.isNotBlank(bo.getExcludeStatus()), CrmContract::getStatus, bo.getExcludeStatus());
|
||
```
|
||
|
||
| 值 | SQL |
|
||
|----|-----|
|
||
| `excludeStatus = 'invalid'` | `status <> 'invalid'` |
|
||
|
||
**前端规则(`getList`):**
|
||
|
||
- 若 **`status` 未设置** → 传 `excludeStatus = 'invalid'`(默认隐藏作废合同)
|
||
- 若 **`status` 已设置**(含 Tab「已作废」或筛选项)→ **不传** `excludeStatus`
|
||
|
||
因此:`my` / `all` / `submitted` / `approved` Tab 下默认都看不到作废合同;只有 `invalid` Tab 或用户手动选状态=作废时才显示。
|
||
|
||
### 3.4 金额区间
|
||
|
||
| Bo 来源 | 列 | 条件 |
|
||
|---------|-----|------|
|
||
| `params.minAmount` | `amount` | `>=` |
|
||
| `params.maxAmount` | `amount` | `<=` |
|
||
|
||
RuoYi 将部分 Query 参数放在 `BaseEntity.params` Map;前端 `ContractQuery` 若传 `minAmount`/`maxAmount`,Python 需从 body/query 同等读取。
|
||
|
||
### 3.5 签订日期 `signDateBegin` / `signDateEnd`
|
||
|
||
| 字段 | 列 | 条件 |
|
||
|------|-----|------|
|
||
| `signDateBegin` | `sign_date` | `>= LocalDate.parse(begin)` |
|
||
| `signDateEnd` | `sign_date` | `<= LocalDate.parse(end)` |
|
||
|
||
前端 `dateRange` 在 `getList` 里写入这两个字段;**快捷日期**与 **筛选区日期** 共用 `dateRange`。
|
||
|
||
### 3.6 创建时间 `beginTime` / `endTime`
|
||
|
||
```java
|
||
lqw.between(beginTime != null && endTime != null, create_time, beginTime, endTime);
|
||
```
|
||
|
||
**两端都有值**才加 BETWEEN;列表页默认不传。
|
||
|
||
---
|
||
|
||
## 4. 审批相关子查询(Tab 核心)
|
||
|
||
### 4.1 待审批 Tab — `pendingApprovalUserId`
|
||
|
||
**前端:** `submitted` Tab → `pendingApprovalUserId = 当前登录用户 id`
|
||
|
||
**Java:**
|
||
|
||
```sql
|
||
contract.id IN (
|
||
SELECT DISTINCT contract_id FROM crm_contract_approval
|
||
WHERE approver_id = :pendingApprovalUserId
|
||
AND approval_result = 'pending'
|
||
AND is_current = 1
|
||
AND del_flag = 0
|
||
)
|
||
```
|
||
|
||
**语义:**
|
||
|
||
- 仅看 **当前审批节点**(`is_current = 1`)且 **轮到当前用户** 的合同
|
||
- 合同本身 `status` 通常为 `approving`,但 Wrapper **不再额外过滤 status**(靠审批表约束)
|
||
|
||
**Python 安全写法:** 用 SQLAlchemy `in_(select(...))` 或 `exists()`,**禁止** Java 式字符串拼接 `approver_id`。
|
||
|
||
### 4.2 已审批 Tab — `approvedByUserId`
|
||
|
||
**前端:** `approved` Tab → `approvedByUserId = 当前登录用户 id`
|
||
|
||
**Java(两步,非子查询):**
|
||
|
||
1. 查 `crm_contract_approval`:`approver_id = ?` AND `approval_result IN ('approved','rejected')`
|
||
2. 取 distinct `contract_id` → `crm_contract.id IN (...)`
|
||
3. 若第 1 步无记录 → **`id = -1`**(保证空结果,避免 IN 空集语义差异)
|
||
|
||
**语义:**
|
||
|
||
- 当前用户 **曾经审批过**(通过或驳回)的合同都会列出
|
||
- **不**要求 `is_current = 1`
|
||
- **不**过滤合同当前 `status`(可能已 executing / finished 等)
|
||
|
||
### 4.3 Tab 与审批字段互斥
|
||
|
||
切换 Tab 时前端会 **显式清空** 其它 Tab 用的 id 字段(见 §2 表),避免 `ownerUserId` 与 `pendingApprovalUserId` 叠加导致结果为空。
|
||
|
||
---
|
||
|
||
## 5. 列表结果后处理 — `canApprove`
|
||
|
||
`buildQueryWrapper` **不包含**审批权限;在 `queryPageList` 查完分页后:
|
||
|
||
```java
|
||
vo.setCanApprove(contractApprovalService.canUserApprove(vo.getId(), currentUserId));
|
||
```
|
||
|
||
### `canUserApprove(contractId, userId)` 规则
|
||
|
||
1. 查该合同全部审批记录,按 `step` 升序
|
||
2. 找到 `approver_id == userId` 的记录;找不到 → `false`
|
||
3. 该记录 `approval_result` 必须为 `pending`
|
||
4. **前置步骤**(step 更小)的记录的 `approval_result` 必须全部为 `approved`
|
||
5. 全部满足 → `true`(列表/详情展示「审批」按钮)
|
||
|
||
**与 Tab「待审批」的区别:**
|
||
|
||
| | 待审批 Tab 列表 | `canApprove` |
|
||
|--|----------------|--------------|
|
||
| 用途 | 筛出「待我处理」的合同集合 | 单行是否显示审批按钮 |
|
||
| 条件 | `is_current=1` + pending | 前置步骤已通过 + 本人 pending |
|
||
| 典型 | 合同在 submitted Tab 出现 | 多步审批中,仅当前步骤审批人为 true |
|
||
|
||
---
|
||
|
||
## 6. 列表页统计卡片(非 API)
|
||
|
||
`index.vue` 的 `loadStatistics()` **不调用** `GET /statistics/overview`,而是对 **当前页** `contractList` 聚合:
|
||
|
||
| 指标 | 计算 |
|
||
|------|------|
|
||
| 总数 | `total`(分页 total,非当前页 rows 长度) |
|
||
| 总金额 | Σ `amount` / 10000(万元) |
|
||
| 已回款 | Σ `receivedAmount` / 10000 |
|
||
| 回款进度 | 已回款 / 总金额 × 100% |
|
||
|
||
**移植注意:** 后端 C17 统计接口与列表 Tab 统计 **无关**;列表页 Fresh Mint 改造时保留前端聚合逻辑即可。
|
||
|
||
---
|
||
|
||
## 7. 详情查询补充(`queryById`,列表抽屉用)
|
||
|
||
列表点击行打开抽屉时调用 `GET /crm/contract/{id}`,在 `buildQueryWrapper` 之外还有:
|
||
|
||
| 步骤 | 说明 |
|
||
|------|------|
|
||
| `canApprove` | 同 §5 |
|
||
| `supplierName` | 有 `supplierId` 时查 `crm_supplier_info` |
|
||
| `products` | `contractProductService.queryListByContractId` |
|
||
| 框架合同 `priceTiers` | `contract_type=framework` 时加载 `crm_contract_product_price_tier` 挂到各产品 |
|
||
|
||
列表 **展开行产品** 若仅用列表 Vo 字段,可能不含 tiers;详情抽屉走 C02 全量数据。
|
||
|
||
---
|
||
|
||
## 8. Python 重写检查清单(C01)
|
||
|
||
翻译 `buildQueryWrapper` 时逐项对照:
|
||
|
||
- [ ] 租户 `tenant_no` + `del_flag=0`
|
||
- [ ] 联合 OR / 分别 LIKE(§3.1)
|
||
- [ ] `excludeStatus` 与前端 `getList` 联动(§3.3)
|
||
- [ ] Tab 五类用户 id / status 字段(§2)
|
||
- [ ] `pendingApprovalUserId` 子查询含 `is_current=1`(§4.1)
|
||
- [ ] `approvedByUserId` 无记录时返回空列表(§4.2)
|
||
- [ ] `signDateBegin/End`、`min/maxAmount`、`beginTime/endTime`(§3.4–3.6)
|
||
- [ ] `ORDER BY create_time DESC`
|
||
- [ ] 分页后逐行 `canApprove`(§5)
|
||
- [ ] 参数化查询,不用 Java `inSql` 字符串拼接
|
||
|
||
---
|
||
|
||
## 9. 与状态机章节的衔接
|
||
|
||
列表查询 **只读** `status` / 审批表,不修改状态。写入类操作的状态约束见 **§10**。
|
||
|
||
---
|
||
|
||
## 10. 合同状态机与操作约束(步骤 3)
|
||
|
||
### 10.1 双字段:`status` 与 `approval_status`
|
||
|
||
数据库 `crm_contract`(`sx.sql`):
|
||
|
||
| 字段 | 字典/取值 | 含义 |
|
||
|------|-----------|------|
|
||
| `status` | `draft` 草稿 · `approving` 审批中 · `approved` 已审批 · `executing` 执行中 · `finished` 已完成 · `invalid` 已作废 | **主业务状态**,驱动列表 Tab、按钮可见性 |
|
||
| `approval_status` | `NULL` · `pending` · `approved` · `rejected` | **审批结论**;草稿时为 `NULL` |
|
||
|
||
**约定(Java 注释):**
|
||
|
||
- 新增/编辑草稿:`status=draft`,**`approval_status` 必须 NULL**(勿留 pending)
|
||
- 提交审批:`status=approving`,`approval_status=pending`
|
||
- 审批全部通过:`status=approved`,`approval_status=approved`
|
||
- 审批驳回:`status=draft`,`approval_status=rejected`(可改稿后再次提交)
|
||
- 撤回审批:`status=draft`,**`approval_status` 置 NULL**(需用 UpdateWrapper 显式写 null)
|
||
|
||
前端 `index.vue` 按钮示例:
|
||
|
||
- 「开始执行」:`status === 'approved' && approvalStatus === 'approved'`
|
||
- 「作废」:`status` ∈ `draft | approving | approved | executing`
|
||
- 「编辑/删除」:仅 `draft`
|
||
|
||
---
|
||
|
||
### 10.2 主状态流转图
|
||
|
||
```mermaid
|
||
stateDiagram-v2
|
||
[*] --> draft: insertByBo / 撤回 / 审批驳回
|
||
draft --> approving: submitApproval
|
||
approving --> draft: withdrawApproval
|
||
approving --> approved: 末步审批通过
|
||
approved --> executing: startExecute 或 signContract
|
||
executing --> finished: finishContract
|
||
draft --> invalid: invalidContract
|
||
approving --> invalid: invalidContract
|
||
approved --> invalid: invalidContract
|
||
executing --> invalid: invalidContract
|
||
finished --> [*]: 不可作废
|
||
invalid --> [*]
|
||
```
|
||
|
||
**进入 `executing` 的两条路径(均要求当前 `status=approved`):**
|
||
|
||
| 接口 | 额外写入 |
|
||
|------|----------|
|
||
| `PUT /startExecute` | 仅 `status=executing` |
|
||
| `PUT /sign` | `status=executing` + `actualSignDate` + `contractFile` + `performanceStatus=normal` + `performanceRate=0` |
|
||
|
||
二者互斥使用场景:列表/ExecuteDialog 走 startExecute;form 签订走 signContract。
|
||
|
||
---
|
||
|
||
### 10.3 各操作前置条件与副作用
|
||
|
||
| 操作 | API | 前置 `status` | 合同字段变更 | 审批表 / 其它 |
|
||
|------|-----|---------------|--------------|---------------|
|
||
| 新增 | `POST /` | — | 默认 `draft`,`approval_status=NULL`,生成编号/名称,金额字段初始化 | — |
|
||
| 修改 | `PUT /` | **draft** | 可改业务字段;草稿保持 `approval_status=NULL`;签供方/日期变则 **重生成编号名称** | 产品明细 batch 更新 |
|
||
| 删除 | `DELETE /{ids}` | **draft**(逐条校验) | 逻辑删除 `del_flag=1` | — |
|
||
| 提交审批 | `PUT /submitApproval` | **draft** | → `approving` + `approval_status=pending` | 按 `approverIds` 插入 N 条 approval;step=1..N,`is_current` 仅首步=1;框架合同走 §10.5 校验 |
|
||
| 批量提交 | `PUT /batchSubmitApproval` | 同上 | 同上 | 逐 id 调 submitApproval,失败记日志继续 |
|
||
| 撤回 | `PUT /withdrawApproval` | **approving** | → `draft`,`approval_status=NULL` | **物理删除**该合同下 `approval_result=pending` 的记录 |
|
||
| 审批通过(逐步) | `POST /approval` 等 | 合同通常 `approving` | 末步通过 → `approved`+`approved`;否则不动 status | 当前记录 → approved,`is_current=0`;下一步 pending → `is_current=1` |
|
||
| 审批驳回 | 同上 | — | → **`draft` + `rejected`** | 当前记录 → rejected |
|
||
| 开始执行 | `PUT /startExecute` | **approved** | → `executing` | — |
|
||
| 签订 | `PUT /sign` | **approved** | → `executing` + 签订信息 + 履约字段 | TODO:回款计划激活(Java 未实现) |
|
||
| 完成 | `PUT /finish` | **executing** | → `finished`;可写 `remark` | — |
|
||
| 作废 | `PUT /invalid` | **非 finished** | → `invalid` + `invalidReason` + `invalidTime` + `remark` | — |
|
||
| 续签 | `POST /renew/{parentId}` | 原合同任意态可读 | **新建**子合同 `draft`,`contractNature=renewal`,`parentContractId`,`renewalCount+1` | 复制产品等 |
|
||
| 批量分配负责人 | `PUT /batch/assign` | **无 status 限制** | 更新 `ownerUserId` / `ownerUserName` | — |
|
||
| 上传附件/模板 | POST 若干 | 存在即可 | 更新文件/模板字段 | 不改变 status |
|
||
|
||
**错误文案(Python 应对齐):**
|
||
|
||
- 「只有草稿状态的合同才能修改 / 提交审批 / 删除」
|
||
- 「只有审批中的合同才能撤回」
|
||
- 「只有已审批的合同才能开始执行」
|
||
- 「只有执行中的合同才能完成」
|
||
- 「已完成的合同不能作废」
|
||
- 「作废原因不能为空」
|
||
|
||
---
|
||
|
||
### 10.4 审批流与主状态的联动(`CrmContractApprovalServiceImpl.approveContract`)
|
||
|
||
1. 校验:当前用户=审批人、记录=pending、`canUserApprove=true`
|
||
2. 写审批记录:`approval_result`、`approval_opinion`、`approval_time`、`is_current=0`
|
||
3. **若 approved:**
|
||
- 存在更大 `step` 且仍为 pending 的下一条 → 将其 `is_current=1`
|
||
- 否则(全部通过)→ 合同 `status=approved`,`approval_status=approved`
|
||
4. **若 rejected:** → 合同 `status=draft`,`approval_status=rejected`(**不会**设为 invalid)
|
||
|
||
列表页 `approveContract(id, comment)` 走 **A08**(按合同 id 找 `is_current=1` 且 pending 的本人记录,再调 `approveContract(approvalId, …)`)。
|
||
|
||
---
|
||
|
||
### 10.5 提交审批专项校验 — 框架合同
|
||
|
||
`validateFrameworkContractForSubmitApproval`(仅 `contract_type=framework`):
|
||
|
||
- 至少一条产品明细
|
||
- 每个产品至少一条 `crm_contract_product_price_tier`
|
||
- 每条 tier:`startDate`/`endDate` 合法、单价>0、**必须含调价函 ossId**
|
||
|
||
非框架合同:走通用产品/金额校验(`validateContractTypeAndProductsPresence` 等),在 insert/update 时已执行。
|
||
|
||
---
|
||
|
||
### 10.6 删除 vs 作废
|
||
|
||
| | 删除 `deleteWithValidByIds` | 作废 `invalidContract` |
|
||
|--|----------------------------|------------------------|
|
||
| 允许状态 | 仅 **draft** | draft / approving / approved / executing |
|
||
| 禁止 | 非 draft | **finished** |
|
||
| 数据 | MyBatis 逻辑删 | 保留行,`status=invalid` |
|
||
| 列表 | 默认 exclude invalid | 「已作废」Tab `status=invalid` |
|
||
|
||
---
|
||
|
||
### 10.7 创建类操作的初始状态
|
||
|
||
| 入口 | 初始 `status` | 说明 |
|
||
|------|---------------|------|
|
||
| `insertByBo` | `draft` | 默认负责人=当前用户 |
|
||
| `renewContract` | **draft**(新合同) | 不修改原合同状态 |
|
||
| `createFromQuote` / `createFromOpportunity` | 走 `insertByBo` → draft | 报价单状态改为 `converted`(quote 侧) |
|
||
|
||
---
|
||
|
||
### 10.8 Python 重写检查清单(状态相关 API)
|
||
|
||
实现 C03–C13、C25–C26、A08 时逐项对照:
|
||
|
||
- [ ] 草稿 `approval_status` 为 NULL(insert/update/withdraw)
|
||
- [ ] 撤回时删除 pending 审批行,不是 soft delete
|
||
- [ ] 驳回回到 draft+rejected,不是 invalid
|
||
- [ ] 多步审批:中间步通过不改合同 status,只推进 `is_current`
|
||
- [ ] 末步通过才 `approved/approved`
|
||
- [ ] `startExecute` 与 `signContract` 均要求 `status=approved`
|
||
- [ ] 作废写 `invalidReason`、`invalidTime`
|
||
- [ ] 删除仅 draft + 软删
|
||
- [ ] 框架合同 submit 前 tier 校验(§10.5)
|
||
- [ ] 事务边界与 Java `@Transactional` 一致(审批+改合同同事务)
|
||
|
||
---
|
||
|
||
## 变更记录
|
||
|
||
| 日期 | 说明 |
|
||
|------|------|
|
||
| 2026-05-31 | 步骤 2:buildQueryWrapper + Tab + 联合搜索 + 审批子查询 + canApprove |
|
||
| 2026-05-31 | 步骤 3:status/approval_status 双字段、状态机、操作约束、审批联动 |
|
||
| 2026-05-31 | 步骤 6:跨模块金额回调见 [`05-cross-module-calls.md`](./05-cross-module-calls.md) |
|