✨ 接单后自动将接单节点置为已打卡
管理端打卡时间轴上的「接单」节点此前恒显示未打卡:接单动作只写运单字段,不落打卡流水, 而时间轴的节点来源含接单/回单,没有流水即判为未打卡。 改为按运单的接单事实派生:司机确认接单即为已打卡,时间取接单时间;项目「无需确认接单」时, 运单越过接单阶段(进行中/已完成/已取消)即为已打卡,但没有完成时刻——运单上没有任何字段 记着那一刻,时间栏留空,不拿创建时间或更新时间编一个。 不写打卡流水的原因:接单环节有 5 个产生点(司机确认、免确认落库、复制运单、导入进行中、 配载单开始),其中两处是绕过 Java 逻辑的批量 SQL;接单事实另有 4 个清除点。写流水要在每处 挂点,漏一处就出现「待接单却已打卡」或「进行中却未打卡」;派生只需在 listPunchRecords 一处判定, 且历史运单立即正确,无需回填。打卡表 (waybill_id, node_code) 也没有唯一约束,幂等只能靠代码自保。 配套: - 接单节点不再允许补录或取消打卡:它由接单动作决定,人工订正只会造出一条与运单事实打架的 流水(时间轴显示已打卡、运单却还没人接单),故后端拒绝、前端不展示编辑入口 - 接单节点判据提为 WaybillProcessSupport.isAcceptNode(nodeCode, nodeName) 公开方法, 时间轴与补录校验共用同一份,不再各写一处硬编码 - 补 8 个时间轴用例(真接单带时间/待接单/已拒单/免确认进行中无时间/免确认草稿/免确认待执行/ 免确认已取消/普通节点不受影响)+ 1 个补录拒绝用例
This commit is contained in:
+40
@@ -458,6 +458,29 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
|||||||
continue;
|
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);
|
WaybillNodePunch punched = findLatestNodePunch(latestNodePunch, nodeCode, nodeName);
|
||||||
if (punched != null) {
|
if (punched != null) {
|
||||||
List<String> types = resolveNodeVoucherTypes(voucherTypesByNode, punched.getNodeCode(), nodeName);
|
List<String> types = resolveNodeVoucherTypes(voucherTypesByNode, punched.getNodeCode(), nodeName);
|
||||||
@@ -524,6 +547,11 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
|||||||
if (Func.isEmpty(nodeCode) && Func.isEmpty(nodeName)) {
|
if (Func.isEmpty(nodeCode) && Func.isEmpty(nodeName)) {
|
||||||
throw new ServiceException("过程节点不能为空");
|
throw new ServiceException("过程节点不能为空");
|
||||||
}
|
}
|
||||||
|
// 接单节点没有打卡流水:它是否完成由运单的接单事实决定,补录与取消都改不动它。
|
||||||
|
// 放行只会造出一条与运单事实打架的流水——时间轴显示已打卡,运单却还没人接单。
|
||||||
|
if (WaybillProcessSupport.isAcceptNode(nodeCode, nodeName)) {
|
||||||
|
throw new ServiceException("接单节点由接单动作决定,不支持补录或取消打卡");
|
||||||
|
}
|
||||||
List<WaybillNodePunch> rows = nodePunchRows(waybillId, nodeCode, nodeName);
|
List<WaybillNodePunch> rows = nodePunchRows(waybillId, nodeCode, nodeName);
|
||||||
if (!punched) {
|
if (!punched) {
|
||||||
if (!rows.isEmpty()) {
|
if (!rows.isEmpty()) {
|
||||||
@@ -910,6 +938,18 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
|||||||
return photo;
|
return photo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运单是否已越过接单阶段。
|
||||||
|
* <p>
|
||||||
|
* 正向列举进行中/已完成/已取消,不能用「不是终态」取反:终态里还包含草稿、待调度、
|
||||||
|
* 调度中这些并未越过接单的阶段,取反会把它们判成已接单。
|
||||||
|
*/
|
||||||
|
private static boolean passedAcceptStage(String businessStatus) {
|
||||||
|
return "running".equals(businessStatus)
|
||||||
|
|| "completed".equals(businessStatus)
|
||||||
|
|| "cancelled".equals(businessStatus);
|
||||||
|
}
|
||||||
|
|
||||||
private String formatPunchTime(Date time) {
|
private String formatPunchTime(Date time) {
|
||||||
if (time == null) {
|
if (time == null) {
|
||||||
return "";
|
return "";
|
||||||
|
|||||||
+11
-3
@@ -536,10 +536,18 @@ public final class WaybillProcessSupport {
|
|||||||
return "return".equals(key) || "回单".equals(name);
|
return "return".equals(key) || "回单".equals(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否接单节点:key=accept 或 name=接单。
|
||||||
|
* <p>
|
||||||
|
* 管理端打卡时间轴与补录校验共用它——接单节点的完成由运单的接单事实决定,
|
||||||
|
* 不落打卡流水,两处的判据必须是同一个。
|
||||||
|
*/
|
||||||
|
public static boolean isAcceptNode(String nodeCode, String nodeName) {
|
||||||
|
return "accept".equals(nodeCode) || "接单".equals(nodeName);
|
||||||
|
}
|
||||||
|
|
||||||
private static boolean isAcceptNode(Map<String, Object> node) {
|
private static boolean isAcceptNode(Map<String, Object> node) {
|
||||||
String key = stringVal(node.get("key"));
|
return isAcceptNode(stringVal(node.get("key")), stringVal(node.get("name")));
|
||||||
String name = stringVal(node.get("name"));
|
|
||||||
return "accept".equals(key) || "接单".equals(name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isTransitNode(Map<String, Object> node) {
|
private static boolean isTransitNode(Map<String, Object> node) {
|
||||||
|
|||||||
+225
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理端打卡时间轴的接单节点判定测试。
|
||||||
|
* <p>
|
||||||
|
* 接单节点不落打卡流水,是否完成由运单的接单事实决定:司机确认接单即为已打卡;
|
||||||
|
* 项目「无需确认接单」时运单越过接单阶段即为已打卡,但没有完成时刻。
|
||||||
|
* 这条口径若被改成「看打卡表」,时间轴上的接单会退回恒显示未打卡。
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+17
@@ -163,6 +163,23 @@ class WaybillPunchRecordSaveTest {
|
|||||||
verify(nodePunchMapper, never()).updateById(any(WaybillNodePunch.class));
|
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
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user