新增客户号映射选择实施计划

This commit is contained in:
wkc
2026-04-29 11:33:51 +08:00
parent ddf2f976f5
commit a90ab42be6
3 changed files with 1354 additions and 0 deletions

View File

@@ -0,0 +1,678 @@
# Customer Map Selection Backend Implementation Plan
> **For agentic workers:** Steps use checkbox (`- [ ]`) syntax for tracking. Follow this repository's AGENTS.md rule: do not enable subagents or superpowers execution modes unless the user explicitly requests them for the implementation session.
**Goal:** Add backend customer-id-to-customer-internal-code query APIs for personal and corporate workflow creation, backed by local mock interfaces and profile configuration.
**Architecture:** Keep workflow creation APIs unchanged. Add a small customer-map service that reads separate personal/corporate URLs from configuration, calls them with GET `cust_id`, and returns mapping records with underscore JSON field names through the existing RuoYi `AjaxResult` response convention. Add mock endpoints under the existing mock controller so all active profiles can point to local mock URLs first.
**Tech Stack:** Spring Boot, RuoYi `AjaxResult`, Spring `RestTemplate`, Jackson `@JsonProperty`, JUnit 5, Mockito, Maven.
---
## File Structure
- Create: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/vo/CustomerMapRecordVO.java`
- Holds one customer mapping record.
- Uses Java camelCase fields internally and `@JsonProperty` to serialize/deserialize underscore field names.
- Create: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/LoanPricingCustomerMapService.java`
- Owns config URL selection, GET forwarding, parameter validation, and response parsing.
- Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/controller/LoanPricingWorkflowController.java`
- Adds authenticated business endpoints used by the frontend.
- Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/controller/LoanRatePricingMockController.java`
- Adds anonymous local mock endpoints.
- Modify: `ruoyi-admin/src/main/resources/application-dev.yml`
- Adds `customer-map` mock URL config.
- Modify: `ruoyi-admin/src/main/resources/application-uat.yml`
- Adds `customer-map` mock URL config.
- Modify: `ruoyi-admin/src/main/resources/application-pro.yml`
- Adds `customer-map` mock URL config for the production profile, currently pointing to local mock as requested.
- Create: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/domain/vo/CustomerMapRecordVOTest.java`
- Verifies JSON field names stay underscored.
- Create: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingCustomerMapServiceTest.java`
- Verifies personal/corporate URL routing, `cust_id` forwarding, response parsing, and missing customer-id errors.
- Create: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/controller/LoanRatePricingMockControllerCustomerMapTest.java`
- Verifies mock endpoints return one or more underscore-field records.
- Create: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/controller/LoanPricingWorkflowControllerCustomerMapTest.java`
- Verifies business controller methods delegate to the service and preserve the result records.
## Task 1: Add Customer Map Record VO
**Files:**
- Create: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/vo/CustomerMapRecordVO.java`
- Create: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/domain/vo/CustomerMapRecordVOTest.java`
- [ ] **Step 1: Write the failing serialization test**
Create `CustomerMapRecordVOTest`:
```java
package com.ruoyi.loanpricing.domain.vo;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
class CustomerMapRecordVOTest
{
@Test
void shouldSerializeCustomerMapFieldsWithUnderscoreNames() throws Exception
{
CustomerMapRecordVO record = new CustomerMapRecordVO();
record.setCustId("101330419198206033217");
record.setCustIsn("81033011438");
record.setCustName("张三");
record.setFaithDay("20");
record.setBalanceAvg("300000");
record.setLoanCountHis("2");
record.setLastLoanDate("2025-12-01");
String json = new ObjectMapper().writeValueAsString(record);
assertTrue(json.contains("\"cust_id\""));
assertTrue(json.contains("\"cust_isn\""));
assertTrue(json.contains("\"cust_name\""));
assertTrue(json.contains("\"faith_day\""));
assertTrue(json.contains("\"balance_avg\""));
assertTrue(json.contains("\"loan_count_his\""));
assertTrue(json.contains("\"last_loan_date\""));
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```bash
mvn -pl ruoyi-loan-pricing -am -Dtest=CustomerMapRecordVOTest -Dsurefire.failIfNoSpecifiedTests=false test
```
Expected: FAIL because `CustomerMapRecordVO` does not exist.
- [ ] **Step 3: Implement the VO**
Create `CustomerMapRecordVO.java`:
```java
package com.ruoyi.loanpricing.domain.vo;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.io.Serializable;
import lombok.Data;
@Data
public class CustomerMapRecordVO implements Serializable
{
private static final long serialVersionUID = 1L;
@JsonProperty("cust_id")
private String custId;
@JsonProperty("cust_isn")
private String custIsn;
@JsonProperty("cust_name")
private String custName;
@JsonProperty("faith_day")
private String faithDay;
@JsonProperty("balance_avg")
private String balanceAvg;
@JsonProperty("loan_count_his")
private String loanCountHis;
@JsonProperty("last_loan_date")
private String lastLoanDate;
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run:
```bash
mvn -pl ruoyi-loan-pricing -am -Dtest=CustomerMapRecordVOTest -Dsurefire.failIfNoSpecifiedTests=false test
```
Expected: PASS.
## Task 2: Add Customer Map Service
**Files:**
- Create: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/LoanPricingCustomerMapService.java`
- Create: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingCustomerMapServiceTest.java`
- [ ] **Step 1: Write service tests first**
Create tests that use `MockRestServiceServer` and the package-private test constructor:
```java
package com.ruoyi.loanpricing.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.loanpricing.domain.vo.CustomerMapRecordVO;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.web.client.RestTemplate;
import org.springframework.test.web.client.MockRestServiceServer;
class LoanPricingCustomerMapServiceTest
{
@Test
void shouldQueryPersonalCustomerMapWithCustIdParam()
{
RestTemplate restTemplate = new RestTemplate();
MockRestServiceServer server = MockRestServiceServer.createServer(restTemplate);
LoanPricingCustomerMapService service = new LoanPricingCustomerMapService(
restTemplate,
"http://mock/personal",
"http://mock/corporate");
server.expect(requestTo("http://mock/personal?cust_id=P001"))
.andRespond(withSuccess("{\"code\":200,\"msg\":\"操作成功\",\"data\":[{\"cust_id\":\"P001\",\"cust_isn\":\"81033011438\",\"cust_name\":\"张三\"}]}",
MediaType.APPLICATION_JSON));
List<CustomerMapRecordVO> result = service.queryPersonal("P001");
assertEquals(1, result.size());
assertEquals("81033011438", result.get(0).getCustIsn());
assertEquals("张三", result.get(0).getCustName());
server.verify();
}
@Test
void shouldQueryCorporateCustomerMapWithCustIdParam()
{
RestTemplate restTemplate = new RestTemplate();
MockRestServiceServer server = MockRestServiceServer.createServer(restTemplate);
LoanPricingCustomerMapService service = new LoanPricingCustomerMapService(
restTemplate,
"http://mock/personal",
"http://mock/corporate");
server.expect(requestTo("http://mock/corporate?cust_id=C001"))
.andRespond(withSuccess("{\"code\":200,\"data\":[{\"cust_id\":\"C001\",\"cust_isn\":\"82002469287\",\"cust_name\":\"测试企业\"}]}",
MediaType.APPLICATION_JSON));
List<CustomerMapRecordVO> result = service.queryCorporate("C001");
assertEquals("82002469287", result.get(0).getCustIsn());
assertEquals("测试企业", result.get(0).getCustName());
server.verify();
}
@Test
void shouldRejectBlankCustId()
{
LoanPricingCustomerMapService service = new LoanPricingCustomerMapService(
new RestTemplate(),
"http://mock/personal",
"http://mock/corporate");
ServiceException ex = assertThrows(ServiceException.class, () -> service.queryPersonal(" "));
assertEquals("客户号不能为空", ex.getMessage());
}
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run:
```bash
mvn -pl ruoyi-loan-pricing -am -Dtest=LoanPricingCustomerMapServiceTest -Dsurefire.failIfNoSpecifiedTests=false test
```
Expected: FAIL because `LoanPricingCustomerMapService` does not exist.
- [ ] **Step 3: Implement the service**
Create the service with a default Spring constructor and a package-private test constructor:
```java
package com.ruoyi.loanpricing.service;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.loanpricing.domain.vo.CustomerMapRecordVO;
import java.net.URI;
import java.util.Collections;
import java.util.List;
import lombok.Data;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
@Service
public class LoanPricingCustomerMapService
{
private final RestTemplate restTemplate;
@Value("${customer-map.personal-url}")
private String personalUrl;
@Value("${customer-map.corporate-url}")
private String corporateUrl;
public LoanPricingCustomerMapService()
{
this(new RestTemplate());
}
LoanPricingCustomerMapService(RestTemplate restTemplate)
{
this.restTemplate = restTemplate;
}
LoanPricingCustomerMapService(RestTemplate restTemplate, String personalUrl, String corporateUrl)
{
this.restTemplate = restTemplate;
this.personalUrl = personalUrl;
this.corporateUrl = corporateUrl;
}
public List<CustomerMapRecordVO> queryPersonal(String custId)
{
return query(personalUrl, custId);
}
public List<CustomerMapRecordVO> queryCorporate(String custId)
{
return query(corporateUrl, custId);
}
private List<CustomerMapRecordVO> query(String url, String custId)
{
if (!StringUtils.hasText(custId))
{
throw new ServiceException("客户号不能为空");
}
URI uri = UriComponentsBuilder.fromHttpUrl(url)
.queryParam("cust_id", custId)
.build()
.toUri();
CustomerMapResponse response = restTemplate.getForObject(uri, CustomerMapResponse.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 CustomerMapResponse
{
private Integer code;
private String msg;
private List<CustomerMapRecordVO> data;
}
}
```
- [ ] **Step 4: Run service tests**
Run:
```bash
mvn -pl ruoyi-loan-pricing -am -Dtest=LoanPricingCustomerMapServiceTest -Dsurefire.failIfNoSpecifiedTests=false test
```
Expected: PASS.
## Task 3: Add Mock Endpoints
**Files:**
- Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/controller/LoanRatePricingMockController.java`
- Create: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/controller/LoanRatePricingMockControllerCustomerMapTest.java`
- [ ] **Step 1: Write controller tests for mock endpoints**
```java
package com.ruoyi.loanpricing.controller;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.common.core.domain.AjaxResult;
import java.util.List;
import org.junit.jupiter.api.Test;
class LoanRatePricingMockControllerCustomerMapTest
{
@Test
void shouldReturnPersonalCustomerMapRecords() throws Exception
{
LoanRatePricingMockController controller = new LoanRatePricingMockController();
AjaxResult result = controller.queryPersonalCustomerMap("P001");
List<?> rows = (List<?>) result.get("data");
assertFalse(rows.isEmpty());
String json = new ObjectMapper().writeValueAsString(rows.get(0));
assertTrue(json.contains("\"cust_id\""));
assertTrue(json.contains("\"cust_isn\""));
assertTrue(json.contains("\"cust_name\""));
}
@Test
void shouldReturnCorporateCustomerMapRecords() throws Exception
{
LoanRatePricingMockController controller = new LoanRatePricingMockController();
AjaxResult result = controller.queryCorporateCustomerMap("C001");
List<?> rows = (List<?>) result.get("data");
assertFalse(rows.isEmpty());
String json = new ObjectMapper().writeValueAsString(rows.get(0));
assertTrue(json.contains("\"cust_id\""));
assertTrue(json.contains("\"loan_count_his\""));
assertTrue(json.contains("\"last_loan_date\""));
}
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run:
```bash
mvn -pl ruoyi-loan-pricing -am -Dtest=LoanRatePricingMockControllerCustomerMapTest -Dsurefire.failIfNoSpecifiedTests=false test
```
Expected: FAIL because the mock methods do not exist.
- [ ] **Step 3: Implement mock methods**
Modify `LoanRatePricingMockController`:
```java
@Anonymous
@Operation(summary = "模拟个人客户号映射查询")
@GetMapping("/customer-map/personal")
public AjaxResult queryPersonalCustomerMap(@RequestParam("cust_id") String custId)
{
return success(randomCustomerMapRecords(custId, "个人客户"));
}
@Anonymous
@Operation(summary = "模拟企业客户号映射查询")
@GetMapping("/customer-map/corporate")
public AjaxResult queryCorporateCustomerMap(@RequestParam("cust_id") String custId)
{
return success(randomCustomerMapRecords(custId, "企业客户"));
}
```
Add a private helper in the same controller:
```java
private List<CustomerMapRecordVO> randomCustomerMapRecords(String custId, String namePrefix)
{
if (!StringUtils.hasText(custId))
{
throw new ServiceException("客户号不能为空");
}
int count = ThreadLocalRandom.current().nextInt(1, 4);
List<CustomerMapRecordVO> records = new ArrayList<>();
for (int i = 1; i <= count; i++)
{
CustomerMapRecordVO record = new CustomerMapRecordVO();
record.setCustId(custId);
record.setCustIsn(String.valueOf(81000000000L + ThreadLocalRandom.current().nextInt(1000000)));
record.setCustName(namePrefix + i);
record.setFaithDay(String.valueOf(ThreadLocalRandom.current().nextInt(1, 365)));
record.setBalanceAvg(String.valueOf(ThreadLocalRandom.current().nextInt(10000, 900000)));
record.setLoanCountHis(String.valueOf(ThreadLocalRandom.current().nextInt(0, 10)));
record.setLastLoanDate(LocalDate.now().minusDays(ThreadLocalRandom.current().nextInt(1, 800)).toString());
records.add(record);
}
return records;
}
```
Required imports:
```java
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.loanpricing.domain.vo.CustomerMapRecordVO;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
```
- [ ] **Step 4: Run mock controller tests**
Run:
```bash
mvn -pl ruoyi-loan-pricing -am -Dtest=LoanRatePricingMockControllerCustomerMapTest -Dsurefire.failIfNoSpecifiedTests=false test
```
Expected: PASS.
## Task 4: Add Business Endpoints
**Files:**
- Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/controller/LoanPricingWorkflowController.java`
- Create: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/controller/LoanPricingWorkflowControllerCustomerMapTest.java`
- [ ] **Step 1: Write controller delegation tests**
```java
package com.ruoyi.loanpricing.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.loanpricing.domain.vo.CustomerMapRecordVO;
import com.ruoyi.loanpricing.service.LoanPricingCustomerMapService;
import java.lang.reflect.Field;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
class LoanPricingWorkflowControllerCustomerMapTest
{
@Test
void shouldReturnPersonalCustomerMapFromService() throws Exception
{
LoanPricingCustomerMapService service = Mockito.mock(LoanPricingCustomerMapService.class);
CustomerMapRecordVO row = new CustomerMapRecordVO();
row.setCustIsn("81033011438");
when(service.queryPersonal("P001")).thenReturn(List.of(row));
LoanPricingWorkflowController controller = new LoanPricingWorkflowController();
setField(controller, "customerMapService", service);
AjaxResult result = controller.queryPersonalCustomerMap("P001");
List<?> rows = (List<?>) result.get("data");
assertEquals(1, rows.size());
}
@Test
void shouldReturnCorporateCustomerMapFromService() throws Exception
{
LoanPricingCustomerMapService service = Mockito.mock(LoanPricingCustomerMapService.class);
CustomerMapRecordVO row = new CustomerMapRecordVO();
row.setCustIsn("82002469287");
when(service.queryCorporate("C001")).thenReturn(List.of(row));
LoanPricingWorkflowController controller = new LoanPricingWorkflowController();
setField(controller, "customerMapService", service);
AjaxResult result = controller.queryCorporateCustomerMap("C001");
List<?> rows = (List<?>) result.get("data");
assertEquals(1, rows.size());
}
private static void setField(Object target, String fieldName, Object value) throws Exception
{
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run:
```bash
mvn -pl ruoyi-loan-pricing -am -Dtest=LoanPricingWorkflowControllerCustomerMapTest -Dsurefire.failIfNoSpecifiedTests=false test
```
Expected: FAIL because controller methods and field do not exist.
- [ ] **Step 3: Implement controller methods**
In `LoanPricingWorkflowController`, add:
```java
@Autowired
private LoanPricingCustomerMapService customerMapService;
@Operation(summary = "查询个人客户号映射")
@GetMapping("/customer-map/personal")
public AjaxResult queryPersonalCustomerMap(@RequestParam("custId") String custId)
{
return success(customerMapService.queryPersonal(custId));
}
@Operation(summary = "查询企业客户号映射")
@GetMapping("/customer-map/corporate")
public AjaxResult queryCorporateCustomerMap(@RequestParam("custId") String custId)
{
return success(customerMapService.queryCorporate(custId));
}
```
Required import:
```java
import com.ruoyi.loanpricing.service.LoanPricingCustomerMapService;
```
- [ ] **Step 4: Run controller tests**
Run:
```bash
mvn -pl ruoyi-loan-pricing -am -Dtest=LoanPricingWorkflowControllerCustomerMapTest -Dsurefire.failIfNoSpecifiedTests=false test
```
Expected: PASS.
## Task 5: Add Profile Configuration
**Files:**
- 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`
- [ ] **Step 1: Add `customer-map` to every active profile**
Place this block next to the existing `model:` block in each profile file:
```yaml
customer-map:
personal-url: http://localhost:63310/rate/pricing/mock/customer-map/personal
corporate-url: http://localhost:63310/rate/pricing/mock/customer-map/corporate
```
Keep the existing `model:` URLs unchanged.
- [ ] **Step 2: Verify config keys exist**
Run:
```bash
rg -n "customer-map:|personal-url: http://localhost:63310/rate/pricing/mock/customer-map/personal|corporate-url: http://localhost:63310/rate/pricing/mock/customer-map/corporate" ruoyi-admin/src/main/resources/application-dev.yml ruoyi-admin/src/main/resources/application-uat.yml ruoyi-admin/src/main/resources/application-pro.yml
```
Expected: each profile contains one `customer-map` block with both mock URLs.
## Task 6: Backend Verification
**Files:**
- Verify only, no new files.
- [ ] **Step 1: Run focused backend tests**
Run:
```bash
mvn -pl ruoyi-loan-pricing -am -Dtest=CustomerMapRecordVOTest,LoanPricingCustomerMapServiceTest,LoanRatePricingMockControllerCustomerMapTest,LoanPricingWorkflowControllerCustomerMapTest -Dsurefire.failIfNoSpecifiedTests=false test
```
Expected: PASS.
- [ ] **Step 2: Run affected existing model/workflow tests**
Run:
```bash
mvn -pl ruoyi-loan-pricing -am -Dtest=LoanPricingModelServiceTest,LoanPricingWorkflowServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test
```
Expected: PASS.
- [ ] **Step 3: Manual API verification after backend restart**
Restart backend so config and routes are active, then call:
```bash
curl -sS 'http://localhost:63310/rate/pricing/mock/customer-map/personal?cust_id=P001'
curl -sS 'http://localhost:63310/rate/pricing/mock/customer-map/corporate?cust_id=C001'
TOKEN=$(curl -sS -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin123"}' 'http://localhost:63310/login/test' | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
curl -sS -H "Authorization: Bearer ${TOKEN}" 'http://localhost:63310/loanPricing/workflow/customer-map/personal?custId=P001'
curl -sS -H "Authorization: Bearer ${TOKEN}" 'http://localhost:63310/loanPricing/workflow/customer-map/corporate?custId=C001'
```
Expected: each successful response uses the RuoYi response wrapper and `data` contains one or more records with underscore fields. If the local admin password differs, replace `admin/admin123` with a valid local test account before calling the authenticated workflow endpoints.
- [ ] **Step 4: Commit backend work**
Use a Chinese commit message and do not include unrelated dirty files:
```bash
git status --short
git add ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/vo/CustomerMapRecordVO.java \
ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/LoanPricingCustomerMapService.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-loan-pricing/src/test/java/com/ruoyi/loanpricing/domain/vo/CustomerMapRecordVOTest.java \
ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingCustomerMapServiceTest.java \
ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/controller/LoanRatePricingMockControllerCustomerMapTest.java \
ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/controller/LoanPricingWorkflowControllerCustomerMapTest.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
git commit -m "新增客户号映射后端接口"
```
Do not commit temporary curl output, screenshots, or generated test data.

View File

@@ -0,0 +1,662 @@
# Customer Map Selection Frontend Implementation Plan
> **For agentic workers:** Steps use checkbox (`- [ ]`) syntax for tracking. Follow this repository's AGENTS.md rule: do not enable subagents or superpowers execution modes unless the user explicitly requests them for the implementation session.
**Goal:** Add the frontend customer-id query and customer-internal-code selection step before opening personal or corporate workflow creation dialogs.
**Architecture:** Keep the existing list page and personal/corporate creation dialogs. Insert a shared customer-map selector dialog between customer-type selection and workflow creation, then pass the selected underscore-field record into the existing create dialogs. Existing workflow create APIs stay unchanged and still receive `custIsn` and `custName` in camelCase.
**Tech Stack:** Vue 2, Element UI, existing RuoYi request wrapper, Node static assertion tests, nvm-managed frontend runtime, Playwright/browser verification after implementation.
---
## File Structure
- Modify: `ruoyi-ui/src/api/loanPricing/workflow.js`
- Adds personal/corporate customer-map query functions.
- Create: `ruoyi-ui/src/views/loanPricing/workflow/components/CustomerMapSelector.vue`
- Owns customer-id input, query action, result table, loading state, and row selection.
- Modify: `ruoyi-ui/src/views/loanPricing/workflow/index.vue`
- Opens customer-map selector after customer type selection and then opens the correct create dialog after row selection.
- Modify: `ruoyi-ui/src/views/loanPricing/workflow/components/PersonalCreateDialog.vue`
- Accepts selected customer-map record, fills `custIsn` and `custName`, and makes both fields read-only.
- Modify: `ruoyi-ui/src/views/loanPricing/workflow/components/CorporateCreateDialog.vue`
- Same selected-record behavior for corporate creation.
- Modify: `ruoyi-ui/package.json`
- Adds a focused test script for the new customer-map selection checks.
- Create: `ruoyi-ui/tests/customer-map-selection.test.js`
- Static test coverage for API paths, selector fields, parent wiring, selected underscore fields, and read-only create dialog inputs.
## Task 1: Add Frontend API Methods
**Files:**
- Modify: `ruoyi-ui/src/api/loanPricing/workflow.js`
- Create: `ruoyi-ui/tests/customer-map-selection.test.js`
- Modify: `ruoyi-ui/package.json`
- [ ] **Step 1: Write failing API assertions**
Create the first version of `ruoyi-ui/tests/customer-map-selection.test.js`:
```js
const fs = require('fs')
const path = require('path')
const assert = require('assert')
function read(relativePath) {
return fs.readFileSync(path.join(__dirname, '..', relativePath), 'utf8')
}
const workflowApi = read('src/api/loanPricing/workflow.js')
assert(
workflowApi.includes('export function queryPersonalCustomerMap') &&
workflowApi.includes("url: '/loanPricing/workflow/customer-map/personal'") &&
workflowApi.includes('params: { custId: custId }'),
'缺少个人客户号映射查询 API'
)
assert(
workflowApi.includes('export function queryCorporateCustomerMap') &&
workflowApi.includes("url: '/loanPricing/workflow/customer-map/corporate'") &&
workflowApi.includes('params: { custId: custId }'),
'缺少企业客户号映射查询 API'
)
console.log('customer map selection assertions passed')
```
Add a script in `ruoyi-ui/package.json`:
```json
"test:customer-map-selection": "node tests/customer-map-selection.test.js"
```
- [ ] **Step 2: Run the test to verify it fails**
Run with the project Node version:
```bash
zsh -lic 'nvm use 14.21.3 >/dev/null && npm --prefix ruoyi-ui run test:customer-map-selection'
```
Expected: FAIL because the API methods do not exist.
- [ ] **Step 3: Implement the API methods**
Append to `workflow.js`:
```js
// 查询个人客户号映射
export function queryPersonalCustomerMap(custId) {
return request({
url: '/loanPricing/workflow/customer-map/personal',
method: 'get',
params: { custId: custId }
})
}
// 查询企业客户号映射
export function queryCorporateCustomerMap(custId) {
return request({
url: '/loanPricing/workflow/customer-map/corporate',
method: 'get',
params: { custId: custId }
})
}
```
- [ ] **Step 4: Run the API test**
Run:
```bash
zsh -lic 'nvm use 14.21.3 >/dev/null && npm --prefix ruoyi-ui run test:customer-map-selection'
```
Expected: PASS for API assertions.
## Task 2: Create Customer Map Selector Dialog
**Files:**
- Create: `ruoyi-ui/src/views/loanPricing/workflow/components/CustomerMapSelector.vue`
- Modify: `ruoyi-ui/tests/customer-map-selection.test.js`
- [ ] **Step 1: Extend the static test for selector requirements**
Add these assertions to `customer-map-selection.test.js`:
```js
const selector = read('src/views/loanPricing/workflow/components/CustomerMapSelector.vue')
assert(
selector.includes('title="客户号查询"') &&
selector.includes('v-model="queryForm.custId"') &&
selector.includes('handleQuery'),
'客户号查询弹窗缺少客户号输入或查询动作'
)
assert(
selector.includes("queryPersonalCustomerMap") &&
selector.includes("queryCorporateCustomerMap") &&
selector.includes("this.customerType === 'personal'"),
'客户号查询弹窗未按客户类型调用个人/企业接口'
)
;['cust_id', 'cust_isn', 'cust_name', 'faith_day', 'balance_avg', 'loan_count_his', 'last_loan_date'].forEach((field) => {
assert(selector.includes(`prop="${field}"`) || selector.includes(`row.${field}`), `查询结果表格缺少字段 ${field}`)
})
assert(
selector.includes("this.$emit('select', row)") &&
selector.includes('未查询到客户信息') &&
selector.includes('请输入客户号'),
'客户号查询弹窗缺少选择事件或关键提示'
)
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```bash
zsh -lic 'nvm use 14.21.3 >/dev/null && npm --prefix ruoyi-ui run test:customer-map-selection'
```
Expected: FAIL because `CustomerMapSelector.vue` does not exist.
- [ ] **Step 3: Implement `CustomerMapSelector.vue`**
Create the component:
```vue
<template>
<el-dialog title="客户号查询" :visible.sync="dialogVisible" width="900px" append-to-body
:close-on-click-modal="false" @close="handleClose">
<el-form :model="queryForm" inline size="small">
<el-form-item label="客户号">
<el-input v-model="queryForm.custId" placeholder="请输入客户号" clearable @keyup.enter.native="handleQuery"/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" :loading="loading" @click="handleQuery">查询</el-button>
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="customerList">
<el-table-column label="客户号" prop="cust_id" align="center" :show-overflow-tooltip="true"/>
<el-table-column label="客户内码" prop="cust_isn" align="center" :show-overflow-tooltip="true"/>
<el-table-column label="客户名称" prop="cust_name" align="center" :show-overflow-tooltip="true"/>
<el-table-column label="用信天数" prop="faith_day" align="center"/>
<el-table-column label="存款年日均" prop="balance_avg" align="center"/>
<el-table-column label="历史贷款次数" prop="loan_count_his" align="center"/>
<el-table-column label="上次贷款日期" prop="last_loan_date" align="center" width="130"/>
<el-table-column label="操作" align="center" width="90">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="handleSelect(scope.row)">选择</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
</template>
<script>
import {queryPersonalCustomerMap, queryCorporateCustomerMap} from "@/api/loanPricing/workflow"
export default {
name: "CustomerMapSelector",
props: {
visible: {
type: Boolean,
default: false
},
customerType: {
type: String,
default: undefined
}
},
data() {
return {
loading: false,
queryForm: {
custId: undefined
},
customerList: []
}
},
computed: {
dialogVisible: {
get() {
return this.visible
},
set(val) {
this.$emit('update:visible', val)
}
}
},
methods: {
handleQuery() {
if (!this.queryForm.custId) {
this.$modal.msgWarning("请输入客户号")
return
}
this.loading = true
const request = this.customerType === 'personal'
? queryPersonalCustomerMap
: queryCorporateCustomerMap
request(this.queryForm.custId).then(response => {
this.customerList = response.data || []
if (this.customerList.length === 0) {
this.$modal.msgWarning("未查询到客户信息")
}
}).finally(() => {
this.loading = false
})
},
handleSelect(row) {
this.$emit('select', row)
this.dialogVisible = false
},
handleClose() {
this.queryForm.custId = undefined
this.customerList = []
this.loading = false
}
}
}
</script>
```
- [ ] **Step 4: Run the selector test**
Run:
```bash
zsh -lic 'nvm use 14.21.3 >/dev/null && npm --prefix ruoyi-ui run test:customer-map-selection'
```
Expected: PASS through selector assertions.
## Task 3: Wire Selector Into Workflow List Page
**Files:**
- Modify: `ruoyi-ui/src/views/loanPricing/workflow/index.vue`
- Modify: `ruoyi-ui/tests/customer-map-selection.test.js`
- [ ] **Step 1: Extend the test for parent wiring**
Add assertions:
```js
const workflowIndex = read('src/views/loanPricing/workflow/index.vue')
assert(
workflowIndex.includes('CustomerMapSelector') &&
workflowIndex.includes('<customer-map-selector') &&
workflowIndex.includes(':customer-type="selectedCustomerType"') &&
workflowIndex.includes('@select="handleCustomerMapSelect"'),
'流程列表页未接入客户号查询选择弹窗'
)
assert(
workflowIndex.includes('selectedCustomerType') &&
workflowIndex.includes('selectedCustomerMap') &&
workflowIndex.includes('showCustomerMapSelector'),
'流程列表页缺少客户类型、客户映射选择状态'
)
assert(
workflowIndex.includes("this.selectedCustomerType = type") &&
workflowIndex.includes('this.showCustomerMapSelector = true') &&
!workflowIndex.includes("if (type === 'personal') {\\n this.showPersonalDialog = true"),
'选择客户类型后应先打开客户号查询弹窗,而不是直接打开新增弹窗'
)
assert(
workflowIndex.includes('handleCustomerMapSelect') &&
workflowIndex.includes('this.selectedCustomerMap = row') &&
workflowIndex.includes('this.showPersonalDialog = true') &&
workflowIndex.includes('this.showCorporateDialog = true'),
'选择客户内码后未打开对应新增弹窗'
)
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```bash
zsh -lic 'nvm use 14.21.3 >/dev/null && npm --prefix ruoyi-ui run test:customer-map-selection'
```
Expected: FAIL because the list page does not yet use the selector.
- [ ] **Step 3: Update `index.vue` template and script**
Add component usage after the customer type selector:
```vue
<customer-map-selector
:visible.sync="showCustomerMapSelector"
:customer-type="selectedCustomerType"
@select="handleCustomerMapSelect"
/>
```
Pass the selected record into both create dialogs:
```vue
<personal-create-dialog
:visible.sync="showPersonalDialog"
:customer-map="selectedCustomerMap"
@success="handleCreateSuccess"
/>
<corporate-create-dialog
:visible.sync="showCorporateDialog"
:customer-map="selectedCustomerMap"
@success="handleCreateSuccess"
/>
```
Import and register the selector:
```js
import CustomerMapSelector from "./components/CustomerMapSelector"
```
Add state:
```js
showCustomerMapSelector: false,
selectedCustomerType: undefined,
selectedCustomerMap: null,
```
Change `handleSelectType`:
```js
handleSelectType(type) {
this.selectedCustomerType = type
this.selectedCustomerMap = null
this.showCustomerMapSelector = true
}
```
Add selected-row handler:
```js
handleCustomerMapSelect(row) {
this.selectedCustomerMap = row
if (this.selectedCustomerType === 'personal') {
this.showPersonalDialog = true
} else if (this.selectedCustomerType === 'corporate') {
this.showCorporateDialog = true
}
}
```
Clear selected state when the create flow completes:
```js
handleCreateSuccess() {
this.selectedCustomerMap = null
this.selectedCustomerType = undefined
this.getList()
}
```
Add watchers for dialog close so cancellation also clears the selected record:
```js
watch: {
showPersonalDialog(val) {
if (!val && !this.showCorporateDialog) {
this.selectedCustomerMap = null
this.selectedCustomerType = undefined
}
},
showCorporateDialog(val) {
if (!val && !this.showPersonalDialog) {
this.selectedCustomerMap = null
this.selectedCustomerType = undefined
}
}
}
```
- [ ] **Step 4: Run the parent wiring test**
Run:
```bash
zsh -lic 'nvm use 14.21.3 >/dev/null && npm --prefix ruoyi-ui run test:customer-map-selection'
```
Expected: PASS through parent wiring assertions.
## Task 4: Make Create Dialog Customer Fields Read-Only and Auto-Filled
**Files:**
- Modify: `ruoyi-ui/src/views/loanPricing/workflow/components/PersonalCreateDialog.vue`
- Modify: `ruoyi-ui/src/views/loanPricing/workflow/components/CorporateCreateDialog.vue`
- Modify: `ruoyi-ui/tests/customer-map-selection.test.js`
- [ ] **Step 1: Extend test for create dialog behavior**
Add assertions:
```js
const personalCreateDialog = read('src/views/loanPricing/workflow/components/PersonalCreateDialog.vue')
const corporateCreateDialog = read('src/views/loanPricing/workflow/components/CorporateCreateDialog.vue')
;[
['个人新增弹窗', personalCreateDialog],
['企业新增弹窗', corporateCreateDialog]
].forEach(([name, source]) => {
assert(source.includes('customerMap'), `${name} 缺少 customerMap 入参`)
assert(source.includes(':readonly="true"') || source.includes('readonly'), `${name} 客户内码和客户名称应只读`)
assert(source.includes('this.customerMap.cust_isn'), `${name} 未从 cust_isn 自动带入客户内码`)
assert(source.includes('this.customerMap.cust_name'), `${name} 未从 cust_name 自动带入客户名称`)
assert(source.includes('clearValidate'), `${name} 应清空校验而不是用 resetFields 覆盖已选客户`)
assert(!source.includes('this.resetForm("form")'), `${name} 不应在 reset() 中调用 resetForm("form") 覆盖只读客户字段`)
})
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```bash
zsh -lic 'nvm use 14.21.3 >/dev/null && npm --prefix ruoyi-ui run test:customer-map-selection'
```
Expected: FAIL because dialogs do not yet consume `customerMap`.
- [ ] **Step 3: Update personal create dialog**
Add prop:
```js
customerMap: {
type: Object,
default: null
}
```
Make customer fields read-only:
```vue
<el-input v-model="form.custIsn" placeholder="请选择客户内码" :readonly="true"/>
<el-input v-model="form.custName" placeholder="请选择客户名称" :readonly="true"/>
```
In `reset()`, initialize from selected row and do not call `this.resetForm("form")`. The existing RuoYi helper delegates to Element UI `resetFields()`, which can restore stale initial values and overwrite the selected read-only customer fields. After assigning the new form object, only clear validation:
```js
reset() {
this.form = {
orgCode: '892000',
runType: '1',
custIsn: this.customerMap ? this.customerMap.cust_isn : undefined,
custName: this.customerMap ? this.customerMap.cust_name : undefined,
idType: undefined,
idNum: undefined,
guarType: undefined,
applyAmt: undefined,
loanPurpose: undefined,
loanTerm: undefined,
bizProof: false,
loanLoop: false,
collType: undefined,
collThirdParty: false
}
this.submitting = false
this.$nextTick(() => {
if (this.$refs.form) {
this.$refs.form.clearValidate()
}
})
}
```
Keep existing required validation for `custIsn` and `custName`.
- [ ] **Step 4: Update corporate create dialog**
Apply the same prop, read-only inputs, and `reset()` initialization to `CorporateCreateDialog.vue`. Do not call `this.resetForm("form")` in the corporate dialog reset path; assign the new form object with `custIsn` and `custName` from `customerMap`, then use `this.$refs.form.clearValidate()` inside `$nextTick()`.
- [ ] **Step 5: Run the create dialog test**
Run:
```bash
zsh -lic 'nvm use 14.21.3 >/dev/null && npm --prefix ruoyi-ui run test:customer-map-selection'
```
Expected: PASS through create dialog assertions.
## Task 5: Frontend Verification
**Files:**
- Verify only, no new source files.
- [ ] **Step 1: Run focused customer-map test**
Run:
```bash
zsh -lic 'nvm use 14.21.3 >/dev/null && npm --prefix ruoyi-ui run test:customer-map-selection'
```
Expected: PASS.
- [ ] **Step 2: Run affected existing frontend tests**
Run:
```bash
zsh -lic 'nvm use 14.21.3 >/dev/null && npm --prefix ruoyi-ui run test:personal-create-input-params && npm --prefix ruoyi-ui run test:corporate-create-input-params && node ruoyi-ui/tests/workflow-index-refresh.test.js'
```
Expected: PASS.
- [ ] **Step 3: Build frontend**
Run:
```bash
zsh -lic 'nvm use 14.21.3 >/dev/null && npm --prefix ruoyi-ui run build:prod'
```
Expected: build succeeds.
- [ ] **Step 4: Browser verification after backend and frontend are running**
Use the real application page, not a prototype page:
1. Open the workflow list page.
2. Click “新增”.
3. Select “个人客户”.
4. Confirm the customer-id query dialog opens.
5. Query any customer number.
6. Select one result row.
7. Confirm the personal create dialog opens and `客户内码` / `客户名称` are auto-filled and read-only.
8. Fill the remaining required fields and submit.
9. Repeat the same flow for “企业客户”.
10. Close and reopen the create flow at least once with a second selected row, and confirm the new row's `cust_isn` / `cust_name` replace the previous values in the create dialog.
11. Confirm both created records appear in the list or detail page.
Expected: both personal and corporate flows pass through customer-map selection before creation.
- [ ] **Step 5: Cleanup test processes**
Stop any backend or frontend processes started for verification before ending the task.
- [ ] **Step 6: Commit frontend work**
Use a Chinese commit message and avoid unrelated dirty files:
```bash
git status --short
git add ruoyi-ui/src/api/loanPricing/workflow.js \
ruoyi-ui/src/views/loanPricing/workflow/components/CustomerMapSelector.vue \
ruoyi-ui/src/views/loanPricing/workflow/index.vue \
ruoyi-ui/src/views/loanPricing/workflow/components/PersonalCreateDialog.vue \
ruoyi-ui/src/views/loanPricing/workflow/components/CorporateCreateDialog.vue \
ruoyi-ui/package.json \
ruoyi-ui/tests/customer-map-selection.test.js
git commit -m "新增客户号查询选择前端流程"
```
Do not commit screenshots, browser traces, temporary spreadsheets, or generated test data.
## Task 6: Final End-to-End Verification and Implementation Record
**Files:**
- Create: `doc/implementation-report-2026-04-29-customer-map-selection.md`
- [ ] **Step 1: Run backend and frontend verification together**
After backend and frontend implementation are both complete:
```bash
mvn -pl ruoyi-loan-pricing -am -Dtest=CustomerMapRecordVOTest,LoanPricingCustomerMapServiceTest,LoanRatePricingMockControllerCustomerMapTest,LoanPricingWorkflowControllerCustomerMapTest,LoanPricingModelServiceTest,LoanPricingWorkflowServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test
zsh -lic 'nvm use 14.21.3 >/dev/null && npm --prefix ruoyi-ui run test:customer-map-selection && npm --prefix ruoyi-ui run test:personal-create-input-params && npm --prefix ruoyi-ui run test:corporate-create-input-params && node ruoyi-ui/tests/workflow-index-refresh.test.js'
```
Expected: all tests pass.
- [ ] **Step 2: Complete browser verification**
Run the browser flow from Task 5 for both personal and corporate customers. Confirm the backend API response wrapper is still the existing RuoYi wrapper while records inside `data` keep underscore fields.
- [ ] **Step 3: Write implementation report**
Create `doc/implementation-report-2026-04-29-customer-map-selection.md` with:
```markdown
# 2026-04-29 客户号查询选择客户内码实施记录
## 修改内容
- 后端新增个人/企业客户号映射业务接口。
- 后端新增个人/企业客户号映射 mock 接口。
- 配置文件新增 `customer-map` 个人/企业地址并指向本项目 mock。
- 前端新增客户号查询选择弹窗。
- 个人/企业新增流程改为先查询客户号、选择客户内码,再打开新增弹窗。
- 新增弹窗客户内码和客户名称由选中记录自动带入并只读。
## 验证结果
- 后端测试:填写实际执行命令和通过/失败结果。
- 前端测试:填写实际执行命令和通过/失败结果。
- 真实页面验证:填写个人、企业两条浏览器验证流程和结果。
- 进程清理:填写本次启动的前后端进程是否已关闭。
```
- [ ] **Step 4: Commit implementation report**
```bash
git add doc/implementation-report-2026-04-29-customer-map-selection.md
git commit -m "补充客户号映射选择实施记录"
```