diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java index 3b4440c8..02878fae 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java @@ -458,6 +458,29 @@ public class WaybillServiceImpl extends BaseServiceImpl continue; } + // 接单节点不落打卡流水:它是否完成由运单的接单事实决定,不看打卡表。 + // 司机确认接单即为已打卡;项目「无需确认接单」时,运单进入进行中即视为该环节已完成。 + // 后者没有接单动作,也就没有完成时刻,时间栏留空——运单上没有任何字段记着那一刻。 + if (WaybillProcessSupport.isAcceptNode(nodeCode, nodeName)) { + boolean accepted = WaybillProcessSupport.isAccepted(waybill.getDriverAcceptStatus()); + boolean noConfirm = !WaybillProcessSupport.requiresDriverAcceptConfirmation(processJson); + if (accepted) { + item.setPunched(true); + item.setStatusName("已打卡"); + item.setPunchTime(formatPunchTime(waybill.getDriverAcceptTime())); + } else if (noConfirm && passedAcceptStage(waybill.getBusinessStatus())) { + item.setPunched(true); + item.setStatusName("已打卡"); + item.setPunchTime(""); + } else { + item.setPunched(false); + item.setStatusName("未打卡"); + item.setPunchTime(""); + } + records.add(item); + continue; + } + WaybillNodePunch punched = findLatestNodePunch(latestNodePunch, nodeCode, nodeName); if (punched != null) { List types = resolveNodeVoucherTypes(voucherTypesByNode, punched.getNodeCode(), nodeName); @@ -524,6 +547,11 @@ public class WaybillServiceImpl extends BaseServiceImpl if (Func.isEmpty(nodeCode) && Func.isEmpty(nodeName)) { throw new ServiceException("过程节点不能为空"); } + // 接单节点没有打卡流水:它是否完成由运单的接单事实决定,补录与取消都改不动它。 + // 放行只会造出一条与运单事实打架的流水——时间轴显示已打卡,运单却还没人接单。 + if (WaybillProcessSupport.isAcceptNode(nodeCode, nodeName)) { + throw new ServiceException("接单节点由接单动作决定,不支持补录或取消打卡"); + } List rows = nodePunchRows(waybillId, nodeCode, nodeName); if (!punched) { if (!rows.isEmpty()) { @@ -910,6 +938,18 @@ public class WaybillServiceImpl extends BaseServiceImpl return photo; } + /** + * 运单是否已越过接单阶段。 + *

+ * 正向列举进行中/已完成/已取消,不能用「不是终态」取反:终态里还包含草稿、待调度、 + * 调度中这些并未越过接单的阶段,取反会把它们判成已接单。 + */ + private static boolean passedAcceptStage(String businessStatus) { + return "running".equals(businessStatus) + || "completed".equals(businessStatus) + || "cancelled".equals(businessStatus); + } + private String formatPunchTime(Date time) { if (time == null) { return ""; 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 2d685d0c..bdc9c0fb 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 @@ -536,10 +536,18 @@ public final class WaybillProcessSupport { return "return".equals(key) || "回单".equals(name); } + /** + * 是否接单节点:key=accept 或 name=接单。 + *

+ * 管理端打卡时间轴与补录校验共用它——接单节点的完成由运单的接单事实决定, + * 不落打卡流水,两处的判据必须是同一个。 + */ + public static boolean isAcceptNode(String nodeCode, String nodeName) { + return "accept".equals(nodeCode) || "接单".equals(nodeName); + } + private static boolean isAcceptNode(Map node) { - String key = stringVal(node.get("key")); - String name = stringVal(node.get("name")); - return "accept".equals(key) || "接单".equals(name); + return isAcceptNode(stringVal(node.get("key")), stringVal(node.get("name"))); } private static boolean isTransitNode(Map node) { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordListTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordListTest.java new file mode 100644 index 00000000..c61dd1ec --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordListTest.java @@ -0,0 +1,225 @@ +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.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +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.WaybillPunchRecordItemVO; + +import java.lang.reflect.Field; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +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.when; + +/** + * 管理端打卡时间轴的接单节点判定测试。 + *

+ * 接单节点不落打卡流水,是否完成由运单的接单事实决定:司机确认接单即为已打卡; + * 项目「无需确认接单」时运单越过接单阶段即为已打卡,但没有完成时刻。 + * 这条口径若被改成「看打卡表」,时间轴上的接单会退回恒显示未打卡。 + */ +class WaybillPunchRecordListTest { + + private static final Long WAYBILL_ID = 6101L; + private static final String WAYBILL_NO = "YD202610060002"; + + /** 需确认接单:接单节点 + 一个普通打卡节点 */ + private static final String CONFIRM_ACCEPT_JSON = + "[{\"key\":\"accept\",\"name\":\"接单\",\"type\":\"confirm\",\"enabled\":true,\"punch\":true," + + "\"confirmMode\":\"yes\"}," + + "{\"key\":\"load\",\"name\":\"装货\",\"enabled\":true,\"punch\":true}]"; + + /** 无需确认接单 */ + private static final String NO_CONFIRM_ACCEPT_JSON = + "[{\"key\":\"accept\",\"name\":\"接单\",\"type\":\"confirm\",\"enabled\":true,\"punch\":true," + + "\"confirmMode\":\"no_confirm_accept\"}," + + "{\"key\":\"load\",\"name\":\"装货\",\"enabled\":true,\"punch\":true}]"; + + private WaybillMapper waybillMapper; + private WaybillNodePunchMapper nodePunchMapper; + private WaybillEnroutePunchMapper enroutePunchMapper; + private WaybillServiceImpl 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); + } + + @BeforeEach + void setUp() throws Exception { + waybillMapper = mock(WaybillMapper.class); + nodePunchMapper = mock(WaybillNodePunchMapper.class); + enroutePunchMapper = mock(WaybillEnroutePunchMapper.class); + service = new WaybillServiceImpl(); + inject("baseMapper", waybillMapper); + inject("waybillNodePunchMapper", nodePunchMapper); + inject("waybillEnroutePunchMapper", enroutePunchMapper); + } + + @Test + @DisplayName("司机确认接单:接单节点显示已打卡,时间取接单时间") + void acceptNodePunchedWhenDriverAccepted() { + Waybill waybill = waybill(CONFIRM_ACCEPT_JSON, "running", "accepted"); + waybill.setDriverAcceptTime(parseTime("2026-10-06 10:04:07")); + stubWaybill(waybill); + + WaybillPunchRecordItemVO accept = acceptRecord(); + + assertTrue(accept.getPunched()); + assertEquals("已打卡", accept.getStatusName()); + assertEquals("2026-10-06 10:04:07", accept.getPunchTime()); + assertNull(accept.getId(), "派生记录没有打卡流水,不能带流水 id"); + } + + @Test + @DisplayName("待接单:接单节点仍是未打卡") + void acceptNodeUnpunchedWhenPending() { + stubWaybill(waybill(CONFIRM_ACCEPT_JSON, "pending", "pending")); + + WaybillPunchRecordItemVO accept = acceptRecord(); + + assertFalse(accept.getPunched()); + assertEquals("未打卡", accept.getStatusName()); + assertEquals("", accept.getPunchTime()); + } + + @Test + @DisplayName("已拒单:接单节点仍是未打卡") + void acceptNodeUnpunchedWhenRejected() { + stubWaybill(waybill(CONFIRM_ACCEPT_JSON, "pending", "rejected")); + + assertFalse(acceptRecord().getPunched()); + } + + @Test + @DisplayName("无需确认接单且已进入进行中:接单节点已打卡,但没有完成时刻") + void acceptNodePunchedWithoutTimeWhenNoConfirm() { + stubWaybill(waybill(NO_CONFIRM_ACCEPT_JSON, "running", null)); + + WaybillPunchRecordItemVO accept = acceptRecord(); + + assertTrue(accept.getPunched()); + assertEquals("已打卡", accept.getStatusName()); + assertEquals("", accept.getPunchTime(), "没有接单动作就没有完成时刻,不得拿别的字段编一个"); + } + + @Test + @DisplayName("无需确认接单但仍是草稿:接单节点未打卡") + void acceptNodeUnpunchedWhenDraft() { + stubWaybill(waybill(NO_CONFIRM_ACCEPT_JSON, "draft", null)); + + assertFalse(acceptRecord().getPunched()); + } + + @Test + @DisplayName("无需确认接单但停在待执行:接单节点未打卡") + void acceptNodeUnpunchedWhenPendingWithoutConfirm() { + stubWaybill(waybill(NO_CONFIRM_ACCEPT_JSON, "pending", null)); + + assertFalse(acceptRecord().getPunched()); + } + + @Test + @DisplayName("已取消的无需确认接单运单:判为已打卡——状态本身不带是否曾进入进行中的历史") + void acceptNodePunchedWhenCancelled() { + stubWaybill(waybill(NO_CONFIRM_ACCEPT_JSON, "cancelled", null)); + + assertTrue(acceptRecord().getPunched()); + } + + @Test + @DisplayName("接单节点的派生不影响普通节点:有流水的普通节点照旧已打卡并带流水 id") + void otherNodesStillReadPunchTable() { + stubWaybill(waybill(CONFIRM_ACCEPT_JSON, "running", "accepted")); + WaybillNodePunch loadPunch = new WaybillNodePunch(); + loadPunch.setId(7001L); + loadPunch.setWaybillId(WAYBILL_ID); + loadPunch.setNodeCode("load"); + loadPunch.setNodeName("装货"); + loadPunch.setPunchTime(parseTime("2026-10-06 09:30:00")); + when(nodePunchMapper.selectList(any())).thenReturn(List.of(loadPunch)); + + WaybillPunchRecordItemVO load = service.listPunchRecords(WAYBILL_ID).getRecords().stream() + .filter(record -> "load".equals(record.getNodeCode())) + .findFirst() + .orElseThrow(() -> new AssertionError("时间轴里没有装货节点")); + + assertTrue(load.getPunched()); + assertEquals("已打卡", load.getStatusName()); + assertEquals("2026-10-06 09:30:00", load.getPunchTime()); + assertEquals(7001L, load.getId()); + } + + // ------------------------------------------------------------ 夹具 + + private void stubWaybill(Waybill waybill) { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + when(enroutePunchMapper.selectList(any())).thenReturn(List.of()); + } + + private WaybillPunchRecordItemVO acceptRecord() { + return service.listPunchRecords(WAYBILL_ID).getRecords().stream() + .filter(record -> "accept".equals(record.getNodeCode())) + .findFirst() + .orElseThrow(() -> new AssertionError("时间轴里没有接单节点")); + } + + private Waybill waybill(String processJson, String businessStatus, String acceptStatus) { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo(WAYBILL_NO); + waybill.setProcessJson(processJson); + waybill.setBusinessStatus(businessStatus); + waybill.setDriverAcceptStatus(acceptStatus); + waybill.setIsDeleted(0); + return waybill; + } + + private static Date parseTime(String text) { + try { + return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(text); + } catch (ParseException exception) { + throw new IllegalArgumentException(text, exception); + } + } + + private void inject(String field, Object value) throws Exception { + Class type = service.getClass(); + while (type != null) { + try { + Field declared = type.getDeclaredField(field); + declared.setAccessible(true); + declared.set(service, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new IllegalStateException("未找到字段:" + field); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java index e59ee890..0a50eee5 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java @@ -163,6 +163,23 @@ class WaybillPunchRecordSaveTest { verify(nodePunchMapper, never()).updateById(any(WaybillNodePunch.class)); } + @Test + @DisplayName("接单节点:补录与取消打卡都拒绝——它的完成由运单的接单事实决定") + void rejectAcceptNodePunchEdit() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + + WaybillPunchRecordSaveRequest punched = request("node", "accept", "接单"); + punched.setPunched(true); + assertThrows(ServiceException.class, () -> service.savePunchRecord(punched)); + + WaybillPunchRecordSaveRequest unpunched = request("node", "accept", "接单"); + unpunched.setPunched(false); + assertThrows(ServiceException.class, () -> service.savePunchRecord(unpunched)); + + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + verify(nodePunchMapper, never()).deleteBatchIds(any()); + } + // ------------------------------------------------------------ 在途打卡 @Test