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

18 KiB
Raw Blame History

合同管理 — 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 无关时可单独选)。

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:把同一关键字写入三个字段
    queryParams.contractNo = queryParams.contractName = queryParams.customerName = quickSearch
    
  • 签订日期 快捷按钮(今日/昨日/明日/本周)在改 dateRange 后会调用 handleQuickSearch(),若快捷框有字则同样走 OR 逻辑

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

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

lqw.between(beginTime != null && endTime != null, create_time, beginTime, endTime);

两端都有值才加 BETWEEN;列表页默认不传。


4. 审批相关子查询(Tab 核心)

4.1 待审批 Tab — pendingApprovalUserId

前端: submitted Tab → pendingApprovalUserId = 当前登录用户 id

Java:

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 查完分页后:

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 主状态流转图

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