fix(order): project exact customer fulfillment progress in detail

This commit is contained in:
2026-10-15 19:46:34 +08:00
parent 097bec33b1
commit ae33780dae
27 changed files with 391 additions and 50 deletions
@@ -10,7 +10,8 @@ public final class MerchantWorkbenchFilters {
private MerchantWorkbenchFilters() {}
public record Request(@NotNull @Positive Long tenantId,
@Pattern(regexp = "WAIT_MEASURE|WAIT_PALLETIZE|WAIT_SPLIT|LOADING|LOADED|DELIVERED") String mainStatus) {}
@Pattern(regexp = "WAIT_MEASURE|WAIT_PALLETIZE|WAIT_SPLIT|LOADING|LOADED|DELIVERED") String mainStatus,
@jakarta.validation.constraints.Size(min=1,max=200) List<@NotNull @Positive Long> orderIds) {}
public record Response(List<Long> matchingStatusOrderIds, List<Long> feeOrderIds,
List<Long> customsOrderIds, List<Progress> progress) {}
@@ -65,6 +65,7 @@ public interface MerchantWorkbenchFilterMapper {
List<Long> pendingCustoms(@Param("tenantId") long tenantId);
/** Only physically loaded fragments can expose container milestones to the customer. */
@Select("""
<script>
SELECT o.order_id, CASE WHEN owned.container_booking_id IS NULL THEN 0
WHEN EXISTS (SELECT 1 FROM fl_carrier_schedule_node_fact n
WHERE n.container_booking_id=owned.container_booking_id
@@ -76,6 +77,11 @@ public interface MerchantWorkbenchFilterMapper {
LEFT JOIN (
SELECT DISTINCT i.inbound_id, i.account_id, p.id AS loading_plan_id, p.container_booking_id
FROM fl_loading_plan_item i
<if test="orderIds != null">
JOIN fl_inbound_order selected ON selected.id=i.inbound_id AND selected.tenant_id=i.account_id
AND selected.deleted=0 AND selected.order_id IN
<foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
</if>
JOIN fl_loading_plan p ON p.id=i.loading_plan_id AND p.plan_status='COMPLETED'
JOIN fl_loading_pick_task t ON t.loading_plan_id=p.id AND t.task_status='COMPLETED'
JOIN fl_loading_pick_item picked ON picked.pick_task_id=t.id AND picked.loading_plan_item_id=i.id
@@ -84,8 +90,12 @@ public interface MerchantWorkbenchFilterMapper {
AND picked.item_status IN ('PICKED','PARTIAL') AND picked.picked_package_count>0
) owned ON owned.inbound_id=o.id AND owned.account_id=o.tenant_id
WHERE o.tenant_id=#{tenantId} AND o.deleted=0 AND o.order_id IS NOT NULL
AND o.process_status <> 'CANCELLED'
AND o.process_status &lt;&gt; 'CANCELLED'
<if test="orderIds != null">
AND o.order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
</if>
ORDER BY o.order_id
</script>
""")
List<com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Fragment> progressFragments(@Param("tenantId") long tenantId);
List<com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Fragment> progressFragments(@Param("tenantId") long tenantId, @Param("orderIds") List<Long> orderIds);
}
@@ -12,11 +12,15 @@ public class MerchantWorkbenchFilterService {
@org.springframework.transaction.annotation.Transactional(readOnly=true)
public MerchantWorkbenchFilters.Response read(MerchantWorkbenchFilters.Request request) {
if (request.orderIds() != null) {
return new MerchantWorkbenchFilters.Response(null, java.util.List.of(), java.util.List.of(),
progress(mapper.progressFragments(request.tenantId(), request.orderIds())));
}
return new MerchantWorkbenchFilters.Response(
request.mainStatus() == null ? null : mapper.byProcessStatuses(request.tenantId(),
unified.internalStatuses(request.mainStatus())),
mapper.pendingFees(request.tenantId()), mapper.pendingCustoms(request.tenantId()),
progress(mapper.progressFragments(request.tenantId())));
progress(mapper.progressFragments(request.tenantId(), null)));
}
static java.util.List<MerchantWorkbenchFilters.Progress> progress(java.util.List<MerchantWorkbenchFilters.Fragment> facts) {
@@ -355,7 +355,7 @@ paths:
/internal/firstleg/v1/inbounds/unified-orders/workbench-filters:
post:
summary: 查询单一客户工作台履约筛选标识(Order内部)
description: 只读返回命中状态、最新待确认费用及待上传资料的订单标识。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。
description: 只读返回命中状态、最新待确认费用及待上传资料的订单标识。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。传入orderIds为精确进度模式,仅计算progress,matchingStatusOrderIds为null,feeOrderIds和customsOrderIds为空;mainStatus在该模式不参与查询。
security: [{internalServiceToken: []}]
requestBody:
required: true
@@ -367,6 +367,13 @@ paths:
properties:
tenantId: {type: integer, format: int64, minimum: 1}
mainStatus: {type: string, nullable: true, enum: [WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED]}
orderIds:
type: array
nullable: true
minItems: 1
maxItems: 200
items: {type: integer, format: int64, minimum: 1}
description: 省略或null查询当前客户全部进度;传入时仅在SQL内查询这些来源Order订单,空数组拒绝,不允许扩大到全客户。
responses:
'200':
description: 按响应code判断,标识只供Order在自身客户范围内过滤、计数和分页
@@ -45,7 +45,7 @@ class InternalInboundOrderReferenceControllerTest {
private InternalInboundOrderController controller;
@Test void workbenchFiltersRejectMissingAndWrongInternalIdentity() {
var request = new com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Request(11L, "LOADED");
var request = new com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Request(11L, "LOADED", null);
assertThatThrownBy(() -> controller.workbenchFilters(null, request)).isInstanceOf(BizException.class);
assertThatThrownBy(() -> controller.workbenchFilters("wrong", request)).isInstanceOf(BizException.class);
org.mockito.Mockito.verifyNoInteractions(workbenchFilters);
@@ -143,4 +143,18 @@ class InternalInboundOrderReferenceControllerTest {
carrierId == null ? null : "承运商", null, null, null, null,
null, null, null, null, null, null);
}
@Test void optionalExactScopeRejectsEmptyOversizeNullOrNonPositiveItems() {
try(var factory=jakarta.validation.Validation.buildDefaultValidatorFactory()) {
var validator=factory.getValidator();
for(java.util.List<Long> ids: java.util.List.of(java.util.List.<Long>of(),java.util.List.of(0L),
java.util.Arrays.asList(1L,null),java.util.stream.LongStream.rangeClosed(1,201).boxed().toList())) {
assertThat(validator.validate(new com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Request(11L,null,ids))).isNotEmpty();
}
for(java.util.List<Long> ids: java.util.Arrays.asList(null,java.util.List.of(1L),java.util.stream.LongStream.rangeClosed(1,200).boxed().toList())) {
assertThat(validator.validate(new com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Request(11L,null,ids))).isEmpty();
}
}
}
}
@@ -74,4 +74,16 @@ class UnifiedInboundOpenApiContractTest {
.containsOnlyKeys("standardNode","nodeSequence","providerCode","estimatedAt","actualAt","locationCode","locationName");
}
@Test @SuppressWarnings("unchecked") void documentsBoundedOptionalExactOrderScope() throws Exception {
var document=(java.util.Map<String,Object>)new org.yaml.snakeyaml.Yaml().load(
new org.springframework.core.io.ClassPathResource("openapi/firstleg-v1.yaml").getContentAsString(java.nio.charset.StandardCharsets.UTF_8));
var paths=(java.util.Map<String,Object>)document.get("paths");
var operation=(java.util.Map<String,Object>)((java.util.Map<String,Object>)paths.get("/internal/firstleg/v1/inbounds/unified-orders/workbench-filters")).get("post");
var content=(java.util.Map<String,Object>)((java.util.Map<String,Object>)operation.get("requestBody")).get("content");
var schema=(java.util.Map<String,Object>)((java.util.Map<String,Object>)content.get("application/json")).get("schema");
var ids=(java.util.Map<String,Object>)((java.util.Map<String,Object>)schema.get("properties")).get("orderIds");
assertThat(ids).containsEntry("minItems",1).containsEntry("maxItems",200).containsEntry("nullable",true);
assertThat((java.util.List<String>)schema.get("required")).doesNotContain("orderIds");
}
}
@@ -83,19 +83,28 @@ class MerchantWorkbenchFilterMapperTest {
}
@Test void onlyActualLoadedOwnedFragmentsAndActualClearanceAdvanceProgress() {
assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==0);
assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==0);
jdbc.update("INSERT INTO fl_loading_pick_task VALUES(900,100,'COMPLETED')");
jdbc.update("INSERT INTO fl_loading_pick_item SELECT 900,id,'PICKED',2 FROM fl_loading_plan_item");
assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==1).noneMatch(f->f.orderId()==103||f.orderId()==104);
assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==1).noneMatch(f->f.orderId()==103||f.orderId()==104);
jdbc.update("INSERT INTO fl_customs_container VALUES(200,100,'DECLARING')");
assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==1);
assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==1);
jdbc.update("UPDATE fl_customs_container SET status='COMPLETED'");
assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==2);
assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==2);
jdbc.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(100,'PICKUP_READY',NULL)");
assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==2);
assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==2);
jdbc.update("UPDATE fl_carrier_schedule_node_fact SET actual_at=CURRENT_TIMESTAMP");
assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==3);
assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==3);
assertThat(mapper.progressFragments(11,List.of(101L))).singleElement().satisfies(f->{assertThat(f.orderId()).isEqualTo(101);assertThat(f.stage()).isEqualTo(3);});
jdbc.update("UPDATE fl_container_booking SET record_status='CANCELLED'");
assertThat(mapper.progressFragments(11)).allMatch(f->f.stage()==0);
assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==0);
}
@Test void exactOrderScopeKeepsAllItsFragmentsAndCannotReadForeignOrDeletedOrders() {
jdbc.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,deleted,tax_mode,process_status) VALUES(50,11,101,0,'TAX_REFUND','LOADED')");
assertThat(mapper.progressFragments(11,List.of(101L,103L,104L))).extracting(f->f.orderId()).containsExactly(101L,101L);
assertThat(mapper.progressFragments(11,List.of(999L))).isEmpty();
assertThat(mapper.progressFragments(22,List.of(101L))).isEmpty();
}
}
@@ -0,0 +1,26 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.dto.MerchantWorkbenchFilters;
import com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class MerchantWorkbenchFilterServiceTest {
@Test void exactDetailProgressSkipsUnrelatedCustomerQueues() {
var mapper=mock(MerchantWorkbenchFilterMapper.class);
var unified=mock(UnifiedInboundQueryService.class);
var service=new MerchantWorkbenchFilterService();
ReflectionTestUtils.setField(service,"mapper",mapper);
ReflectionTestUtils.setField(service,"unified",unified);
when(mapper.progressFragments(11,List.of(101L))).thenReturn(List.of(
new MerchantWorkbenchFilters.Fragment(101,3),new MerchantWorkbenchFilters.Fragment(101,1)));
var response=service.read(new MerchantWorkbenchFilters.Request(11L,null,List.of(101L)));
assertThat(response.progress()).containsExactly(new MerchantWorkbenchFilters.Progress(101,"cleared",true));
assertThat(response.feeOrderIds()).isEmpty();assertThat(response.customsOrderIds()).isEmpty();
assertThat(response.matchingStatusOrderIds()).isNull();
verify(mapper).progressFragments(11,List.of(101L));verifyNoMoreInteractions(mapper);verifyNoInteractions(unified);
}
}