diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FulfillmentTaskRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FulfillmentTaskRepository.java index 3b4ce85f4..ee6b3000a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FulfillmentTaskRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FulfillmentTaskRepository.java @@ -34,6 +34,17 @@ public class FulfillmentTaskRepository { .eq(FulfillmentTaskEntity::getOrderId, orderId)); } + public FulfillmentTaskEntity findByOrderForUpdate(long tenantId, long orderId) { + return tasks.selectOne(new LambdaQueryWrapper() + .eq(FulfillmentTaskEntity::getTenantId, tenantId) + .eq(FulfillmentTaskEntity::getOrderId, orderId) + .last("FOR UPDATE")); + } + + public void insert(FulfillmentTaskEntity task) { + tasks.insert(task); + } + public PageResult page(long tenantId, FulfillmentTaskQuery query) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(FulfillmentTaskEntity::getTenantId, tenantId) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FulfillmentTaskProvisioningService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FulfillmentTaskProvisioningService.java new file mode 100644 index 000000000..15ec85496 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FulfillmentTaskProvisioningService.java @@ -0,0 +1,74 @@ +package com.baiyue.firstleg.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.dto.OrderApprovedInboundCommand; +import com.baiyue.firstleg.entity.FulfillmentTaskEntity; +import com.baiyue.firstleg.repository.FulfillmentTaskRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.Objects; + +/** 将订单审核快照幂等转换为待分配履约任务。 */ +@Service +public class FulfillmentTaskProvisioningService { + private final FulfillmentTaskRepository repository; + private final ObjectMapper objectMapper; + + public FulfillmentTaskProvisioningService(FulfillmentTaskRepository repository, ObjectMapper objectMapper) { + this.repository = repository; + this.objectMapper = objectMapper; + } + + public FulfillmentTaskEntity provision(OrderApprovedInboundCommand command, LocalDateTime now) { + FulfillmentTaskEntity existing = repository.findByOrder(command.tenantId(), command.orderId()); + if (existing != null) { + return requireMatchingOrder(existing, command.orderNo()); + } + + FulfillmentTaskEntity task = new FulfillmentTaskEntity(); + task.setId(IdWorker.getId()); + task.setTenantId(command.tenantId()); + task.setOrderId(command.orderId()); + task.setOrderNo(command.orderNo()); + task.setOrderSnapshotJson(snapshot(command)); + task.setStatus("PENDING_ASSIGNMENT"); + task.setCreatedBy(0L); + task.setUpdatedBy(0L); + task.setCreatedAt(now); + task.setUpdatedAt(now); + task.setDeleted(false); + task.setVersion(0); + try { + repository.insert(task); + return task; + } catch (DuplicateKeyException duplicate) { + FulfillmentTaskEntity winner = repository.findByOrderForUpdate( + command.tenantId(), command.orderId()); + if (winner == null) { + throw new BizException(ResultCode.CONFLICT, "履约任务已被并发创建但无法读取"); + } + return requireMatchingOrder(winner, command.orderNo()); + } + } + + private String snapshot(OrderApprovedInboundCommand command) { + try { + return objectMapper.writeValueAsString(command); + } catch (JsonProcessingException exception) { + throw new BizException(ResultCode.INTERNAL_ERROR, "订单履约快照序列化失败"); + } + } + + private FulfillmentTaskEntity requireMatchingOrder(FulfillmentTaskEntity task, String orderNo) { + if (!Objects.equals(task.getOrderNo(), orderNo)) { + throw new BizException(ResultCode.CONFLICT, "订单号与已有履约任务不一致"); + } + return task; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java index c8ce95b51..a5091c397 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java @@ -7,6 +7,7 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.OrderApprovedInboundCommand; import com.baiyue.firstleg.dto.InboundOrderReference; +import com.baiyue.firstleg.entity.FulfillmentTaskEntity; import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; import com.baiyue.firstleg.mapper.InboundOrderMapper; @@ -25,18 +26,25 @@ public class InboundOrderCreationService { private InboundOrderMapper orders; @Resource private InboundStatusHistoryMapper histories; + @Resource + private FulfillmentTaskProvisioningService fulfillmentTasks; @Transactional public long createFromApprovedOrder(OrderApprovedInboundCommand command) { InboundOrderEntity existing = findByOrder(command.tenantId(), command.orderId(), false); if (existing != null) { - return requireMatchingInboundCode(existing, command.inboundCode()); + long inboundId = requireMatchingInboundCode(existing, command.inboundCode()); + FulfillmentTaskEntity task = fulfillmentTasks.provision(command, LocalDateTime.now()); + linkTask(existing, task.getId()); + return inboundId; } LocalDateTime now = LocalDateTime.now(); + FulfillmentTaskEntity task = fulfillmentTasks.provision(command, now); InboundOrderEntity inbound = new InboundOrderEntity(); inbound.setId(IdWorker.getId()); inbound.setTenantId(command.tenantId()); + inbound.setTaskId(task.getId()); inbound.setOrderId(command.orderId()); inbound.setOrderNo(command.orderNo()); inbound.setInboundCode(command.inboundCode()); @@ -115,6 +123,29 @@ public class InboundOrderCreationService { return existing.getId(); } + private void linkTask(InboundOrderEntity inbound, long taskId) { + if (inbound.getTaskId() != null) { + if (!inbound.getTaskId().equals(taskId)) { + throw new BizException(ResultCode.CONFLICT, "入库单已关联其他履约任务"); + } + return; + } + int updated = orders.update(null, + new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() + .eq(InboundOrderEntity::getTenantId, inbound.getTenantId()) + .eq(InboundOrderEntity::getId, inbound.getId()) + .isNull(InboundOrderEntity::getTaskId) + .set(InboundOrderEntity::getTaskId, taskId)); + if (updated == 1) { + inbound.setTaskId(taskId); + return; + } + InboundOrderEntity current = findByOrder(inbound.getTenantId(), inbound.getOrderId(), true); + if (current == null || !Long.valueOf(taskId).equals(current.getTaskId())) { + throw new BizException(ResultCode.CONFLICT, "入库单履约任务关联已发生变化"); + } + } + public List findReferences(long tenantId, List orderIds) { if (orderIds.isEmpty()) return List.of(); return orders.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FulfillmentTaskProvisioningServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FulfillmentTaskProvisioningServiceTest.java new file mode 100644 index 000000000..87b4d0b6e --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FulfillmentTaskProvisioningServiceTest.java @@ -0,0 +1,84 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.dto.OrderApprovedInboundCommand; +import com.baiyue.firstleg.entity.FulfillmentTaskEntity; +import com.baiyue.firstleg.repository.FulfillmentTaskRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DuplicateKeyException; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class FulfillmentTaskProvisioningServiceTest { + private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 8, 10, 0); + @Mock private FulfillmentTaskRepository repository; + private FulfillmentTaskProvisioningService service; + + @BeforeEach + void setUp() { + service = new FulfillmentTaskProvisioningService(repository, new ObjectMapper().findAndRegisterModules()); + } + + @Test + void shouldCreatePendingTaskFromApprovedOrderSnapshot() { + FulfillmentTaskEntity created = service.provision(command(), NOW); + + ArgumentCaptor captor = ArgumentCaptor.forClass(FulfillmentTaskEntity.class); + verify(repository).insert(captor.capture()); + assertThat(created).isSameAs(captor.getValue()); + assertThat(created.getTenantId()).isEqualTo(10001L); + assertThat(created.getOrderId()).isEqualTo(77L); + assertThat(created.getOrderNo()).isEqualTo("FL-77"); + assertThat(created.getStatus()).isEqualTo("PENDING_ASSIGNMENT"); + assertThat(created.getOrderSnapshotJson()).contains("\"tenantId\":10001", "\"orderId\":77", "\"inboundCode\":\"RK-77\""); + assertThat(created.getCreatedAt()).isEqualTo(NOW); + assertThat(created.getDeleted()).isFalse(); + assertThat(created.getVersion()).isZero(); + } + + @Test + void shouldReturnExistingTaskForIdempotentReplay() { + FulfillmentTaskEntity existing = task(700L); + when(repository.findByOrder(10001L, 77L)).thenReturn(existing); + + assertThat(service.provision(command(), NOW)).isSameAs(existing); + + verify(repository, never()).insert(any()); + } + + @Test + void shouldConvergeOnConcurrentTaskCreation() { + FulfillmentTaskEntity winner = task(701L); + doThrow(new DuplicateKeyException("concurrent insert")).when(repository).insert(any()); + when(repository.findByOrderForUpdate(10001L, 77L)).thenReturn(winner); + + assertThat(service.provision(command(), NOW)).isSameAs(winner); + } + + private static FulfillmentTaskEntity task(long id) { + FulfillmentTaskEntity task = new FulfillmentTaskEntity(); + task.setId(id); task.setTenantId(10001L); task.setOrderId(77L); task.setOrderNo("FL-77"); + return task; + } + + private static OrderApprovedInboundCommand command() { + return new OrderApprovedInboundCommand( + 10001L, 77L, "FL-77", "RK-77", 7L, "测试客户", 9L, "义乌仓", + null, null, null, "FBA", "AIR", "空运", null, null, + "BUY_DECLARATION", "TAX_EXCLUDED", null, null, "FBA", null, + "ONT8", "ONT8", "验收备注", null, null); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FulfillmentTaskServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FulfillmentTaskServiceTest.java index 330beec53..5ed6c4772 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FulfillmentTaskServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FulfillmentTaskServiceTest.java @@ -51,6 +51,18 @@ class FulfillmentTaskServiceTest { .isEqualTo(ResultCode.NOT_FOUND); } + @Test + @DisplayName("按客户和订单查询返回审核后生成的履约任务") + void shouldReturnProvisionedTaskByTenantAndOrder() { + FulfillmentTaskEntity task = new FulfillmentTaskEntity(); + task.setId(700L); task.setTenantId(10001L); task.setOrderId(77L); + when(repository.findByOrder(10001L, 77L)).thenReturn(task); + + assertThat(service.byOrder(10001L, 77L)).isSameAs(task); + + verify(repository).findByOrder(10001L, 77L); + } + @Test @DisplayName("查询分配历史前先校验任务归属租户") void shouldVerifyTaskTenantBeforeLoadingAssignmentHistory() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java index 7995676b0..7bd98d3c7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java @@ -1,15 +1,20 @@ package com.baiyue.firstleg.service; import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.baiyue.firstleg.dto.OrderApprovedInboundCommand; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; +import com.baiyue.firstleg.entity.FulfillmentTaskEntity; import com.baiyue.firstleg.mapper.InboundOrderMapper; import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.extension.ExtendWith; +import org.apache.ibatis.builder.MapperBuilderAssistant; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -26,10 +31,18 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class InboundOrderCreationServiceTest { + @BeforeAll + static void initializeMetadata() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), "test"), InboundOrderEntity.class); + } + @Mock private InboundOrderMapper orders; @Mock private InboundStatusHistoryMapper histories; + @Mock + private FulfillmentTaskProvisioningService fulfillmentTasks; @InjectMocks private InboundOrderCreationService service; @@ -41,7 +54,9 @@ class InboundOrderCreationServiceTest { existing.setTenantId(10001L); existing.setOrderId(77L); existing.setInboundCode("RK-CUSTOM-77"); + existing.setTaskId(700L); when(orders.selectOne(any(Wrapper.class))).thenReturn(null, existing); + when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L)); long createdId = service.createFromApprovedOrder(command); long replayedId = service.createFromApprovedOrder(command); @@ -50,6 +65,7 @@ class InboundOrderCreationServiceTest { verify(orders, times(1)).insert(inboundCaptor.capture()); verify(histories, times(1)).insert(any(InboundStatusHistoryEntity.class)); assertThat(inboundCaptor.getValue().getInboundCode()).isEqualTo("RK-CUSTOM-77"); + assertThat(inboundCaptor.getValue().getTaskId()).isEqualTo(700L); assertThat(inboundCaptor.getValue().getCarrierId()).isNull(); assertThat(inboundCaptor.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION"); assertThat(inboundCaptor.getValue().getPricingTaxMode()).isEqualTo("TAX_EXCLUDED"); @@ -72,7 +88,9 @@ class InboundOrderCreationServiceTest { @Test void shouldConvergeOnConcurrentDuplicateInsertWinner() { InboundOrderEntity winner = inbound(901L, "RK-CUSTOM-77", "UNPRICED", null, null); + winner.setTaskId(700L); when(orders.selectOne(any(Wrapper.class))).thenReturn(null, winner); + when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L)); doThrow(new org.springframework.dao.DuplicateKeyException("concurrent insert")) .when(orders).insert(any(InboundOrderEntity.class)); @@ -80,6 +98,20 @@ class InboundOrderCreationServiceTest { verify(histories, never()).insert(any(InboundStatusHistoryEntity.class)); } + @Test + void shouldBackfillTaskLinkForInboundCreatedBeforeTaskProvisioning() { + InboundOrderEntity existing = inbound(900L, "RK-CUSTOM-77", "UNPRICED", null, null); + when(orders.selectOne(any(Wrapper.class))).thenReturn(existing); + when(orders.update(any(), any(Wrapper.class))).thenReturn(1); + when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L)); + + assertThat(service.createFromApprovedOrder(approvedCommand("RK-CUSTOM-77"))).isEqualTo(900L); + + assertThat(existing.getTaskId()).isEqualTo(700L); + verify(orders).update(any(), any(Wrapper.class)); + verify(orders, never()).insert(any(InboundOrderEntity.class)); + } + private static InboundOrderEntity inbound(long id, String code, String feeStatus, Long carrierId, String carrierName) { InboundOrderEntity inbound = new InboundOrderEntity(); @@ -93,6 +125,15 @@ class InboundOrderCreationServiceTest { return inbound; } + private static FulfillmentTaskEntity task(long id) { + FulfillmentTaskEntity task = new FulfillmentTaskEntity(); + task.setId(id); + task.setTenantId(10001L); + task.setOrderId(77L); + task.setOrderNo("FL-77"); + return task; + } + private static OrderApprovedInboundCommand approvedCommand(String inboundCode) { return new OrderApprovedInboundCommand( 10001L, 77L, "FL-77", inboundCode, 7L, "测试客户", 9L, "义乌仓",