Files
shanxia2.0/docs/crm-migration/plans/contract/02-business-logic.md
T
2026-06-01 08:21:21 +08:00

452 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 合同管理 — 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) |