diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java index 4a6acf7e..2e19ee29 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java @@ -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()); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java index 98aff41c..996cfb77 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java @@ -1478,13 +1478,23 @@ public class WaybillServiceImpl extends BaseServiceImpl 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); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/TransportBusinessSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/TransportBusinessSupport.java index 1a17390d..a7e156dd 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/TransportBusinessSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/TransportBusinessSupport.java @@ -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(); } + /** + * 按地址推导站名:省 + 市 + 区/县/旗。 + *

+ * 库里的站名就是这个口径(如「内蒙古自治区鄂尔多斯市鄂托克旗」「北京市朝阳区」), + * 只写地址不写站名会让司机端继续显示旧城市。地址里的「/」是分隔写法,推导时去掉; + * 认不出城市的退回整段地址,不拼半截区县名。 + */ + 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; + } + } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java index eb658339..1b2ff39d 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalRouteAdjustTest.java @@ -313,6 +313,8 @@ class ExceptionDisposalRouteAdjustTest { assertEquals(START, updated.getDepartureAddress()); assertEquals(NEW_END, updated.getArrivalAddress()); + assertEquals("南宁市邕宁区", updated.getDepartureName(), "站名按新地址重推,不能停在旧城市"); + assertEquals("成都市新都区", updated.getArrivalName()); Map 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() { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillChangeRouteStationNameTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillChangeRouteStationNameTest.java new file mode 100644 index 00000000..08eefa1b --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillChangeRouteStationNameTest.java @@ -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; + +/** + * 运单「变更运输路线」的站名同步测试。 + *

+ * 司机端与运单列表读的是 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 captor = ArgumentCaptor.forClass(Waybill.class); + verify(waybillMapper).updateById(captor.capture()); + return captor.getValue(); + } + + private void asManager(Runnable action) { + try (MockedStatic 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); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/TransportBusinessSupportRegionNameTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/TransportBusinessSupportRegionNameTest.java new file mode 100644 index 00000000..e57a897d --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/TransportBusinessSupportRegionNameTest.java @@ -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; + +/** + * 站名推导测试。 + *

+ * 司机端与列表展示的是运单的 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号", " ")); + } + +}