From 44197a4cffece82f28a97f8a272ab0cb900831d1 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Wed, 7 Oct 2026 23:00:28 +0800 Subject: [PATCH 1/8] =?UTF-8?q?:bug:=20=E5=AE=A1=E6=A0=B8=E9=80=9A?= =?UTF-8?q?=E8=BF=87=E4=B8=8E=E9=87=8D=E6=96=B0=E6=8F=90=E4=BA=A4=E6=97=B6?= =?UTF-8?q?=E6=B8=85=E7=A9=BA=E6=A1=A3=E6=A1=88=E4=B8=8A=E7=9A=84=E6=97=A7?= =?UTF-8?q?=E9=A9=B3=E5=9B=9E=E5=8E=9F=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 司机/车辆档案上的 certification_reject_reason 是展示缓存,没有驳回原因时应为 NULL(建表即 DEFAULT NULL,数据订正脚本也写 NULL)。但框架的 not_null 更新策略会跳过 patch 里的 null 字段,把 null 交给 updateById 到不了 SQL,于是两处都留下了陈旧文案:审核通过后状态已是「已通过」,档案上却还挂着上一次的驳回原因;被驳回后重新提交,旧原因会一直挂到审核通过,而提交侧本就写明「新提交不能继承上一次的驳回文案」。现在这两条路径各显式发一条只改该列的语句,带主键条件,不碰生效快照指针、不碰车辆绑定,驳回时仍照常写入原因。改用 ALWAYS 更新策略会让改手机号、绑定车辆这类无关操作也清掉该列,故不做。 --- .../service/impl/CertAuditServiceImpl.java | 47 ++++++++-- .../service/impl/DriverCertServiceImpl.java | 13 +++ .../impl/CertAuditServiceImplTest.java | 86 +++++++++++++++++++ .../impl/DriverCertServiceImplTest.java | 43 ++++++++++ .../service/impl/PersonCertRoundTripTest.java | 13 +++ 5 files changed, 197 insertions(+), 5 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java index 9d1c07dc..b7bb4e34 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java @@ -191,7 +191,6 @@ public class CertAuditServiceImpl implements ICertAuditService { private void updateCertTarget(TransportCertRecord record, boolean pass, String rejectReason) { boolean vehicleTarget = TransportCertRecord.BIZ_TYPE_VEHICLE.equals(record.getBizType()); Integer status = pass ? TransportCertRecord.STATUS_APPROVED : TransportCertRecord.STATUS_REJECTED; - String reason = pass ? null : CertificationRuleSupport.reasonForReject(rejectReason); // 只有通过才切指针;驳回时保持 null,框架的 not_null 更新策略会跳过该列 Long newEffectiveId = pass ? record.getId() : null; @@ -199,22 +198,25 @@ public class CertAuditServiceImpl implements ICertAuditService { TransportVehicle vehicle = new TransportVehicle(); vehicle.setId(record.getBizId()); vehicle.setCertificationStatus(status); - vehicle.setCertificationRejectReason(reason); if (pass) { vehicle.setEffectiveCertRecordId(newEffectiveId); // 通过即生效:把这次快照的证件内容一并写回车辆档案,让「车辆档案 = 生效值」成立。 // 不写回的话,审核通过之后管理端与司机端看到的仍是提交之前的旧值—— // 状态显示「已通过」、内容却还是旧的,是最容易让人误判的一种不一致。 - // 与指针合并成同一次 UPDATE,不额外多写一次同一行。 + // 与指针合并成同一次 UPDATE。 applySnapshotToVehicle(vehicle, record.getSnapshotJson()); + } else { + vehicle.setCertificationRejectReason(CertificationRuleSupport.reasonForReject(rejectReason)); } requireTargetUpdated(transportVehicleService.updateById(vehicle), "车辆", record.getBizId()); + if (pass) { + clearVehicleRejectReason(record.getBizId()); + } return; } Driver driver = new Driver(); driver.setId(record.getBizId()); driver.setCertificationStatus(status); - driver.setCertificationRejectReason(reason); // 必须带上 driving_vehicle 原值:该列是 FieldStrategy.ALWAYS, // 更新时忽略 null 直接写,patch 里不带它就会把司机的车辆绑定清成 NULL, // 而车辆列表正是按这一列匹配的(清空即「暂无绑定车辆」)。 @@ -224,10 +226,45 @@ public class CertAuditServiceImpl implements ICertAuditService { // 通过即生效:与车辆侧同理,把这次快照的证件内容一并写回司机档案。 // 不写回的话,司机档案里始终是管理端当初录入的旧值(甚至为空), // 而认证状态已是「已通过」——状态与内容不一致最容易让人误判。 - // 与指针合并成同一次 UPDATE,不额外多写一次同一行。 + // 与指针合并成同一次 UPDATE。 applySnapshotToDriver(driver, record.getSnapshotJson()); + } else { + driver.setCertificationRejectReason(CertificationRuleSupport.reasonForReject(rejectReason)); } requireTargetUpdated(driverService.updateById(driver), "司机", record.getBizId()); + if (pass) { + clearDriverRejectReason(record.getBizId()); + } + } + + /** + * 清空车辆档案上的驳回原因展示缓存 + *

+ * 该列没有驳回原因时必须是 NULL(建表即 DEFAULT NULL)。但框架的 not_null 更新策略 + * 会跳过 patch 里的 null 字段,交给 updateById 的 null 到不了 SQL, + * 「已通过」的车辆会一直挂着上一次的驳回文案,故单独发一条只改该列的语句。 + * 它不碰生效快照指针。 + * + * @param vehicleId 车辆主键 + */ + private void clearVehicleRejectReason(Long vehicleId) { + transportVehicleService.update(Wrappers.lambdaUpdate() + .eq(TransportVehicle::getId, vehicleId) + .set(TransportVehicle::getCertificationRejectReason, null)); + } + + /** + * 清空司机档案上的驳回原因展示缓存 + *

+ * 与车辆侧同理:null 交给 updateById 到不了 SQL,只能单独发一条只改该列的语句。 + * 它不碰生效快照指针,也不碰车辆绑定。 + * + * @param driverId 司机主键 + */ + private void clearDriverRejectReason(Long driverId) { + driverService.update(Wrappers.lambdaUpdate() + .eq(Driver::getId, driverId) + .set(Driver::getCertificationRejectReason, null)); } /** diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java index 7246529d..f172a547 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java @@ -376,6 +376,13 @@ public class DriverCertServiceImpl implements IDriverCertService { patch.setDrivingVehicle(current.getDrivingVehicle()); } driverService.updateById(patch); + if (Func.isBlank(rejectReason)) { + // 重新提交时驳回原因应为空,但框架的 not_null 更新策略会跳过 patch 里的 null, + // 只写 null 到不了 SQL,旧驳回文案会一直挂到审核通过,故单独清一次 + driverService.update(Wrappers.lambdaUpdate() + .eq(Driver::getId, driverId) + .set(Driver::getCertificationRejectReason, null)); + } } /** @@ -389,6 +396,12 @@ public class DriverCertServiceImpl implements IDriverCertService { patch.setCertificationStatus(status); patch.setCertificationRejectReason(rejectReason); transportVehicleService.updateById(patch); + if (Func.isBlank(rejectReason)) { + // 与司机侧同理:null 交给 updateById 到不了 SQL,旧驳回文案会一直挂着 + transportVehicleService.update(Wrappers.lambdaUpdate() + .eq(TransportVehicle::getId, vehicleId) + .set(TransportVehicle::getCertificationRejectReason, null)); + } } /** diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java index 503311a8..e570361d 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java @@ -1,5 +1,11 @@ package org.springblade.transport.service.impl; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +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; @@ -14,7 +20,10 @@ import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.ITransportCertRecordService; import org.springblade.transport.service.ITransportVehicleService; +import java.util.Collection; + import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; @@ -43,6 +52,16 @@ class CertAuditServiceImplTest { private IDriverService driverService; private CertAuditServiceImpl service; + @BeforeAll + static void initTableInfo() { + // 构造 LambdaUpdateWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Driver.class); + TableInfoHelper.initTableInfo(assistant, TransportVehicle.class); + } + @BeforeEach void setUp() { certRecordService = mock(ITransportCertRecordService.class); @@ -330,6 +349,73 @@ class CertAuditServiceImplTest { assertNull(detail.getEffectiveRecordId()); } + // -------------------------------------------- 驳回原因的展示缓存 + + @Test + @DisplayName("通过车辆认证:显式清空档案上的驳回原因(not_null 策略会让 null 到不了 SQL)") + @SuppressWarnings("unchecked") + void approveClearsStaleVehicleRejectReason() { + TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); + + service.audit(RECORD_ID, true, null); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(transportVehicleService).update(captor.capture()); + LambdaUpdateWrapper wrapper = + (LambdaUpdateWrapper) captor.getValue(); + assertTrue(wrapper.getSqlSet().contains("certification_reject_reason"), + "通过时必须显式清空驳回原因,实际: " + wrapper.getSqlSet()); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(values.contains(null), "清空必须显式写 null(该列无原因时的取值)"); + assertTrue(wrapper.getSqlSegment().contains("id =") + && values.stream().anyMatch(v -> v instanceof Number + && ((Number) v).longValue() == VEHICLE_ID), + "必须带主键条件且落在被审核的那一行,否则会更新整张表,实际: " + wrapper.getSqlSegment()); + } + + @Test + @DisplayName("通过司机认证:同样显式清空驳回原因,且不顺带动车辆绑定") + @SuppressWarnings("unchecked") + void approveClearsStaleDriverRejectReason() { + TransportCertRecord record = driverRecord(TransportCertRecord.STATUS_PENDING); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(driverService.getById(DRIVER_ID)).thenReturn(driver(null)); + + service.audit(RECORD_ID, true, null); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(driverService).update(captor.capture()); + LambdaUpdateWrapper wrapper = (LambdaUpdateWrapper) captor.getValue(); + assertTrue(wrapper.getSqlSet().contains("certification_reject_reason"), + "通过时必须显式清空驳回原因,实际: " + wrapper.getSqlSet()); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(values.contains(null)); + assertTrue(wrapper.getSqlSegment().contains("id =") + && values.stream().anyMatch(v -> v instanceof Number + && ((Number) v).longValue() == DRIVER_ID), + "必须带主键条件且落在被审核的那一行,否则会更新整张表,实际: " + wrapper.getSqlSegment()); + assertFalse(wrapper.getSqlSet().contains("driving_vehicle"), + "清空驳回原因不得顺带动车辆绑定"); + } + + @Test + @DisplayName("驳回:原因去空白后写入档案,且不发清空语句") + void rejectWritesReasonAndDoesNotClear() { + TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(777L)); + + service.audit(RECORD_ID, false, " 行驶证模糊 "); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(transportVehicleService).updateById(captor.capture()); + assertEquals("行驶证模糊", captor.getValue().getCertificationRejectReason(), + "驳回原因去首尾空白后落库"); + verify(transportVehicleService, never()).update(any()); + } + // ------------------------------------------------------------ 辅助 private TransportCertRecord vehicleRecord(Integer status) { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java index 43cac1ed..505f57a1 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java @@ -1,7 +1,13 @@ package org.springblade.transport.service.impl; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.alibaba.fastjson2.JSON; +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; @@ -18,6 +24,7 @@ import org.springblade.transport.service.ITransportCertRecordService; import org.springblade.transport.service.ITransportVehicleService; import java.time.LocalDate; +import java.util.Collection; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -53,6 +60,16 @@ class DriverCertServiceImplTest { private ITransportCertRecordService certRecordService; private DriverCertServiceImpl service; + @BeforeAll + static void initTableInfo() { + // 构造 LambdaUpdateWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Driver.class); + TableInfoHelper.initTableInfo(assistant, TransportVehicle.class); + } + @BeforeEach void setUp() { driverAppService = mock(IDriverAppService.class); @@ -279,6 +296,32 @@ class DriverCertServiceImplTest { "提交链路不得写生效快照指针"); } + @Test + @DisplayName("个人提交:清空上一次的驳回原因(null 交给 updateById 到不了 SQL)") + @SuppressWarnings("unchecked") + void submitPersonCertClearsStaleRejectReason() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + PersonCertSubmitRequest request = new PersonCertSubmitRequest(); + request.setRealName("张三"); + request.setIdCardNo("110101199001011234"); + + service.submitPersonCert(request); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(driverService).update(captor.capture()); + LambdaUpdateWrapper wrapper = (LambdaUpdateWrapper) captor.getValue(); + assertTrue(wrapper.getSqlSet().contains("certification_reject_reason"), + "重新提交时旧驳回原因必须清掉,实际: " + wrapper.getSqlSet()); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(values.contains(null), "清空必须显式写 null"); + assertTrue(wrapper.getSqlSegment().contains("id =") + && values.stream().anyMatch(v -> v instanceof Number + && ((Number) v).longValue() == DRIVER_ID), + "必须带主键条件且落在被审核的那一行,否则会更新整张表,实际: " + wrapper.getSqlSegment()); + } + @Test @DisplayName("个人提交:内容为空必须拒绝") void submitPersonCertRejectsEmpty() { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java index 14926434..e4639e36 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java @@ -1,6 +1,10 @@ package org.springblade.transport.service.impl; import com.alibaba.fastjson2.JSON; +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; @@ -53,6 +57,15 @@ class PersonCertRoundTripTest { private Driver driver; private long idSequence = 100L; + @BeforeAll + static void initTableInfo() { + // 构造 LambdaUpdateWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Driver.class); + } + @BeforeEach void setUp() { recordStore.clear(); From f3ae784f200514d63204526f25f4a9daf0f3c80e Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 8 Oct 2026 00:15:35 +0800 Subject: [PATCH 2/8] =?UTF-8?q?:bug:=20=E5=AF=BC=E5=85=A5=E8=BF=90?= =?UTF-8?q?=E5=8D=95=E5=9C=B0=E7=82=B9=E5=90=8D=E7=A7=B0=E4=B8=BA=E7=A9=BA?= =?UTF-8?q?=E6=97=B6=E5=8F=B8=E6=9C=BA=E7=AB=AF=E5=9B=9E=E9=80=80=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E8=AF=A6=E7=BB=86=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/DriverWaybillServiceImpl.java | 10 +- .../impl/DriverWaybillCardLocationTest.java | 136 ++++++++++++++++++ 2 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index 83987141..72914b8b 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -632,10 +632,12 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { DriverWaybillCardVO card = new DriverWaybillCardVO(); card.setId(waybill.getId()); card.setWaybillNo(waybill.getWaybillNo()); - card.setFromName(Func.toStr(waybill.getDepartureName(), "")); - card.setToName(Func.toStr(waybill.getArrivalName(), "")); - card.setFromAddress(Func.toStr(waybill.getDepartureAddress(), card.getFromName())); - card.setToAddress(Func.toStr(waybill.getArrivalAddress(), card.getToName())); + // 批量导入的运单只有发货/到货地址、没有地点名称,名称为空时回退到地址, + // 否则司机端首页、运单列表与打卡页的地点位置都是空的 + card.setFromName(Func.toStrWithEmpty(waybill.getDepartureName(), Func.toStr(waybill.getDepartureAddress(), ""))); + card.setToName(Func.toStrWithEmpty(waybill.getArrivalName(), Func.toStr(waybill.getArrivalAddress(), ""))); + card.setFromAddress(Func.toStrWithEmpty(waybill.getDepartureAddress(), card.getFromName())); + card.setToAddress(Func.toStrWithEmpty(waybill.getArrivalAddress(), card.getToName())); card.setCargoNames(splitCargoNames(waybill.getCargoName())); card.setCargoCategory(Func.toStr(waybill.getCargoType(), "")); card.setWeight(formatWeight(waybill.getQuantity(), waybill.getQuantityUnit())); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java new file mode 100644 index 00000000..92e98433 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java @@ -0,0 +1,136 @@ +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.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.entity.WaybillEnroutePunch; +import org.springblade.transport.pojo.entity.WaybillNodePunch; +import org.springblade.transport.pojo.vo.DriverWaybillCardVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.CertificationGate; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 司机端卡片上的发货地 / 到货地。 + *

+ * 地点名称与详细地址是两列:批量导入的运单只填了地址(导入模板里没有「发货地」「收货地」列), + * 名称为空时卡片要回退到地址,否则司机端的地点位置是空的。 + */ +class DriverWaybillCardLocationTest { + + private static final Long WAYBILL_ID = 5601L; + + private static final String PROCESS_JSON = """ + [{"key":"accept","name":"接单","type":"confirm","enabled":true,"punch":true}, + {"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true}, + {"key":"transit","name":"在途","type":"transit","enabled":true,"punch":true}, + {"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}] + """; + + private static final String DEPARTURE_ADDRESS = "广西壮族自治区南宁市西乡塘区安吉大道"; + private static final String ARRIVAL_ADDRESS = "广西壮族自治区河池市金城江区金城中路1号"; + + private IWaybillService waybillService; + private DriverWaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class); + TableInfoHelper.initTableInfo(assistant, WaybillEnroutePunch.class); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class); + } + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + WaybillNodePunchMapper nodePunchMapper = mock(WaybillNodePunchMapper.class); + WaybillEnroutePunchMapper enroutePunchMapper = mock(WaybillEnroutePunchMapper.class); + ExceptionDisposalMapper exceptionDisposalMapper = mock(ExceptionDisposalMapper.class); + service = new DriverWaybillServiceImpl( + waybillService, + mock(IDriverService.class), + mock(IProcessConfigService.class), + mock(ITransportVehicleService.class), + mock(CertificationGate.class), + enroutePunchMapper, + nodePunchMapper, + exceptionDisposalMapper); + + when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + when(enroutePunchMapper.selectOne(any())).thenReturn(null); + when(enroutePunchMapper.selectList(any())).thenReturn(List.of()); + when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of()); + } + + @Test + @DisplayName("地点名称为空(导入的运单)时,名称回退到详细地址") + void nameFallsBackToAddressWhenBlank() { + Waybill waybill = waybill(); + waybill.setDepartureName(null); + waybill.setDepartureAddress(DEPARTURE_ADDRESS); + waybill.setArrivalName(""); + waybill.setArrivalAddress(ARRIVAL_ADDRESS); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill); + + DriverWaybillCardVO card = service.detailPunchSnapshot(WAYBILL_ID); + + assertEquals(DEPARTURE_ADDRESS, card.getFromName()); + assertEquals(ARRIVAL_ADDRESS, card.getToName()); + assertEquals(DEPARTURE_ADDRESS, card.getFromAddress()); + assertEquals(ARRIVAL_ADDRESS, card.getToAddress()); + assertEquals(DEPARTURE_ADDRESS, card.getPickupAddress()); + assertEquals(ARRIVAL_ADDRESS, card.getUnloadAddress()); + } + + @Test + @DisplayName("地点名称有值时用它,不被地址覆盖") + void nameWinsWhenPresent() { + Waybill waybill = waybill(); + waybill.setDepartureName("南宁市西乡塘区"); + waybill.setDepartureAddress(DEPARTURE_ADDRESS); + waybill.setArrivalName("河池市金城江区"); + waybill.setArrivalAddress(ARRIVAL_ADDRESS); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill); + + DriverWaybillCardVO card = service.detailPunchSnapshot(WAYBILL_ID); + + assertEquals("南宁市西乡塘区", card.getFromName()); + assertEquals("河池市金城江区", card.getToName()); + assertEquals(DEPARTURE_ADDRESS, card.getFromAddress()); + assertEquals(ARRIVAL_ADDRESS, card.getToAddress()); + } + + private Waybill waybill() { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo("YD-20261007-0006"); + waybill.setProcessJson(PROCESS_JSON); + waybill.setBusinessStatus("running"); + waybill.setIsDeleted(0); + return waybill; + } + +} From 9f8ea5412d89f113033ed6ebb0e56027f0f61904 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 8 Oct 2026 09:09:17 +0800 Subject: [PATCH 3/8] =?UTF-8?q?:sparkles:=20=E8=BF=90=E5=8D=95=E8=BF=87?= =?UTF-8?q?=E7=A8=8B=E5=88=A4=E5=AE=9A=E8=A1=A5=E5=85=85=E5=88=B0=E8=B4=A7?= =?UTF-8?q?=E5=8D=B8=E8=B4=A7=E7=AD=BE=E6=94=B6=E3=80=81=E6=9C=AB=E4=BD=8D?= =?UTF-8?q?=E6=89=93=E5=8D=A1=E4=B8=8E=E5=9B=9E=E5=8D=95=E6=97=A0=E9=9C=80?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E5=AE=8C=E6=88=90=E4=B8=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../support/WaybillProcessSupport.java | 46 ++++++ .../support/WaybillProcessSupportTest.java | 140 ++++++++++++++++++ 2 files changed, 186 insertions(+) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java index 7682948d..8de4c8b9 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java @@ -53,6 +53,7 @@ public final class WaybillProcessSupport { public static final String ACCEPT_REJECTED = "rejected"; private static final String CONFIRM_YES = "yes"; private static final String CONFIRM_NO_ACCEPT = "no_confirm_accept"; + private static final String CONFIRM_NO_COMPLETE = "no_confirm_complete"; private static final String NODE_TRANSIT = "transit"; private static final String NODE_TRANSIT_NAME = "在途"; /** 异常处置终态:completed=已完成,revoked=已撤销。只有终态解除打卡门禁 */ @@ -244,6 +245,21 @@ public final class WaybillProcessSupport { return result; } + /** + * 本次打卡的节点是否是司机可打卡节点的末位。 + *

+ * 末位取「司机可打卡节点」序列:接单与回单不打卡,不参与排位—— + * 回单只要启用就必然排在配置最末,按全部启用节点取末位会永远取到回单。 + */ + public static boolean isLastDriverPunchNode(String processJson, String nodeCode) { + List> nodes = listDriverPunchNodes(processJson); + if (nodes.isEmpty()) { + return false; + } + String code = stringVal(nodeCode); + return !code.isEmpty() && code.equals(nodeKey(nodes.get(nodes.size() - 1))); + } + /** * 启用中的过程节点(按配置顺序,含接单/回单)。 */ @@ -591,6 +607,36 @@ public final class WaybillProcessSupport { return isAcceptNode(stringVal(node.get("key")), stringVal(node.get("name"))); } + /** + * 是否到货 / 卸货 / 签收节点:key 或中文名任一命中即算。 + */ + public static boolean isDeliveryNode(String nodeCode, String nodeName) { + String key = stringVal(nodeCode); + String name = stringVal(nodeName); + return "arrival".equals(key) || "到货".equals(name) + || "unload".equals(key) || "卸货".equals(name) + || "sign".equals(key) || "签收".equals(name); + } + + /** + * 回单是否无需确认完成:配置里没有回单节点、回单未启用,或启用且配置为「无需确认完成」。 + *

+ * 未启用的回单节点不看 confirmMode——没有回单环节就不会有人来确认; + * 启用但 confirmMode 缺失按「需确认完成」算。 + */ + public static boolean returnNeedsNoConfirmation(String processJson) { + for (Map node : parseProcessNodes(processJson)) { + if (!isReturnNode(node)) { + continue; + } + if (!isEnabled(node)) { + return true; + } + return CONFIRM_NO_COMPLETE.equals(stringVal(node.get("confirmMode"))); + } + return true; + } + private static boolean isTransitNode(Map node) { String key = stringVal(node.get("key")); String name = stringVal(node.get("name")); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java index 3584712d..fa3334c2 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java @@ -172,4 +172,144 @@ class WaybillProcessSupportTest { assertFalse(WaybillProcessSupport.sameNodeCode(null, null)); } + // ------------------------------------------------------------ 到货/卸货/签收 + + @Test + @DisplayName("到货/卸货/签收:key 或中文名任一命中即可,两者都为空不算") + void deliveryNodeMatchesKeyOrName() { + assertTrue(WaybillProcessSupport.isDeliveryNode("arrival", "到货")); + assertTrue(WaybillProcessSupport.isDeliveryNode("unload", "卸货")); + assertTrue(WaybillProcessSupport.isDeliveryNode("sign", "签收")); + assertTrue(WaybillProcessSupport.isDeliveryNode("arrival", ""), "只有 key 也要认,别把中文名当必要条件"); + assertTrue(WaybillProcessSupport.isDeliveryNode("", "签收"), "只有中文名也要认——key 可能被改成自定义编码"); + assertFalse(WaybillProcessSupport.isDeliveryNode("dispatch", "发货")); + assertFalse(WaybillProcessSupport.isDeliveryNode("transit", "在途")); + assertFalse(WaybillProcessSupport.isDeliveryNode("", "")); + assertFalse(WaybillProcessSupport.isDeliveryNode(null, null)); + } + + // ------------------------------------------------------------ 末位司机打卡节点 + + /** 末位是「到货」,其后没有打卡节点 */ + private static final String ARRIVAL_LAST_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"arrival","name":"到货","enabled":true,"punch":true}] + """; + + /** 末位是「卸货」 */ + private static final String UNLOAD_LAST_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"unload","name":"卸货","enabled":true,"punch":true}] + """; + + /** 末位是「在途」 */ + private static final String TRANSIT_LAST_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"transit","name":"在途","type":"transit","enabled":true,"punch":true}] + """; + + /** 末位是「发货」 */ + private static final String DISPATCH_LAST_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"dispatch","name":"发货","enabled":true,"punch":true}] + """; + + @Test + @DisplayName("末位打卡节点取「司机可打卡节点」的末位:接单与回单就算启用也不算") + void lastDriverPunchNodeSkipsAcceptAndReturn() { + assertTrue(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "sign"), + "回单启用在配置里排最末,但回单不打卡,末位打卡节点仍是签收"); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "unload")); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "arrival")); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "return")); + } + + @Test + @DisplayName("末位是签收 / 到货 / 卸货才同时成立「末位」与「到货卸货签收」;末位是在途 / 发货只满足前者") + void lastNodeIsDeliveryOnlyForDeliveryTypes() { + assertTrue(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "sign") + && WaybillProcessSupport.isDeliveryNode("sign", "签收")); + assertTrue(WaybillProcessSupport.isLastDriverPunchNode(ARRIVAL_LAST_JSON, "arrival") + && WaybillProcessSupport.isDeliveryNode("arrival", "到货")); + assertTrue(WaybillProcessSupport.isLastDriverPunchNode(UNLOAD_LAST_JSON, "unload") + && WaybillProcessSupport.isDeliveryNode("unload", "卸货")); + assertTrue(WaybillProcessSupport.isLastDriverPunchNode(TRANSIT_LAST_JSON, "transit"), + "在途排在末位时它确实是末位打卡节点"); + assertFalse(WaybillProcessSupport.isDeliveryNode("transit", "在途"), "在途不是可完成节点"); + assertTrue(WaybillProcessSupport.isLastDriverPunchNode(DISPATCH_LAST_JSON, "dispatch")); + assertFalse(WaybillProcessSupport.isDeliveryNode("dispatch", "发货"), "发货不是可完成节点"); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(DISPATCH_LAST_JSON, "unload"), + "该配置里没有卸货节点,不能因为编码眼熟就认下"); + } + + @Test + @DisplayName("没有可打卡节点(或没有过程配置)时,谁都不是末位") + void lastDriverPunchNodeWithoutPunchNodes() { + String onlyAcceptAndReturn = """ + [{"key":"accept","name":"接单","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true}] + """; + + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(onlyAcceptAndReturn, "accept")); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(onlyAcceptAndReturn, "return")); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode("", "sign")); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(null, "sign")); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, null)); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "")); + } + + // ------------------------------------------------------------ 回单是否无需确认完成 + + /** 回单启用且配置为「无需确认完成」 */ + private static final String RETURN_NO_CONFIRM_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"sign","name":"签收","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"no_confirm_complete"}] + """; + + /** 回单启用且配置为「需确认完成」 */ + private static final String RETURN_NEED_CONFIRM_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"sign","name":"签收","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}] + """; + + /** 回单未启用,但 confirmMode 写着「需确认完成」 */ + private static final String RETURN_DISABLED_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"sign","name":"签收","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":false,"punch":true,"confirmMode":"yes"}] + """; + + /** 配置里根本没有回单节点 */ + private static final String NO_RETURN_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"sign","name":"签收","enabled":true,"punch":true}] + """; + + @Test + @DisplayName("回单启用时要看 confirmMode:只有「无需确认完成」才成立") + void enabledReturnNeedsConfirmMode() { + assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation(RETURN_NO_CONFIRM_JSON)); + assertFalse(WaybillProcessSupport.returnNeedsNoConfirmation(RETURN_NEED_CONFIRM_JSON), + "回单需要确认完成时,打完签收不能算整单完成"); + } + + @Test + @DisplayName("回单未启用、或配置里没有回单节点,一律视为无需确认完成") + void absentOrDisabledReturnNeedsNoConfirmation() { + assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation(RETURN_DISABLED_JSON), + "回单未启用时 confirmMode 写着需确认也不算数:没启用的环节不会有人来确认"); + assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation(NO_RETURN_JSON)); + assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation("")); + assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation(null)); + } + + @Test + @DisplayName("回单启用但 confirmMode 缺失时按「需确认完成」算") + void enabledReturnWithoutConfirmModeNeedsConfirmation() { + assertFalse(WaybillProcessSupport.returnNeedsNoConfirmation(TRANSPORT_JSON), + "TRANSPORT_JSON 的回单启用且没有 confirmMode:缺字段当没配「无需确认完成」,否则会跳过本该人工确认的回单"); + } + } From 7c488b6f09030ec728b265f71ee49eae98d6bc96 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 8 Oct 2026 09:24:30 +0800 Subject: [PATCH 4/8] =?UTF-8?q?:sparkles:=20=E5=8F=B8=E6=9C=BA=E6=89=93?= =?UTF-8?q?=E5=AE=8C=E6=9C=AB=E4=BD=8D=E5=88=B0=E8=B4=A7=E5=8D=B8=E8=B4=A7?= =?UTF-8?q?=E7=AD=BE=E6=94=B6=E8=8A=82=E7=82=B9=E4=B8=94=E5=9B=9E=E5=8D=95?= =?UTF-8?q?=E6=97=A0=E9=9C=80=E7=A1=AE=E8=AE=A4=E6=97=B6=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E8=BF=90=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transport/pojo/vo/DriverNodePunchVO.java | 3 + .../impl/DriverWaybillServiceImpl.java | 29 +- .../impl/DriverWaybillAutoCompleteTest.java | 248 ++++++++++++++++++ 3 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java index 4bca6317..691cb573 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java @@ -49,4 +49,7 @@ public class DriverNodePunchVO implements Serializable { @Schema(description = "数量") private String quantity; + @Schema(description = "本次打卡是否使运单自动完成") + private Boolean waybillCompleted; + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index 72914b8b..a5aad0ef 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -360,7 +360,20 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { nodePunchMapper.insert(punch); advanceCurrentProcessNode(waybill, nodeCfg); - return toNodePunchVO(punch); + // 末位打卡节点是到货/卸货/签收、且回单无需确认完成时,本次打卡一并把运单置为完成; + // 判定与找节点共用上面已解析的那份过程配置,不重新解析。 + String nodeKey = WaybillProcessSupport.nodeKey(nodeCfg); + String nodeName = WaybillProcessSupport.nodeName(nodeCfg); + boolean completed = false; + if (WaybillProcessSupport.isLastDriverPunchNode(processJson, nodeKey) + && WaybillProcessSupport.isDeliveryNode(nodeKey, nodeName) + && WaybillProcessSupport.returnNeedsNoConfirmation(processJson) + && canAutoCompleteWaybill(waybill)) { + completed = waybillService.completeWithoutDeptCheck(waybill.getId()); + } + DriverNodePunchVO vo = toNodePunchVO(punch); + vo.setWaybillCompleted(completed); + return vo; } @Override @@ -1022,6 +1035,20 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { return countByType; } + /** + * 自动完成的前置:与司机端「完成运单」同一套——业务状态允许完成,且没有未了结异常。 + *

+ * 不满足返回 false,由调用方静默跳过,打卡不受影响。 + */ + private boolean canAutoCompleteWaybill(Waybill waybill) { + String status = waybill.getBusinessStatus(); + boolean statusAllowed = STATUS_RUNNING.equals(status) + || STATUS_PENDING.equals(status) + || STATUS_WAITING_DISPATCH.equals(status) + || STATUS_DISPATCHING.equals(status); + return statusAllowed && unresolvedExceptionNodeCodeList(waybill.getId()).isEmpty(); + } + /** * 断言该运单没有未了结的异常。 *

diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java new file mode 100644 index 00000000..d7b86e23 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java @@ -0,0 +1,248 @@ +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.MockedStatic; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.pojo.dto.NodeSubmitDTO; +import org.springblade.transport.pojo.dto.PunchPhotoDTO; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.entity.WaybillNodePunch; +import org.springblade.transport.pojo.vo.DriverNodePunchVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.CertificationGate; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +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; + +/** + * 司机打完末位打卡节点后运单自动完成。 + *

+ * 命中要四个条件同时成立:本次打卡的是「司机可打卡节点」的末位、该节点是到货/卸货/签收、 + * 回单无需确认完成、运单本身允许完成。任一不成立都只落打卡流水, + * 响应里的 waybillCompleted 始终取完成接口的返回值,前端据此决定是否提示「运单已完成」。 + */ +class DriverWaybillAutoCompleteTest { + + private static final Long WAYBILL_ID = 5801L; + private static final Long USER_ID = 7001L; + private static final String PLATE = "桂AAW3255"; + private static final String CLOCK_PHOTO = "https://oss/clock.jpg"; + + /** 末位打卡节点是「签收」,没有回单节点 */ + private static final String SIGN_LAST_JSON = """ + [{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}] + """; + + /** 末位打卡节点是「签收」,回单启用且需确认完成 */ + private static final String SIGN_LAST_RETURN_CONFIRM_JSON = """ + [{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}] + """; + + /** 末位打卡节点是「发货」,不是到货/卸货/签收 */ + private static final String DISPATCH_LAST_JSON = """ + [{"key":"dispatch","name":"发货","type":"cargo","enabled":true,"punch":true}] + """; + + private IWaybillService waybillService; + private IDriverService driverService; + private WaybillNodePunchMapper nodePunchMapper; + private ExceptionDisposalMapper exceptionDisposalMapper; + private DriverWaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + TableInfoHelper.initTableInfo(assistant, Driver.class); + TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class); + } + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + driverService = mock(IDriverService.class); + nodePunchMapper = mock(WaybillNodePunchMapper.class); + exceptionDisposalMapper = mock(ExceptionDisposalMapper.class); + service = new DriverWaybillServiceImpl( + waybillService, + driverService, + mock(IProcessConfigService.class), + mock(ITransportVehicleService.class), + mock(CertificationGate.class), + mock(WaybillEnroutePunchMapper.class), + nodePunchMapper, + exceptionDisposalMapper); + + when(driverService.getOne(any())).thenReturn(driver()); + when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(waybillService.updateById(any())).thenReturn(true); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of()); + when(nodePunchMapper.insert(any(WaybillNodePunch.class))).thenReturn(1); + } + + // ------------------------------------------------------------ 命中 + + @Test + @DisplayName("末位签收打卡且回单无需确认时调完成接口,响应标记 waybillCompleted") + void lastDeliveryPunchCompletesWaybill() { + givenWaybill(SIGN_LAST_JSON, "running"); + when(waybillService.completeWithoutDeptCheck(WAYBILL_ID)).thenReturn(true); + + DriverNodePunchVO vo = submit("sign"); + + verify(waybillService).completeWithoutDeptCheck(WAYBILL_ID); + assertTrue(vo.getWaybillCompleted(), "完成接口返回成功,响应要告诉前端运单已完成"); + } + + @Test + @DisplayName("waybillCompleted 取完成接口的返回值,不按判定命中臆断") + void completionFlagFollowsServiceReturn() { + givenWaybill(SIGN_LAST_JSON, "running"); + when(waybillService.completeWithoutDeptCheck(WAYBILL_ID)).thenReturn(false); + + DriverNodePunchVO vo = submit("sign"); + + verify(waybillService).completeWithoutDeptCheck(WAYBILL_ID); + assertFalse(vo.getWaybillCompleted(), "完成接口自己说不算完成时,前端不能提示运单已完成"); + } + + // ------------------------------------------------------------ 不命中 + + @Test + @DisplayName("末位是发货时只打卡不完成") + void punchOnNonDeliveryLastNodeDoesNotComplete() { + givenWaybill(DISPATCH_LAST_JSON, "running"); + + DriverNodePunchVO vo = submit("dispatch"); + + verify(waybillService, never()).completeWithoutDeptCheck(any()); + verify(nodePunchMapper).insert(any(WaybillNodePunch.class)); + assertFalse(vo.getWaybillCompleted()); + } + + @Test + @DisplayName("回单启用且需确认完成时只打卡不完成") + void returnNeedingConfirmationDoesNotComplete() { + givenWaybill(SIGN_LAST_RETURN_CONFIRM_JSON, "running"); + + DriverNodePunchVO vo = submit("sign"); + + verify(waybillService, never()).completeWithoutDeptCheck(any()); + verify(nodePunchMapper).insert(any(WaybillNodePunch.class)); + assertFalse(vo.getWaybillCompleted()); + } + + // ------------------------------------------------------------ 前置:运单本身能否完成 + + @Test + @DisplayName("业务状态为 draft 时只打卡不完成,打卡照常成功") + void draftWaybillIsPunchedButNotCompleted() { + givenWaybill(SIGN_LAST_JSON, "draft"); + + DriverNodePunchVO vo = submit("sign"); + + verify(waybillService, never()).completeWithoutDeptCheck(any()); + verify(nodePunchMapper).insert(any(WaybillNodePunch.class)); + assertFalse(vo.getWaybillCompleted()); + } + + @Test + @DisplayName("待执行与调度中间态同样允许自动完成") + void dispatchableStatusesStillComplete() { + for (String status : List.of("pending", "waiting_dispatch", "dispatching")) { + givenWaybill(SIGN_LAST_JSON, status); + when(waybillService.completeWithoutDeptCheck(WAYBILL_ID)).thenReturn(true); + + DriverNodePunchVO vo = submit("sign"); + + assertTrue(vo.getWaybillCompleted(), status + " 属于可完成状态"); + } + } + + @Test + @DisplayName("有未了结异常时不自动完成——与司机端「完成运单」同一口径,异常不落在打卡节点上也拦") + void unresolvedExceptionBlocksAutoComplete() { + givenWaybill(SIGN_LAST_JSON, "running"); + when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of(unresolvedException("unload"))); + + DriverNodePunchVO vo = submit("sign"); + + verify(waybillService, never()).completeWithoutDeptCheck(any()); + verify(nodePunchMapper).insert(any(WaybillNodePunch.class)); + assertFalse(vo.getWaybillCompleted()); + } + + // ------------------------------------------------------------ 夹具 + + private DriverNodePunchVO submit(String nodeCode) { + NodeSubmitDTO dto = new NodeSubmitDTO(); + dto.setWaybillId(WAYBILL_ID); + dto.setNodeCode(nodeCode); + dto.setPhotoItems(List.of(photo("打卡照片", CLOCK_PHOTO))); + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + return service.submitNode(dto); + } + } + + private void givenWaybill(String processJson, String businessStatus) { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo("YD202610080001"); + waybill.setVehicleNo(PLATE); + waybill.setProcessJson(processJson); + waybill.setBusinessStatus(businessStatus); + waybill.setIsDeleted(0); + when(waybillService.getOne(any())).thenReturn(waybill); + } + + private PunchPhotoDTO photo(String type, String url) { + PunchPhotoDTO photo = new PunchPhotoDTO(); + photo.setType(type); + photo.setUrl(url); + return photo; + } + + private Driver driver() { + Driver driver = new Driver(); + driver.setId(9001L); + driver.setUserId(USER_ID); + driver.setDrivingVehicle(PLATE); + return driver; + } + + private ExceptionDisposal unresolvedException(String nodeCode) { + ExceptionDisposal exception = new ExceptionDisposal(); + exception.setNodeCode(nodeCode); + exception.setDisposalStatus("processing"); + return exception; + } + +} From 9f9ce7842eb0fe1a1ab55683c84ba77c93ed3f30 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 8 Oct 2026 10:07:00 +0800 Subject: [PATCH 5/8] =?UTF-8?q?:recycle:=20=E5=8F=B8=E6=9C=BA=E7=AB=AF?= =?UTF-8?q?=E5=8F=AF=E5=AE=8C=E6=88=90=E7=8A=B6=E6=80=81=E7=99=BD=E5=90=8D?= =?UTF-8?q?=E5=8D=95=E6=94=B6=E6=95=9B=E4=B8=BA=E5=8D=95=E4=B8=80=E5=B8=B8?= =?UTF-8?q?=E9=87=8F=E5=B9=B6=E8=AE=A2=E6=AD=A3=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/DriverWaybillServiceImpl.java | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index a5aad0ef..33a84710 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -106,6 +106,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { private static final List TAB_ALL_STATUSES = Arrays.asList( STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING, STATUS_RUNNING, STATUS_COMPLETED ); + /** 允许完成运单的业务状态 */ + private static final List COMPLETABLE_STATUSES = Arrays.asList( + STATUS_RUNNING, STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING + ); private static final int DEFAULT_PREVIEW_SIZE = 2; private static final int MAX_PREVIEW_SIZE = 20; @@ -361,7 +365,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { advanceCurrentProcessNode(waybill, nodeCfg); // 末位打卡节点是到货/卸货/签收、且回单无需确认完成时,本次打卡一并把运单置为完成; - // 判定与找节点共用上面已解析的那份过程配置,不重新解析。 + // isLastDriverPunchNode 与 returnNeedsNoConfirmation 传的就是上面那份 processJson,未再调 resolveProcessJson 取过程配置。 String nodeKey = WaybillProcessSupport.nodeKey(nodeCfg); String nodeName = WaybillProcessSupport.nodeName(nodeCfg); boolean completed = false; @@ -460,10 +464,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { Driver driver = requireCurrentDriver(); Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver)); waybill = normalizeAcceptStatus(waybill); - if (!STATUS_RUNNING.equals(waybill.getBusinessStatus()) - && !STATUS_PENDING.equals(waybill.getBusinessStatus()) - && !STATUS_WAITING_DISPATCH.equals(waybill.getBusinessStatus()) - && !STATUS_DISPATCHING.equals(waybill.getBusinessStatus())) { + if (!COMPLETABLE_STATUSES.contains(waybill.getBusinessStatus())) { throw new ServiceException("当前运单状态不允许完成"); } // 有未了结异常时不允许完成——完成接口不要求节点全部打卡,不挡住就能绕过打卡门禁 @@ -535,8 +536,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } switch (status) { case 0 -> wrapper.in(Waybill::getBusinessStatus, PENDING_STATUSES); - case 1 -> wrapper.in(Waybill::getBusinessStatus, List.of(STATUS_RUNNING, STATUS_PENDING, - STATUS_WAITING_DISPATCH, STATUS_DISPATCHING)); + case 1 -> wrapper.in(Waybill::getBusinessStatus, COMPLETABLE_STATUSES); case 2 -> wrapper.eq(Waybill::getBusinessStatus, STATUS_COMPLETED); case 3 -> wrapper.eq(Waybill::getBusinessStatus, STATUS_CANCELLED); default -> wrapper.in(Waybill::getBusinessStatus, TAB_ALL_STATUSES); @@ -1041,12 +1041,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { * 不满足返回 false,由调用方静默跳过,打卡不受影响。 */ private boolean canAutoCompleteWaybill(Waybill waybill) { - String status = waybill.getBusinessStatus(); - boolean statusAllowed = STATUS_RUNNING.equals(status) - || STATUS_PENDING.equals(status) - || STATUS_WAITING_DISPATCH.equals(status) - || STATUS_DISPATCHING.equals(status); - return statusAllowed && unresolvedExceptionNodeCodeList(waybill.getId()).isEmpty(); + return COMPLETABLE_STATUSES.contains(waybill.getBusinessStatus()) + && unresolvedExceptionNodeCodeList(waybill.getId()).isEmpty(); } /** From 143c6f99fb3f8535890ec69987de9bfc80018c35 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 9 Oct 2026 20:13:38 +0800 Subject: [PATCH 6/8] =?UTF-8?q?:bug:=20=E4=BF=AE=E5=A4=8D=E5=8F=B8?= =?UTF-8?q?=E6=9C=BA=E7=AB=AF=E6=A1=A3=E6=A1=88=E6=8C=89=E8=84=B1=E6=95=8F?= =?UTF-8?q?=E6=89=8B=E6=9C=BA=E5=8F=B7=E5=8C=B9=E9=85=8D=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E6=9C=AC=E4=BA=BA=E6=A1=A3=E6=A1=88=E6=9F=A5=E4=B8=8D=E5=88=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/DriverAppServiceImpl.java | 37 ++++++++---- .../impl/DriverAppServiceImplTest.java | 57 +++++++++++++++++++ 2 files changed, 83 insertions(+), 11 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverAppServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverAppServiceImpl.java index ee8feb27..1d09822f 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverAppServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverAppServiceImpl.java @@ -186,7 +186,13 @@ public class DriverAppServiceImpl implements IDriverAppService { String selfPhone = resolveSelfPhone(userId); String hint = Func.isEmpty(mobileHint) ? null : mobileHint.trim(); if (Func.isNotEmpty(hint)) { - if (Func.isNotEmpty(selfPhone) && !selfPhone.equals(hint)) { + if (Func.isEmpty(selfPhone)) { + // 解析不出本人号码时不能拿前端传的 mobile 去查: + // currentDriver 会直接按 mobile 等值查询,传入他人号码即可读到他人档案。 + // 这里返回空让它退回按 user_id 查,宁可查不到也不越权。 + return null; + } + if (!selfPhone.equals(hint)) { throw new ServiceException("只能查询本人司机档案"); } return hint; @@ -196,21 +202,30 @@ public class DriverAppServiceImpl implements IDriverAppService { private String resolveSelfPhone(Long userId) { String account = AuthUtil.getUserAccount(); - if (Func.isNotEmpty(account) && account.matches("^1\\d{10}$")) { + if (isMobileLike(account)) { return account.trim(); } + // 用户中心的 phone 带 @Sensitive(MOBILE),经 Feign 回来是「175****12082」这类脱敏串, + // 拿它去 eq(mobile) 永远查不到、拿它比对本人的真实号码又必然不等。 + // 因此这里只取未脱敏的 account;脱敏的 phone 一律不用。 R result = userClient.userInfoById(userId); - if (result == null || !R.isSuccess(result) || result.getData() == null) { - return Func.isEmpty(account) ? null : account.trim(); - } - User user = result.getData(); - if (Func.isNotEmpty(user.getPhone())) { - return user.getPhone().trim(); - } - if (Func.isNotEmpty(user.getAccount()) && user.getAccount().matches("^1\\d{10}$")) { - return user.getAccount().trim(); + if (result != null && R.isSuccess(result) && result.getData() != null) { + String userAccount = result.getData().getAccount(); + if (isMobileLike(userAccount)) { + return userAccount.trim(); + } } return Func.isEmpty(account) ? null : account.trim(); } + /** + * 是否是「可作为司机档案手机号使用」的号码串。 + *

+ * 只判 1 开头的 11~15 位纯数字:库里既有 11 位真实手机号,也有 12 位账号 + * (如 175874212082),写死 11 位会让这类账号的司机解析不出本人号码。 + */ + private boolean isMobileLike(String value) { + return Func.isNotEmpty(value) && value.trim().matches("^1\\d{10,14}$"); + } + } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverAppServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverAppServiceImplTest.java index 0894e214..6d2c0607 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverAppServiceImplTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverAppServiceImplTest.java @@ -5,7 +5,10 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.core.tool.api.R; +import org.springblade.system.pojo.entity.User; import org.springblade.transport.pojo.entity.Driver; import org.springblade.transport.pojo.entity.TransportVehicle; import org.springblade.transport.pojo.vo.DriverVehicleCardVO; @@ -17,6 +20,7 @@ import java.time.LocalDate; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; @@ -81,6 +85,31 @@ class DriverAppServiceImplTest { assertEquals("2028-12-04", card.getRoadTransportCertStartDate()); } + // ---------------------------------------------------------------- 手机号解析 + + @Test + @DisplayName("12 位手机号也能解析出本人号码——库里存在 12 位司机账号,写死 11 位会让他们查不到自己的档案") + void twelveDigitAccountIsUsableAsPhone() { + assertEquals("黄大富", currentByPhone("175874212082", "175874212082", null).getDriverName()); + } + + @Test + @DisplayName("用户中心 phone 是脱敏串时改用其 account——脱敏 phone 既查不到档案,也会把本人真实号码误判成越权") + void maskedUserPhoneFallsBackToAccount() { + User user = new User(); + user.setPhone("175****12082"); + user.setAccount("175874212082"); + + assertEquals("黄大富", currentByPhone(null, null, user).getDriverName()); + } + + @Test + @DisplayName("传他人手机号一律拒绝——不能因为解析不到本人号码就放行,那等于允许按手机号查任意档案") + void otherPeoplesMobileIsRejected() { + assertEquals("只能查询本人司机档案", assertThrows(ServiceException.class, + () -> currentByPhone("13900000001", "175874212082", null)).getMessage()); + } + // ---------------------------------------------------------------- 辅助 /** 造一辆只关心有效期三个字段的车 */ @@ -122,4 +151,32 @@ class DriverAppServiceImplTest { } } + /** + * 按给定的登录态跑一次 currentByPhone。 + * + * @param mobileHint 前端传入的 mobile,可为 null + * @param account JWT 里的账号,可为 null + * @param user 用户中心返回的用户,可为 null(表示走不到用户中心) + */ + private Driver currentByPhone(String mobileHint, String account, User user) { + IUserClient userClient = mock(IUserClient.class); + if (user != null) { + when(userClient.userInfoById(USER_ID)).thenReturn(R.data(user)); + } + DriverAppServiceImpl scoped = new DriverAppServiceImpl(driverService, transportVehicleService, userClient); + + Driver driver = new Driver(); + driver.setId(9002L); + driver.setUserId(USER_ID); + driver.setDriverName("黄大富"); + driver.setMobile("175874212082"); + when(driverService.getOne(any(LambdaQueryWrapper.class))).thenReturn(driver); + + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + authUtil.when(AuthUtil::getUserAccount).thenReturn(account); + return scoped.currentByPhone(mobileHint); + } + } + } From b66984d3a1c0f46da51d6d2ff4b7ce53d89513e4 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 9 Oct 2026 20:47:22 +0800 Subject: [PATCH 7/8] =?UTF-8?q?:sparkles:=20=E8=AE=A4=E8=AF=81=E5=AE=A1?= =?UTF-8?q?=E6=A0=B8=E8=AF=A6=E6=83=85=E4=B8=8B=E5=8F=91=E5=AE=A1=E6=A0=B8?= =?UTF-8?q?=E4=BA=BA=E4=B8=8E=E5=AE=A1=E6=A0=B8=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../springblade/transport/pojo/vo/CertAuditDetailVO.java | 6 ++++++ .../transport/service/impl/CertAuditServiceImpl.java | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CertAuditDetailVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CertAuditDetailVO.java index 9df6f808..577ce62e 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CertAuditDetailVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CertAuditDetailVO.java @@ -70,6 +70,12 @@ public class CertAuditDetailVO implements Serializable { private Long submitUser; @Schema(description = "本次提交人姓名") private String submitUserName; + @Schema(description = "本次审核人;未审核时为 null") + private Long auditUser; + @Schema(description = "本次审核人姓名;未审核时为空") + private String auditUserName; + @Schema(description = "审核时间(通过或驳回的时间);未审核时为 null") + private LocalDateTime auditTime; @Schema(description = "待审快照:本次提交的全量内容") private String pendingSnapshotJson; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java index b7bb4e34..0a36f125 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java @@ -126,6 +126,11 @@ public class CertAuditServiceImpl implements ICertAuditService { detail.setSubmitUser(record.getSubmitUser()); // 面向前端的展示字段必须由服务端填好,否则审核页「提交人」一栏恒为空白 detail.setSubmitUserName(UserCache.getUserRealName(record.getSubmitUser())); + // 审核留痕同理:审核人姓名由服务端查好,前端不做用户ID → 姓名的二次请求。 + // 记录还在「审核中」时审核人与审核时间都为空,由前端显示占位符 + detail.setAuditUser(record.getAuditUser()); + detail.setAuditTime(record.getAuditTime()); + detail.setAuditUserName(UserCache.getUserRealName(record.getAuditUser())); detail.setPendingSnapshotJson(record.getSnapshotJson()); // 生效值一侧:从未认证通过时为 null,前端按「无对比基准」处理 From 4fe0c8a968c81a4db7432b9caa571dd9cd7eb907 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Sat, 10 Oct 2026 01:48:49 +0800 Subject: [PATCH 8/8] =?UTF-8?q?:sparkles:=20=E8=B0=83=E5=BA=A6=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E6=8A=8A=E5=8F=B8=E6=9C=BA=E5=B7=B2=E6=8B=92=E5=8D=95?= =?UTF-8?q?=E7=9A=84=E8=BF=90=E5=8D=95=E5=88=97=E4=B8=BA=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E6=B4=BE=E5=8D=95=E5=BE=85=E5=A4=84=E7=90=86=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E5=9C=A8=E5=8F=B8=E6=9C=BA=E7=AB=AF=E8=AE=A9=E8=AF=A5=E8=BF=90?= =?UTF-8?q?=E5=8D=95=E8=84=B1=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transport/pojo/vo/AdminHomeBadgesVO.java | 3 + .../transport/pojo/vo/AdminHomeVO.java | 2 +- .../controller/ManageWaybillController.java | 2 +- .../impl/DriverWaybillServiceImpl.java | 38 ++- .../impl/ManageWaybillServiceImpl.java | 98 +++++++- .../impl/DriverWaybillRejectDetachTest.java | 230 ++++++++++++++++++ .../service/impl/ManageAdminHomeFeedTest.java | 198 +++++++++++++++ 7 files changed, 560 insertions(+), 11 deletions(-) create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageAdminHomeFeedTest.java diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeBadgesVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeBadgesVO.java index d37bd5bd..bb6081fb 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeBadgesVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeBadgesVO.java @@ -44,4 +44,7 @@ public class AdminHomeBadgesVO implements Serializable { @Schema(description = "风险记录待办数(disposalStatus=pending)") private long risk; + @Schema(description = "重新派单待办数(司机已拒单且运单未取消未完成)") + private long pendingReassign; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeVO.java index 2302825d..25c34e4a 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeVO.java @@ -49,7 +49,7 @@ public class AdminHomeVO implements Serializable { @Schema(description = "快捷入口角标") private AdminHomeBadgesVO badges = new AdminHomeBadgesVO(); - @Schema(description = "待处理事项(异常处置状态≠已完成)") + @Schema(description = "待处理事项(异常处置状态≠已完成,或司机已拒单待重新派单)") private List feed = new ArrayList<>(); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java index 119cf5b9..6d381639 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java @@ -72,7 +72,7 @@ public class ManageWaybillController extends BladeController { @GetMapping("/home") @ApiOperationSupport(order = 2) - @Operation(summary = "首页聚合", description = "统计 + 异常/风险角标 + 待处理事项(异常处置≠已完成)+ 当前用户名") + @Operation(summary = "首页聚合", description = "统计 + 异常/风险/重新派单角标 + 待处理事项(异常处置≠已完成,或司机已拒单待重新派单)+ 当前用户名") public R home() { return R.data(manageWaybillService.home()); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index 33a84710..742cb529 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -262,7 +262,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { throw new ServiceException("运单ID不能为空"); } Driver driver = requireCurrentDriver(); - Waybill waybill = loadDriverWaybill(dto.getWaybillId(), currentBoundPlates(driver)); + // 在途打卡单独保留原取单判据,不排除已拒单(见 scopedQuery 的重载说明) + Waybill waybill = loadDriverWaybill(dto.getWaybillId(), currentBoundPlates(driver), true); waybill = normalizeAcceptStatus(waybill); String processJson = resolveProcessJson(waybill); @@ -483,6 +484,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { if (WaybillProcessSupport.isAccepted(waybill.getDriverAcceptStatus())) { throw new ServiceException("该运单已接单"); } + // 拒单后运单已对本人脱离,不给自己接回来的入口;要改主意只能由调度重新派单 + if (WaybillProcessSupport.isRejected(waybill.getDriverAcceptStatus())) { + throw new ServiceException("该运单已拒单,请联系调度重新派单"); + } } private void assertRejectable(Waybill waybill) { @@ -498,13 +503,19 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } private Waybill loadDriverWaybill(Long id, List plates) { + return loadDriverWaybill(id, plates, false); + } + + /** @param includeRejected 仅 submitEnroute 传 true,见 scopedQuery 的重载说明 */ + private Waybill loadDriverWaybill(Long id, List plates, boolean includeRejected) { if (id == null) { throw new ServiceException("运单ID不能为空"); } if (plates.isEmpty()) { throw new ServiceException("当前司机未绑定车辆"); } - Waybill waybill = waybillService.getOne(scopedQuery(plates).eq(Waybill::getId, id).last("LIMIT 1")); + Waybill waybill = waybillService.getOne(scopedQuery(plates, includeRejected) + .eq(Waybill::getId, id).last("LIMIT 1")); if (waybill == null) { throw new ServiceException("运单不存在或无权操作"); } @@ -560,11 +571,23 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } /** - * 司机可见运单范围:运单车牌(主车/挂车)落在当前司机绑定车牌内。 - * 绑定来源:blade_transport_driver.driving_vehicle + * 司机可见运单范围:运单车牌(主车/挂车)落在当前司机绑定车牌内, + * 且该运单未被这个司机拒单。绑定来源:blade_transport_driver.driving_vehicle + *

+ * 拒单后运单对司机彻底脱离——列表、计数、详情、打卡、接单一律不可达,等管理员重新派单。 + * 运单上的司机姓名/手机/车牌不清空,调度重新派单时要知道原来派给谁。 */ private LambdaQueryWrapper scopedQuery(List plates) { - return Wrappers.lambdaQuery().and(w -> { + return scopedQuery(plates, false); + } + + /** + * @param includeRejected true 时不排除已拒单的运单,仅限在途打卡使用: + * 拒单的运单不可能处于运输中,收口拦不到合法操作, + * 而保留原判据可避免「已拒单 + 进行中」的历史脏数据被误伤 + */ + private LambdaQueryWrapper scopedQuery(List plates, boolean includeRejected) { + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery().and(w -> { w.in(Waybill::getVehicleNo, plates) .or().in(Waybill::getTrailerVehicleNo, plates); for (String plate : plates) { @@ -578,6 +601,11 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { ); } }); + if (!includeRejected) { + wrapper.and(w -> w.isNull(Waybill::getDriverAcceptStatus) + .or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED)); + } + return wrapper; } private void applyKeyword(LambdaQueryWrapper wrapper, String keyword) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java index 5854689f..1f4868f2 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java @@ -57,6 +57,7 @@ import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.HashSet; @@ -85,6 +86,13 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { private static final String DISPOSAL_PROCESSING = "processing"; private static final String RISK_PENDING = "pending"; + /** 小程序首页待处理事项的类型:异常待处置 */ + private static final String TODO_EXCEPTION = "exception"; + /** 小程序首页待处理事项的类型:重新派单待处理 */ + private static final String TODO_REASSIGN = "reassign"; + /** 重派条目跳转页码:参数 id 是运单主键(不是运单号),该页据此回显运单;buyerPaid 恒为 0,与列表卡片一致 */ + private static final String REASSIGN_TARGET_URL = "/pages/waybill/reassign?id=%s&buyerPaid=0"; + private static final String EXCEPTION_YES = "exception"; private static final String EXCEPTION_NO = "normal"; private static final String TRANSPORT_COMMON = "common"; @@ -122,8 +130,9 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { AdminHomeBadgesVO badges = new AdminHomeBadgesVO(); badges.setException(home.getStats().getException()); badges.setRisk(countPendingRisks()); + badges.setPendingReassign(countPendingReassignments()); home.setBadges(badges); - home.setFeed(buildExceptionFeed()); + home.setFeed(buildAdminFeed()); return home; } @@ -561,18 +570,66 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { .eq(RiskDisposal::getDisposalStatus, RISK_PENDING)); } - private List buildExceptionFeed() { + /** 待重新派单:司机已拒单,且运单尚未取消/完成 */ + private long countPendingReassignments() { + return waybillService.count(pendingReassignQuery()); + } + + private LambdaQueryWrapper pendingReassignQuery() { + return Wrappers.lambdaQuery() + .eq(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED) + .in(Waybill::getBusinessStatus, STATUS_PENDING, STATUS_RUNNING); + } + + /** + * 首页待处理事项:异常待处置 + 重新派单待处理。 + * 两类各按自己的业务发生时间(上报时刻 / 拒单时刻)倒序,合并后再整体倒序取前 N 条。 + * 排序时间随条目一起携带,不塞进 id 字段——id 在两型下各自是处置主键与运单主键。 + */ + List buildAdminFeed() { + List merged = new ArrayList<>(); + merged.addAll(buildExceptionFeed()); + merged.addAll(buildReassignFeed()); + merged.sort(java.util.Comparator.comparing( + (TimedTodo entry) -> entry.time, java.util.Comparator.nullsFirst(java.util.Comparator.naturalOrder()))); + java.util.Collections.reverse(merged); + int to = Math.min(merged.size(), FEED_LIMIT); + return merged.subList(0, to).stream().map(TimedTodo::item).collect(Collectors.toList()); + } + + /** 待处理事项 + 它的业务发生时间(仅用于排序) */ + private record TimedTodo(AdminTodoItemVO item, LocalDateTime time) { + } + + private List buildExceptionFeed() { List list = exceptionDisposalService.list(Wrappers.lambdaQuery() .in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING) .orderByDesc(ExceptionDisposal::getReportTime) .last("LIMIT " + FEED_LIMIT)); - return list.stream().map(this::toTodoItem).collect(Collectors.toList()); + return list.stream() + .map(disposal -> new TimedTodo(toTodoItem(disposal), exceptionTimeOf(disposal))) + .collect(Collectors.toList()); + } + + private List buildReassignFeed() { + List list = waybillService.list(pendingReassignQuery() + .orderByDesc(Waybill::getDriverRejectTime) + .last("LIMIT " + FEED_LIMIT)); + return list.stream() + .map(waybill -> new TimedTodo(toReassignTodoItem(waybill), rejectTimeOf(waybill))) + .collect(Collectors.toList()); + } + + private LocalDateTime exceptionTimeOf(ExceptionDisposal disposal) { + return disposal.getReportTime() != null + ? disposal.getReportTime() + : toLocalDateTime(disposal.getCreateTime()); } private AdminTodoItemVO toTodoItem(ExceptionDisposal disposal) { AdminTodoItemVO item = new AdminTodoItemVO(); item.setId(disposal.getId()); - item.setType("exception"); + item.setType(TODO_EXCEPTION); item.setTitle("异常待处置"); item.setTimeAgo(formatTimeAgo(disposal.getReportTime() != null ? disposal.getReportTime() @@ -585,6 +642,38 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { return item; } + private AdminTodoItemVO toReassignTodoItem(Waybill waybill) { + AdminTodoItemVO item = new AdminTodoItemVO(); + item.setId(waybill.getId()); + item.setType(TODO_REASSIGN); + item.setTitle("重新派单待处理"); + item.setTimeAgo(formatTimeAgo(rejectTimeOf(waybill))); + item.setDesc(buildReassignDesc(waybill)); + item.setWaybillNo(Func.toStr(waybill.getWaybillNo(), "")); + item.setActionLabel("重新派单"); + item.setTargetUrl(String.format(REASSIGN_TARGET_URL, waybill.getId())); + return item; + } + + /** 拒单时刻是「被拒了多久」的唯一依据;为空(历史数据)才回退到最后修改时间 */ + private LocalDateTime rejectTimeOf(Waybill waybill) { + if (waybill.getDriverRejectTime() != null) { + return toLocalDateTime(waybill.getDriverRejectTime()); + } + return toLocalDateTime(waybill.getUpdateTime()); + } + + /** 拒单原因的输入是可选的,没填时正文不能留下空的「:」 */ + private String buildReassignDesc(Waybill waybill) { + String driver = Func.toStr(waybill.getDriverName(), "原司机"); + String text = "运单已指派" + driver + ",被拒绝,请重新派单"; + String reason = Func.toStr(waybill.getDriverRejectReason(), "").trim(); + if (Func.isNotBlank(reason)) { + text = text + ":" + reason; + } + return text.length() > 80 ? text.substring(0, 80) + "…" : text; + } + private String buildExceptionDesc(ExceptionDisposal disposal) { String reporter = Func.toStr(disposal.getReporterName(), "司机"); String type = Func.toStr(disposal.getExceptionType(), "异常"); @@ -606,6 +695,7 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { return Func.toStr(AuthUtil.getUserName(), ""); } + /** 相对时间文案;时间取不到时留空,避免把「现在」当成发生时刻显示成「刚刚」 */ private String formatTimeAgo(LocalDateTime time) { if (time == null) { return ""; diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java new file mode 100644 index 00000000..096a837c --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java @@ -0,0 +1,230 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +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.ArgumentMatchers; +import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.pojo.dto.EnrouteSubmitDTO; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.vo.DriverWaybillCardVO; +import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.CertificationGate; + +import java.util.List; +import java.util.function.Supplier; + +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.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 司机拒单后运单对本人脱离。 + *

+ * 拒单不再只是一个标记:该运单从列表、计数、详情到接单全部不可达,等管理员重新派单。 + *

+ * 拦截落在司机端共用的取单条件上,而单元测试里 {@code waybillService} 是 mock、不会真的执行 + * 这个条件,所以「已拒单被排除」只能断言条件本身;过滤之后的行为则用「已过滤的输入」来验, + * 不假装 mock 会替我们过滤。 + */ +class DriverWaybillRejectDetachTest { + + private static final Long USER_ID = 7401L; + private static final Long DRIVER_ID = 9001L; + private static final String PLATE = "沪AE31312"; + private static final Long REJECTED_ID = 7201L; + + private IDriverService driverService; + private CertificationGate certificationGate; + private IWaybillService waybillService; + private DriverWaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + } + + @BeforeEach + void setUp() { + driverService = mock(IDriverService.class); + waybillService = mock(IWaybillService.class); + certificationGate = mock(CertificationGate.class); + lenient().when(certificationGate.isDriverAvailable(any())).thenReturn(true); + service = new DriverWaybillServiceImpl( + waybillService, + driverService, + mock(IProcessConfigService.class), + mock(ITransportVehicleService.class), + certificationGate, + mock(WaybillEnroutePunchMapper.class), + mock(WaybillNodePunchMapper.class), + mock(ExceptionDisposalMapper.class)); + lenient().when(driverService.getOne(any())).thenReturn(driver()); + lenient().when(waybillService.updateById(any())).thenReturn(true); + // syncDriverAcceptState 是 IWaybillService 的方法,mock 默认返 null 会让调用方 NPE; + // 真实实现是把入参校正后返回同一个对象 + lenient().when(waybillService.syncDriverAcceptState(any())) + .thenAnswer(invocation -> invocation.getArgument(0)); + } + + @Test + @DisplayName("首页待接预览的取单条件排除已拒单,且排除子句与车牌条件并列") + void pendingPreviewQueryExcludesRejected() { + when(waybillService.list(ArgumentMatchers.>any())).thenReturn(List.of()); + + runAsDriver(() -> service.pendingPreview(5)); + + String sql = capturedListQuery().getTargetSql(); + assertTrue(sql.contains("driver_accept_status"), + "取单条件必须排除已拒单:" + sql); + assertTrue(sql.toUpperCase().contains("AND"), + "排除子句不能与车牌条件「或」在一起,否则等于没排除:" + sql); + } + + @Test + @DisplayName("运单列表的取单条件同样排除已拒单") + void pageQueryExcludesRejected() { + when(waybillService.list(ArgumentMatchers.>any())).thenReturn(List.of()); + + runAsDriver(() -> service.page(1, 10, 0, null)); + + String sql = capturedListQuery().getTargetSql(); + assertTrue(sql.contains("driver_accept_status"), "列表也要排除已拒单:" + sql); + } + + @Test + @DisplayName("Tab 计数不再把已拒单算进待接单") + void tabCountsOnFilteredInputCountsOnlyUnrejected() { + // 查询层已排除拒单后落回来的输入:一张待接、一张运输中、一张已完成 + when(waybillService.list(ArgumentMatchers.>any())).thenReturn(List.of( + waybill(7202L, "YD-20261001-0002", "pending", "pending"), + waybill(7203L, "YD-20261001-0003", "running", "accepted"), + waybill(7204L, "YD-20261001-0004", "completed", "accepted"))); + + DriverWaybillTabCountsVO counts = runAsDriver(() -> service.tabCounts()); + + assertEquals(1, counts.getPending(), "待接单只算那张未被拒的"); + assertEquals(1, counts.getDoing()); + assertEquals(1, counts.getDone()); + assertEquals(3, counts.getAll(), "全部 = 待接 + 进行中 + 已完成"); + } + + @Test + @DisplayName("未拒单的运单不被误伤:待接与运输中照常出现") + void unrejectedWaybillsUnaffected() { + when(waybillService.list(ArgumentMatchers.>any())).thenReturn(List.of( + waybill(7202L, "YD-20261001-0002", "pending", "pending"), + waybill(7203L, "YD-20261001-0003", "running", "accepted"))); + + IPage page = runAsDriver(() -> service.page(1, 10, null, null)); + + assertEquals(2, page.getTotal(), "未被拒的运单条数不变"); + assertEquals(2, page.getRecords().size()); + } + + @Test + @DisplayName("已拒单运单的详情不可达:取单条件已把它挡住") + void detailOfRejectedIsUnreachable() { + // 被取单条件排除后 getOne 落回 null,与「不属于这个司机」同一条报错 + when(waybillService.getOne(any())).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsDriver(() -> service.detail(REJECTED_ID))); + + assertEquals("运单不存在或无权操作", exception.getMessage()); + } + + @Test + @DisplayName("已拒单运单不能自行接回来") + void acceptOfRejectedIsRejected() { + when(waybillService.getOne(any())).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsDriver(() -> service.accept(REJECTED_ID))); + + assertEquals("运单不存在或无权操作", exception.getMessage()); + } + + @Test + @DisplayName("在途打卡保留原取单判据,不排除已拒单的历史脏数据") + void enrouteSubmitKeepsUnfilteredScope() { + EnrouteSubmitDTO dto = new EnrouteSubmitDTO(); + dto.setWaybillId(REJECTED_ID); + when(waybillService.getOne(any())).thenReturn(rejectedWaybill()); + + // 断言的是「取单这一步没有被拒单条件拦住」:若被拦住会抛「运单不存在或无权操作」, + // 而后面因过程配置为空会走「该运单未启用在途打卡」,两者可区分 + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsDriver(() -> service.submitEnroute(dto))); + + assertEquals("该运单未启用在途打卡", exception.getMessage(), + "在途打卡不应因运单已拒单而在取单阶段被挡下"); + verify(waybillService, org.mockito.Mockito.atLeastOnce()).getOne(any()); + } + + // ------------------------------------------------------------ 夹具 + + private T runAsDriver(Supplier action) { + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + return action.get(); + } + } + + private LambdaQueryWrapper capturedListQuery() { + ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(waybillService).list(captor.capture()); + return captor.getValue(); + } + + private Driver driver() { + Driver driver = new Driver(); + driver.setId(DRIVER_ID); + driver.setUserId(USER_ID); + driver.setDriverName("黄大富"); + driver.setDrivingVehicle(PLATE); + return driver; + } + + private Waybill rejectedWaybill() { + return waybill(REJECTED_ID, "YD-20261010-0001", "pending", "rejected"); + } + + private Waybill waybill(Long id, String no, String businessStatus, String acceptStatus) { + Waybill waybill = new Waybill(); + waybill.setId(id); + waybill.setWaybillNo(no); + waybill.setVehicleNo(PLATE); + waybill.setBusinessStatus(businessStatus); + waybill.setDriverAcceptStatus(acceptStatus); + waybill.setIsDeleted(0); + return waybill; + } +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageAdminHomeFeedTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageAdminHomeFeedTest.java new file mode 100644 index 00000000..1d27f177 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageAdminHomeFeedTest.java @@ -0,0 +1,198 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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.ArgumentMatchers; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.vo.AdminTodoItemVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IDriverWaybillService; +import org.springblade.transport.service.IExceptionDisposalService; +import org.springblade.transport.service.IRiskDisposalService; +import org.springblade.transport.service.IWaybillService; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 调度端首页待处理事项:异常待处置 + 重新派单待处理。 + *

+ * 拒单条目的判据是「司机已拒单且运单未取消未完成」,必须与待处理运单列表的 needReassign + * 口径一致,否则两个页面的条数对不上;卡片上「多久之前」取拒单时刻,不取最后修改时间 + * (后者会被任何无关改动刷新)。 + *

+ * 这里直接验归并方法本身,不走 {@code home()}:那个入口会解析当前用户名、依赖 Spring 缓存, + * 在单测里没有上下文。 + */ +class ManageAdminHomeFeedTest { + + private static final Long EXCEPTION_ID = 8801L; + private static final String REJECTED_WAYBILL_NO = "YD-20261010-0001"; + + private ManageWaybillServiceImpl service; + private IWaybillService waybillService; + private IExceptionDisposalService exceptionService; + + @BeforeAll + static void initTableInfo() { + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class); + } + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + exceptionService = mock(IExceptionDisposalService.class); + service = new ManageWaybillServiceImpl( + waybillService, + mock(IDriverService.class), + exceptionService, + mock(IRiskDisposalService.class), + mock(IDriverWaybillService.class)); + when(exceptionService.list(ArgumentMatchers.>any())) + .thenReturn(new ArrayList<>()); + when(waybillService.list(ArgumentMatchers.>any())) + .thenReturn(new ArrayList<>()); + } + + @Test + @DisplayName("拒单运单产出一条重新派单待处理,字段与跳转参数对齐小程序约定") + void rejectedWaybillAppearsAsReassignTodo() { + when(waybillService.list(ArgumentMatchers.>any())) + .thenReturn(List.of(rejectedWaybill(7201L, "黄大富", "时间冲突"))); + + List feed = service.buildAdminFeed(); + + assertEquals(1, feed.size()); + AdminTodoItemVO item = feed.get(0); + assertEquals("reassign", item.getType()); + assertEquals("重新派单待处理", item.getTitle()); + assertEquals("重新派单", item.getActionLabel()); + assertEquals(REJECTED_WAYBILL_NO, item.getWaybillNo()); + assertEquals(Long.valueOf(7201L), item.getId(), "重派条目应当带运单主键"); + assertEquals("运单已指派黄大富,被拒绝,请重新派单:时间冲突", item.getDesc()); + assertEquals("/pages/waybill/reassign?id=7201&buyerPaid=0", item.getTargetUrl(), + "跳转参数必须是运单主键:该页按主键回显运单,填运单号读不出数据"); + assertFalse(item.getTimeAgo().isEmpty(), "拒单时刻应换算成相对时间"); + } + + @Test + @DisplayName("拒单原因为空时正文不带空冒号") + void withoutRejectReasonKeepsDescClean() { + when(waybillService.list(ArgumentMatchers.>any())) + .thenReturn(List.of(rejectedWaybill(7202L, "黄宇富", null))); + + AdminTodoItemVO item = service.buildAdminFeed().get(0); + + assertEquals("运单已指派黄宇富,被拒绝,请重新派单", item.getDesc()); + assertFalse(item.getDesc().contains(":"), "原因缺失时不得留下空的「:」"); + } + + @Test + @DisplayName("没有拒单时只出异常条目,不凭空造重派条目") + void withoutRejectedWaybillNoReassignTodo() { + when(exceptionService.list(ArgumentMatchers.>any())) + .thenReturn(List.of(exceptionDisposal())); + + List feed = service.buildAdminFeed(); + + assertEquals(1, feed.size()); + assertEquals("exception", feed.get(0).getType()); + assertEquals("异常待处置", feed.get(0).getTitle()); + } + + @Test + @DisplayName("两类条目按各自业务发生时间倒序混排") + void feedIsMergedByBusinessTime() { + when(exceptionService.list(ArgumentMatchers.>any())) + .thenReturn(List.of(exceptionDisposal())); + when(waybillService.list(ArgumentMatchers.>any())) + .thenReturn(List.of(rejectedWaybill(7203L, "黄大富", "时间冲突"))); + + List feed = service.buildAdminFeed(); + + assertEquals(2, feed.size()); + assertEquals(List.of("reassign", "exception"), + feed.stream().map(AdminTodoItemVO::getType).collect(Collectors.toList()), + "拒单发生在昨日 22:00,异常上报于 26 小时前(更早),故重派条目在前"); + } + + @Test + @DisplayName("判据只认「已拒单 + 未取消未完成」,已取消已完成即便被拒过也不参与") + void reassignQueryExcludesTerminalStatus() { + service.buildAdminFeed(); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(waybillService).list(captor.capture()); + LambdaQueryWrapper wrapper = (LambdaQueryWrapper) captor.getValue(); + String sql = wrapper.getTargetSql(); + + assertTrue(sql.contains("driver_accept_status"), "重派判据要落在接单状态上:" + sql); + assertTrue(sql.contains("business_status"), + "已取消/已完成的运单不该再催重新派单:" + sql); + + Set values = new HashSet<>(wrapper.getParamNameValuePairs().values()); + assertTrue(values.contains("rejected"), "要带上已拒绝:" + values); + assertTrue(values.contains("pending") && values.contains("running"), + "业务状态只应放待执行与进行中:" + values); + } + + // ------------------------------------------------------------ 夹具 + + private Waybill rejectedWaybill(Long id, String driverName, String reason) { + Waybill waybill = new Waybill(); + waybill.setId(id); + waybill.setWaybillNo(REJECTED_WAYBILL_NO); + waybill.setDriverName(driverName); + waybill.setDriverAcceptStatus("rejected"); + waybill.setBusinessStatus("pending"); + waybill.setDriverRejectReason(reason); + waybill.setDriverRejectTime(yesterdayAt22()); + return waybill; + } + + private ExceptionDisposal exceptionDisposal() { + ExceptionDisposal disposal = new ExceptionDisposal(); + disposal.setId(EXCEPTION_ID); + disposal.setWaybillNo("YD-20261006-0001"); + disposal.setReporterName("黄宇富"); + disposal.setExceptionType("交通异常"); + disposal.setDisposalStatus("pending"); + disposal.setReportTime(LocalDateTime.now().minusHours(26)); + return disposal; + } + + private Date yesterdayAt22() { + java.util.Calendar calendar = java.util.Calendar.getInstance(); + calendar.add(java.util.Calendar.DAY_OF_MONTH, -1); + calendar.set(java.util.Calendar.HOUR_OF_DAY, 22); + calendar.set(java.util.Calendar.MINUTE, 0); + calendar.set(java.util.Calendar.SECOND, 0); + calendar.set(java.util.Calendar.MILLISECOND, 0); + return calendar.getTime(); + } +}