提交
This commit is contained in:
@@ -40,8 +40,14 @@ public class GlobalExceptionHandler {
|
|||||||
@Autowired(required = false)
|
@Autowired(required = false)
|
||||||
private MeterRegistry meterRegistry;
|
private MeterRegistry meterRegistry;
|
||||||
|
|
||||||
|
/** 系统类业务失败必须记录链路和异常,普通业务校验仍只返回统一响应。 */
|
||||||
@ExceptionHandler(BizException.class)
|
@ExceptionHandler(BizException.class)
|
||||||
public ResponseEntity<ApiResponse<Void>> handleBusiness(BizException exception, HttpServletRequest request) {
|
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);
|
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 io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
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.mock.web.MockHttpServletRequest;
|
||||||
import org.springframework.core.MethodParameter;
|
import org.springframework.core.MethodParameter;
|
||||||
import org.springframework.validation.BeanPropertyBindingResult;
|
import org.springframework.validation.BeanPropertyBindingResult;
|
||||||
@@ -39,6 +42,37 @@ class GlobalExceptionHandlerTest {
|
|||||||
assertThat(response.getBody().requestId()).isEqualTo("r1");
|
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
|
@Test
|
||||||
void invalidQueryParameterTypeMapsToBadRequest() throws Exception {
|
void invalidQueryParameterTypeMapsToBadRequest() throws Exception {
|
||||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public class WorkflowTabService {
|
|||||||
var facts=repository.facts(batch.stream().map(InboundOrderEntity::getId).toList());
|
var facts=repository.facts(batch.stream().map(InboundOrderEntity::getId).toList());
|
||||||
// 确认页签复用已推送费用版本。
|
// 确认页签复用已推送费用版本。
|
||||||
for(var fee:repository.feeConfirmationRows(facts.stream().map(WorkflowFacts.Fact::inboundId).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);
|
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();
|
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()) {
|
if(!sources.isEmpty()) {
|
||||||
try {
|
try {
|
||||||
var response=lastleg.read(token,new WorkflowFacts.Request(sources));
|
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);
|
var allowed=new HashSet<>(sources);
|
||||||
for(var fact:response.data()) {
|
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()));
|
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();
|
if(overseas.size()!=sources.size()) throw unavailable("尾程事实缺少来源 expected="+sources.size()+" actual="+overseas.size()
|
||||||
} catch(BizException e) { throw e; } catch(Exception e) { throw unavailable(); }
|
+" 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排除仅作汇总的父票;保留承接实际装柜数量的来源票及独立余量票。
|
// SQL排除仅作汇总的父票;保留承接实际装柜数量的来源票及独立余量票。
|
||||||
for(var fact:facts) {
|
for(var fact:facts) {
|
||||||
var inbound=expected.get(fact.inboundId());
|
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());
|
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));
|
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;
|
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");
|
inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
|
||||||
when(repository.facts(List.of(1L))).thenReturn(List.of(fact()));
|
when(repository.facts(List.of(1L))).thenReturn(List.of(fact()));
|
||||||
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of()));
|
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(
|
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of(
|
||||||
new WorkflowFacts.Overseas(22,1,true,true,false,true))));
|
new WorkflowFacts.Overseas(22,1,true,true,false,true))));
|
||||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
|
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
|
||||||
when(downstream.read(any(),any())).thenThrow(new IllegalStateException("unavailable"));
|
var failure=new IllegalStateException("unavailable");
|
||||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
|
when(downstream.read(any(),any())).thenThrow(failure);
|
||||||
|
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class).hasCause(failure);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 未记录离港时,报关完成即可待清关;上传清关资料后也必须查询精确海外来源。 */
|
/** 未记录离港时,报关完成即可待清关;上传清关资料后也必须查询精确海外来源。 */
|
||||||
|
|||||||
+10
-49
@@ -4,62 +4,23 @@ import com.baiyue.lastleg.dto.CustomerOrderProgress;
|
|||||||
import org.apache.ibatis.annotations.*;
|
import org.apache.ibatis.annotations.*;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/** Batch read of every actual source, never the representative MIN(order_id) on a shared pallet. */
|
/** 按客户及不可变来源明细读取进度,不使用混托的代表订单。 */
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface CustomerOrderProgressMapper {
|
public interface CustomerOrderProgressMapper {
|
||||||
/** 本客户明细决定归属,真实主阶段和实际发车时间决定待排及在途数量。 */
|
/** 已排车只统计有效待出库单的来源数量,与平台出库单节点一致;在途仍要求实际发车。 */
|
||||||
@Select("""
|
@Select("""
|
||||||
<script>
|
<script>
|
||||||
WITH scheduled AS (
|
WITH scheduled AS (
|
||||||
SELECT line.order_id,SUM(a.planned_package_count) AS packages
|
SELECT i.source_order_id AS order_id,SUM(i.package_count) AS packages
|
||||||
FROM ll_dispatch_order d
|
FROM ll_outbound_order b
|
||||||
JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id
|
JOIN ll_outbound_pallet_item i ON i.outbound_order_id=b.id AND i.customer_id=b.customer_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
|
|
||||||
<if test="orderIds != null">
|
<if test="orderIds != null">
|
||||||
JOIN ll_order planned_source ON planned_source.id=line.order_id AND planned_source.customer_id=#{customerId}
|
JOIN ll_order outbound_source ON outbound_source.id=i.source_order_id AND outbound_source.customer_id=#{customerId}
|
||||||
AND planned_source.source_order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
AND outbound_source.source_order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||||
</if>
|
</if>
|
||||||
WHERE i.customer_id=#{customerId} AND d.status='ACTIVE'
|
WHERE i.customer_id=#{customerId}
|
||||||
AND d.stage IN ('BEFORE_LOADING','LOADING','AFTER_LOADING')
|
AND b.status NOT IN ('CANCELLED','CANCELED','OUTBOUNDED')
|
||||||
AND i.warehouse_pallet_id IS NULL
|
GROUP BY i.source_order_id
|
||||||
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
|
|
||||||
), departed AS (
|
), departed AS (
|
||||||
SELECT a.order_id,SUM(a.packages) AS packages
|
SELECT a.order_id,SUM(a.packages) AS packages
|
||||||
FROM ll_dispatch_pda_pick_order a
|
FROM ll_dispatch_pda_pick_order a
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ paths:
|
|||||||
summary: Order按认证客户读取排车和实际派送进度
|
summary: Order按认证客户读取排车和实际派送进度
|
||||||
description: >-
|
description: >-
|
||||||
只读内部接口,无公网路由。customerId由Order认证上下文提供,所有业务表在Lastleg自身Schema内按客户读取。
|
只读内部接口,无公网路由。customerId由Order认证上下文提供,所有业务表在Lastleg自身Schema内按客户读取。
|
||||||
正式排车单PENDING_DEPARTURE/PICKING/READY_TO_DEPART按计划或库存来源计算,预排草稿和取消单不计入。
|
已排车scheduled按有效待出库单的不可变来源明细及箱数计算,与平台出库单节点一致;仅生成排车单号、审核中、审核通过但未生成出库单均不计入。
|
||||||
派送必须已实际DEPARTED、出库OUTBOUNDED且对应目的地派送单仍WAIT_DELIVERY/DELIVERING。
|
取消的出库单及已实际出库的单据不计入scheduled;排车审核、真实拣货核平、装车确认及安排派送仍按原流程执行。
|
||||||
混托使用所有库存及不可变拣货来源,不使用代表orderId;数量覆盖不完整或异常时标记partial,不签发派送完成事实。
|
派送必须排车单ACTIVE且处于DELIVERING阶段、具有实际发车时间、出库OUTBOUNDED且对应目的地派送单仍WAIT_DELIVERY/DELIVERING。
|
||||||
|
混托使用全部不可变出库及拣货来源,不使用代表orderId;数量覆盖不完整或异常时标记partial,不签发派送完成事实。
|
||||||
security: [{internalServiceToken: []}]
|
security: [{internalServiceToken: []}]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
+57
-9
@@ -13,16 +13,19 @@ import static org.assertj.core.api.Assertions.*;
|
|||||||
class CustomerOrderProgressMapperTest {
|
class CustomerOrderProgressMapperTest {
|
||||||
private JdbcTemplate jdbc;
|
private JdbcTemplate jdbc;
|
||||||
private CustomerOrderProgressMapper mapper;
|
private CustomerOrderProgressMapper mapper;
|
||||||
/** 两客户共用一单一任务,计划和在途均按各自真实明细归属统计。 */
|
/** 两客户共用一单一任务,出库和在途均按各自真实明细归属统计。 */
|
||||||
@Test void sharedDispatchAndTaskKeepBothCustomerProgressSeparate() {
|
@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_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)");
|
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("11",null)).extracting(s->s.scheduledPackages()).containsExactly(10L,20L);
|
||||||
assertThat(mapper.sources("22",null)).singleElement().satisfies(s->assertThat(s.scheduledPackages()).isEqualTo(30));
|
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_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 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_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("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");
|
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);
|
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_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_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_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_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_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')");
|
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);
|
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
|
||||||
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(CustomerOrderProgressMapper.class);
|
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);
|
var sources=mapper.sources("11", null);assertThat(sources).hasSize(2);
|
||||||
assertThat(sources.get(0).scheduledPackages()).isEqualTo(10);
|
assertThat(sources.get(0).scheduledPackages()).isEqualTo(10);
|
||||||
assertThat(sources.get(1).scheduledPackages()).isEqualTo(20);
|
assertThat(sources.get(1).scheduledPackages()).isEqualTo(20);
|
||||||
assertThat(mapper.sources("11",java.util.List.of("101"))).containsExactly(sources.get(0));
|
assertThat(mapper.sources("11",java.util.List.of("101"))).containsExactly(sources.get(0));
|
||||||
assertThat(sources).allMatch(s->s.deliveringPackages()==0);
|
assertThat(sources).allMatch(s->s.deliveringPackages()==0);
|
||||||
jdbc.update("UPDATE ll_dispatch_order SET status='CANCELLED'");
|
for (String status : java.util.List.of("CANCELLED","CANCELED","OUTBOUNDED")) {
|
||||||
assertThat(mapper.sources("11", null)).allMatch(s->s.scheduledPackages()==0);
|
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);
|
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("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_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_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)");
|
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(sources.get(1).scheduledPackages()).isEqualTo(20);
|
||||||
assertThat(mapper.sources("11",java.util.List.of("101"))).containsExactly(sources.get(0));
|
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);
|
assertThat(mapper.sources("11", null)).allMatch(s->s.deliveringPackages()==0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 同源多入仓片段完整返回,过滤不得扩大到其他订单或客户。 */
|
||||||
@Test void exactSourceOrderScopePreservesAllOwnInboundFragmentsAndExcludesForeignOrders() {
|
@Test void exactSourceOrderScopePreservesAllOwnInboundFragmentsAndExcludesForeignOrders() {
|
||||||
jdbc.update("INSERT INTO ll_order VALUES(4,'11','101','204','FIRSTLEG','FBA',5)");
|
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"));
|
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.orderId()).containsExactly("101","101");
|
||||||
assertThat(own).extracting(s->s.scheduledPackages()).containsExactly(10L,0L);
|
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();
|
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());
|
|| StringUtils.hasText(query.measurementReviewStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 保留内部调用的原始异常,便于按请求链路定位连接和协议故障。 */
|
||||||
private <T> ApiResponse<T> call(java.util.concurrent.Callable<ApiResponse<T>> invocation) {
|
private <T> ApiResponse<T> call(java.util.concurrent.Callable<ApiResponse<T>> invocation) {
|
||||||
try { return invocation.call(); }
|
try { return invocation.call(); }
|
||||||
catch (FeignException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务暂时不可用"); }
|
catch (FeignException exception) {
|
||||||
catch (Exception exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务查询失败"); }
|
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) {
|
private <T> T require(ApiResponse<T> response, String message) {
|
||||||
if (response == null || !response.successful() || response.data() == null) {
|
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);
|
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, message);
|
||||||
}
|
}
|
||||||
return response.data();
|
return response.data();
|
||||||
|
|||||||
Reference in New Issue
Block a user