🐛 合并 websoft/dev:改路线重推站名并回退进行中运单配载准入

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-27 16:23:57 +08:00
13 changed files with 427 additions and 32 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());
@@ -884,8 +884,8 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
String prefix = "运单" + waybill.getWaybillNo();
return prefix + switch (reason) {
case NOT_ROAD -> "运输方式不是公路,不可配载";
case NOT_LOADABLE_STATUS -> "当前状态不是待执行或进行中,不可配载";
case ON_OTHER_MANIFEST -> "已关联其他有效配载单";
case NOT_PENDING -> "当前状态不是待执行,不可配载";
case ON_OTHER_MANIFEST -> "已关联其他配载单";
};
}
@@ -1510,13 +1510,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);
}
@@ -29,7 +29,7 @@ import java.util.List;
import java.util.Locale;
/**
* 可配载准入判据:公路 + 待执行/进行中 + 未关联其它配载单。
* 可配载准入判据:公路 + 待执行 + 未关联其它配载单。
* <p>
* 接单状态不进判据:拒单票照样可配载。配载提交会把它的接单事实清成待接单,
* 否则新司机看不到这张票——司机端入口一律排除已拒绝的运单。
@@ -38,11 +38,8 @@ import java.util.Locale;
*/
public final class LoadableAdmissionSupport {
/** 可配载业务状态:待执行、进行中;已完成、已取消、草稿与调度中间态一律不可配载 */
public static final List<String> LOADABLE_BUSINESS_STATUSES = List.of(
WaybillProcessSupport.STATUS_PENDING,
WaybillProcessSupport.STATUS_RUNNING
);
/** 可配载只放行待执行:已完成、已取消、草稿与调度中间态一律不可配载 */
public static final String LOADABLE_BUSINESS_STATUS = WaybillProcessSupport.STATUS_PENDING;
/**
* 公路运输方式的同义写法,按「包含」比对,与前端运单管理页的识别口径一致。
@@ -59,7 +56,7 @@ public final class LoadableAdmissionSupport {
}
/**
* 不可配载的原因,按判定顺序排列:公路 → 待执行/进行中 → 未关联其它配载单。
* 不可配载的原因,按判定顺序排列:公路 → 待执行 → 未关联其它配载单。
* <p>
* 判据只产出原因,中文文案由调用方映射——面向用户的措辞不进这一层。
*/
@@ -68,8 +65,8 @@ public final class LoadableAdmissionSupport {
/** 运输方式不是公路 */
NOT_ROAD,
/** 业务状态不是待执行或进行中 */
NOT_LOADABLE_STATUS,
/** 业务状态不是待执行 */
NOT_PENDING,
/** 已关联其它配载单 */
ON_OTHER_MANIFEST
@@ -122,8 +119,8 @@ public final class LoadableAdmissionSupport {
if (waybill == null || !isRoadTransport(waybill.getTransportType())) {
return UnloadableReason.NOT_ROAD;
}
if (!LOADABLE_BUSINESS_STATUSES.contains(text(waybill.getBusinessStatus()))) {
return UnloadableReason.NOT_LOADABLE_STATUS;
if (!LOADABLE_BUSINESS_STATUS.equals(text(waybill.getBusinessStatus()))) {
return UnloadableReason.NOT_PENDING;
}
String loadingNo = text(waybill.getLoadingNo());
if (loadingNo.isEmpty() || loadingNo.equals(text(ownLoadingNo))) {
@@ -151,13 +148,13 @@ public final class LoadableAdmissionSupport {
}
/**
* 把整条可配载判据加到运单查询上:公路 + 待执行/进行中 + 未配载。
* 把整条可配载判据加到运单查询上:公路 + 待执行 + 未配载。
*
* @param wrapper 运单列表查询条件
*/
public static void applyLoadableConditions(LambdaQueryWrapper<Waybill> wrapper) {
applyRoadTransportCondition(wrapper);
wrapper.in(Waybill::getBusinessStatus, LOADABLE_BUSINESS_STATUSES);
wrapper.eq(Waybill::getBusinessStatus, LOADABLE_BUSINESS_STATUS);
wrapper.and(nested -> nested.isNull(Waybill::getLoadingNo).or().eq(Waybill::getLoadingNo, ""));
}
@@ -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 BigDecimal MIN_LONGITUDE = new BigDecimal("-180");
private static final BigDecimal MAX_LONGITUDE = new BigDecimal("180");
private static final BigDecimal MIN_LATITUDE = new BigDecimal("-90");
@@ -426,4 +437,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;
}
}
@@ -257,14 +257,14 @@ class DriverTripCardTest {
}
@Test
@DisplayName("首页当前任务取运输中运次,最多一条")
@DisplayName("首页当前任务取运输中与待回单确认运次,最多一条")
void tripCurrentTaskTakesRunningTrip() {
stubTripPage(1, loadingTrip());
DriverWaybillCardVO card = runAsDriver(() -> service.tripCurrentTask());
assertNotNull(card);
assertEquals(1, capturedQuery().getStatus());
assertEquals(List.of(1, 4), capturedQuery().getStatuses(), "只剩待回单确认的司机首页仍有当前任务");
assertEquals(1, capturedQuery().getSize());
Page<TripVO> empty = new Page<>(1, 1, 0);
@@ -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() {
@@ -369,7 +369,7 @@ class LoadingManageChildStatusWriteTest {
waybill.setProjectId(projectId);
waybill.setProcessJson(processJson);
waybill.setDeptId(DEPT_ID_LONG);
// 可配载放行公路 + 待执行/进行中 + 未配载,夹具默认待执行
// 可配载只放行公路 + 待执行 + 未配载,夹具的票都是这种票
waybill.setTransportType("公路运输");
waybill.setBusinessStatus("pending");
return waybill;
@@ -168,7 +168,7 @@ class ManageTripListDetailTest {
}
@Test
@DisplayName("待处理列表:两档与「未拒单」交给同一次共享取数;待重新派单仍走子单被拒那条路")
@DisplayName("待处理列表:三档(待接单/进行中/待回单确认)与「未拒单」交给同一次共享取数;待重新派单仍走子单被拒那条路")
void pendingListUsesSharedQuery() {
when(tripQueryService.page(any())).thenReturn(pageOf(0));
when(waybillService.page(any(), any())).thenAnswer(invocation -> invocation.getArgument(0));
@@ -177,7 +177,7 @@ class ManageTripListDetailTest {
ArgumentCaptor<TripQueryVO> captor = ArgumentCaptor.forClass(TripQueryVO.class);
verify(tripQueryService, times(1)).page(captor.capture());
assertEquals(List.of(0, 1), captor.getValue().getStatuses(), "待处理只要待接单与进行中两档");
assertEquals(List.of(0, 1, 4), captor.getValue().getStatuses(), "待处理含待接单、进行中、待回单确认三档");
assertTrue(captor.getValue().isExcludeRejectedTrips(), "未拒单也交给共享取数");
service.pendingList(1, 10, null, true);
@@ -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);
}
}
@@ -68,7 +68,7 @@ class WaybillLoadableQueryTest {
}
@Test
@DisplayName("开关开启:公路词表、待执行/进行中、未配载一次全部进 wrapper,接单状态不参与")
@DisplayName("开关开启:公路词表、待执行、未配载一次全部进 wrapper,接单状态不参与")
void loadableSwitchAppliesWholePredicate() {
LambdaQueryWrapper<Waybill> wrapper = capturedQuery(loadableQuery());
String sql = String.valueOf(wrapper.getTargetSql());
@@ -79,8 +79,10 @@ class WaybillLoadableQueryTest {
assertTrue(params.contains("%" + keyword + "%"),
"公路词表要整份进条件,只写 road 会漏掉中文取值的运单:" + params);
}
assertTrue(sql.contains("business_status") && params.contains("pending") && params.contains("running"),
"放行待执行与进行中:" + sql + " " + params);
assertTrue(sql.contains("business_status =") && params.contains("pending"),
"只放行待执行:" + sql + " " + params);
assertFalse(params.contains("running"),
"进行中一律不可配载,不能沿用「待执行与进行中都可配载」的旧口径:" + params);
assertTrue(sql.contains("loading_no IS NULL") && sql.contains("loading_no ="),
"未配载要同时覆盖 NULL 与空串:" + sql);
assertFalse(sql.contains("driver_accept_status"),
@@ -50,12 +50,10 @@ class LoadableAdmissionSupportTest {
// ------------------------------------------------------------ 单张运单准入
@Test
@DisplayName("公路 + 待执行/进行中 + 未配载才可配载")
@DisplayName("公路 + 待执行 + 未配载才可配载")
void loadableOnlyWhenAllConditionsHold() {
assertTrue(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", null, "pending"), null));
assertTrue(LoadableAdmissionSupport.isLoadable(waybill("公路运输", "pending", "", null), null));
assertTrue(LoadableAdmissionSupport.isLoadable(waybill("road", "running", null, null), null),
"进行中也可配载");
}
@Test
@@ -66,10 +64,12 @@ class LoadableAdmissionSupportTest {
}
@Test
@DisplayName("非公路、非待执行/进行中、已关联配载单各自单独即可拒绝")
@DisplayName("非公路、非待执行、已关联配载单各自单独即可拒绝")
void eachConditionRejectsOnItsOwn() {
assertFalse(LoadableAdmissionSupport.isLoadable(waybill("railway", "pending", null, "pending"), null),
"非公路不可配载");
assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "running", null, null), null),
"进行中不可配载:不需要接单的项目保存后即为进行中,那是过程配置的产物,不代表可配载");
assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "draft", null, null), null));
assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "completed", null, null), null));
assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "cancelled", null, null), null));
@@ -79,19 +79,18 @@ class LoadableAdmissionSupportTest {
}
@Test
@DisplayName("不可配载原因按 公路 → 待执行/进行中 → 未关联其它配载单 取第一条")
@DisplayName("不可配载原因按 公路 → 待执行 → 未关联其它配载单 取第一条")
void unloadableReasonFollowsPredicateOrder() {
assertEquals(LoadableAdmissionSupport.UnloadableReason.NOT_ROAD,
LoadableAdmissionSupport.unloadableReason(
waybill("railway", "completed", "PZ20260101-002", null), "PZ20260101-001"));
assertEquals(LoadableAdmissionSupport.UnloadableReason.NOT_LOADABLE_STATUS,
waybill("railway", "running", "PZ20260101-002", null), "PZ20260101-001"));
assertEquals(LoadableAdmissionSupport.UnloadableReason.NOT_PENDING,
LoadableAdmissionSupport.unloadableReason(
waybill("road", "completed", "PZ20260101-002", null), "PZ20260101-001"));
waybill("road", "running", "PZ20260101-002", null), "PZ20260101-001"));
assertEquals(LoadableAdmissionSupport.UnloadableReason.ON_OTHER_MANIFEST,
LoadableAdmissionSupport.unloadableReason(
waybill("road", "pending", "PZ20260101-002", null), "PZ20260101-001"));
assertNull(LoadableAdmissionSupport.unloadableReason(waybill("road", "pending", null, null), null));
assertNull(LoadableAdmissionSupport.unloadableReason(waybill("road", "running", null, null), null));
assertNull(LoadableAdmissionSupport.unloadableReason(
waybill("road", "pending", "PZ20260101-001", null), "PZ20260101-001"));
}
@@ -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号", " "));
}
}