Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a9ab27fd2 | ||
|
|
4d12b56cac | ||
|
|
91b6574d53 | ||
|
|
e5f9cdcf01 | ||
|
|
b072c0a884 |
@@ -40,8 +40,14 @@ public class GlobalExceptionHandler {
|
||||
@Autowired(required = false)
|
||||
private MeterRegistry meterRegistry;
|
||||
|
||||
/** 系统类业务失败必须记录链路和异常,普通业务校验仍只返回统一响应。 */
|
||||
@ExceptionHandler(BizException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleBusiness(BizException exception, HttpServletRequest request) {
|
||||
if (exception.getResultCode().httpStatus().is5xxServerError()) {
|
||||
log.error("系统业务请求失败 method={} path={} code={} requestId={} traceId={}",
|
||||
request.getMethod(), request.getRequestURI(), exception.getResultCode().code(),
|
||||
CorrelationContext.requestId(request), CorrelationContext.traceId(request), exception);
|
||||
}
|
||||
return failure(exception.getResultCode(), exception.getMessage(), request);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import com.baiyue.common.core.exception.BizException;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.springframework.boot.test.system.CapturedOutput;
|
||||
import org.springframework.boot.test.system.OutputCaptureExtension;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.validation.BeanPropertyBindingResult;
|
||||
@@ -39,6 +42,37 @@ class GlobalExceptionHandlerTest {
|
||||
assertThat(response.getBody().requestId()).isEqualTo("r1");
|
||||
}
|
||||
|
||||
/** 系统类业务失败留下路径、关联标识和根因,但不打印请求参数或令牌。 */
|
||||
@Test
|
||||
@ExtendWith(OutputCaptureExtension.class)
|
||||
void systemBusinessFailureLogsCorrelationAndCause(CapturedOutput output) {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/platform/v1/orders/status-counts");
|
||||
request.setAttribute(CorrelationContext.REQUEST_ID, "status-count-request");
|
||||
request.setAttribute(CorrelationContext.TRACE_ID, "status-count-trace");
|
||||
request.setQueryString("keyword=private-query");
|
||||
request.addHeader("X-Internal-Service-Token", "private-service-token");
|
||||
BizException exception = new BizException(ResultCode.SERVICE_UNAVAILABLE, "统计暂不可用");
|
||||
exception.initCause(new IllegalStateException("缺少尾程来源"));
|
||||
|
||||
var response = handler.handleBusiness(exception, request);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody().code()).isEqualTo("S0503");
|
||||
assertThat(response.getBody().message()).isEqualTo("统计暂不可用");
|
||||
assertThat(output.getOut()).contains("ERROR", "GET", "/api/platform/v1/orders/status-counts",
|
||||
"S0503", "status-count-request", "status-count-trace", "缺少尾程来源")
|
||||
.doesNotContain("private-query", "private-service-token");
|
||||
}
|
||||
|
||||
/** 预期的业务冲突不应升级为系统错误日志。 */
|
||||
@Test
|
||||
@ExtendWith(OutputCaptureExtension.class)
|
||||
void normalBusinessFailureDoesNotLogSystemError(CapturedOutput output) {
|
||||
handler.handleBusiness(new BizException(ResultCode.CONFLICT, "normal-business-conflict"),
|
||||
new MockHttpServletRequest());
|
||||
assertThat(output.getAll()).doesNotContain("系统业务请求失败", "normal-business-conflict");
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidQueryParameterTypeMapsToBadRequest() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest;
|
||||
import com.baiyue.finance.client.FirstlegFeeSourceClient;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import feign.FeignException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -437,7 +438,14 @@ public class FinancePhase1OperationService {
|
||||
private static Long nullableLong(ResultSet rs, String column) throws SQLException { long value = rs.getLong(column); return rs.wasNull() ? null : value; }
|
||||
private JsonNode parse(String json) { try { return objectMapper.readTree(json); } catch (Exception e) { throw error("FIN-DATA-500", "费用来源快照损坏"); } }
|
||||
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw error("FIN-DATA-500", "财务数据序列化失败"); } }
|
||||
private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw error("FIN-DATA-500", "幂等请求哈希失败"); } }
|
||||
/** 固定映射键序,使同一请求在服务重启前后使用相同摘要。 */
|
||||
private String hash(Object value) {
|
||||
try {
|
||||
byte[] canonical = objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
|
||||
.writeValueAsBytes(value);
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical));
|
||||
} catch (Exception failure) { throw error("FIN-DATA-500", "幂等请求哈希失败"); }
|
||||
}
|
||||
private static String text(JsonNode node, String field) { JsonNode value = node.path(field); return value.isMissingNode() || value.isNull() ? null : value.asText(); }
|
||||
private static Long jsonLong(JsonNode node, String field) {
|
||||
JsonNode value = node.path(field);
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
import org.springframework.jdbc.support.KeyHolder;
|
||||
@@ -306,7 +307,14 @@ public class FinanceService {
|
||||
default -> throw new IllegalArgumentException("未知财务幂等操作: " + operation);
|
||||
};
|
||||
}
|
||||
private String hash(Object request) { try { byte[] bytes = java.security.MessageDigest.getInstance("SHA-256").digest(json(request).getBytes(java.nio.charset.StandardCharsets.UTF_8)); return java.util.HexFormat.of().formatHex(bytes); } catch (Exception ex) { throw new IllegalStateException("请求哈希失败", ex); } }
|
||||
/** 固定请求映射键序,避免服务重启后拒绝同一财务命令。 */
|
||||
private String hash(Object request) {
|
||||
try {
|
||||
byte[] canonical = objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
|
||||
.writeValueAsBytes(request == null ? List.of() : request);
|
||||
return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(canonical));
|
||||
} catch (Exception ex) { throw new IllegalStateException("请求哈希失败", ex); }
|
||||
}
|
||||
|
||||
private static void requireCustomer(long tenantId) { if (tenantId <= 0) throw bad("客户范围无效"); }
|
||||
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
@@ -523,10 +524,13 @@ public class FinanceSettlementService {
|
||||
return documentNumbers.next("PAY");
|
||||
}
|
||||
|
||||
/** 固定映射键序,避免 JVM 重启改变 Map.of 遍历顺序而误拒绝原请求。 */
|
||||
private String hash(Object value) {
|
||||
try {
|
||||
String canonical = objectMapper.writer()
|
||||
.with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS).writeValueAsString(value);
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(json(value).getBytes(StandardCharsets.UTF_8)));
|
||||
.digest(canonical.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception failure) {
|
||||
throw error("FIN_001", "幂等请求哈希失败");
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import com.baiyue.finance.dto.FinanceWalletDtos.WalletHoldActionRequest;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletLedgerEntry;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletPage;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -694,8 +695,9 @@ public class FinanceWalletService {
|
||||
timestamp(rs.getTimestamp("reviewed_at")));
|
||||
}
|
||||
|
||||
/** 摘要固定映射键序,保证充值和资金指令跨进程重放稳定。 */
|
||||
private String hash(Object value) {
|
||||
try { return sha256(objectMapper.writeValueAsBytes(value)); }
|
||||
try { return sha256(objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS).writeValueAsBytes(value)); }
|
||||
catch (Exception exception) { throw error("FIN_500", "请求摘要生成失败"); }
|
||||
}
|
||||
private String fileHash(MultipartFile file) {
|
||||
|
||||
+7
-2
@@ -10,6 +10,7 @@ import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationLine;
|
||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||
@@ -521,9 +522,13 @@ public class ReconciliationStatementService {
|
||||
catch (Exception exception) { throw error("FIN_001", "财务数据序列化失败"); }
|
||||
}
|
||||
|
||||
/** 固定映射键序,使同一请求在服务重启前后使用相同摘要。 */
|
||||
private String hash(Object value) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (Exception exception) { throw error("FIN_001", "幂等请求哈希失败"); }
|
||||
try {
|
||||
byte[] canonical = objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
|
||||
.writeValueAsBytes(value);
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical));
|
||||
} catch (Exception failure) { throw error("FIN_001", "幂等请求哈希失败"); }
|
||||
}
|
||||
|
||||
private static LocalDateTime localTime(Object value) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.baiyue.finance;
|
||||
|
||||
import com.baiyue.finance.service.FinanceSettlementService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import com.baiyue.finance.service.FinanceWalletService;
|
||||
import com.baiyue.finance.service.FinancePhase1OperationService;
|
||||
import com.baiyue.finance.service.FinanceService;
|
||||
import com.baiyue.finance.service.ReconciliationStatementService;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class FinanceSettlementRequestHashTest {
|
||||
/** 模拟不同 JVM 的 Map 遍历顺序;同一付款请求必须得到相同摘要。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(classes = {FinanceSettlementService.class, FinanceWalletService.class,
|
||||
FinancePhase1OperationService.class, FinanceService.class, ReconciliationStatementService.class})
|
||||
void equivalentRequestsIgnoreMapIterationOrderButDetectChangedPayment(Class<?> serviceType) {
|
||||
Object service = org.mockito.Mockito.mock(serviceType, org.mockito.Mockito.CALLS_REAL_METHODS);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("amount", 190); body.put("expectedVersion", 0);
|
||||
Map<String, Object> first = new LinkedHashMap<>();
|
||||
first.put("path", "/settlements/202/transactions"); first.put("method", "POST");
|
||||
first.put("customerId", 10L); first.put("body", body);
|
||||
Map<String, Object> reversedBody = new LinkedHashMap<>();
|
||||
reversedBody.put("expectedVersion", 0); reversedBody.put("amount", 190);
|
||||
Map<String, Object> second = new LinkedHashMap<>();
|
||||
second.put("body", reversedBody); second.put("customerId", 10L);
|
||||
second.put("method", "POST"); second.put("path", "/settlements/202/transactions");
|
||||
|
||||
String original = ReflectionTestUtils.invokeMethod(service, "hash", first);
|
||||
String reordered = ReflectionTestUtils.invokeMethod(service, "hash", second);
|
||||
assertThat(reordered).isEqualTo(original);
|
||||
reversedBody.put("amount", 191);
|
||||
String changed = ReflectionTestUtils.invokeMethod(service, "hash", second);
|
||||
assertThat(changed).isNotEqualTo(original);
|
||||
second.put("customerId", 11L); reversedBody.put("amount", 190);
|
||||
String otherCustomer = ReflectionTestUtils.invokeMethod(service, "hash", second);
|
||||
assertThat(otherCustomer).isNotEqualTo(original);
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ public class WorkflowTabService {
|
||||
var facts=repository.facts(batch.stream().map(InboundOrderEntity::getId).toList());
|
||||
// 确认页签复用已推送费用版本。
|
||||
for(var fee:repository.feeConfirmationRows(facts.stream().map(WorkflowFacts.Fact::inboundId).toList())) {
|
||||
if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable();
|
||||
if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable("费用确认事实不属于本批入仓来源");
|
||||
result.putIfAbsent(fee.orderId()+":"+fee.mainStatus(),fee);
|
||||
}
|
||||
var sources=facts.stream().filter(f->f.departed() || f.clearanceUploaded()).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
|
||||
@@ -32,19 +32,24 @@ public class WorkflowTabService {
|
||||
if(!sources.isEmpty()) {
|
||||
try {
|
||||
var response=lastleg.read(token,new WorkflowFacts.Request(sources));
|
||||
if(response==null || !response.successful() || response.data()==null) throw unavailable();
|
||||
if(response==null || !response.successful() || response.data()==null) throw unavailable(
|
||||
"尾程事实响应无效 code="+(response==null?null:response.code())
|
||||
+" requestId="+(response==null?null:response.requestId())+" traceId="+(response==null?null:response.traceId()));
|
||||
var allowed=new HashSet<>(sources);
|
||||
for(var fact:response.data()) {
|
||||
if(fact==null || !allowed.contains(new WorkflowFacts.Source(fact.tenantId(),fact.inboundId()))) throw unavailable();
|
||||
if(fact==null || !allowed.contains(new WorkflowFacts.Source(fact.tenantId(),fact.inboundId()))) throw unavailable("尾程事实包含空记录或本批以外的客户/入仓来源");
|
||||
overseas.merge(fact.tenantId()+":"+fact.inboundId(),fact,(a,b)->new WorkflowFacts.Overseas(a.tenantId(),a.inboundId(),a.outboundExists()||b.outboundExists(),a.pendingOutbound()||b.pendingOutbound(),a.delivering()||b.delivering(),a.unloaded()&&b.unloaded()));
|
||||
}
|
||||
if(overseas.size()!=sources.size()) throw unavailable();
|
||||
} catch(BizException e) { throw e; } catch(Exception e) { throw unavailable(); }
|
||||
if(overseas.size()!=sources.size()) throw unavailable("尾程事实缺少来源 expected="+sources.size()+" actual="+overseas.size()
|
||||
+" missing="+sources.stream().filter(s->!overseas.containsKey(s.tenantId()+":"+s.inboundId())).limit(20).toList());
|
||||
} catch(BizException e) { throw e; } catch(Exception e) {
|
||||
throw (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试").initCause(e);
|
||||
}
|
||||
}
|
||||
// SQL排除仅作汇总的父票;保留承接实际装柜数量的来源票及独立余量票。
|
||||
for(var fact:facts) {
|
||||
var inbound=expected.get(fact.inboundId());
|
||||
if(inbound==null || !Objects.equals(inbound.getTenantId(),fact.tenantId()) || !Objects.equals(inbound.getOrderId(),fact.orderId())) throw unavailable();
|
||||
if(inbound==null || !Objects.equals(inbound.getTenantId(),fact.tenantId()) || !Objects.equals(inbound.getOrderId(),fact.orderId())) throw unavailable("头程事实与入仓单归属不一致 inboundId="+fact.inboundId());
|
||||
var o=overseas.get(fact.tenantId()+":"+fact.inboundId());
|
||||
for(String key:classify(fact,inbound.getProcessStatus(),o)) result.putIfAbsent(fact.orderId()+":"+key,new StatusRow(fact.tenantId(),fact.orderId(),key));
|
||||
}
|
||||
@@ -77,5 +82,9 @@ public class WorkflowTabService {
|
||||
return keys;
|
||||
}
|
||||
/** 下游缺失或归属异常不能生成部分数量。 */
|
||||
private static BizException unavailable() { return new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试"); }
|
||||
private static BizException unavailable(String reason) {
|
||||
// 诊断信息只进入服务日志,公开响应保持稳定,不暴露内部来源标识。
|
||||
return (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试")
|
||||
.initCause(new IllegalStateException(reason));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,12 +25,15 @@ class WorkflowBoundaryTest {
|
||||
inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
|
||||
when(repository.facts(List.of(1L))).thenReturn(List.of(fact()));
|
||||
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of()));
|
||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class)
|
||||
.hasMessage("订单业务页签事实暂不可用,请稍后重试")
|
||||
.hasRootCauseMessage("尾程事实缺少来源 expected=1 actual=0 missing=[Source[tenantId=11, inboundId=1]]");
|
||||
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of(
|
||||
new WorkflowFacts.Overseas(22,1,true,true,false,true))));
|
||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
|
||||
when(downstream.read(any(),any())).thenThrow(new IllegalStateException("unavailable"));
|
||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
|
||||
var failure=new IllegalStateException("unavailable");
|
||||
when(downstream.read(any(),any())).thenThrow(failure);
|
||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class).hasCause(failure);
|
||||
}
|
||||
|
||||
/** 未记录离港时,报关完成即可待清关;上传清关资料后也必须查询精确海外来源。 */
|
||||
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
package com.baiyue.lastleg.fleet.trip;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/** 日历只读访问保留真实主单范围,整批读取原货物快照而不逐车查询。 */
|
||||
@Repository
|
||||
public class FleetTripCalendarRepository {
|
||||
public record Source(String dispatchNo, String sourceSnapshotJson) { }
|
||||
private final FleetTripMapper mapper;
|
||||
|
||||
/** 装配现有车次Mapper,日历服务不直接访问SQL。 */
|
||||
public FleetTripCalendarRepository(FleetTripMapper mapper) { this.mapper = mapper; }
|
||||
|
||||
/** 按真实UTC时间窗口读取有唯一整车主单的运输记录。 */
|
||||
public List<FleetTripCalendarRow> calendar(LocalDateTime from, LocalDateTime to) {
|
||||
return mapper.calendar(from, to);
|
||||
}
|
||||
|
||||
/** 一次读取指定主单的全部货物快照,空窗口不发送空IN查询。 */
|
||||
public List<Source> sourceSnapshots(List<String> dispatchNumbers) {
|
||||
if (dispatchNumbers.isEmpty()) return List.of();
|
||||
return mapper.calendarSources(dispatchNumbers).stream()
|
||||
.map(row -> new Source(row.dispatchNo(), row.sourceSnapshotJson())).toList();
|
||||
}
|
||||
}
|
||||
+6
-36
@@ -6,7 +6,6 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Isolation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.time.*;
|
||||
@@ -19,29 +18,27 @@ public class FleetTripCalendarService {
|
||||
String originWarehouseCode, String originWarehouseName, String originCountryCode,
|
||||
String originTimezone, String originWarehouseStatus, Instant plannedDepartureAt,
|
||||
Instant expectedDriverEndAt, Instant departedAt, Instant completedAt,
|
||||
List<FleetTripModels.Stop> stops, List<String> containerNumbers, List<String> incompleteReasons) { }
|
||||
List<FleetTripModels.Stop> stops, List<String> incompleteReasons) { }
|
||||
public record Result(List<Entry> records, List<Entry> unresolvedRecords) { }
|
||||
private static final Set<String> COUNTRIES = Set.of(Locale.getISOCountries());
|
||||
private final FleetTripCalendarRepository trips;
|
||||
private final FleetTripMapper trips;
|
||||
private final FleetWarehouseClient warehouses;
|
||||
private final ObjectMapper json;
|
||||
private final String token;
|
||||
|
||||
/** 装配日历持久化入口与真实仓库元数据提供者。 */
|
||||
public FleetTripCalendarService(FleetTripCalendarRepository trips, FleetWarehouseClient warehouses, ObjectMapper json,
|
||||
public FleetTripCalendarService(FleetTripMapper trips, FleetWarehouseClient warehouses, ObjectMapper json,
|
||||
@Value("${baiyue.security.internal-service-token:}") String token) {
|
||||
this.trips=trips; this.warehouses=warehouses; this.json=json; this.token=token;
|
||||
}
|
||||
|
||||
/** 同一快照批读真实主单和货物,仓库资料只查询一次,保留原业务地点时区。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
/** 一次读取真实排车及一次仓库资料查询,按业务地点判定日历归属。 */
|
||||
@Transactional(readOnly=true)
|
||||
public Result calendar(Instant from, Instant to) {
|
||||
if (from==null || to==null || !to.isAfter(from) || Duration.between(from,to).compareTo(Duration.ofDays(44))>0)
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"查询须为不超过44天的UTC时间范围");
|
||||
var rows=trips.calendar(LocalDateTime.ofInstant(from,ZoneOffset.UTC),LocalDateTime.ofInstant(to,ZoneOffset.UTC));
|
||||
if (rows.size()>2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"排车过多,请缩小查询日期范围");
|
||||
if (rows.isEmpty()) return new Result(List.of(),List.of());
|
||||
var containers=containerNumbers(rows);
|
||||
var ids=rows.stream().map(FleetTripEntity::getWarehouseId).filter(Objects::nonNull).filter(id->id>0).distinct().sorted().toList();
|
||||
var metadata=metadata(ids);
|
||||
var records=new ArrayList<Entry>(); var unresolved=new ArrayList<Entry>();
|
||||
@@ -67,35 +64,12 @@ public class FleetTripCalendarService {
|
||||
var entry=new Entry("DISPATCH",row.getDispatchNo(),row.getStatus(),row.getWarehouseId()==null?null:row.getWarehouseId().toString(),
|
||||
warehouse==null?row.getWarehouseCode():warehouse.warehouseCode(),warehouse==null?row.getWarehouseName():warehouse.warehouseName(),
|
||||
country,zone,warehouse==null?null:warehouse.status(),instant(row.getPlannedDepartureAt()),instant(row.getExpectedDriverEndAt()),
|
||||
instant(row.getDepartedAt()),instant(row.getCompletedAt()),stops,containers.getOrDefault(row.getDispatchNo(),List.of()),List.copyOf(reasons));
|
||||
instant(row.getDepartedAt()),instant(row.getCompletedAt()),stops,List.copyOf(reasons));
|
||||
(unresolvedOrigin?unresolved:records).add(entry);
|
||||
}
|
||||
return new Result(List.copyOf(records),List.copyOf(unresolved));
|
||||
}
|
||||
|
||||
/** 柜号只取排车建单时正式保存的SourceDisplay.containerNo,不反查或推测其他来源。 */
|
||||
private Map<String,List<String>> containerNumbers(List<FleetTripCalendarRow> rows) {
|
||||
var numbers=rows.stream().map(FleetTripCalendarRow::getDispatchNo).distinct().sorted().toList();
|
||||
var requested=new HashSet<>(numbers);var grouped=new LinkedHashMap<String,LinkedHashSet<String>>();
|
||||
for(var source:trips.sourceSnapshots(numbers)) {
|
||||
if(source==null || !requested.contains(source.dispatchNo()) || source.sourceSnapshotJson()==null || source.sourceSnapshotJson().isBlank())
|
||||
throw sourceUnavailable();
|
||||
try {
|
||||
var snapshot=json.reader().with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
|
||||
.readTree(source.sourceSnapshotJson());
|
||||
if(snapshot==null || !snapshot.isObject())throw sourceUnavailable();
|
||||
var value=snapshot.get("containerNo");
|
||||
if(value==null || value.isNull())continue;
|
||||
if(!value.isTextual())throw sourceUnavailable();
|
||||
var number=value.textValue().strip();
|
||||
if(!number.isEmpty())grouped.computeIfAbsent(source.dispatchNo(),ignored->new LinkedHashSet<>()).add(number);
|
||||
} catch(com.fasterxml.jackson.core.JsonProcessingException invalid) { throw sourceUnavailable(); }
|
||||
}
|
||||
var result=new LinkedHashMap<String,List<String>>();
|
||||
grouped.forEach((number,values)->result.put(number,List.copyOf(values)));return Map.copyOf(result);
|
||||
}
|
||||
|
||||
/** 元数据批次必须完整覆盖请求身份,未知地点保持显式未核实。 */
|
||||
private Map<Long,FleetWarehouseClient.Metadata> metadata(List<Long> ids) {
|
||||
if (ids.isEmpty()) return Map.of();
|
||||
try {
|
||||
@@ -111,10 +85,6 @@ public class FleetTripCalendarService {
|
||||
return result;
|
||||
} catch (RuntimeException failure) { throw unavailable(); }
|
||||
}
|
||||
/** 持久UTC时间只转换为UTC瞬时,不套用浏览器日期。 */
|
||||
private static Instant instant(LocalDateTime value) { return value==null?null:value.toInstant(ZoneOffset.UTC); }
|
||||
/** 仓库服务失败明确报错,不能把未知时区伪装成完整空日历。 */
|
||||
private static ResponseStatusException unavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"仓库时区资料暂不可用,请重试"); }
|
||||
/** 损坏货物快照保留明确错误,不静默丢弃真实排车或柜号。 */
|
||||
private static ResponseStatusException sourceUnavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"排车货物柜号资料暂不可用,请重试"); }
|
||||
}
|
||||
|
||||
@@ -19,18 +19,6 @@ public interface FleetTripMapper extends BaseMapper<FleetTripEntity> {
|
||||
ORDER BY COALESCE(t.departed_at,t.planned_departure_at),d.dispatch_no LIMIT 2001
|
||||
""")
|
||||
java.util.List<FleetTripCalendarRow> calendar(@Param("from") java.time.LocalDateTime from,@Param("to") java.time.LocalDateTime to);
|
||||
record CalendarSource(String dispatchNo, String sourceSnapshotJson) { }
|
||||
/** 主查询限额之后批量读取原货物快照,不以货物JOIN放大整车记录或截断多柜。 */
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT d.dispatch_no AS dispatchNo,i.source_snapshot_json AS sourceSnapshotJson
|
||||
FROM ll_dispatch_order d JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id
|
||||
WHERE d.status='ACTIVE' AND d.dispatch_no IN
|
||||
<foreach collection='numbers' item='number' open='(' separator=',' close=')'>#{number}</foreach>
|
||||
ORDER BY d.dispatch_no,i.id
|
||||
</script>
|
||||
""")
|
||||
java.util.List<CalendarSource> calendarSources(@Param("numbers") java.util.List<String> dispatchNumbers);
|
||||
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo} FOR UPDATE")
|
||||
FleetTripEntity lockByNo(@Param("tripNo") String tripNo);
|
||||
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo}")
|
||||
|
||||
+10
-49
@@ -4,62 +4,23 @@ import com.baiyue.lastleg.dto.CustomerOrderProgress;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.List;
|
||||
|
||||
/** Batch read of every actual source, never the representative MIN(order_id) on a shared pallet. */
|
||||
/** 按客户及不可变来源明细读取进度,不使用混托的代表订单。 */
|
||||
@Mapper
|
||||
public interface CustomerOrderProgressMapper {
|
||||
/** 本客户明细决定归属,真实主阶段和实际发车时间决定待排及在途数量。 */
|
||||
/** 已排车只统计有效待出库单的来源数量,与平台出库单节点一致;在途仍要求实际发车。 */
|
||||
@Select("""
|
||||
<script>
|
||||
WITH scheduled AS (
|
||||
SELECT line.order_id,SUM(a.planned_package_count) AS packages
|
||||
FROM ll_dispatch_order d
|
||||
JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id
|
||||
JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=i.plan_pallet_id
|
||||
JOIN ll_inbound_plan_line line ON line.id=a.line_id AND line.customer_id=i.customer_id
|
||||
SELECT i.source_order_id AS order_id,SUM(i.package_count) AS packages
|
||||
FROM ll_outbound_order b
|
||||
JOIN ll_outbound_pallet_item i ON i.outbound_order_id=b.id AND i.customer_id=b.customer_id
|
||||
<if test="orderIds != null">
|
||||
JOIN ll_order planned_source ON planned_source.id=line.order_id AND planned_source.customer_id=#{customerId}
|
||||
AND planned_source.source_order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
JOIN ll_order outbound_source ON outbound_source.id=i.source_order_id AND outbound_source.customer_id=#{customerId}
|
||||
AND outbound_source.source_order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
</if>
|
||||
WHERE i.customer_id=#{customerId} AND d.status='ACTIVE'
|
||||
AND d.stage IN ('BEFORE_LOADING','LOADING','AFTER_LOADING')
|
||||
AND i.warehouse_pallet_id IS NULL
|
||||
GROUP BY line.order_id
|
||||
UNION ALL
|
||||
SELECT b.inbound_order_id AS order_id,SUM(p.available_packages+COALESCE(pk.packages,0)) AS packages
|
||||
FROM ll_dispatch_order d
|
||||
JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id
|
||||
JOIN ll_inventory_position p ON p.customer_id=i.customer_id AND p.pallet_id=i.warehouse_pallet_id
|
||||
AND p.overseas_warehouse_id=i.overseas_warehouse_id
|
||||
JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=i.customer_id
|
||||
<if test="orderIds != null">
|
||||
JOIN ll_order stock_source ON stock_source.id=b.inbound_order_id AND stock_source.customer_id=#{customerId}
|
||||
AND stock_source.source_order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
</if>
|
||||
LEFT JOIN (SELECT pick.dispatch_item_id,pick.inventory_position_id,SUM(pick.packages) AS packages
|
||||
FROM ll_dispatch_pda_pick pick
|
||||
<if test="orderIds != null">
|
||||
JOIN ll_inventory_position scoped_position ON scoped_position.id=pick.inventory_position_id
|
||||
AND scoped_position.customer_id=pick.customer_id
|
||||
JOIN ll_inventory_balance scoped_balance ON scoped_balance.id=scoped_position.balance_id
|
||||
AND scoped_balance.customer_id=pick.customer_id
|
||||
JOIN ll_order scoped_order ON scoped_order.id=scoped_balance.inbound_order_id
|
||||
AND scoped_order.customer_id=pick.customer_id
|
||||
AND scoped_order.source_order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
</if>
|
||||
WHERE pick.customer_id=#{customerId}
|
||||
GROUP BY pick.dispatch_item_id,pick.inventory_position_id) pk
|
||||
ON pk.dispatch_item_id=i.id AND pk.inventory_position_id=p.id
|
||||
WHERE i.customer_id=#{customerId} AND d.status='ACTIVE'
|
||||
AND d.stage IN ('BEFORE_LOADING','LOADING','AFTER_LOADING')
|
||||
AND (i.plan_pallet_id IS NULL OR EXISTS (
|
||||
SELECT 1 FROM ll_inbound_plan_pallet_allocation a
|
||||
JOIN ll_inbound_plan_line line ON line.id=a.line_id AND line.customer_id=i.customer_id
|
||||
<if test="orderIds != null">
|
||||
JOIN ll_order planned_source ON planned_source.id=line.order_id AND planned_source.customer_id=#{customerId}
|
||||
AND planned_source.source_order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
</if>
|
||||
WHERE a.pallet_id=i.plan_pallet_id AND line.order_id=b.inbound_order_id))
|
||||
GROUP BY b.inbound_order_id
|
||||
WHERE i.customer_id=#{customerId}
|
||||
AND b.status NOT IN ('CANCELLED','CANCELED','OUTBOUNDED')
|
||||
GROUP BY i.source_order_id
|
||||
), departed AS (
|
||||
SELECT a.order_id,SUM(a.packages) AS packages
|
||||
FROM ll_dispatch_pda_pick_order a
|
||||
|
||||
@@ -359,7 +359,7 @@ components:
|
||||
schemas:
|
||||
TripCalendarEntry:
|
||||
type: object
|
||||
required: [sourceType, dispatchNo, status, plannedDepartureAt, expectedDriverEndAt, stops, containerNumbers, incompleteReasons]
|
||||
required: [sourceType, dispatchNo, status, plannedDepartureAt, expectedDriverEndAt, stops, incompleteReasons]
|
||||
properties:
|
||||
sourceType: {type: string, enum: [DISPATCH]}
|
||||
dispatchNo: {type: string, description: 真实整车主单排车号,用于排车详情路由}
|
||||
@@ -375,11 +375,6 @@ components:
|
||||
departedAt: {type: string, format: date-time, nullable: true}
|
||||
completedAt: {type: string, format: date-time, nullable: true}
|
||||
stops: {type: array, items: {$ref: '#/components/schemas/Stop'}}
|
||||
containerNumbers:
|
||||
type: array
|
||||
description: 本车全部货物正式来源快照中的真实柜号,去空去重;多客户、多柜保留完整数组,无柜号为空数组,不推测或补造。
|
||||
items: {type: string}
|
||||
uniqueItems: true
|
||||
incompleteReasons:
|
||||
type: array
|
||||
items: {type: string, enum: [ORIGIN_WAREHOUSE_UNKNOWN, ORIGIN_COUNTRY_UNKNOWN, ORIGIN_TIMEZONE_UNKNOWN, ROUTE_INCOMPLETE]}
|
||||
|
||||
@@ -8,9 +8,10 @@ paths:
|
||||
summary: Order按认证客户读取排车和实际派送进度
|
||||
description: >-
|
||||
只读内部接口,无公网路由。customerId由Order认证上下文提供,所有业务表在Lastleg自身Schema内按客户读取。
|
||||
正式排车单PENDING_DEPARTURE/PICKING/READY_TO_DEPART按计划或库存来源计算,预排草稿和取消单不计入。
|
||||
派送必须已实际DEPARTED、出库OUTBOUNDED且对应目的地派送单仍WAIT_DELIVERY/DELIVERING。
|
||||
混托使用所有库存及不可变拣货来源,不使用代表orderId;数量覆盖不完整或异常时标记partial,不签发派送完成事实。
|
||||
已排车scheduled按有效待出库单的不可变来源明细及箱数计算,与平台出库单节点一致;仅生成排车单号、审核中、审核通过但未生成出库单均不计入。
|
||||
取消的出库单及已实际出库的单据不计入scheduled;排车审核、真实拣货核平、装车确认及安排派送仍按原流程执行。
|
||||
派送必须排车单ACTIVE且处于DELIVERING阶段、具有实际发车时间、出库OUTBOUNDED且对应目的地派送单仍WAIT_DELIVERY/DELIVERING。
|
||||
混托使用全部不可变出库及拣货来源,不使用代表orderId;数量覆盖不完整或异常时标记partial,不签发派送完成事实。
|
||||
security: [{internalServiceToken: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
|
||||
-20
@@ -62,24 +62,4 @@ class FleetTripCalendarMapperTest {
|
||||
bindDispatches(jdbc);
|
||||
assertThat(mapper.calendar(from,to)).hasSize(2001);
|
||||
}
|
||||
/** 实际Mapper批读同单跨客户多柜,排除其他主单与取消主单,不用货物行放大日历。 */
|
||||
@Test void batchReadsEverySourceSnapshotOnlyForTheRequestedActiveDispatches() throws Exception {
|
||||
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
|
||||
var jdbc=new JdbcTemplate(ds);
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(80) UNIQUE,status VARCHAR(32))");
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(64),source_snapshot_json VARCHAR(2000))");
|
||||
jdbc.update("INSERT INTO ll_dispatch_order VALUES(1,'PC-A','ACTIVE'),(2,'PC-B','ACTIVE'),(3,'PC-OTHER','ACTIVE'),(4,'PC-CANCELLED','CANCELLED')");
|
||||
jdbc.update("INSERT INTO ll_dispatch_item VALUES(11,1,'101',?), (12,1,'102',?), (13,1,'101',?), (21,2,'103',?), (31,3,'101',?), (41,4,'101',?)",
|
||||
"{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU7654321\"}",
|
||||
"{\"planNo\":\"PLAN-B\"}","{\"containerNo\":\"OTHER-CARGO\"}","{\"containerNo\":\"CANCELLED-CARGO\"}");
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(FleetTripMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
|
||||
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(FleetTripMapper.class);
|
||||
var repository=new FleetTripCalendarRepository(mapper);
|
||||
assertThat(repository.sourceSnapshots(List.of())).isEmpty();
|
||||
var rows=repository.sourceSnapshots(List.of("PC-A","PC-B","PC-CANCELLED"));
|
||||
assertThat(rows).extracting(FleetTripCalendarRepository.Source::dispatchNo).containsExactly("PC-A","PC-A","PC-A","PC-B");
|
||||
assertThat(rows).extracting(FleetTripCalendarRepository.Source::sourceSnapshotJson).containsExactly(
|
||||
"{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU7654321\"}","{\"planNo\":\"PLAN-B\"}");
|
||||
}
|
||||
}
|
||||
|
||||
+1
-48
@@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class FleetTripCalendarServiceTest {
|
||||
final FleetTripCalendarRepository trips=mock(FleetTripCalendarRepository.class);
|
||||
final FleetTripMapper trips=mock(FleetTripMapper.class);
|
||||
final FleetWarehouseClient warehouses=mock(FleetWarehouseClient.class);
|
||||
final FleetTripCalendarService service=new FleetTripCalendarService(trips,warehouses,new ObjectMapper(),"internal-test");
|
||||
final Instant from=Instant.parse("2026-10-01T00:00:00Z"),to=from.plus(Duration.ofDays(44));
|
||||
@@ -107,51 +107,4 @@ class FleetTripCalendarServiceTest {
|
||||
var platform=new com.baiyue.common.security.AuthenticatedUser(1L,0L,0L,1L,"platform","PLATFORM","WEB",Set.of("platform:overseas-warehouse:dispatch:list"));
|
||||
controller.calendar(platform,from,to);verify(calendar).calendar(from,to);
|
||||
}
|
||||
/** 同车全部来源只批读一次,跨客户重复柜号去重,多柜完整保留。 */
|
||||
@Test void readsAllContainerNumbersInOneBatchAndPreservesTheLocalDateFacts() {
|
||||
var first=trip("PC-A",1L);first.setPlannedDepartureAt(LocalDateTime.of(2026,10,22,16,0));
|
||||
when(trips.calendar(any(),any())).thenReturn(List.of(first,trip("PC-B",1L)));
|
||||
when(trips.sourceSnapshots(List.of("PC-A","PC-B"))).thenReturn(List.of(
|
||||
new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":\" TSTU1234567 \",\"planNo\":\"PLAN-A\"}"),
|
||||
new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":\"TSTU1234567\"}"),
|
||||
new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":\"TSTU7654321\"}"),
|
||||
new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":null}"),
|
||||
new FleetTripCalendarRepository.Source("PC-B","{}"),
|
||||
new FleetTripCalendarRepository.Source("PC-B","{\"containerNo\":\" \"}")));
|
||||
metadata(List.of(warehouse(1,"US","America/Los_Angeles")),List.of());
|
||||
var result=service.calendar(from,to);
|
||||
assertThat(result.records()).hasSize(2);
|
||||
assertThat(result.records().get(0).containerNumbers()).containsExactly("TSTU1234567","TSTU7654321");
|
||||
assertThat(result.records().get(1).containerNumbers()).isEmpty();
|
||||
assertThat(result.records().get(0).plannedDepartureAt()).isEqualTo(Instant.parse("2026-10-22T16:00:00Z"));
|
||||
assertThat(result.records().get(0).originTimezone()).isEqualTo("America/Los_Angeles");
|
||||
assertThat(result.unresolvedRecords()).isEmpty();
|
||||
verify(trips).calendar(any(),any());verify(trips).sourceSnapshots(List.of("PC-A","PC-B"));verifyNoMoreInteractions(trips);
|
||||
verify(warehouses).platformMetadata("internal-test",new FleetWarehouseClient.MetadataRequest(List.of(1L)));
|
||||
verifyNoMoreInteractions(warehouses);
|
||||
}
|
||||
/** 缺时区的主单仍保留真实柜号,不将货物信息丢失误作起运地已核实。 */
|
||||
@Test void keepsKnownContainersOnAnUnresolvedOrigin() {
|
||||
when(trips.calendar(any(),any())).thenReturn(List.of(trip("PC-UNKNOWN",1L)));
|
||||
when(trips.sourceSnapshots(List.of("PC-UNKNOWN"))).thenReturn(List.of(
|
||||
new FleetTripCalendarRepository.Source("PC-UNKNOWN","{\"containerNo\":\"TSTU1234567\"}")));
|
||||
metadata(List.of(warehouse(1,"US",null)),List.of());
|
||||
var result=service.calendar(from,to);assertThat(result.records()).isEmpty();
|
||||
assertThat(result.unresolvedRecords()).singleElement().satisfies(row->{
|
||||
assertThat(row.containerNumbers()).containsExactly("TSTU1234567");
|
||||
assertThat(row.incompleteReasons()).containsExactly("ORIGIN_TIMEZONE_UNKNOWN");
|
||||
});
|
||||
}
|
||||
/** 损坏或非当前正式文本快照明确失败,不伪造空数组或静默隐藏整月。 */
|
||||
@Test void rejectsMalformedOrForeignSourceSnapshotsExplicitly() {
|
||||
when(trips.calendar(any(),any())).thenReturn(List.of(trip("PC-A",1L)));
|
||||
for(var invalid:Arrays.asList(null,"","not-json","null","[]","{\"containerNo\":123}","{\"containerNo\":[\"TSTU1234567\"]}","{} {}")) {
|
||||
when(trips.sourceSnapshots(List.of("PC-A"))).thenReturn(List.of(new FleetTripCalendarRepository.Source("PC-A",invalid)));
|
||||
assertThatThrownBy(()->service.calendar(from,to)).isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("柜号资料暂不可用");
|
||||
}
|
||||
when(trips.sourceSnapshots(List.of("PC-A"))).thenReturn(List.of(new FleetTripCalendarRepository.Source("OTHER-PC","{}")));
|
||||
assertThatThrownBy(()->service.calendar(from,to)).isInstanceOf(ResponseStatusException.class).hasMessageContaining("柜号资料暂不可用");
|
||||
verifyNoInteractions(warehouses);
|
||||
}
|
||||
}
|
||||
|
||||
+57
-9
@@ -13,16 +13,19 @@ import static org.assertj.core.api.Assertions.*;
|
||||
class CustomerOrderProgressMapperTest {
|
||||
private JdbcTemplate jdbc;
|
||||
private CustomerOrderProgressMapper mapper;
|
||||
/** 两客户共用一单一任务,计划和在途均按各自真实明细归属统计。 */
|
||||
/** 两客户共用一单一任务,出库和在途均按各自真实明细归属统计。 */
|
||||
@Test void sharedDispatchAndTaskKeepBothCustomerProgressSeparate() {
|
||||
jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,plan_pallet_id,overseas_warehouse_id,destination_warehouse_code) VALUES(101,10,'22',501,'W1','FBA2')");
|
||||
jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(501,1002,30)");
|
||||
pendingOutbound(10000,"11",1,10);
|
||||
pendingOutbound(10000,"11",2,20);
|
||||
pendingOutbound(10002,"22",3,30);
|
||||
assertThat(mapper.sources("11",null)).extracting(s->s.scheduledPackages()).containsExactly(10L,20L);
|
||||
assertThat(mapper.sources("22",null)).singleElement().satisfies(s->assertThat(s.scheduledPackages()).isEqualTo(30));
|
||||
jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(700,10)");
|
||||
jdbc.update("INSERT INTO ll_dispatch_pda_pick VALUES(600,'11',100,400,4,700),(601,'22',101,401,7,700)");
|
||||
jdbc.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(900,600,'11',1,4),(901,601,'22',3,7)");
|
||||
jdbc.update("INSERT INTO ll_outbound_order VALUES(10000,'11','OUTBOUNDED'),(10002,'22','OUTBOUNDED')");
|
||||
jdbc.update("UPDATE ll_outbound_order SET status='OUTBOUNDED'");
|
||||
jdbc.update("INSERT INTO ll_delivery_order VALUES(10001,'11',10,10000,'DELIVERING','CODE:FBA1'),(10003,'22',10,10002,'DELIVERING','CODE:FBA2')");
|
||||
jdbc.update("UPDATE ll_dispatch_order SET stage='DELIVERING',departed_at=CURRENT_TIMESTAMP");
|
||||
assertThat(mapper.sources("11",null)).extracting(s->s.deliveringPackages()).containsExactly(4L,0L);
|
||||
@@ -45,6 +48,7 @@ class CustomerOrderProgressMapperTest {
|
||||
jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,dispatch_order_id BIGINT UNIQUE)");
|
||||
jdbc.execute("CREATE TABLE ll_delivery_order(id BIGINT,customer_id VARCHAR,dispatch_order_id BIGINT,outbound_order_id BIGINT,status VARCHAR,delivery_group_key VARCHAR)");
|
||||
jdbc.execute("CREATE TABLE ll_outbound_order(id BIGINT,customer_id VARCHAR,status VARCHAR)");
|
||||
jdbc.execute("CREATE TABLE ll_outbound_pallet_item(outbound_order_id BIGINT,customer_id VARCHAR,source_order_id BIGINT,package_count INT)");
|
||||
jdbc.update("INSERT INTO ll_order VALUES(1,'11','101','201','FIRSTLEG','FBA',10),(2,'11','102','202','FIRSTLEG','FBA',20),(3,'22','103','203','FIRSTLEG','FBA',30)");
|
||||
jdbc.update("INSERT INTO ll_dispatch_order VALUES(10,'ACTIVE','BEFORE_LOADING',NULL)");
|
||||
jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,plan_pallet_id,overseas_warehouse_id,destination_warehouse_code) VALUES(100,10,'11',500,'W1','FBA1')");
|
||||
@@ -54,24 +58,40 @@ class CustomerOrderProgressMapperTest {
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
|
||||
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(CustomerOrderProgressMapper.class);
|
||||
}
|
||||
/** 计划托盘上的所有本客户来源均计入,取消后不再计入。 */
|
||||
@Test void plannedSharedPalletIncludesEveryOwnSourceAndExcludesCancellation() {
|
||||
/** 排车号及审核阶段不代表有效出库,生成出库后才计入,取消后自动移出。 */
|
||||
@Test void onlyEffectiveOutboundSourcesCountAsScheduled() {
|
||||
jdbc.execute("ALTER TABLE ll_dispatch_order ADD review_state VARCHAR");
|
||||
for (String review : java.util.List.of("DRAFT","DISPATCH_PENDING","DISPATCH_REJECTED","LOADING_PENDING","LOADING_REJECTED","APPROVED")) {
|
||||
jdbc.update("UPDATE ll_dispatch_order SET review_state=?",review);
|
||||
assertThat(mapper.sources("11", null)).allMatch(s->s.scheduledPackages()==0);
|
||||
assertThat(new com.baiyue.lastleg.service.CustomerOrderProgressService(mapper).read(11,null).progress()).isEmpty();
|
||||
}
|
||||
jdbc.update("UPDATE ll_dispatch_order SET stage='AFTER_LOADING'");
|
||||
assertThat(mapper.sources("11", null)).allMatch(s->s.scheduledPackages()==0);
|
||||
pendingOutbound(10000,"11",1,10);
|
||||
pendingOutbound(10000,"11",2,20);
|
||||
var sources=mapper.sources("11", null);assertThat(sources).hasSize(2);
|
||||
assertThat(sources.get(0).scheduledPackages()).isEqualTo(10);
|
||||
assertThat(sources.get(1).scheduledPackages()).isEqualTo(20);
|
||||
assertThat(mapper.sources("11",java.util.List.of("101"))).containsExactly(sources.get(0));
|
||||
assertThat(sources).allMatch(s->s.deliveringPackages()==0);
|
||||
jdbc.update("UPDATE ll_dispatch_order SET status='CANCELLED'");
|
||||
assertThat(mapper.sources("11", null)).allMatch(s->s.scheduledPackages()==0);
|
||||
for (String status : java.util.List.of("CANCELLED","CANCELED","OUTBOUNDED")) {
|
||||
jdbc.update("UPDATE ll_outbound_order SET status=?",status);
|
||||
assertThat(mapper.sources("11", null)).allMatch(s->s.scheduledPackages()==0);
|
||||
}
|
||||
assertThat(mapper.sources("22", null)).hasSize(1).allMatch(s->s.scheduledPackages()==0);
|
||||
}
|
||||
/** 已收货使用实际库存和扫码,不与计划量重复累计。 */
|
||||
@Test void receivedPalletUsesInventoryAndActualPicksWithoutCountingBothPlanAndStock() {
|
||||
/** 已收货和扫码不能提前推进,出库来源快照不随剩余库存变化。 */
|
||||
@Test void scheduledUsesOutboundSnapshotInsteadOfPlannedOrRemainingInventory() {
|
||||
jdbc.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=800");
|
||||
jdbc.update("INSERT INTO ll_inventory_balance VALUES(300,'11',1),(301,'11',2)");
|
||||
jdbc.update("INSERT INTO ll_inventory_position VALUES(400,'11',800,'W1',300,6),(401,'11',800,'W1',301,20)");
|
||||
jdbc.update("INSERT INTO ll_dispatch_pda_pick VALUES(600,'11',100,400,4,700)");
|
||||
var sources=mapper.sources("11", null);assertThat(sources.get(0).scheduledPackages()).isEqualTo(10);
|
||||
assertThat(mapper.sources("11", null)).allMatch(s->s.scheduledPackages()==0);
|
||||
pendingOutbound(10000,"11",1,4);
|
||||
pendingOutbound(10000,"11",2,20);
|
||||
jdbc.update("UPDATE ll_inventory_position SET available_packages=0");
|
||||
var sources=mapper.sources("11", null);assertThat(sources.get(0).scheduledPackages()).isEqualTo(4);
|
||||
assertThat(sources.get(1).scheduledPackages()).isEqualTo(20);
|
||||
assertThat(mapper.sources("11",java.util.List.of("101"))).containsExactly(sources.get(0));
|
||||
}
|
||||
@@ -95,8 +115,10 @@ class CustomerOrderProgressMapperTest {
|
||||
assertThat(mapper.sources("11", null)).allMatch(s->s.deliveringPackages()==0);
|
||||
}
|
||||
|
||||
/** 同源多入仓片段完整返回,过滤不得扩大到其他订单或客户。 */
|
||||
@Test void exactSourceOrderScopePreservesAllOwnInboundFragmentsAndExcludesForeignOrders() {
|
||||
jdbc.update("INSERT INTO ll_order VALUES(4,'11','101','204','FIRSTLEG','FBA',5)");
|
||||
pendingOutbound(10000,"11",1,10);
|
||||
var own=mapper.sources("11",java.util.List.of("101","103"));
|
||||
assertThat(own).extracting(s->s.orderId()).containsExactly("101","101");
|
||||
assertThat(own).extracting(s->s.scheduledPackages()).containsExactly(10L,0L);
|
||||
@@ -104,4 +126,30 @@ class CustomerOrderProgressMapperTest {
|
||||
assertThat(mapper.sources("11",java.util.List.of("999"))).isEmpty();
|
||||
}
|
||||
|
||||
/** 多张出库只汇总本客户的有效来源数量,不借用其他来源或客户单据。 */
|
||||
@Test void splitOutboundQuantitiesExcludeCancelledAndForeignDocuments() {
|
||||
pendingOutbound(10000,"11",1,4);
|
||||
pendingOutbound(10001,"11",1,6);
|
||||
pendingOutbound(10002,"11",2,20);
|
||||
jdbc.update("UPDATE ll_outbound_order SET status='CANCELLED' WHERE id=10001");
|
||||
jdbc.update("INSERT INTO ll_outbound_order VALUES(10003,'22','WAIT_OUTBOUND')");
|
||||
jdbc.update("INSERT INTO ll_outbound_pallet_item VALUES(10003,'11',1,100),(10000,'22',3,100),(10003,'22',1,100)");
|
||||
assertThat(mapper.sources("11", null)).extracting(s->s.scheduledPackages()).containsExactly(4L,20L);
|
||||
assertThat(new com.baiyue.lastleg.service.CustomerOrderProgressService(mapper).read(11,java.util.List.of(101L)).progress())
|
||||
.singleElement().satisfies(p->{assertThat(p.stage()).isEqualTo("scheduled");assertThat(p.partial()).isTrue();});
|
||||
assertThat(mapper.sources("22", null)).allMatch(s->s.scheduledPackages()==0);
|
||||
jdbc.update("UPDATE ll_outbound_order SET status='WAIT_OUTBOUND' WHERE id=10001");
|
||||
assertThat(mapper.sources("11", null)).extracting(s->s.scheduledPackages()).containsExactly(10L,20L);
|
||||
assertThat(new com.baiyue.lastleg.service.CustomerOrderProgressService(mapper).read(11,java.util.List.of(101L)).progress())
|
||||
.singleElement().satisfies(p->{assertThat(p.stage()).isEqualTo("scheduled");assertThat(p.partial()).isFalse();});
|
||||
}
|
||||
|
||||
/** 创建同客户的待出库单及精确来源明细,允许一单包含多个来源。 */
|
||||
private void pendingOutbound(long outboundId,String customerId,long sourceOrderId,int packages) {
|
||||
if (jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_order WHERE id=?",Integer.class,outboundId)==0) {
|
||||
jdbc.update("INSERT INTO ll_outbound_order VALUES(?,?,'WAIT_OUTBOUND')",outboundId,customerId);
|
||||
}
|
||||
jdbc.update("INSERT INTO ll_outbound_pallet_item VALUES(?,?,?,?)",outboundId,customerId,sourceOrderId,packages);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -716,13 +716,22 @@ public class UnifiedOrderQueryService {
|
||||
|| StringUtils.hasText(query.measurementReviewStatus());
|
||||
}
|
||||
|
||||
/** 保留内部调用的原始异常,便于按请求链路定位连接和协议故障。 */
|
||||
private <T> ApiResponse<T> call(java.util.concurrent.Callable<ApiResponse<T>> invocation) {
|
||||
try { return invocation.call(); }
|
||||
catch (FeignException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务暂时不可用"); }
|
||||
catch (Exception exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务查询失败"); }
|
||||
catch (FeignException exception) {
|
||||
throw (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务暂时不可用").initCause(exception);
|
||||
}
|
||||
catch (Exception exception) {
|
||||
throw (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务查询失败").initCause(exception);
|
||||
}
|
||||
}
|
||||
/** 失败关闭时保留下游错误码与链路标识,不记录业务载荷或服务令牌。 */
|
||||
private <T> T require(ApiResponse<T> response, String message) {
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
LOGGER.warn("头程查询响应无效 operation={} downstreamCode={} downstreamMessage={} downstreamRequestId={} downstreamTraceId={}",
|
||||
message, response == null ? null : response.code(), response == null ? null : response.message(),
|
||||
response == null ? null : response.requestId(), response == null ? null : response.traceId());
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, message);
|
||||
}
|
||||
return response.data();
|
||||
|
||||
@@ -112,11 +112,13 @@ test('流水断言逐行核对归属和业务引用,不能仅凭合计相等
|
||||
assert.equal(exactReceiptLedger(undefined, []), false)
|
||||
})
|
||||
|
||||
test('真实退货验收检查五类完整编号且保留同键重放检查', async () => {
|
||||
test('真实退货验收使用TH及RR和RB日流水并保留原箱码与重放检查', async () => {
|
||||
const source = await readFile(new URL('return-boundary-scenarios.mjs', import.meta.url), 'utf8')
|
||||
for (const prefix of ['RT', 'RETU', 'RR', 'RB', 'RC']) {
|
||||
assert.ok(source.includes(`/^${prefix}-[0-9A-Z]{25}$/`), prefix)
|
||||
for (const prefix of ['TH', 'RR', 'RB']) {
|
||||
assert.ok(source.includes(`/^${prefix}[0-9]{12}$/`), prefix)
|
||||
}
|
||||
assert.ok(source.includes('data.order.lastlegOrderNo === data.order.returnNo'))
|
||||
assert.ok(source.includes('/^RC-[0-9A-Z]{25}$/'))
|
||||
assert.ok(source.includes('同键并发合箱后重试仍返回原箱'))
|
||||
assert.ok(source.includes('签收重放不重复产生流水'))
|
||||
})
|
||||
|
||||
@@ -108,7 +108,10 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p
|
||||
return { returnNo: data.order.returnNo, sku, skus: items.map(item => item.skuCode), quantity, token, warehouseId, unitCode: `PKG-${data.order.returnNo}` }
|
||||
}
|
||||
function receiptBody(order, quantity = order.quantity, idempotencyKey = key('RECEIPT')) {
|
||||
return { returnNo: order.returnNo, startedBy: 'RETURN_NO', unitType: 'PARCEL', unitCode: order.unitCode, items: [{ skuCode: order.sku, receivedQuantity: quantity }], idempotencyKey }
|
||||
// 合成包裹明确原包装选择与实测输入,使边界场景进入待验证的库存/并发规则。
|
||||
return { returnNo: order.returnNo, startedBy: 'RETURN_NO', unitType: 'PARCEL', unitCode: order.unitCode,
|
||||
originalPackaging: true, length: 30, width: 20, height: 10, weight: 2,
|
||||
items: [{ skuCode: order.sku, receivedQuantity: quantity }], idempotencyKey }
|
||||
}
|
||||
async function receive(order, operator = picker) {
|
||||
const data = await good(operator.token, 'POST', '准备边界场景实收库存', `${RECEIPTS}/confirm`, receiptBody(order))
|
||||
|
||||
Reference in New Issue
Block a user