🐛 改路线时按新地址重推运单站名

- TransportBusinessSupport 新增 deriveRegionName / syncRegionName:按「省+市+区县旗」推导站名,
  与库里存量口径一致,县级市只取到地级市,认不出行政区划退回整段地址并截断 100 字
- WaybillServiceImpl.changeRoute(PC 后台改路线)与
  ExceptionDisposalServiceImpl.applyWaybillRoute(小程序「同意并修改路线」)写地址时同步写站名;
  地址没变保留库里原站名,避免清洗旧的手填站名
- 此前两处都只写 departure_address / arrival_address,站名一直是派单时的旧值,
  司机端列表与详情标题改完仍是旧城市
- 补 TransportBusinessSupportRegionNameTest、WaybillChangeRouteStationNameTest 与异常处置站名断言
This commit is contained in:
2026-10-27 15:44:17 +08:00
parent dca59720bf
commit c82657ca56
6 changed files with 397 additions and 0 deletions
@@ -863,6 +863,10 @@ public class ExceptionDisposalServiceImpl
if (Func.isNotBlank(plan.routeJson())) {
waybill.setRouteJson(plan.routeJson());
}
waybill.setDepartureName(TransportBusinessSupport.syncRegionName(
waybill.getDepartureName(), waybill.getDepartureAddress(), plan.departureAddress()));
waybill.setArrivalName(TransportBusinessSupport.syncRegionName(
waybill.getArrivalName(), waybill.getArrivalAddress(), plan.arrivalAddress()));
waybill.setDepartureAddress(plan.departureAddress());
waybill.setArrivalAddress(plan.arrivalAddress());
waybill.setTaskInfoJson(plan.taskInfoJson());
@@ -1478,13 +1478,23 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
if ("completed".equals(oldRecord.getBusinessStatus()) || "cancelled".equals(oldRecord.getBusinessStatus())) {
throw new ServiceException("当前运单状态不允许变更运输路线");
}
String previousDepartureAddress = oldRecord.getDepartureAddress();
String previousArrivalAddress = oldRecord.getArrivalAddress();
oldRecord.setRouteJson(TransportBusinessSupport.trimToNull(waybill.getRouteJson()));
oldRecord.setDepartureAddress(TransportBusinessSupport.trimToNull(waybill.getDepartureAddress()));
oldRecord.setArrivalAddress(TransportBusinessSupport.trimToNull(waybill.getArrivalAddress()));
oldRecord.setDepartureName(TransportBusinessSupport.syncRegionName(
oldRecord.getDepartureName(), previousDepartureAddress, oldRecord.getDepartureAddress()));
oldRecord.setArrivalName(TransportBusinessSupport.syncRegionName(
oldRecord.getArrivalName(), previousArrivalAddress, oldRecord.getArrivalAddress()));
oldRecord.setTaskInfoJson(TransportBusinessSupport.trimToNull(waybill.getTaskInfoJson()));
TransportBusinessSupport.validateLength(oldRecord.getRouteJson(), 8000, "路线信息不能超过8000字");
TransportBusinessSupport.validateLength(oldRecord.getDepartureAddress(), 255, "发货地址不能超过255字");
TransportBusinessSupport.validateLength(oldRecord.getArrivalAddress(), 255, "收货地址不能超过255字");
TransportBusinessSupport.validateLength(oldRecord.getDepartureName(),
TransportBusinessSupport.REGION_NAME_MAX_LENGTH, "发货地不能超过100字");
TransportBusinessSupport.validateLength(oldRecord.getArrivalName(),
TransportBusinessSupport.REGION_NAME_MAX_LENGTH, "收货地不能超过100字");
TransportBusinessSupport.validateLength(oldRecord.getTaskInfoJson(), 8000, "任务信息不能超过8000字");
return updateById(oldRecord);
}
@@ -37,6 +37,7 @@ import java.time.LocalDate;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
@@ -50,8 +51,18 @@ public final class TransportBusinessSupport {
public static final String DATA_SOURCE_MANUAL = "手工创建";
public static final String DATA_SOURCE_PLAN_DISPATCH = "计划调度";
public static final String DATA_SOURCE_EXTERNAL = "外部系统";
/** 站点名(省+市+区县)列宽:blade_waybill.departure_name / arrival_name 为 varchar(100) */
public static final int REGION_NAME_MAX_LENGTH = 100;
private static final Pattern PHONE_PATTERN = Pattern.compile("^(1\\d{10}|0\\d{2,3}-?\\d{7,8})$");
/** 省级前缀:主体不含市/区/县,避免把「北京市朝阳区某省道」当省级 */
private static final Pattern PROVINCE_PATTERN = Pattern.compile("^[^市区县]{2,10}?(?:省|特别行政区|自治区)");
/** 市级:地级市名 2~4 字,「市场」的市不算(地址里常见「农贸市场」「超市」) */
private static final Pattern CITY_PATTERN = Pattern.compile("^[^区县旗市]{2,4}?市(?!场)");
/** 市级:自治州/地区/盟 的名字可以更长 */
private static final Pattern PREFECTURE_PATTERN = Pattern.compile("^[^区县旗市]{2,10}?(?:自治州|地区|盟)");
/** 区县:县级市不单独进站名,只取到地级市 */
private static final Pattern DISTRICT_PATTERN = Pattern.compile("^[^市县]{2,10}?(?:区|县|旗)");
/** 用户档案「数据权限范围」的取值:全部组织,与组织树过滤口径一致 */
private static final int DATA_SCOPE_ALL = 2;
private static final BigDecimal MIN_LONGITUDE = new BigDecimal("-180");
@@ -291,4 +302,64 @@ public final class TransportBusinessSupport {
return mileage == null ? null : mileage.stripTrailingZeros().toPlainString();
}
/**
* 按地址推导站名:省 + 市 + 区/县/旗。
* <p>
* 库里的站名就是这个口径(如「内蒙古自治区鄂尔多斯市鄂托克旗」「北京市朝阳区」),
* 只写地址不写站名会让司机端继续显示旧城市。地址里的「/」是分隔写法,推导时去掉;
* 认不出城市的退回整段地址,不拼半截区县名。
*/
public static String deriveRegionName(String address) {
String value = trimToNull(address);
if (value == null) {
return null;
}
String rest = value.replace("/", "");
String province = matchPrefix(rest, PROVINCE_PATTERN);
if (!province.isEmpty()) {
rest = rest.substring(province.length());
}
String city = matchPrefix(rest, CITY_PATTERN);
if (city.isEmpty()) {
city = matchPrefix(rest, PREFECTURE_PATTERN);
}
if (city.isEmpty()) {
return truncateRegionName(value);
}
StringBuilder name = new StringBuilder(province).append(city);
String district = matchPrefix(rest.substring(city.length()), DISTRICT_PATTERN);
if (!district.isEmpty()) {
name.append(district);
}
return truncateRegionName(name.toString());
}
/**
* 地址变了才按新地址重推站名,地址没动保留库里原有的站名。
*
* @param currentName 库里当前的站名
* @param previousAddress 改动前的地址
* @param nextAddress 改动后的地址
* @return 新地址对应的站名,地址被清空时返回 null
*/
public static String syncRegionName(String currentName, String previousAddress, String nextAddress) {
String next = trimToNull(nextAddress);
if (next == null) {
return null;
}
if (Objects.equals(trimToNull(previousAddress), next)) {
return trimToNull(currentName);
}
return deriveRegionName(next);
}
private static String matchPrefix(String text, Pattern pattern) {
Matcher matcher = pattern.matcher(text);
return matcher.find() ? matcher.group() : "";
}
private static String truncateRegionName(String value) {
return value.length() > REGION_NAME_MAX_LENGTH ? value.substring(0, REGION_NAME_MAX_LENGTH) : value;
}
}
@@ -313,6 +313,8 @@ class ExceptionDisposalRouteAdjustTest {
assertEquals(START, updated.getDepartureAddress());
assertEquals(NEW_END, updated.getArrivalAddress());
assertEquals("南宁市邕宁区", updated.getDepartureName(), "站名按新地址重推,不能停在旧城市");
assertEquals("成都市新都区", updated.getArrivalName());
Map<String, Object> taskInfo = JsonUtil.toMap(updated.getTaskInfoJson());
Map<?, ?> latest = (Map<?, ?>) ((List<?>) taskInfo.get("routeChangeRecords")).get(0);
assertEquals(routeContent(START, TRANSIT, NEW_END), latest.get("content"));
@@ -320,6 +322,27 @@ class ExceptionDisposalRouteAdjustTest {
verify(loadingManageMapper, never()).selectOne(any());
}
@Test
@DisplayName("只改到达地时发货地站名保留库里的原值,改了的到达地按新地址重推")
void adjustRouteKeepsDepartureNameWhenAddressUnchanged() {
stubPendingDisposal();
Waybill waybill = waybill(null, "in_progress", segmentRouteJson());
waybill.setDepartureAddress(START);
waybill.setDepartureName("南宁市邕宁区");
waybill.setArrivalAddress(END);
waybill.setArrivalName("成都市双流区");
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill);
asScheduler(() -> service.adjustRoute(request(
node("start", START, null),
node("transit", TRANSIT, null),
node("end", NEW_END, null))));
Waybill updated = updatedWaybill();
assertEquals("南宁市邕宁区", updated.getDepartureName(), "地址没动,站名不得被改写");
assertEquals("成都市新都区", updated.getArrivalName());
}
@Test
@DisplayName("运单已关联配载单但配载单查不到时拒绝,不越权改运单")
void adjustRouteRejectsWaybillBranchWhenLoadingNoPresent() {
@@ -0,0 +1,166 @@
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.transport.mapper.WaybillMapper;
import org.springblade.transport.pojo.entity.Waybill;
import java.lang.reflect.Field;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 运单「变更运输路线」的站名同步测试。
* <p>
* 司机端与运单列表读的是 departure_name / arrival_name,PC 后台改路线只提交地址。
* 只写地址不写站名,页面上就还是旧城市(YD-VF-ADMIN-R1 改成北京通州后仍显示福建省三明市)。
* 站名一律由后端按新地址推导,且只在地址真的变了才重推——地址没动时不得把库里已有的站名写坏。
*/
class WaybillChangeRouteStationNameTest {
private static final Long WAYBILL_ID = 7301L;
private static final Long DEPT_ID = 100L;
private static final String OLD_START = "福建省三明市梅列区列东街道88号";
private static final String OLD_END = "福建省三明市三元区江滨路9号";
private static final String NEW_END = "北京市通州区西集镇辛集路";
private WaybillMapper waybillMapper;
private WaybillServiceImpl service;
@BeforeAll
static void initTableInfo() {
// updateById 判主键、读实体元数据需要 MyBatis-Plus 的缓存
MybatisConfiguration configuration = new MybatisConfiguration();
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
assistant.setCurrentNamespace("test");
TableInfoHelper.initTableInfo(assistant, Waybill.class);
}
@BeforeEach
void setUp() throws Exception {
waybillMapper = mock(WaybillMapper.class);
service = new WaybillServiceImpl();
inject("baseMapper", waybillMapper);
when(waybillMapper.updateById(any(Waybill.class))).thenReturn(1);
}
@Test
@DisplayName("改到达地:地址写新值,到达站名按新地址重推,发货站名原样保留")
void changeRouteDerivesArrivalName() {
Waybill oldRecord = waybill();
oldRecord.setDepartureAddress(OLD_START);
oldRecord.setDepartureName("福建省三明市梅列区");
oldRecord.setArrivalAddress(OLD_END);
oldRecord.setArrivalName("福建省三明市三元区");
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(oldRecord);
asManager(() -> assertTrue(service.changeRoute(request(OLD_START, NEW_END))));
Waybill updated = updatedWaybill();
assertEquals(NEW_END, updated.getArrivalAddress());
assertEquals("北京市通州区", updated.getArrivalName(), "站名不能停在旧城市");
assertEquals(OLD_START, updated.getDepartureAddress());
assertEquals("福建省三明市梅列区", updated.getDepartureName(), "地址没动,站名不得被改写");
}
@Test
@DisplayName("只改路线节点与任务信息:地址未变,站名一个都不动")
void changeRouteKeepsNamesWhenAddressesUnchanged() {
Waybill oldRecord = waybill();
oldRecord.setDepartureAddress(OLD_START);
oldRecord.setDepartureName("福建省三明市梅列区");
oldRecord.setArrivalAddress(OLD_END);
oldRecord.setArrivalName("福建省三明市三元区");
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(oldRecord);
asManager(() -> assertTrue(service.changeRoute(request(OLD_START, OLD_END))));
Waybill updated = updatedWaybill();
assertEquals("福建省三明市梅列区", updated.getDepartureName());
assertEquals("福建省三明市三元区", updated.getArrivalName());
}
@Test
@DisplayName("已配载的运单不允许在这里改路线,站名也不写")
void changeRouteRejectsLoadedWaybill() {
Waybill oldRecord = waybill();
oldRecord.setLoadingNo("PZ-20261024-001");
oldRecord.setDepartureAddress(OLD_START);
oldRecord.setArrivalAddress(OLD_END);
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(oldRecord);
ServiceException exception = assertThrows(ServiceException.class,
() -> asManager(() -> service.changeRoute(request(OLD_START, NEW_END))));
assertEquals("运单已关联配载单,请在配载单中修改", exception.getMessage());
verify(waybillMapper, never()).updateById(any(Waybill.class));
}
// ------------------------------------------------------------ 夹具
private Waybill waybill() {
Waybill waybill = new Waybill();
waybill.setId(WAYBILL_ID);
waybill.setWaybillNo("YD20261024001");
waybill.setBusinessStatus("running");
waybill.setDeptId(DEPT_ID);
waybill.setDeptName("测试组织");
waybill.setIsDeleted(0);
return waybill;
}
private Waybill request(String departureAddress, String arrivalAddress) {
Waybill request = new Waybill();
request.setId(WAYBILL_ID);
request.setRouteJson("[{\"address\":\"" + departureAddress + "\"}]");
request.setDepartureAddress(departureAddress);
request.setArrivalAddress(arrivalAddress);
return request;
}
private Waybill updatedWaybill() {
ArgumentCaptor<Waybill> captor = ArgumentCaptor.forClass(Waybill.class);
verify(waybillMapper).updateById(captor.capture());
return captor.getValue();
}
private void asManager(Runnable action) {
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
authUtil.when(AuthUtil::getDeptId).thenReturn(String.valueOf(DEPT_ID));
action.run();
}
}
private void inject(String field, Object value) throws Exception {
Class<?> type = service.getClass();
while (type != null) {
try {
Field declared = type.getDeclaredField(field);
declared.setAccessible(true);
declared.set(service, value);
return;
} catch (NoSuchFieldException ignored) {
type = type.getSuperclass();
}
}
throw new IllegalStateException("未找到字段:" + field);
}
}
@@ -0,0 +1,123 @@
package org.springblade.transport.support;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* 站名推导测试。
* <p>
* 司机端与列表展示的是运单的 departure_name / arrival_name,不是地址;改路线只写地址、
* 不写站名,页面上就还是旧城市。库里的站名口径是「省 + 市 + 区/县/旗」,
* 例如 内蒙古自治区鄂尔多斯市鄂托克旗、北京市朝阳区、广西壮族自治区北海市合浦县。
*/
class TransportBusinessSupportRegionNameTest {
@Test
@DisplayName("省+市+区:只取行政区划,街道门牌不进站名")
void provinceCityDistrict() {
assertEquals("北京市朝阳区",
TransportBusinessSupport.deriveRegionName("北京市朝阳区十八里店乡周庄村1号院"));
assertEquals("福建省三明市梅列区",
TransportBusinessSupport.deriveRegionName("福建省三明市梅列区列东街道88号"));
}
@Test
@DisplayName("自治区+市+旗、市+县:旗与县同样算到区县一级")
void autonomousRegionCityBanner() {
assertEquals("内蒙古自治区鄂尔多斯市鄂托克旗",
TransportBusinessSupport.deriveRegionName("内蒙古自治区鄂尔多斯市鄂托克旗棋盘井镇运煤专线"));
assertEquals("广西壮族自治区北海市合浦县",
TransportBusinessSupport.deriveRegionName("广西壮族自治区北海市合浦县廉州镇还珠大道"));
}
@Test
@DisplayName("直辖市与自治州:省这一级可以没有")
void municipalityAndAutonomousPrefecture() {
assertEquals("天津市北辰区",
TransportBusinessSupport.deriveRegionName("天津市北辰区青光镇津围公路"));
assertEquals("湖北省恩施土家族苗族自治州",
TransportBusinessSupport.deriveRegionName("湖北省恩施土家族苗族自治州恩施市舞阳大道"));
}
@Test
@DisplayName("斜杠分隔的地址写法:拼站名时去掉斜杠")
void slashSeparatedAddress() {
assertEquals("南宁市邕宁区",
TransportBusinessSupport.deriveRegionName("南宁市/邕宁区/那赖二路神网数码物流园"));
}
@Test
@DisplayName("只到市一级:不硬凑区县")
void cityOnly() {
assertEquals("贵州省贵阳市", TransportBusinessSupport.deriveRegionName("贵州省贵阳市新店镇农贸市场"));
}
@Test
@DisplayName("「市场」「超市」里的市不是城市名,县级市也只取到地级市")
void marketNameIsNotACity() {
assertEquals("贵州省贵阳市",
TransportBusinessSupport.deriveRegionName("贵州省贵阳市城东农贸市场3号门"));
assertEquals("江苏省苏州市",
TransportBusinessSupport.deriveRegionName("江苏省苏州市昆山市玉山镇紫竹路"));
}
@Test
@DisplayName("地址里的「省道」不当省级:仍按市、区推导")
void provinceRoadIsNotProvince() {
assertEquals("北京市朝阳区", TransportBusinessSupport.deriveRegionName("北京市朝阳区某省道88号"));
}
@Test
@DisplayName("认不出城市时退回整段地址,不拼半截区县名")
void fallsBackToFullAddress() {
assertEquals("新店镇农贸市场3号门", TransportBusinessSupport.deriveRegionName("新店镇农贸市场3号门"));
assertEquals("南宁吴圩国际机场货运站", TransportBusinessSupport.deriveRegionName("南宁吴圩国际机场货运站"));
}
@Test
@DisplayName("空地址没有站名")
void blankAddress() {
assertNull(TransportBusinessSupport.deriveRegionName(null));
assertNull(TransportBusinessSupport.deriveRegionName(" "));
}
@Test
@DisplayName("站名超出列宽时按 100 字截断")
void truncatesToColumnWidth() {
String address = "新店镇" + "甲乙丙丁".repeat(30);
String name = TransportBusinessSupport.deriveRegionName(address);
assertEquals(TransportBusinessSupport.REGION_NAME_MAX_LENGTH, name.length());
assertEquals(address.substring(0, TransportBusinessSupport.REGION_NAME_MAX_LENGTH), name);
}
@Test
@DisplayName("地址没变时保留库里原有的站名")
void keepsNameWhenAddressUnchanged() {
assertEquals("福建省三明市", TransportBusinessSupport.syncRegionName(
"福建省三明市", "福建省三明市梅列区列东街道88号", "福建省三明市梅列区列东街道88号"));
assertNull(TransportBusinessSupport.syncRegionName(
null, "福建省三明市梅列区列东街道88号", "福建省三明市梅列区列东街道88号"));
}
@Test
@DisplayName("地址变了按新地址重推站名,与旧站名无关")
void derivesNameWhenAddressChanged() {
assertEquals("北京市通州区", TransportBusinessSupport.syncRegionName(
"福建省三明市", "福建省三明市梅列区列东街道88号", "北京市通州区西集镇辛集路"));
}
@Test
@DisplayName("地址被清空时站名一起清空")
void clearsNameWhenAddressCleared() {
assertNull(TransportBusinessSupport.syncRegionName(
"福建省三明市", "福建省三明市梅列区列东街道88号", null));
assertNull(TransportBusinessSupport.syncRegionName(
"福建省三明市", "福建省三明市梅列区列东街道88号", " "));
}
}