This commit is contained in:
2026-08-08 10:17:50 +08:00
6 changed files with 254 additions and 1 deletions
@@ -34,6 +34,17 @@ public class FulfillmentTaskRepository {
.eq(FulfillmentTaskEntity::getOrderId, orderId));
}
public FulfillmentTaskEntity findByOrderForUpdate(long tenantId, long orderId) {
return tasks.selectOne(new LambdaQueryWrapper<FulfillmentTaskEntity>()
.eq(FulfillmentTaskEntity::getTenantId, tenantId)
.eq(FulfillmentTaskEntity::getOrderId, orderId)
.last("FOR UPDATE"));
}
public void insert(FulfillmentTaskEntity task) {
tasks.insert(task);
}
public PageResult<FulfillmentTaskEntity> page(long tenantId, FulfillmentTaskQuery query) {
LambdaQueryWrapper<FulfillmentTaskEntity> wrapper = new LambdaQueryWrapper<FulfillmentTaskEntity>()
.eq(FulfillmentTaskEntity::getTenantId, tenantId)
@@ -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;
}
}
@@ -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());
@@ -116,6 +124,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<InboundOrderEntity>()
.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<InboundOrderReference.Item> findReferences(long tenantId, List<Long> orderIds) {
if (orderIds.isEmpty()) return List.of();
return orders.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<InboundOrderEntity>()
@@ -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<FulfillmentTaskEntity> 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);
}
}
@@ -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() {
@@ -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");
@@ -73,7 +89,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));
@@ -81,6 +99,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();
@@ -94,6 +126,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, "义乌仓",