# Business Type History Rate Backend Implementation Plan

> **For agentic workers:** Follow this repository's `AGENTS.md`: do not enable subagents or `using-superpowers` during implementation unless the user explicitly requests them. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add backend support for business type persistence, historical loan-rate lookup, and historical loan-rate model input for both personal and corporate workflow creation.

**Architecture:** Keep the existing workflow creation API shape and extend the request DTOs, entity, converter, service validation, SQL schema, and model DTO. Add one backend proxy service/controller endpoint for historical contracts, following the existing customer-map proxy pattern, plus a mock endpoint for local development and browser-use testing.

**Tech Stack:** Spring Boot, RuoYi, MyBatis Plus, Lombok, JUnit 5, Mockito, Spring `MockRestServiceServer`, MySQL schema SQL.

---

## File Structure

- Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/dto/PersonalLoanPricingCreateDTO.java`
  - Add `businessType` and `loanRateHistory`.
- Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/dto/CorporateLoanPricingCreateDTO.java`
  - Add the same fields.
- Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/entity/LoanPricingWorkflow.java`
  - Add persisted fields `businessType` and `loanRateHistory`.
- Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/dto/ModelInvokeDTO.java`
  - Add `loanRateHistory` only.
- Create: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/vo/HistoryLoanContractVO.java`
  - Represent external historical contract rows with underscore JSON names.
- Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/util/LoanPricingConverter.java`
  - Map `businessType` and `loanRateHistory` for personal and corporate create DTOs.
- Create: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/LoanRateHistoryService.java`
  - Proxy external historical contract lookup.
- Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/controller/LoanPricingWorkflowController.java`
  - Add `GET /loanPricing/workflow/history-contract`.
- Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/impl/LoanPricingWorkflowServiceImpl.java`
  - Add service-layer cross-field validation before insert/model invocation.
- Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/controller/LoanRatePricingMockController.java`
  - Add mock historical contract endpoint with fixed normal, empty, and empty-rate scenarios.
  - Add fixed customer-map mock customer IDs that return `cust_isn=EMPTY_HISTORY` and `cust_isn=EMPTY_RATE` for browser-use testing.
- Modify: `ruoyi-admin/src/main/resources/application-dev.yml`
  - Add local mock `loan-rate-history.url`.
- Modify: `ruoyi-admin/src/main/resources/application-uat.yml`
  - Add local mock `loan-rate-history.url`.
- Modify: `ruoyi-admin/src/main/resources/application-pro.yml`
  - Add real external `loan-rate-history.url` without `cust_isn=`.
- Create: `sql/add_business_type_history_rate_20260429.sql`
  - Migration for existing databases.
- Modify: `sql/loan_pricing_workflow.sql`
  - Update standalone workflow schema.
- Modify: `sql/loan_pricing_schema_20260328.sql`
  - Update bundled schema.
- Modify: `sql/loan_pricing_prod_init_20260331.sql`
  - Update production init schema.
- Test: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/domain/vo/HistoryLoanContractVOTest.java`
- Test: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanRateHistoryServiceTest.java`
- Test: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/controller/LoanPricingWorkflowControllerHistoryContractTest.java`
- Test: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/controller/LoanRatePricingMockControllerHistoryContractTest.java`
- Modify Test: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/controller/LoanRatePricingMockControllerCustomerMapTest.java`
- Modify Test: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/impl/LoanPricingWorkflowServiceImplTest.java`
- Modify Test: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingModelServicePersonalParamsTest.java`
- Modify Test: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingModelServiceTest.java`

## Task 1: Add Backend Field Contracts

**Files:**
- Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/dto/PersonalLoanPricingCreateDTO.java`
- Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/dto/CorporateLoanPricingCreateDTO.java`
- Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/entity/LoanPricingWorkflow.java`
- Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/dto/ModelInvokeDTO.java`
- Create: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/vo/HistoryLoanContractVO.java`
- Test: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/domain/vo/HistoryLoanContractVOTest.java`
- Modify Test: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingModelServicePersonalParamsTest.java`

- [ ] **Step 1: Write failing field tests**

Add to `LoanPricingModelServicePersonalParamsTest`:

```java
@Test
void shouldContainBusinessTypeAndLoanRateHistoryInCreateDtosAndWorkflow() throws NoSuchFieldException {
    assertNotNull(PersonalLoanPricingCreateDTO.class.getDeclaredField("businessType"));
    assertNotNull(PersonalLoanPricingCreateDTO.class.getDeclaredField("loanRateHistory"));
    assertNotNull(CorporateLoanPricingCreateDTO.class.getDeclaredField("businessType"));
    assertNotNull(CorporateLoanPricingCreateDTO.class.getDeclaredField("loanRateHistory"));
    assertNotNull(LoanPricingWorkflow.class.getDeclaredField("businessType"));
    assertNotNull(LoanPricingWorkflow.class.getDeclaredField("loanRateHistory"));
}

@Test
void shouldContainLoanRateHistoryButNotBusinessTypeInModelInvokeDto() throws NoSuchFieldException {
    assertNotNull(ModelInvokeDTO.class.getDeclaredField("loanRateHistory"));
    assertThrows(NoSuchFieldException.class, () -> ModelInvokeDTO.class.getDeclaredField("businessType"));
}
```

Create `HistoryLoanContractVOTest`:

```java
package com.ruoyi.loanpricing.domain.vo;

import static org.junit.jupiter.api.Assertions.assertNotNull;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;

class HistoryLoanContractVOTest {
    @Test
    void shouldDeserializeUnderscoreFields() throws Exception {
        String json = "{\"cust_isn\":\"81033011438\",\"loan_contract_history\":\"HT001\",\"guar_type_history\":\"信用\",\"product_code_history\":\"P001\",\"loan_rate_history\":\"3.65\",\"loan_amount_history\":\"100000\",\"loan_sign_date_history\":\"2025-01-01\"}";

        HistoryLoanContractVO vo = new ObjectMapper().readValue(json, HistoryLoanContractVO.class);

        assertNotNull(vo.getCustIsn());
        assertNotNull(vo.getLoanContractHistory());
        assertNotNull(vo.getLoanRateHistory());
    }
}
```

- [ ] **Step 2: Run tests to verify they fail**

Run:

```bash
mvn -pl ruoyi-loan-pricing -am -Dtest=LoanPricingModelServicePersonalParamsTest,HistoryLoanContractVOTest test
```

Expected: FAIL because the fields and VO do not exist.

- [ ] **Step 3: Add DTO/entity/model fields**

Add to both create DTOs:

```java
@Schema(description = "业务种类", requiredMode = Schema.RequiredMode.REQUIRED, example = "存量转贷", allowableValues = {"新客", "存量新增", "存量转贷"})
@NotBlank(message = "业务种类不能为空")
@Pattern(regexp = "^(新客|存量新增|存量转贷)$", message = "业务种类必须是：新客、存量新增、存量转贷之一")
private String businessType;

@Schema(description = "历史贷款利率", example = "3.65")
private String loanRateHistory;
```

Add to `LoanPricingWorkflow`:

```java
/** 业务种类: 新客/存量新增/存量转贷 */
private String businessType;

/** 历史贷款利率 */
private String loanRateHistory;
```

Add only this field to `ModelInvokeDTO`:

```java
/**
 * 历史贷款利率
 */
private String loanRateHistory;
```

- [ ] **Step 4: Create `HistoryLoanContractVO`**

```java
package com.ruoyi.loanpricing.domain.vo;

import com.fasterxml.jackson.annotation.JsonProperty;
import java.io.Serializable;
import lombok.Data;

@Data
public class HistoryLoanContractVO implements Serializable {
    private static final long serialVersionUID = 1L;

    @JsonProperty("cust_isn")
    private String custIsn;

    @JsonProperty("loan_contract_history")
    private String loanContractHistory;

    @JsonProperty("guar_type_history")
    private String guarTypeHistory;

    @JsonProperty("product_code_history")
    private String productCodeHistory;

    @JsonProperty("loan_rate_history")
    private String loanRateHistory;

    @JsonProperty("loan_amount_history")
    private String loanAmountHistory;

    @JsonProperty("loan_sign_date_history")
    private String loanSignDateHistory;
}
```

- [ ] **Step 5: Run field tests**

Run:

```bash
mvn -pl ruoyi-loan-pricing -am -Dtest=LoanPricingModelServicePersonalParamsTest,HistoryLoanContractVOTest test
```

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/dto/PersonalLoanPricingCreateDTO.java \
  ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/dto/CorporateLoanPricingCreateDTO.java \
  ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/entity/LoanPricingWorkflow.java \
  ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/dto/ModelInvokeDTO.java \
  ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/vo/HistoryLoanContractVO.java \
  ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/domain/vo/HistoryLoanContractVOTest.java \
  ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingModelServicePersonalParamsTest.java
git commit -m "新增业务种类与历史利率后端字段"
```

## Task 2: Map and Validate Workflow Creation

**Files:**
- Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/util/LoanPricingConverter.java`
- Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/impl/LoanPricingWorkflowServiceImpl.java`
- Modify Test: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingModelServicePersonalParamsTest.java`
- Modify Test: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/impl/LoanPricingWorkflowServiceImplTest.java`

- [ ] **Step 1: Write failing converter tests**

Add:

```java
@Test
void shouldMapBusinessTypeAndLoanRateHistoryFromPersonalDto() {
    PersonalLoanPricingCreateDTO dto = new PersonalLoanPricingCreateDTO();
    dto.setCustIsn("CUST001");
    dto.setGuarType("信用");
    dto.setApplyAmt("100000");
    dto.setBusinessType("存量转贷");
    dto.setLoanRateHistory("3.65");

    LoanPricingWorkflow workflow = LoanPricingConverter.toEntity(dto);

    assertEquals("存量转贷", workflow.getBusinessType());
    assertEquals("3.65", workflow.getLoanRateHistory());
}
```

Add a similar corporate DTO test in `LoanPricingModelServiceTest` or a new converter-focused test.

- [ ] **Step 2: Write failing service validation tests**

Add to `LoanPricingWorkflowServiceImplTest`:

```java
@Test
void shouldRejectMissingBusinessTypeBeforeInsert() {
    LoanPricingWorkflow workflow = validWorkflow();
    workflow.setBusinessType(null);

    ServiceException ex = assertThrows(ServiceException.class, () -> loanPricingWorkflowService.createLoanPricing(workflow));

    assertEquals("业务种类不能为空", ex.getMessage());
    verify(loanPricingWorkflowMapper, never()).insert(any());
}

@Test
void shouldRejectInvalidBusinessTypeBeforeInsert() {
    LoanPricingWorkflow workflow = validWorkflow();
    workflow.setBusinessType("其他");

    ServiceException ex = assertThrows(ServiceException.class, () -> loanPricingWorkflowService.createLoanPricing(workflow));

    assertEquals("业务种类必须是：新客、存量新增、存量转贷之一", ex.getMessage());
}

@Test
void shouldRejectStockTransferWithoutLoanRateHistory() {
    LoanPricingWorkflow workflow = validWorkflow();
    workflow.setBusinessType("存量转贷");
    workflow.setLoanRateHistory(" ");

    ServiceException ex = assertThrows(ServiceException.class, () -> loanPricingWorkflowService.createLoanPricing(workflow));

    assertEquals("请选择历史贷款合同", ex.getMessage());
}
```

Add helper:

```java
private LoanPricingWorkflow validWorkflow() {
    LoanPricingWorkflow workflow = new LoanPricingWorkflow();
    workflow.setCustIsn("81033011438");
    workflow.setCustType("个人");
    workflow.setCustName("张三");
    workflow.setIdNum("110101199001011234");
    workflow.setGuarType("信用");
    workflow.setApplyAmt("100000");
    workflow.setBusinessType("新客");
    return workflow;
}
```

- [ ] **Step 3: Run tests to verify they fail**

```bash
mvn -pl ruoyi-loan-pricing -am -Dtest=LoanPricingModelServicePersonalParamsTest,LoanPricingModelServiceTest,LoanPricingWorkflowServiceImplTest test
```

Expected: FAIL because mapping and validation do not exist.

- [ ] **Step 4: Implement converter mapping**

Add to both `toEntity(...)` methods:

```java
entity.setBusinessType(dto.getBusinessType());
entity.setLoanRateHistory(dto.getLoanRateHistory());
```

- [ ] **Step 5: Implement service validation**

In `LoanPricingWorkflowServiceImpl`, before defaults/encryption/insert in `createLoanPricing(...)`, call:

```java
validateBusinessTypeAndHistoryRate(loanPricingWorkflow);
```

Add:

```java
private void validateBusinessTypeAndHistoryRate(LoanPricingWorkflow workflow) {
    if (!StringUtils.hasText(workflow.getBusinessType())) {
        throw new ServiceException("业务种类不能为空");
    }
    if (!"新客".equals(workflow.getBusinessType())
            && !"存量新增".equals(workflow.getBusinessType())
            && !"存量转贷".equals(workflow.getBusinessType())) {
        throw new ServiceException("业务种类必须是：新客、存量新增、存量转贷之一");
    }
    if ("存量转贷".equals(workflow.getBusinessType())
            && !StringUtils.hasText(workflow.getLoanRateHistory())) {
        throw new ServiceException("请选择历史贷款合同");
    }
}
```

Import `com.ruoyi.common.exception.ServiceException`.

- [ ] **Step 6: Run validation tests**

```bash
mvn -pl ruoyi-loan-pricing -am -Dtest=LoanPricingModelServicePersonalParamsTest,LoanPricingModelServiceTest,LoanPricingWorkflowServiceImplTest test
```

Expected: PASS.

- [ ] **Step 7: Commit**

```bash
git add ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/util/LoanPricingConverter.java \
  ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/impl/LoanPricingWorkflowServiceImpl.java \
  ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingModelServicePersonalParamsTest.java \
  ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingModelServiceTest.java \
  ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/impl/LoanPricingWorkflowServiceImplTest.java
git commit -m "校验并映射业务种类历史利率"
```

## Task 3: Add Historical Contract Proxy and Mock

**Files:**
- Create: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/LoanRateHistoryService.java`
- Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/controller/LoanPricingWorkflowController.java`
- Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/controller/LoanRatePricingMockController.java`
- Modify: `ruoyi-admin/src/main/resources/application-dev.yml`
- Modify: `ruoyi-admin/src/main/resources/application-uat.yml`
- Modify: `ruoyi-admin/src/main/resources/application-pro.yml`
- Test: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanRateHistoryServiceTest.java`
- Test: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/controller/LoanRatePricingMockControllerHistoryContractTest.java`
- Test: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/controller/LoanPricingWorkflowControllerHistoryContractTest.java`
- Modify Test: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/controller/LoanRatePricingMockControllerCustomerMapTest.java`

- [ ] **Step 1: Write failing service tests**

Create tests following `LoanPricingCustomerMapServiceTest`:

```java
@Test
void shouldQueryHistoryContractsWithCustIsnParam() {
    RestTemplate restTemplate = new RestTemplate();
    MockRestServiceServer server = MockRestServiceServer.createServer(restTemplate);
    LoanRateHistoryService service = new LoanRateHistoryService(restTemplate, "http://mock/history?appCode=abc");

    server.expect(requestTo("http://mock/history?appCode=abc&cust_isn=81033011438"))
            .andRespond(withSuccess("{\"code\":200,\"data\":[{\"cust_isn\":\"81033011438\",\"loan_contract_history\":\"HT001\",\"loan_rate_history\":\"3.65\"}]}", MediaType.APPLICATION_JSON));

    List<HistoryLoanContractVO> result = service.query(" 81033011438 ");

    assertEquals(1, result.size());
    assertEquals("3.65", result.get(0).getLoanRateHistory());
    server.verify();
}

@Test
void shouldRejectBlankCustIsn() {
    LoanRateHistoryService service = new LoanRateHistoryService(new RestTemplate(), "http://mock/history");

    ServiceException ex = assertThrows(ServiceException.class, () -> service.query(" "));

    assertEquals("客户内码不能为空", ex.getMessage());
}
```

- [ ] **Step 2: Write failing controller/mock tests**

Mock endpoint tests must cover:

- Normal `cust_isn=81033011438` returns at least one row with `loan_rate_history`.
- Fixed empty scenario, for example `cust_isn=EMPTY_HISTORY`, returns `data: []`.
- Fixed empty-rate scenario, for example `cust_isn=EMPTY_RATE`, returns a row whose `loan_rate_history` is empty.

Customer-map mock tests must also cover browser-use fixed customer numbers:

- `cust_id=HISTORY_EMPTY` returns exactly one customer-map row with `cust_isn=EMPTY_HISTORY`.
- `cust_id=HISTORY_EMPTY_RATE` returns exactly one customer-map row with `cust_isn=EMPTY_RATE`.

Controller test must verify `/loanPricing/workflow/history-contract?custIsn=81033011438` delegates to `LoanRateHistoryService.query("81033011438")`.

- [ ] **Step 3: Run tests to verify they fail**

```bash
mvn -pl ruoyi-loan-pricing -am -Dtest=LoanRateHistoryServiceTest,LoanRatePricingMockControllerHistoryContractTest,LoanPricingWorkflowControllerHistoryContractTest test
```

Expected: FAIL because service/controller/mock do not exist.

- [ ] **Step 4: Implement `LoanRateHistoryService`**

```java
@Service
public class LoanRateHistoryService {
    private final RestTemplate restTemplate;

    @Value("${loan-rate-history.url}")
    private String historyUrl;

    public LoanRateHistoryService() {
        this(new RestTemplate());
    }

    LoanRateHistoryService(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    LoanRateHistoryService(RestTemplate restTemplate, String historyUrl) {
        this.restTemplate = restTemplate;
        this.historyUrl = historyUrl;
    }

    public List<HistoryLoanContractVO> query(String custIsn) {
        String normalizedCustIsn = StringUtils.trimWhitespace(custIsn);
        if (!StringUtils.hasText(normalizedCustIsn)) {
            throw new ServiceException("客户内码不能为空");
        }
        URI uri = UriComponentsBuilder.fromHttpUrl(historyUrl)
                .queryParam("cust_isn", normalizedCustIsn)
                .build()
                .encode()
                .toUri();
        HistoryLoanContractResponse response = restTemplate.getForObject(uri, HistoryLoanContractResponse.class);
        if (response == null) {
            throw new ServiceException("历史贷款合同接口无返回");
        }
        if (response.getCode() != null && response.getCode() != 200) {
            throw new ServiceException(StringUtils.hasText(response.getMsg()) ? response.getMsg() : "历史贷款合同查询失败");
        }
        return response.getData() == null ? Collections.emptyList() : response.getData();
    }

    @Data
    @JsonIgnoreProperties(ignoreUnknown = true)
    static class HistoryLoanContractResponse {
        private Integer code;
        private String msg;
        private List<HistoryLoanContractVO> data;
    }
}
```

- [ ] **Step 5: Add controller endpoint**

Inject `LoanRateHistoryService` into `LoanPricingWorkflowController` and add:

```java
@Operation(summary = "查询历史贷款合同")
@GetMapping("/history-contract")
public AjaxResult queryHistoryContract(@RequestParam("custIsn") String custIsn) {
    return success(loanRateHistoryService.query(custIsn));
}
```

- [ ] **Step 6: Add mock endpoint**

Add to `LoanRatePricingMockController`:

```java
@Anonymous
@Operation(summary = "模拟历史贷款合同查询")
@GetMapping("/history-contract")
public AjaxResult queryHistoryContract(@RequestParam("cust_isn") String custIsn) {
    String normalizedCustIsn = StringUtils.trimWhitespace(custIsn);
    if (!StringUtils.hasText(normalizedCustIsn)) {
        throw new ServiceException("客户内码不能为空");
    }
    if ("EMPTY_HISTORY".equals(normalizedCustIsn)) {
        return success(Collections.emptyList());
    }
    List<HistoryLoanContractVO> records = new ArrayList<>();
    HistoryLoanContractVO record = new HistoryLoanContractVO();
    record.setCustIsn(normalizedCustIsn);
    record.setLoanContractHistory("HT" + normalizedCustIsn);
    record.setGuarTypeHistory("信用");
    record.setProductCodeHistory("P001");
    record.setLoanRateHistory("EMPTY_RATE".equals(normalizedCustIsn) ? "" : "3.65");
    record.setLoanAmountHistory("100000");
    record.setLoanSignDateHistory(LocalDate.now().minusMonths(6).toString());
    records.add(record);
    return success(records);
}
```

- [ ] **Step 7: Add fixed customer-map mock scenarios**

Before the random-record loop in `randomCustomerMapRecords`, add:

```java
if ("HISTORY_EMPTY".equals(normalizedCustId)) {
    CustomerMapRecordVO record = new CustomerMapRecordVO();
    record.setCustId(normalizedCustId);
    record.setCustIsn("EMPTY_HISTORY");
    record.setCustName(namePrefix + "空历史合同");
    record.setFaithDay("0");
    record.setBalanceAvg("0");
    record.setLoanCountHis("0");
    record.setLastLoanDate("");
    return Collections.singletonList(record);
}
if ("HISTORY_EMPTY_RATE".equals(normalizedCustId)) {
    CustomerMapRecordVO record = new CustomerMapRecordVO();
    record.setCustId(normalizedCustId);
    record.setCustIsn("EMPTY_RATE");
    record.setCustName(namePrefix + "空历史利率");
    record.setFaithDay("30");
    record.setBalanceAvg("10000");
    record.setLoanCountHis("1");
    record.setLastLoanDate(LocalDate.now().minusMonths(3).toString());
    return Collections.singletonList(record);
}
```

Import `java.util.Collections` if needed.

- [ ] **Step 8: Add profile config**

Use mock URLs in dev/uat:

```yaml
loan-rate-history:
    url: http://localhost:63310/rate/pricing/mock/history-contract
```

Use real URL in pro without `cust_isn=`:

```yaml
loan-rate-history:
    url: http://552f7aff0acd4c09ac3b83dbfee57fa0.apigateway.res.dc-pdt-zj96596.com/shangyu_loan_rate_history?appCode=1a89fa84abda480ba93ed73fd01ffd07
```

- [ ] **Step 9: Run proxy/mock tests**

```bash
mvn -pl ruoyi-loan-pricing -am -Dtest=LoanRateHistoryServiceTest,LoanRatePricingMockControllerHistoryContractTest,LoanPricingWorkflowControllerHistoryContractTest,LoanRatePricingMockControllerCustomerMapTest test
```

Expected: PASS.

- [ ] **Step 10: Commit**

```bash
git add ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/LoanRateHistoryService.java \
  ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/controller/LoanPricingWorkflowController.java \
  ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/controller/LoanRatePricingMockController.java \
  ruoyi-admin/src/main/resources/application-dev.yml \
  ruoyi-admin/src/main/resources/application-uat.yml \
  ruoyi-admin/src/main/resources/application-pro.yml \
  ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanRateHistoryServiceTest.java \
  ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/controller/LoanRatePricingMockControllerHistoryContractTest.java \
  ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/controller/LoanPricingWorkflowControllerHistoryContractTest.java \
  ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/controller/LoanRatePricingMockControllerCustomerMapTest.java
git commit -m "新增历史贷款合同查询接口"
```

## Task 4: Persist SQL Schema Changes

**Files:**
- Create: `sql/add_business_type_history_rate_20260429.sql`
- Modify: `sql/loan_pricing_workflow.sql`
- Modify: `sql/loan_pricing_schema_20260328.sql`
- Modify: `sql/loan_pricing_prod_init_20260331.sql`

- [ ] **Step 1: Write migration SQL**

Create:

```sql
-- 为利率定价流程添加业务种类和历史贷款利率字段
ALTER TABLE `loan_pricing_workflow`
  ADD COLUMN `business_type` varchar(20) DEFAULT NULL COMMENT '业务种类' AFTER `loan_purpose`,
  ADD COLUMN `loan_rate_history` varchar(100) DEFAULT NULL COMMENT '历史贷款利率' AFTER `business_type`;
```

- [ ] **Step 2: Update schema SQL files**

In each `loan_pricing_workflow` create-table definition, add:

```sql
  `business_type` varchar(20) DEFAULT NULL COMMENT '业务种类',
  `loan_rate_history` varchar(100) DEFAULT NULL COMMENT '历史贷款利率',
```

Place them after `loan_purpose`.

- [ ] **Step 3: Verify SQL text**

Run:

```bash
rg -n "business_type|loan_rate_history" sql/add_business_type_history_rate_20260429.sql sql/loan_pricing_workflow.sql sql/loan_pricing_schema_20260328.sql sql/loan_pricing_prod_init_20260331.sql
```

Expected: each file contains both fields in the workflow table context.

- [ ] **Step 4: Commit**

```bash
git add sql/add_business_type_history_rate_20260429.sql \
  sql/loan_pricing_workflow.sql \
  sql/loan_pricing_schema_20260328.sql \
  sql/loan_pricing_prod_init_20260331.sql
git commit -m "新增业务种类历史利率数据库字段"
```

## Task 5: Verify Model Input Chain

**Files:**
- Modify Test: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingModelServicePersonalParamsTest.java`
- Modify Test: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingModelServiceTest.java`

- [ ] **Step 1: Add personal model assertion**

In `shouldInvokePersonalModelWithExpectedParams`, set:

```java
workflow.setBusinessType("存量转贷");
workflow.setLoanRateHistory("3.65");
```

Add to the `argThat`:

```java
&& Objects.equals("3.65", dto.getLoanRateHistory())
```

- [ ] **Step 2: Add corporate model assertion**

Add or update a corporate model invocation test in `LoanPricingModelServiceTest`:

```java
workflow.setCustType("企业");
workflow.setBusinessType("存量转贷");
workflow.setLoanRateHistory("3.75");
```

Verify:

```java
verify(modelService).invokeCorporateModel(argThat((ModelInvokeDTO dto) ->
        Objects.equals("3.75", dto.getLoanRateHistory())));
```

- [ ] **Step 3: Run model tests**

```bash
mvn -pl ruoyi-loan-pricing -am -Dtest=LoanPricingModelServicePersonalParamsTest,LoanPricingModelServiceTest test
```

Expected: PASS and no `businessType` field exists in `ModelInvokeDTO`.

- [ ] **Step 4: Commit**

```bash
git add ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingModelServicePersonalParamsTest.java \
  ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingModelServiceTest.java
git commit -m "验证历史利率模型入参链路"
```

## Task 6: Backend Final Verification

**Files:**
- No new files unless a test failure requires a focused fix.

- [ ] **Step 1: Run focused backend test suite**

```bash
mvn -pl ruoyi-loan-pricing -am -Dtest=HistoryLoanContractVOTest,LoanRateHistoryServiceTest,LoanPricingWorkflowControllerHistoryContractTest,LoanRatePricingMockControllerHistoryContractTest,LoanPricingWorkflowServiceImplTest,LoanPricingModelServicePersonalParamsTest,LoanPricingModelServiceTest test
```

Expected: PASS.

- [ ] **Step 2: Run existing related customer-map tests**

```bash
mvn -pl ruoyi-loan-pricing -am -Dtest=LoanPricingCustomerMapServiceTest,LoanPricingWorkflowControllerCustomerMapTest,LoanRatePricingMockControllerCustomerMapTest,CustomerMapRecordVOTest test
```

Expected: PASS, confirming the new proxy pattern did not break customer-map behavior.

- [ ] **Step 3: Record backend implementation notes**

Append backend verification notes to a new or existing implementation report, for example:

```text
doc/implementation-report-2026-04-29-business-type-history-rate.md
```

Include commands run and whether browser-use verification remains for the frontend plan.

- [ ] **Step 4: Commit**

```bash
git add doc/implementation-report-2026-04-29-business-type-history-rate.md
git commit -m "记录业务种类历史利率后端验证"
```
