提交
This commit is contained in:
@@ -27,7 +27,9 @@ public class WorkflowTabService {
|
|||||||
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(WorkflowFacts.Fact::departed).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
|
// 出库单由尾程审核建单触发,不能因国内开船节点尚未补录而跳过事实查询。
|
||||||
|
var sources=facts.stream().map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
|
||||||
|
var requiredSources=facts.stream().filter(WorkflowFacts.Fact::departed).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
|
||||||
Map<String,WorkflowFacts.Overseas> overseas=new HashMap<>();
|
Map<String,WorkflowFacts.Overseas> overseas=new HashMap<>();
|
||||||
if(!sources.isEmpty()) {
|
if(!sources.isEmpty()) {
|
||||||
try {
|
try {
|
||||||
@@ -40,8 +42,10 @@ public class WorkflowTabService {
|
|||||||
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("尾程事实缺少来源 expected="+sources.size()+" actual="+overseas.size()
|
// 未开船来源可能尚未产生尾程订单;已开船来源仍须完整返回,避免依赖缺项被误当作零。
|
||||||
+" missing="+sources.stream().filter(s->!overseas.containsKey(s.tenantId()+":"+s.inboundId())).limit(20).toList());
|
var missing=requiredSources.stream().filter(s->!overseas.containsKey(s.tenantId()+":"+s.inboundId())).toList();
|
||||||
|
if(!missing.isEmpty()) throw unavailable("尾程事实缺少来源 expected="+requiredSources.size()+" actual="+(requiredSources.size()-missing.size())
|
||||||
|
+" missing="+missing.stream().limit(20).toList());
|
||||||
} catch(BizException e) { throw e; } catch(Exception e) {
|
} catch(BizException e) { throw e; } catch(Exception e) {
|
||||||
throw (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试").initCause(e);
|
throw (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试").initCause(e);
|
||||||
}
|
}
|
||||||
@@ -61,6 +65,11 @@ public class WorkflowTabService {
|
|||||||
Set<String> keys=new LinkedHashSet<>();
|
Set<String> keys=new LinkedHashSet<>();
|
||||||
if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW");
|
if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW");
|
||||||
boolean signed="DELIVERED".equals(process);
|
boolean signed="DELIVERED".equals(process);
|
||||||
|
// 有效出库与派送事实独立于船期、清关资料;签收后不再保留待出库或派送任务。
|
||||||
|
if(!signed && o!=null) {
|
||||||
|
if(o.pendingOutbound()) keys.add("OUTBOUND_DOCUMENT");
|
||||||
|
if(o.delivering()) keys.add("DELIVERING");
|
||||||
|
}
|
||||||
// 人工补录或船司查询的实际开船节点才推进阶段,报关完成和提前上传资料不能代替开船。
|
// 人工补录或船司查询的实际开船节点才推进阶段,报关完成和提前上传资料不能代替开船。
|
||||||
boolean clearanceStage=f.departed();
|
boolean clearanceStage=f.departed();
|
||||||
if(!clearanceStage && !signed) {
|
if(!clearanceStage && !signed) {
|
||||||
@@ -75,8 +84,6 @@ public class WorkflowTabService {
|
|||||||
if(!o.outboundExists() && !o.unloaded()) keys.add("WAIT_UNLOAD");
|
if(!o.outboundExists() && !o.unloaded()) keys.add("WAIT_UNLOAD");
|
||||||
// 取消的出库不构成有效覆盖,读取事实后自动回显,不能保存永久隐藏标志。
|
// 取消的出库不构成有效覆盖,读取事实后自动回显,不能保存永久隐藏标志。
|
||||||
if(!o.outboundExists()) keys.add("WAIT_DISPATCH");
|
if(!o.outboundExists()) keys.add("WAIT_DISPATCH");
|
||||||
if(o.pendingOutbound()) keys.add("OUTBOUND_DOCUMENT");
|
|
||||||
if(o.delivering()) keys.add("DELIVERING");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return keys;
|
return keys;
|
||||||
|
|||||||
@@ -1679,7 +1679,7 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED, WAIT_WEIGHT_CONFIRM, WEIGHT_CONFIRMED, WAIT_CUSTOMS_DOCS, EXPORT_CUSTOMS, SAILING_CLEARANCE, WAIT_UNLOAD, WAIT_DISPATCH, OUTBOUND_DOCUMENT, DELIVERING, SIGNED, MEASUREMENT_REVIEW]
|
enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED, WAIT_WEIGHT_CONFIRM, WEIGHT_CONFIRMED, WAIT_CUSTOMS_DOCS, EXPORT_CUSTOMS, SAILING_CLEARANCE, WAIT_UNLOAD, WAIT_DISPATCH, OUTBOUND_DOCUMENT, DELIVERING, SIGNED, MEASUREMENT_REVIEW]
|
||||||
nullable: true
|
nullable: true
|
||||||
description: WAIT_SPLIT 为唯一待分柜筛选值;WAIT_CUSTOMS_DOCS 为实际装柜后缺少本票客户报关资料;EXPORT_CUSTOMS 按本票资料已上传且实际装柜完成汇总,买单无需客户退税资料,不依赖点击开始报关,报关完成或实际开船后移出;SAILING_CLEARANCE 在整柜报关完成后进入,也接受实际离港事实;清关资料保存成功后才进入 WAIT_UNLOAD/WAIT_DISPATCH。WAIT_UNLOAD 持续到实际卸柜或生成有效出库单;生成出库单后进入 OUTBOUND_DOCUMENT,实际出库后进入 DELIVERING,派送完成进入 SIGNED。
|
description: WAIT_SPLIT 为唯一待分柜筛选值;WAIT_CUSTOMS_DOCS 为实际装柜后缺少本票客户报关资料;EXPORT_CUSTOMS 按本票资料已上传且实际装柜完成汇总,买单无需客户退税资料,不依赖点击开始报关,报关完成或实际开船后移出;SAILING_CLEARANCE 在整柜报关完成后进入,也接受实际离港事实;清关资料保存成功后才进入 WAIT_UNLOAD/WAIT_DISPATCH。WAIT_UNLOAD 持续到实际卸柜或生成有效出库单;尾程排车理货审核通过并生成有效出库单后进入 OUTBOUND_DOCUMENT,不以前置开船节点或清关资料为条件;取消或实际出库后移出,实际出库并发车后进入 DELIVERING,派送完成进入 SIGNED。
|
||||||
customerId: {type: integer, format: int64, nullable: true}
|
customerId: {type: integer, format: int64, nullable: true}
|
||||||
receivingWarehouseId: {type: integer, format: int64, nullable: true}
|
receivingWarehouseId: {type: integer, format: int64, nullable: true}
|
||||||
destinationWarehouseId: {type: integer, format: int64, nullable: true}
|
destinationWarehouseId: {type: integer, format: int64, nullable: true}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class WorkflowBoundaryTest {
|
|||||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class).hasCause(failure);
|
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class).hasCause(failure);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 报关完成或提前上传资料仍留在出口报关,实际开船后才查询精确海外来源。 */
|
/** 未开船也查询尾程建单事实,未产生尾程来源时仍保留国内阶段。 */
|
||||||
@Test void completedCustomsAndClearanceUploadWaitForActualDeparture() {
|
@Test void completedCustomsAndClearanceUploadWaitForActualDeparture() {
|
||||||
var repository=mock(WorkflowFactRepository.class);
|
var repository=mock(WorkflowFactRepository.class);
|
||||||
var downstream=mock(LastlegWorkflowClient.class);
|
var downstream=mock(LastlegWorkflowClient.class);
|
||||||
@@ -45,12 +45,15 @@ class WorkflowBoundaryTest {
|
|||||||
inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
|
inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
|
||||||
when(repository.facts(List.of(1L))).thenReturn(List.of(
|
when(repository.facts(List.of(1L))).thenReturn(List.of(
|
||||||
new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,true)));
|
new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,true)));
|
||||||
|
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of()));
|
||||||
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("EXPORT_CUSTOMS");
|
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("EXPORT_CUSTOMS");
|
||||||
verifyNoInteractions(downstream);
|
verify(downstream).read(any(),any());
|
||||||
|
clearInvocations(downstream);
|
||||||
when(repository.facts(List.of(1L))).thenReturn(List.of(
|
when(repository.facts(List.of(1L))).thenReturn(List.of(
|
||||||
new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true,true)));
|
new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true,true)));
|
||||||
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("EXPORT_CUSTOMS");
|
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("EXPORT_CUSTOMS");
|
||||||
verifyNoInteractions(downstream);
|
verify(downstream).read(any(),any());
|
||||||
|
clearInvocations(downstream);
|
||||||
when(repository.facts(List.of(1L))).thenReturn(List.of(
|
when(repository.facts(List.of(1L))).thenReturn(List.of(
|
||||||
new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,true,true)));
|
new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,true,true)));
|
||||||
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of(
|
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of(
|
||||||
@@ -64,4 +67,18 @@ class WorkflowBoundaryTest {
|
|||||||
verify(downstream).read(any(),eq(new WorkflowFacts.Request(List.of(new WorkflowFacts.Source(11,1)))));
|
verify(downstream).read(any(),eq(new WorkflowFacts.Request(List.of(new WorkflowFacts.Source(11,1)))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 理货审核建单后,未补录开船与清关资料也必须返回出库待办。 */
|
||||||
|
@Test void outboundBeforeDepartureIsReadAndClassified() {
|
||||||
|
var repository=mock(WorkflowFactRepository.class);
|
||||||
|
var downstream=mock(LastlegWorkflowClient.class);
|
||||||
|
var service=new WorkflowTabService(repository,downstream);
|
||||||
|
var inbound=new InboundOrderEntity();inbound.setId(1L);inbound.setTenantId(11L);
|
||||||
|
inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
|
||||||
|
when(repository.facts(List.of(1L))).thenReturn(List.of(
|
||||||
|
new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,false,false)));
|
||||||
|
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of(
|
||||||
|
new WorkflowFacts.Overseas(11,1,true,true,false,true))));
|
||||||
|
assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("OUTBOUND_DOCUMENT");
|
||||||
|
verify(downstream).read(any(),eq(new WorkflowFacts.Request(List.of(new WorkflowFacts.Source(11,1)))));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,12 +30,12 @@ class WorkflowBusinessTest {
|
|||||||
assertThat(WorkflowTabService.classify(uploaded,"DELIVERED",unloaded))
|
assertThat(WorkflowTabService.classify(uploaded,"DELIVERED",unloaded))
|
||||||
.contains("SIGNED").doesNotContain("EXPORT_CUSTOMS","WAIT_CUSTOMS_DOCS");
|
.contains("SIGNED").doesNotContain("EXPORT_CUSTOMS","WAIT_CUSTOMS_DOCS");
|
||||||
}
|
}
|
||||||
/** 清关前不能被海外建单事实提前推进,清关后出库与派送依次替换待办。 */
|
/** 出库与派送按尾程事实展示,不受清关资料限制。 */
|
||||||
@Test void downstreamFactsCannotBypassClearanceAndOutboundReplacesQueues() {
|
@Test void downstreamFactsCannotBypassClearanceAndOutboundReplacesQueues() {
|
||||||
var pending=new WorkflowFacts.Overseas(11,1,true,true,false,false);
|
var pending=new WorkflowFacts.Overseas(11,1,true,true,false,false);
|
||||||
var shipped=new WorkflowFacts.Overseas(11,1,true,false,true,false);
|
var shipped=new WorkflowFacts.Overseas(11,1,true,false,true,false);
|
||||||
assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",pending)).containsExactly("SAILING_CLEARANCE");
|
assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",pending)).containsExactly("OUTBOUND_DOCUMENT","SAILING_CLEARANCE");
|
||||||
assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",shipped)).containsExactly("SAILING_CLEARANCE");
|
assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",shipped)).containsExactly("DELIVERING","SAILING_CLEARANCE");
|
||||||
assertThat(WorkflowTabService.classify(fact(1,true),"LOADED",pending)).containsExactly("OUTBOUND_DOCUMENT");
|
assertThat(WorkflowTabService.classify(fact(1,true),"LOADED",pending)).containsExactly("OUTBOUND_DOCUMENT");
|
||||||
assertThat(WorkflowTabService.classify(fact(1,true),"LOADED",shipped)).containsExactly("DELIVERING");
|
assertThat(WorkflowTabService.classify(fact(1,true),"LOADED",shipped)).containsExactly("DELIVERING");
|
||||||
assertThat(WorkflowTabService.classify(fact(1,true),"DELIVERED",shipped)).containsExactly("SIGNED");
|
assertThat(WorkflowTabService.classify(fact(1,true),"DELIVERED",shipped)).containsExactly("SIGNED");
|
||||||
|
|||||||
+11
-1
@@ -25,7 +25,17 @@ public interface TripOperationsQueryMapper {
|
|||||||
(SELECT d.package_count FROM ll_dispatch_order d WHERE d.trip_id=t.id AND d.task_type='LOCAL_TRUCK' LIMIT 1)
|
(SELECT d.package_count FROM ll_dispatch_order d WHERE d.trip_id=t.id AND d.task_type='LOCAL_TRUCK' LIMIT 1)
|
||||||
ELSE (SELECT SUM(p.actual_packages) FROM ll_fleet_pickup_task p
|
ELSE (SELECT SUM(p.actual_packages) FROM ll_fleet_pickup_task p
|
||||||
WHERE CAST(p.trip_no AS BINARY(512))=CAST(t.trip_no AS BINARY(512)) AND p.status IN ('PICKED','ARRIVED')) END)
|
WHERE CAST(p.trip_no AS BINARY(512))=CAST(t.trip_no AS BINARY(512)) AND p.status IN ('PICKED','ARRIVED')) END)
|
||||||
ELSE NULL END AS transport_facts
|
ELSE (SELECT JSON_OBJECT('taskType','PHOTO_LOOSEBOARD','packages',
|
||||||
|
JSON_EXTRACT(dh.snapshot_json,'$.shippedPackageCount'))
|
||||||
|
FROM ll_dispatch_order d JOIN ll_dispatch_history dh ON dh.dispatch_id=d.id
|
||||||
|
WHERE d.trip_id=t.id AND dh.action='LOOSEBOARD_DELIVERING'
|
||||||
|
AND JSON_EXTRACT(dh.snapshot_json,'$.departedAt') IS NOT NULL
|
||||||
|
AND JSON_UNQUOTE(JSON_EXTRACT(dh.snapshot_json,'$.departedAt'))!='null'
|
||||||
|
AND (JSON_EXTRACT(dh.snapshot_json,'$.loadingCompletedAt') IS NULL
|
||||||
|
OR JSON_UNQUOTE(JSON_EXTRACT(dh.snapshot_json,'$.loadingCompletedAt'))='null')
|
||||||
|
AND JSON_EXTRACT(dh.snapshot_json,'$.pickedPackageCount')=0
|
||||||
|
AND JSON_EXTRACT(dh.snapshot_json,'$.shippedPackageCount') > 0
|
||||||
|
ORDER BY dh.record_version,dh.id LIMIT 1) END AS transport_facts
|
||||||
FROM ll_fleet_trip t
|
FROM ll_fleet_trip t
|
||||||
WHERE t.departed_at >= #{q.from} AND t.departed_at < #{q.to}
|
WHERE t.departed_at >= #{q.from} AND t.departed_at < #{q.to}
|
||||||
<if test='q.warehouseId != null'>AND t.warehouse_id=#{q.warehouseId}</if>
|
<if test='q.warehouseId != null'>AND t.warehouse_id=#{q.warehouseId}</if>
|
||||||
|
|||||||
+7
-1
@@ -55,6 +55,10 @@ public class TripOperationsService {
|
|||||||
var trip=snapshot.trip(); var load=snapshot.confirmedLoad();
|
var trip=snapshot.trip(); var load=snapshot.confirmedLoad();
|
||||||
if(trip!=null && Set.of("LOCAL_TRUCK","PICKUP").contains(Objects.toString(trip.tripPurpose(),"")))
|
if(trip!=null && Set.of("LOCAL_TRUCK","PICKUP").contains(Objects.toString(trip.tripPurpose(),"")))
|
||||||
return transport(fact,trip);
|
return transport(fact,trip);
|
||||||
|
// 纯散板照片发运没有仓内实载,只接受查询取得的不可变照片发运依据。
|
||||||
|
if(trip!=null && load==null && fact.transportFacts()!=null
|
||||||
|
&& "PHOTO_LOOSEBOARD".equals(json.readTree(fact.transportFacts()).path("taskType").asText()))
|
||||||
|
return transport(fact,trip);
|
||||||
if (trip==null || !fact.tripNo().equals(trip.tripNo()) || trip.departedAt()==null || load==null || load.cargo()==null || load.pallets()==null || load.cargo().isEmpty() || load.pallets().isEmpty())
|
if (trip==null || !fact.tripNo().equals(trip.tripNo()) || trip.departedAt()==null || load==null || load.cargo()==null || load.pallets()==null || load.cargo().isEmpty() || load.pallets().isEmpty())
|
||||||
throw new IllegalStateException("发车历史缺少装载或实际时间,请核对车次"+fact.tripNo());
|
throw new IllegalStateException("发车历史缺少装载或实际时间,请核对车次"+fact.tripNo());
|
||||||
var externalByPallet=new HashMap<String,Long>(); var money=new TreeMap<String,BigDecimal>(); var bookingNumbers=new HashSet<String>();
|
var externalByPallet=new HashMap<String,Long>(); var money=new TreeMap<String,BigDecimal>(); var bookingNumbers=new HashSet<String>();
|
||||||
@@ -111,9 +115,11 @@ public class TripOperationsService {
|
|||||||
if(!fact.tripNo().equals(trip.tripNo()) || trip.departedAt()==null || trip.stops()==null || trip.stops().size()<2)
|
if(!fact.tripNo().equals(trip.tripNo()) || trip.departedAt()==null || trip.stops()==null || trip.stops().size()<2)
|
||||||
throw new IllegalStateException("运输发车历史缺少行程或实际时间");
|
throw new IllegalStateException("运输发车历史缺少行程或实际时间");
|
||||||
var facts=fact.transportFacts()==null?null:json.readTree(fact.transportFacts());
|
var facts=fact.transportFacts()==null?null:json.readTree(fact.transportFacts());
|
||||||
if(facts==null || !trip.tripPurpose().equals(facts.path("taskType").asText()))throw new IllegalStateException("运输出车缺少来源依据");
|
boolean photo=facts!=null && "PHOTO_LOOSEBOARD".equals(facts.path("taskType").asText());
|
||||||
|
if(facts==null || (!photo && !Objects.equals(trip.tripPurpose(),facts.path("taskType").asText())))throw new IllegalStateException("运输出车缺少来源依据");
|
||||||
// 箱数统计仅累计已登记实提,未登记不计入,不能用预报补出运量。
|
// 箱数统计仅累计已登记实提,未登记不计入,不能用预报补出运量。
|
||||||
var packages=facts.get("packages");long quantity=packages==null || packages.isNull()?0:packages.longValue();
|
var packages=facts.get("packages");long quantity=packages==null || packages.isNull()?0:packages.longValue();
|
||||||
|
if(photo && (packages==null || !packages.isIntegralNumber() || quantity<=0))throw new IllegalStateException("散板照片发运缺少实际箱数");
|
||||||
if(quantity<0)throw new IllegalStateException("运输箱数不合法");
|
if(quantity<0)throw new IllegalStateException("运输箱数不合法");
|
||||||
var destinations=new ArrayList<TripOperationsModels.Destination>();
|
var destinations=new ArrayList<TripOperationsModels.Destination>();
|
||||||
for(int i=1;i<trip.stops().size();i++) {
|
for(int i=1;i<trip.stops().size();i++) {
|
||||||
|
|||||||
+2
-1
@@ -44,11 +44,12 @@ public class MerchantLocalLooseboardController {
|
|||||||
return ApiResponse.success(service.page(customer(user), query));
|
return ApiResponse.success(service.page(customer(user), query));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 客户详情使用与列表一致的运输进度。 */
|
||||||
@GetMapping("/{orderNo}")
|
@GetMapping("/{orderNo}")
|
||||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LOOSEBOARD_LIST + "')")
|
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LOOSEBOARD_LIST + "')")
|
||||||
public ApiResponse<LocalLooseboardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<LocalLooseboardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable String orderNo) {
|
@PathVariable String orderNo) {
|
||||||
return ApiResponse.success(service.detail(customer(user), orderNo));
|
return ApiResponse.success(service.merchantDetail(customer(user), orderNo));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{orderNo}")
|
@PutMapping("/{orderNo}")
|
||||||
|
|||||||
+34
@@ -8,6 +8,40 @@ import org.apache.ibatis.annotations.Select;
|
|||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface LocalLooseboardOrderMapper extends BaseMapper<LocalLooseboardOrder> {
|
public interface LocalLooseboardOrderMapper extends BaseMapper<LocalLooseboardOrder> {
|
||||||
|
// 客户状态按正式排车与取货事实投影;不改变收货、测量等内部状态门禁。
|
||||||
|
String DELIVERY = " FROM ll_inbound_plan p"
|
||||||
|
+ " JOIN ll_inbound_plan_pallet pp ON pp.plan_id=p.id AND pp.customer_id=p.customer_id"
|
||||||
|
+ " JOIN ll_dispatch_item i ON i.plan_pallet_id=pp.id AND i.customer_id=p.customer_id"
|
||||||
|
+ " JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id AND d.status='ACTIVE'"
|
||||||
|
+ " WHERE p.source_order_id=ll_local_looseboard_order.lastleg_order_id"
|
||||||
|
+ " AND p.customer_id=ll_local_looseboard_order.customer_id";
|
||||||
|
String PICKUP = " FROM ll_warehouse_transfer_order w"
|
||||||
|
+ " JOIN ll_fleet_pickup_task t ON t.transfer_order_id=w.id AND t.customer_id=w.customer_id"
|
||||||
|
+ " JOIN ll_fleet_trip f ON f.trip_no=t.trip_no AND f.status IN ('PLANNED','IN_TRANSIT')"
|
||||||
|
+ " WHERE w.customer_id=ll_local_looseboard_order.customer_id"
|
||||||
|
+ " AND w.transfer_no=ll_local_looseboard_order.transfer_no";
|
||||||
|
String MERCHANT_STATUS = "(CASE"
|
||||||
|
+ " WHEN ll_local_looseboard_order.status IN ('FUNDING_PENDING','FUNDING_FAILED','PENDING_ACCEPTANCE','COMPLETED','CANCELLED')"
|
||||||
|
+ " THEN ll_local_looseboard_order.status"
|
||||||
|
+ " WHEN ll_local_looseboard_order.status='IN_TRANSIT' OR EXISTS (SELECT 1" + DELIVERY
|
||||||
|
+ " AND d.stage IN ('DELIVERING','DELIVERED')) THEN 'IN_TRANSIT'"
|
||||||
|
+ " WHEN EXISTS (SELECT 1" + PICKUP
|
||||||
|
+ " AND (t.status='PICKED' OR (t.status='ASSIGNED' AND f.status='IN_TRANSIT'))) THEN 'PICKING_UP'"
|
||||||
|
+ " WHEN EXISTS (SELECT 1" + DELIVERY + ") OR EXISTS (SELECT 1" + PICKUP
|
||||||
|
+ " AND t.status='ASSIGNED') THEN 'DISPATCHED'"
|
||||||
|
+ " WHEN ll_local_looseboard_order.status='DISPATCHED' THEN 'DISPATCHED'"
|
||||||
|
+ " ELSE 'PENDING_DISPATCH' END)";
|
||||||
|
|
||||||
|
/** 批量读取同客户散板的运输状态,与分页筛选使用同一表达式。 */
|
||||||
|
@Select("<script>SELECT id," + MERCHANT_STATUS + " AS status FROM ll_local_looseboard_order"
|
||||||
|
+ " WHERE customer_id=#{customerId} AND id IN"
|
||||||
|
+ " <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
|
||||||
|
java.util.List<MerchantStatus> merchantStatuses(@Param("customerId") String customerId,
|
||||||
|
@Param("ids") java.util.List<Long> ids);
|
||||||
|
|
||||||
|
/** 客户列表与详情共享的只读状态。 */
|
||||||
|
record MerchantStatus(Long id, String status) { }
|
||||||
|
|
||||||
@Select("SELECT COUNT(*) FROM ll_local_looseboard_order WHERE customer_id=#{customerId} AND transfer_no=#{transferNo} AND lastleg_order_id IS NOT NULL")
|
@Select("SELECT COUNT(*) FROM ll_local_looseboard_order WHERE customer_id=#{customerId} AND transfer_no=#{transferNo} AND lastleg_order_id IS NOT NULL")
|
||||||
int linkedReceiving(@Param("customerId") String customerId,@Param("transferNo") String transferNo);
|
int linkedReceiving(@Param("customerId") String customerId,@Param("transferNo") String transferNo);
|
||||||
@org.apache.ibatis.annotations.Update("UPDATE ll_local_looseboard_order SET status='PENDING_DISPATCH',updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE customer_id=#{customerId} AND transfer_no=#{transferNo} AND status='WAITING_INBOUND'")
|
@org.apache.ibatis.annotations.Update("UPDATE ll_local_looseboard_order SET status='PENDING_DISPATCH',updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE customer_id=#{customerId} AND transfer_no=#{transferNo} AND status='WAITING_INBOUND'")
|
||||||
|
|||||||
+19
-2
@@ -33,18 +33,35 @@ public class LocalLooseboardRepository {
|
|||||||
public LocalLooseboardOrder lockNo(String customer, String number) { return orders.lockNo(customer, number); }
|
public LocalLooseboardOrder lockNo(String customer, String number) { return orders.lockNo(customer, number); }
|
||||||
/** 在客户范围内组合路线、状态与取货日期筛选并返回分页需求。 */
|
/** 在客户范围内组合路线、状态与取货日期筛选并返回分页需求。 */
|
||||||
public Page<LocalLooseboardOrder> page(LocalLooseboardRequests.Query query) {
|
public Page<LocalLooseboardOrder> page(LocalLooseboardRequests.Query query) {
|
||||||
|
return orders.selectPage(Page.of(query.page(), query.size()), queryWrapper(query, false));
|
||||||
|
}
|
||||||
|
/** 客户按运输状态筛选后分页,确保总数和返回记录使用相同口径。 */
|
||||||
|
public Page<LocalLooseboardOrder> merchantPage(LocalLooseboardRequests.Query query) {
|
||||||
|
return orders.selectPage(Page.of(query.page(), query.size()), queryWrapper(query, true));
|
||||||
|
}
|
||||||
|
/** 按认证客户批量装配状态,空页不查询排车事实。 */
|
||||||
|
public java.util.Map<Long, String> merchantStatuses(String customer, List<Long> ids) {
|
||||||
|
if (ids.isEmpty()) return java.util.Map.of();
|
||||||
|
return orders.merchantStatuses(customer, ids).stream().collect(java.util.stream.Collectors.toMap(
|
||||||
|
LocalLooseboardOrderMapper.MerchantStatus::id, LocalLooseboardOrderMapper.MerchantStatus::status));
|
||||||
|
}
|
||||||
|
/** 路线与时间筛选共用,仅客户查询采用实际运输进度。 */
|
||||||
|
private LambdaQueryWrapper<LocalLooseboardOrder> queryWrapper(LocalLooseboardRequests.Query query, boolean merchant) {
|
||||||
LambdaQueryWrapper<LocalLooseboardOrder> wrapper = new LambdaQueryWrapper<LocalLooseboardOrder>()
|
LambdaQueryWrapper<LocalLooseboardOrder> wrapper = new LambdaQueryWrapper<LocalLooseboardOrder>()
|
||||||
.eq(StringUtils.hasText(query.customerId()), LocalLooseboardOrder::getCustomerId, trim(query.customerId()))
|
.eq(StringUtils.hasText(query.customerId()), LocalLooseboardOrder::getCustomerId, trim(query.customerId()))
|
||||||
.like(StringUtils.hasText(query.orderNo()), LocalLooseboardOrder::getOrderNo, trim(query.orderNo()))
|
.like(StringUtils.hasText(query.orderNo()), LocalLooseboardOrder::getOrderNo, trim(query.orderNo()))
|
||||||
.and(StringUtils.hasText(query.routeKeyword()), value -> value
|
.and(StringUtils.hasText(query.routeKeyword()), value -> value
|
||||||
.like(LocalLooseboardOrder::getOrigin, trim(query.routeKeyword()))
|
.like(LocalLooseboardOrder::getOrigin, trim(query.routeKeyword()))
|
||||||
.or().like(LocalLooseboardOrder::getDestination, trim(query.routeKeyword())))
|
.or().like(LocalLooseboardOrder::getDestination, trim(query.routeKeyword())))
|
||||||
.eq(StringUtils.hasText(query.status()), LocalLooseboardOrder::getStatus,
|
.eq(!merchant && StringUtils.hasText(query.status()), LocalLooseboardOrder::getStatus,
|
||||||
StringUtils.hasText(query.status()) ? query.status().trim().toUpperCase() : null)
|
StringUtils.hasText(query.status()) ? query.status().trim().toUpperCase() : null)
|
||||||
.ge(query.requestedFrom() != null, LocalLooseboardOrder::getPickupAt, query.requestedFrom() == null ? null : query.requestedFrom().atStartOfDay())
|
.ge(query.requestedFrom() != null, LocalLooseboardOrder::getPickupAt, query.requestedFrom() == null ? null : query.requestedFrom().atStartOfDay())
|
||||||
.lt(query.requestedTo() != null, LocalLooseboardOrder::getPickupAt, query.requestedTo() == null ? null : query.requestedTo().plusDays(1).atStartOfDay())
|
.lt(query.requestedTo() != null, LocalLooseboardOrder::getPickupAt, query.requestedTo() == null ? null : query.requestedTo().plusDays(1).atStartOfDay())
|
||||||
.orderByDesc(LocalLooseboardOrder::getCreatedAt).orderByDesc(LocalLooseboardOrder::getId);
|
.orderByDesc(LocalLooseboardOrder::getCreatedAt).orderByDesc(LocalLooseboardOrder::getId);
|
||||||
return orders.selectPage(Page.of(query.page(), query.size()), wrapper);
|
if (merchant && StringUtils.hasText(query.status())) {
|
||||||
|
wrapper.apply(LocalLooseboardOrderMapper.MERCHANT_STATUS + "={0}", query.status().trim().toUpperCase(java.util.Locale.ROOT));
|
||||||
|
}
|
||||||
|
return wrapper;
|
||||||
}
|
}
|
||||||
/** 规范查询文字,缺失值不参与筛选。 */
|
/** 规范查询文字,缺失值不参与筛选。 */
|
||||||
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
|
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
|
||||||
|
|||||||
+88
-19
@@ -51,24 +51,40 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
|
|||||||
@Resource private com.baiyue.lastleg.looseboard.port.LooseboardDispatches dispatches;
|
@Resource private com.baiyue.lastleg.looseboard.port.LooseboardDispatches dispatches;
|
||||||
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries fleetPool;
|
@Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries fleetPool;
|
||||||
public static final String PENDING_ACCEPTANCE = "PENDING_ACCEPTANCE";
|
public static final String PENDING_ACCEPTANCE = "PENDING_ACCEPTANCE";
|
||||||
|
public static final String FUNDING_PENDING = "FUNDING_PENDING";
|
||||||
|
public static final String FUNDING_FAILED = "FUNDING_FAILED";
|
||||||
public static final String DISPATCHED = "DISPATCHED";
|
public static final String DISPATCHED = "DISPATCHED";
|
||||||
public static final String IN_TRANSIT = "IN_TRANSIT";
|
public static final String IN_TRANSIT = "IN_TRANSIT";
|
||||||
public static final String COMPLETED = "COMPLETED";
|
public static final String COMPLETED = "COMPLETED";
|
||||||
public static final String CANCELLED = "CANCELLED";
|
public static final String CANCELLED = "CANCELLED";
|
||||||
private static final Set<String> STATUSES = Set.of(PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED, "WAITING_INBOUND", "RECEIVING", "WAITING_CONSOLIDATION", "PENDING_DISPATCH");
|
private static final Set<String> STATUSES = Set.of(FUNDING_PENDING, FUNDING_FAILED, PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED, "WAITING_INBOUND", "RECEIVING", "WAITING_CONSOLIDATION", "PENDING_DISPATCH");
|
||||||
|
private static final Set<String> MERCHANT_STATUSES = Set.of(FUNDING_PENDING, FUNDING_FAILED, PENDING_ACCEPTANCE,
|
||||||
|
"PENDING_DISPATCH", DISPATCHED, "PICKING_UP", IN_TRANSIT, COMPLETED, CANCELLED);
|
||||||
private static final Map<String, Set<String>> TRANSITIONS = Map.of(
|
private static final Map<String, Set<String>> TRANSITIONS = Map.of(
|
||||||
PENDING_ACCEPTANCE, Set.of(DISPATCHED, CANCELLED),
|
PENDING_ACCEPTANCE, Set.of(DISPATCHED, CANCELLED),
|
||||||
|
FUNDING_PENDING, Set.of(CANCELLED),
|
||||||
|
FUNDING_FAILED, Set.of(CANCELLED),
|
||||||
DISPATCHED, Set.of(IN_TRANSIT, CANCELLED),
|
DISPATCHED, Set.of(IN_TRANSIT, CANCELLED),
|
||||||
IN_TRANSIT, Set.of(COMPLETED));
|
IN_TRANSIT, Set.of(COMPLETED));
|
||||||
|
|
||||||
@Resource private LocalLooseboardRepository repository;
|
@Resource private LocalLooseboardRepository repository;
|
||||||
@Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator;
|
@Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator;
|
||||||
@Resource private ObjectMapper objectMapper;
|
@Resource private ObjectMapper objectMapper;
|
||||||
|
@Resource private com.baiyue.lastleg.wallet.budget.OrderBudgetService orderBudget;
|
||||||
|
|
||||||
/** 在同一事务保存客户需求及创建历史,等待需求池匹配后登记真实来货,创建键重复时核对请求哈希。 */
|
/** 平台代客和客户自助下单共用预算冻结,冻结成功后才进入待审核。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public LocalLooseboardResponses.Detail create(String customerId, String operatorId,
|
public LocalLooseboardResponses.Detail create(String customerId, String operatorId,
|
||||||
LocalLooseboardRequests.Create request) {
|
LocalLooseboardRequests.Create request) {
|
||||||
|
var result = orderBudget.submit(customerId, "looseboard", request.idempotencyKey().trim(), request.budgetAmount(),
|
||||||
|
StringUtils.hasText(request.currency()) ? norm(request.currency()) : "USD",
|
||||||
|
() -> createPending(customerId, operatorId, request), value -> value.summary().orderNo());
|
||||||
|
return detail(customerId, result.summary().orderNo());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 业务单与冻结指令同事务保存,任何创建失败均不留下独立预占。 */
|
||||||
|
private LocalLooseboardResponses.Detail createPending(String customerId, String operatorId,
|
||||||
|
LocalLooseboardRequests.Create request) {
|
||||||
String key = request.idempotencyKey().trim();
|
String key = request.idempotencyKey().trim();
|
||||||
String requestHash = hash(request);
|
String requestHash = hash(request);
|
||||||
LocalLooseboardOrder replay = repository.findKey(customerId, key);
|
LocalLooseboardOrder replay = repository.findKey(customerId, key);
|
||||||
@@ -82,7 +98,7 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
|
|||||||
request.originAddress(), request.destination(), request.destinationAddress(), request.productName(), request.estimatedPalletCount(),
|
request.originAddress(), request.destination(), request.destinationAddress(), request.productName(), request.estimatedPalletCount(),
|
||||||
request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(),
|
request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(),
|
||||||
request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.pickupAt(), request.deliveryAt(), request.remark());
|
request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.pickupAt(), request.deliveryAt(), request.remark());
|
||||||
order.setStatus(PENDING_ACCEPTANCE);
|
order.setStatus(FUNDING_PENDING);
|
||||||
order.setIdempotencyKey(key);
|
order.setIdempotencyKey(key);
|
||||||
order.setRequestHash(requestHash);
|
order.setRequestHash(requestHash);
|
||||||
order.setCreatedBy(operatorId);
|
order.setCreatedBy(operatorId);
|
||||||
@@ -97,7 +113,7 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
|
|||||||
if (concurrent != null) return replay(customerId, concurrent, requestHash);
|
if (concurrent != null) return replay(customerId, concurrent, requestHash);
|
||||||
throw exception;
|
throw exception;
|
||||||
}
|
}
|
||||||
history(order, "CREATE", null, PENDING_ACCEPTANCE, operatorId,
|
history(order, "CREATE", null, FUNDING_PENDING, operatorId,
|
||||||
key, null, request.remark(), now);
|
key, null, request.remark(), now);
|
||||||
|
|
||||||
return detail(customerId, order.getOrderNo());
|
return detail(customerId, order.getOrderNo());
|
||||||
@@ -113,13 +129,28 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
|
|||||||
result.getSize(), result.getTotal(), result.getPages());
|
result.getSize(), result.getTotal(), result.getPages());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 客户查询强制使用认证客户范围,沿同一需求筛选合同分页。 */
|
/** 客户查询使用认证范围,筛选、分页及显示统一按真实运输进度。 */
|
||||||
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
|
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
|
||||||
public PageResult<LocalLooseboardResponses.Summary> page(String customerId,
|
public PageResult<LocalLooseboardResponses.Summary> page(String customerId,
|
||||||
LocalLooseboardRequests.MerchantQuery query) {
|
LocalLooseboardRequests.MerchantQuery query) {
|
||||||
return page(new LocalLooseboardRequests.Query(query.page(), query.size(), customerId,
|
var scoped = new LocalLooseboardRequests.Query(query.page(), query.size(), customerId,
|
||||||
query.orderNo(), query.routeKeyword(), query.status(),
|
query.orderNo(), query.routeKeyword(), query.status(), query.requestedFrom(), query.requestedTo());
|
||||||
query.requestedFrom(), query.requestedTo()));
|
validateQuery(scoped, MERCHANT_STATUSES);
|
||||||
|
var result = repository.merchantPage(scoped);
|
||||||
|
var statuses = repository.merchantStatuses(customerId, result.getRecords().stream().map(LocalLooseboardOrder::getId).toList());
|
||||||
|
var arrangements = fleetPool.arrangements(result.getRecords().stream().map(this::poolKey).toList());
|
||||||
|
return new PageResult<>(result.getRecords().stream()
|
||||||
|
.map(o -> summary(o, arrangements.get(poolKey(o)), Objects.requireNonNull(statuses.get(o.getId())))).toList(),
|
||||||
|
result.getCurrent(), result.getSize(), result.getTotal(), result.getPages());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 客户详情与列表共用运输状态投影,不改写作业用原始状态。 */
|
||||||
|
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
|
||||||
|
public LocalLooseboardResponses.Detail merchantDetail(String customerId, String orderNo) {
|
||||||
|
var order = repository.findNo(customerId, orderNo);
|
||||||
|
if (order == null) throw notFound();
|
||||||
|
var status = repository.merchantStatuses(customerId, java.util.List.of(order.getId())).get(order.getId());
|
||||||
|
return response(order, Objects.requireNonNull(status));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按客户与业务单号读取需求及历史。 */
|
/** 按客户与业务单号读取需求及历史。 */
|
||||||
@@ -134,6 +165,14 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
|
|||||||
@Transactional
|
@Transactional
|
||||||
public LocalLooseboardResponses.Detail update(String customerId, String orderNo, String operatorId,
|
public LocalLooseboardResponses.Detail update(String customerId, String orderNo, String operatorId,
|
||||||
LocalLooseboardRequests.Update request) {
|
LocalLooseboardRequests.Update request) {
|
||||||
|
return orderBudget.update(customerId, "looseboard", orderNo, request.budgetAmount(),
|
||||||
|
StringUtils.hasText(request.currency()) ? norm(request.currency()) : "USD",
|
||||||
|
() -> updateLocked(customerId, orderNo, operatorId, request));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已冻结预算不允许通过普通编辑改价,业务字段仍按订单版本校验。 */
|
||||||
|
private LocalLooseboardResponses.Detail updateLocked(String customerId, String orderNo, String operatorId,
|
||||||
|
LocalLooseboardRequests.Update request) {
|
||||||
LocalLooseboardOrder order = lock(customerId, orderNo);
|
LocalLooseboardOrder order = lock(customerId, orderNo);
|
||||||
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
|
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
|
||||||
if (!PENDING_ACCEPTANCE.equals(order.getStatus())) {
|
if (!PENDING_ACCEPTANCE.equals(order.getStatus())) {
|
||||||
@@ -158,6 +197,13 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
|
|||||||
@Transactional
|
@Transactional
|
||||||
public LocalLooseboardResponses.Detail changeStatus(String customerId, String orderNo, String operatorId,
|
public LocalLooseboardResponses.Detail changeStatus(String customerId, String orderNo, String operatorId,
|
||||||
LocalLooseboardRequests.ChangeStatus request) {
|
LocalLooseboardRequests.ChangeStatus request) {
|
||||||
|
return orderBudget.withOrder(customerId, "looseboard", orderNo,
|
||||||
|
() -> changeStatusLocked(customerId, orderNo, operatorId, request));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消与释放意图同事务,冻结在途回执到达后也会自动释放。 */
|
||||||
|
private LocalLooseboardResponses.Detail changeStatusLocked(String customerId, String orderNo, String operatorId,
|
||||||
|
LocalLooseboardRequests.ChangeStatus request) {
|
||||||
LocalLooseboardOrder order = lock(customerId, orderNo);
|
LocalLooseboardOrder order = lock(customerId, orderNo);
|
||||||
String key = request.idempotencyKey().trim();
|
String key = request.idempotencyKey().trim();
|
||||||
String requestHash = hash(request);
|
String requestHash = hash(request);
|
||||||
@@ -182,6 +228,7 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
|
|||||||
touch(order, now);
|
touch(order, now);
|
||||||
repository.updateOrder(order);
|
repository.updateOrder(order);
|
||||||
syncLastlegOrder(order, next, now);
|
syncLastlegOrder(order, next, now);
|
||||||
|
if (CANCELLED.equals(next)) orderBudget.releaseAfterReview(customerId, "looseboard", orderNo);
|
||||||
LocalLooseboardOperation operation = new LocalLooseboardOperation();
|
LocalLooseboardOperation operation = new LocalLooseboardOperation();
|
||||||
operation.setId(IdWorker.getId()); operation.setOrderId(order.getId());
|
operation.setId(IdWorker.getId()); operation.setOrderId(order.getId());
|
||||||
operation.setCustomerId(customerId); operation.setOperationType("STATUS");
|
operation.setCustomerId(customerId); operation.setOperationType("STATUS");
|
||||||
@@ -196,6 +243,13 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
|
|||||||
@Transactional
|
@Transactional
|
||||||
public LocalLooseboardResponses.Detail review(String customerId, String orderNo, String operatorId,
|
public LocalLooseboardResponses.Detail review(String customerId, String orderNo, String operatorId,
|
||||||
LocalLooseboardRequests.Review request) {
|
LocalLooseboardRequests.Review request) {
|
||||||
|
return orderBudget.withOrder(customerId, "looseboard", orderNo,
|
||||||
|
() -> reviewLocked(customerId, orderNo, operatorId, request));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 冻结与审核串行核对,通过后只释放预算,实际费用继续走原费用链。 */
|
||||||
|
private LocalLooseboardResponses.Detail reviewLocked(String customerId, String orderNo, String operatorId,
|
||||||
|
LocalLooseboardRequests.Review request) {
|
||||||
LocalLooseboardOrder order = lock(customerId, orderNo);
|
LocalLooseboardOrder order = lock(customerId, orderNo);
|
||||||
String requestHash = hash(request);
|
String requestHash = hash(request);
|
||||||
var previous = repository.findOperation(customerId, order.getId(), request.idempotencyKey());
|
var previous = repository.findOperation(customerId, order.getId(), request.idempotencyKey());
|
||||||
@@ -205,16 +259,19 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
|
|||||||
}
|
}
|
||||||
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
|
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
|
||||||
String decision = request.decision().trim().toUpperCase();
|
String decision = request.decision().trim().toUpperCase();
|
||||||
if (!PENDING_ACCEPTANCE.equals(order.getStatus())) throw conflict("散板订单已审核,请刷新后重试");
|
if (!PENDING_ACCEPTANCE.equals(order.getStatus())) throw conflict("散板订单须资金冻结成功且待审核,请刷新后重试");
|
||||||
if ("REJECT".equals(decision)) {
|
if ("REJECT".equals(decision)) {
|
||||||
if (!StringUtils.hasText(request.remark())) throw badRequest("驳回须填写原因");
|
if (!StringUtils.hasText(request.remark())) throw badRequest("驳回须填写原因");
|
||||||
return changeStatus(customerId, orderNo, operatorId, new LocalLooseboardRequests.ChangeStatus(order.getVersion(), "CANCELLED", request.idempotencyKey(), request.remark()));
|
order.setStatus(CANCELLED);
|
||||||
}
|
} else {
|
||||||
if (!"APPROVE".equals(decision)) throw badRequest("审核结果无效");
|
if (!"APPROVE".equals(decision)) throw badRequest("审核结果无效");
|
||||||
if (order.getOperatingWarehouseId()==null || order.getDeliveryMode()==null) throw conflict("旧散板订单缺少收货安排,请先编辑补齐");
|
if (!orderBudget.readOrder(customerId, "looseboard", orderNo).usable()) throw conflict("散板订单资金尚未冻结成功,暂不能审核");
|
||||||
|
if (order.getOperatingWarehouseId()==null || order.getDeliveryMode()==null) throw conflict("散板订单缺少收货安排,请先编辑补齐");
|
||||||
var created = receiving.approve(order, operatorId);
|
var created = receiving.approve(order, operatorId);
|
||||||
order.setTransferNo(created.transferNo()); order.setLastlegOrderId(created.lastlegOrderId()); order.setStatus("WAITING_INBOUND");
|
order.setTransferNo(created.transferNo()); order.setLastlegOrderId(created.lastlegOrderId()); order.setStatus("WAITING_INBOUND");
|
||||||
|
}
|
||||||
touch(order, LocalDateTime.now()); repository.updateOrder(order);
|
touch(order, LocalDateTime.now()); repository.updateOrder(order);
|
||||||
|
orderBudget.releaseAfterReview(customerId, "looseboard", orderNo);
|
||||||
history(order, "REVIEW", PENDING_ACCEPTANCE, order.getStatus(), operatorId, request.idempotencyKey(), null, request.remark(), LocalDateTime.now());
|
history(order, "REVIEW", PENDING_ACCEPTANCE, order.getStatus(), operatorId, request.idempotencyKey(), null, request.remark(), LocalDateTime.now());
|
||||||
var operation=new LocalLooseboardOperation(); operation.setId(IdWorker.getId()); operation.setOrderId(order.getId()); operation.setCustomerId(customerId);
|
var operation=new LocalLooseboardOperation(); operation.setId(IdWorker.getId()); operation.setOrderId(order.getId()); operation.setCustomerId(customerId);
|
||||||
operation.setOperationType("REVIEW"); operation.setIdempotencyKey(request.idempotencyKey()); operation.setRequestHash(requestHash); operation.setResultVersion(order.getVersion()); operation.setCreatedBy(operatorId); operation.setCreatedAt(LocalDateTime.now()); repository.insertOperation(operation);
|
operation.setOperationType("REVIEW"); operation.setIdempotencyKey(request.idempotencyKey()); operation.setRequestHash(requestHash); operation.setResultVersion(order.getVersion()); operation.setCreatedBy(operatorId); operation.setCreatedAt(LocalDateTime.now()); repository.insertOperation(operation);
|
||||||
@@ -281,7 +338,12 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
|
|||||||
|
|
||||||
/** 装配需求详情、真实承运安排、全部有效排车与不可变历史。 */
|
/** 装配需求详情、真实承运安排、全部有效排车与不可变历史。 */
|
||||||
private LocalLooseboardResponses.Detail response(LocalLooseboardOrder order) {
|
private LocalLooseboardResponses.Detail response(LocalLooseboardOrder order) {
|
||||||
return new LocalLooseboardResponses.Detail(summary(order), order.getOriginAddress(),
|
return response(order, order.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 展示状态仅用于响应,原单及历史保持真实作业状态。 */
|
||||||
|
private LocalLooseboardResponses.Detail response(LocalLooseboardOrder order, String status) {
|
||||||
|
return new LocalLooseboardResponses.Detail(summary(order, fleetPool.arrangement(poolKey(order)), status), order.getOriginAddress(),
|
||||||
order.getDestinationAddress(), order.getRemark(), order.getCreatedBy(),
|
order.getDestinationAddress(), order.getRemark(), order.getCreatedBy(),
|
||||||
repository.histories(order.getCustomerId(), order.getId()).stream()
|
repository.histories(order.getCustomerId(), order.getId()).stream()
|
||||||
.map(value -> new LocalLooseboardResponses.History(value.getOperationType(), value.getFromStatus(),
|
.map(value -> new LocalLooseboardResponses.History(value.getOperationType(), value.getFromStatus(),
|
||||||
@@ -295,16 +357,18 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
|
|||||||
return new com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey(order.getCustomerId(), "LOCAL_LOOSEBOARD", order.getId());
|
return new com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey(order.getCustomerId(), "LOCAL_LOOSEBOARD", order.getId());
|
||||||
}
|
}
|
||||||
/** 装配原值需求摘要,预计托数与实际履约保持独立。 */
|
/** 装配原值需求摘要,预计托数与实际履约保持独立。 */
|
||||||
private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order) {
|
|
||||||
return summary(order, fleetPool.arrangement(poolKey(order)));
|
|
||||||
}
|
|
||||||
/** 装配原值需求摘要,预计托数与实际履约保持独立。 */
|
|
||||||
private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement) {
|
private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement) {
|
||||||
|
return summary(order, arrangement, order.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按调用方所需口径装配状态,其余原单与承运事实保持一致。 */
|
||||||
|
private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order,
|
||||||
|
com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement, String status) {
|
||||||
return new LocalLooseboardResponses.Summary(order.getId(), order.getCustomerId(), order.getOrderNo(),
|
return new LocalLooseboardResponses.Summary(order.getId(), order.getCustomerId(), order.getOrderNo(),
|
||||||
order.getPalletRequired(), order.getDeliveryMode(), order.getOperatingWarehouseId(), order.getDestinationType(), order.getDestinationPostalCode(), order.getTransferNo(), order.getDeliveryContactName(), order.getDeliveryContactPhone(), order.getEstimatedArrivalAt(), order.getTrackingNo(), order.getTransportType(), order.getOrigin(),
|
order.getPalletRequired(), order.getDeliveryMode(), order.getOperatingWarehouseId(), order.getDestinationType(), order.getDestinationPostalCode(), order.getTransferNo(), order.getDeliveryContactName(), order.getDeliveryContactPhone(), order.getEstimatedArrivalAt(), order.getTrackingNo(), order.getTransportType(), order.getOrigin(),
|
||||||
order.getDestination(), order.getProductName(), order.getEstimatedPalletCount(), Objects.requireNonNullElse(order.getPackageCount(), 0),
|
order.getDestination(), order.getProductName(), order.getEstimatedPalletCount(), Objects.requireNonNullElse(order.getPackageCount(), 0),
|
||||||
order.getTotalWeightKg(), order.getTotalVolumeCbm(), order.getBudgetAmount(), order.getCurrency(),
|
order.getTotalWeightKg(), order.getTotalVolumeCbm(), order.getBudgetAmount(), order.getCurrency(),
|
||||||
order.getOriginContactName(), order.getOriginContactPhone(), order.getDestinationContactName(), order.getDestinationContactPhone(), order.getPickupAt(), order.getDeliveryAt(), order.getStatus(),
|
order.getOriginContactName(), order.getOriginContactPhone(), order.getDestinationContactName(), order.getDestinationContactPhone(), order.getPickupAt(), order.getDeliveryAt(), status,
|
||||||
value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt(), arrangement);
|
value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt(), arrangement);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,7 +447,12 @@ public class LocalLooseboardService implements com.baiyue.lastleg.dispatch.port.
|
|||||||
|
|
||||||
/** 校验状态与取货日期筛选边界。 */
|
/** 校验状态与取货日期筛选边界。 */
|
||||||
private void validateQuery(LocalLooseboardRequests.Query query) {
|
private void validateQuery(LocalLooseboardRequests.Query query) {
|
||||||
if (StringUtils.hasText(query.status()) && !STATUSES.contains(norm(query.status()))) {
|
validateQuery(query, STATUSES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分别限制客户可见运输状态和平台内部作业状态,并校验时间范围。 */
|
||||||
|
private void validateQuery(LocalLooseboardRequests.Query query, Set<String> statuses) {
|
||||||
|
if (StringUtils.hasText(query.status()) && !statuses.contains(norm(query.status()))) {
|
||||||
throw badRequest("Unsupported looseboard order status");
|
throw badRequest("Unsupported looseboard order status");
|
||||||
}
|
}
|
||||||
if (query.requestedFrom() != null && query.requestedTo() != null
|
if (query.requestedFrom() != null && query.requestedTo() != null
|
||||||
|
|||||||
+19
@@ -12,6 +12,10 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
|
||||||
|
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory;
|
||||||
|
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper;
|
||||||
|
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper;
|
||||||
|
|
||||||
/** 资金门禁只访问本服务采购、卡车原单及其状态历史。 */
|
/** 资金门禁只访问本服务采购、卡车原单及其状态历史。 */
|
||||||
@Repository
|
@Repository
|
||||||
@@ -21,6 +25,21 @@ public class OrderFundingRepository {
|
|||||||
private final OverseasProcurementStatusHistoryMapper purchaseHistory;
|
private final OverseasProcurementStatusHistoryMapper purchaseHistory;
|
||||||
private final UsTruckOrderMapper trucks;
|
private final UsTruckOrderMapper trucks;
|
||||||
private final UsTruckOrderStatusHistoryMapper truckHistory;
|
private final UsTruckOrderStatusHistoryMapper truckHistory;
|
||||||
|
private final LocalLooseboardOrderMapper looseboards;
|
||||||
|
private final LocalLooseboardHistoryMapper looseboardHistory;
|
||||||
|
|
||||||
|
/** 预算锁后按认证客户锁定散板原单。 */
|
||||||
|
public LocalLooseboardOrder looseboard(String customer, String number) { return looseboards.lockNo(customer, number); }
|
||||||
|
/** 保存散板资金状态,不改客户填写的业务资料。 */
|
||||||
|
public void save(LocalLooseboardOrder order) { looseboards.updateById(order); }
|
||||||
|
/** 追加散板冻结结果和重试的不可变历史。 */
|
||||||
|
public void history(LocalLooseboardOrder order, String from, String target, String reason, LocalDateTime now) {
|
||||||
|
var history = new LocalLooseboardHistory();
|
||||||
|
history.setId(IdWorker.getId()); history.setOrderId(order.getId()); history.setCustomerId(order.getCustomerId());
|
||||||
|
history.setOperationType("FUNDING"); history.setFromStatus(from); history.setToStatus(target);
|
||||||
|
history.setOperatorId("SYSTEM"); history.setRemark(reason); history.setCreatedAt(now);
|
||||||
|
looseboardHistory.insert(history);
|
||||||
|
}
|
||||||
|
|
||||||
/** 预算锁后按客户锁定采购原单,统一先预算后订单的加锁顺序。 */
|
/** 预算锁后按客户锁定采购原单,统一先预算后订单的加锁顺序。 */
|
||||||
public OverseasProcurementOrder purchase(String customer, String number) { return purchases.lockNo(customer, number); }
|
public OverseasProcurementOrder purchase(String customer, String number) { return purchases.lockNo(customer, number); }
|
||||||
|
|||||||
+10
-1
@@ -8,7 +8,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/** 两种订单复用资金状态门禁,不反向依赖订单编排服务。 */
|
/** 采购、卡车和散板复用资金状态门禁,不反向依赖订单编排服务。 */
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class OrderFundingService implements OrderFundingPort {
|
public class OrderFundingService implements OrderFundingPort {
|
||||||
@@ -36,6 +36,15 @@ public class OrderFundingService implements OrderFundingPort {
|
|||||||
order.setStatus(target); order.setVersion(order.getVersion() + 1); order.setUpdatedAt(now);
|
order.setStatus(target); order.setVersion(order.getVersion() + 1); order.setUpdatedAt(now);
|
||||||
repository.save(order);
|
repository.save(order);
|
||||||
repository.history(order, from, target, reason, now);
|
repository.history(order, from, target, reason, now);
|
||||||
|
} else if ("looseboard".equals(budget.getKind())) {
|
||||||
|
var order = repository.looseboard(budget.getCustomerId(), budget.getOrderNo());
|
||||||
|
if (order == null) throw new IllegalStateException("资金关联的散板订单不存在");
|
||||||
|
String from = order.getStatus();
|
||||||
|
if (!changeable(from, target)) return;
|
||||||
|
String next = "PENDING_REVIEW".equals(target) ? "PENDING_ACCEPTANCE" : target;
|
||||||
|
order.setStatus(next); order.setVersion(order.getVersion() + 1); order.setUpdatedAt(now);
|
||||||
|
repository.save(order);
|
||||||
|
repository.history(order, from, next, reason, now);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -21,26 +21,26 @@ public class MerchantOrderBudgetController {
|
|||||||
@NotBlank @Pattern(regexp="CNY|USD") String currency) { }
|
@NotBlank @Pattern(regexp="CNY|USD") String currency) { }
|
||||||
/** 按业务创建权限发起预估冻结。 */
|
/** 按业务创建权限发起预估冻结。 */
|
||||||
@PostMapping("/{kind}/{key}")
|
@PostMapping("/{kind}/{key}")
|
||||||
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create'))")
|
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create')) or (#kind == 'looseboard' and hasAuthority('merchant:cross-border:pallet-shipping:create'))")
|
||||||
public ApiResponse<OrderBudgetService.View> prepare(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String kind,@PathVariable @Size(max=128) String key,@Valid @RequestBody Prepare request) {
|
public ApiResponse<OrderBudgetService.View> prepare(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String kind,@PathVariable @Size(max=128) String key,@Valid @RequestBody Prepare request) {
|
||||||
return ApiResponse.success(service.prepare(customer(user),kind,key,request.amount(),request.currency()));
|
return ApiResponse.success(service.prepare(customer(user),kind,key,request.amount(),request.currency()));
|
||||||
}
|
}
|
||||||
/** 只读资金回执沿同一业务权限。 */
|
/** 只读资金回执沿同一业务权限。 */
|
||||||
@GetMapping("/{kind}/{key}")
|
@GetMapping("/{kind}/{key}")
|
||||||
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create'))")
|
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create')) or (#kind == 'looseboard' and hasAuthority('merchant:cross-border:pallet-shipping:create'))")
|
||||||
public ApiResponse<OrderBudgetService.View> read(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String kind,@PathVariable String key) {
|
public ApiResponse<OrderBudgetService.View> read(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String kind,@PathVariable String key) {
|
||||||
return ApiResponse.success(service.read(customer(user),kind,key));
|
return ApiResponse.success(service.read(customer(user),kind,key));
|
||||||
}
|
}
|
||||||
/** 列表及详情可核对当前客户订单资金状态和失败原因。 */
|
/** 列表及详情可核对当前客户订单资金状态和失败原因。 */
|
||||||
@GetMapping("/{kind}/orders/{orderNo}")
|
@GetMapping("/{kind}/orders/{orderNo}")
|
||||||
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:list')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:list'))")
|
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:list')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:list')) or (#kind == 'looseboard' and hasAuthority('merchant:cross-border:pallet-shipping:list'))")
|
||||||
public ApiResponse<OrderBudgetService.View> readOrder(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<OrderBudgetService.View> readOrder(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable String kind, @PathVariable String orderNo) {
|
@PathVariable String kind, @PathVariable String orderNo) {
|
||||||
return ApiResponse.success(service.readOrder(customer(user), kind, orderNo));
|
return ApiResponse.success(service.readOrder(customer(user), kind, orderNo));
|
||||||
}
|
}
|
||||||
/** 失败订单按原预算重试;客户身份只取当前会话。 */
|
/** 失败订单按原预算重试;客户身份只取当前会话。 */
|
||||||
@PostMapping("/{kind}/orders/{orderNo}/retry")
|
@PostMapping("/{kind}/orders/{orderNo}/retry")
|
||||||
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create'))")
|
@PreAuthorize("(#kind == 'procurement' and hasAuthority('merchant:cross-border:procurement:create')) or (#kind == 'trucking' and hasAuthority('merchant:cross-border:domestic-trucking:create')) or (#kind == 'looseboard' and hasAuthority('merchant:cross-border:pallet-shipping:create'))")
|
||||||
public ApiResponse<OrderBudgetService.View> retry(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<OrderBudgetService.View> retry(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable String kind, @PathVariable String orderNo) {
|
@PathVariable String kind, @PathVariable String orderNo) {
|
||||||
return ApiResponse.success(service.retryOrder(customer(user), kind, orderNo));
|
return ApiResponse.success(service.retryOrder(customer(user), kind, orderNo));
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public interface OrderBudgetMapper extends BaseMapper<OrderBudget> {
|
|||||||
(b.order_no IS NULL AND b.created_at < #{expiredBefore})
|
(b.order_no IS NULL AND b.created_at < #{expiredBefore})
|
||||||
OR (b.kind='procurement' AND EXISTS (SELECT 1 FROM ll_overseas_procurement_order o WHERE o.customer_id=b.customer_id AND o.order_no=b.order_no AND o.status IN ('REJECTED','CANCELLED','COMPLETED','EXCEPTION_CLOSED')))
|
OR (b.kind='procurement' AND EXISTS (SELECT 1 FROM ll_overseas_procurement_order o WHERE o.customer_id=b.customer_id AND o.order_no=b.order_no AND o.status IN ('REJECTED','CANCELLED','COMPLETED','EXCEPTION_CLOSED')))
|
||||||
OR (b.kind='trucking' AND EXISTS (SELECT 1 FROM ll_us_truck_order o WHERE o.customer_id=b.customer_id AND o.order_no=b.order_no AND o.status IN ('REJECTED','CANCELLED','COMPLETED')))
|
OR (b.kind='trucking' AND EXISTS (SELECT 1 FROM ll_us_truck_order o WHERE o.customer_id=b.customer_id AND o.order_no=b.order_no AND o.status IN ('REJECTED','CANCELLED','COMPLETED')))
|
||||||
|
OR (b.kind='looseboard' AND EXISTS (SELECT 1 FROM ll_local_looseboard_order o WHERE o.customer_id=b.customer_id AND o.order_no=b.order_no AND o.status IN ('WAITING_INBOUND','RECEIVING','WAITING_CONSOLIDATION','PENDING_DISPATCH','DISPATCHED','IN_TRANSIT','CANCELLED','COMPLETED')))
|
||||||
)) ORDER BY b.id LIMIT 100 FOR UPDATE SKIP LOCKED
|
)) ORDER BY b.id LIMIT 100 FOR UPDATE SKIP LOCKED
|
||||||
""")
|
""")
|
||||||
List<OrderBudget> releasable(@Param("expiredBefore") LocalDateTime expiredBefore,
|
List<OrderBudget> releasable(@Param("expiredBefore") LocalDateTime expiredBefore,
|
||||||
|
|||||||
+10
-2
@@ -115,7 +115,15 @@ public class OrderBudgetService implements WalletResultHandler {
|
|||||||
return change.get();
|
return change.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 审核通过后立即释放采购或卡车预估冻结,后续实际费用按正常余额/授信扣除。 */
|
/** 审核及取消先锁预算再锁业务单,与异步资金回执保持相同锁序。 */
|
||||||
|
@Transactional
|
||||||
|
public <T> T withOrder(String customer,String kind,String no,Supplier<T> change) {
|
||||||
|
requireKind(kind);
|
||||||
|
repository.lockOrder(customer,kind,no);
|
||||||
|
return change.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审核通过后立即释放订单预估冻结,后续实际费用按正常余额/授信扣除。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public void releaseAfterReview(String customer, String kind, String orderNo) {
|
public void releaseAfterReview(String customer, String kind, String orderNo) {
|
||||||
requireKind(kind);
|
requireKind(kind);
|
||||||
@@ -194,7 +202,7 @@ public class OrderBudgetService implements WalletResultHandler {
|
|||||||
};return new View(row.getStatus(),row.getAmountCny(),message,row.getOrderNo(),"FROZEN".equals(row.getStatus()) && !Boolean.TRUE.equals(row.getReleaseRequested()) && (row.getOrderNo()!=null || !row.getCreatedAt().isBefore(now().minusMinutes(15))));
|
};return new View(row.getStatus(),row.getAmountCny(),message,row.getOrderNo(),"FROZEN".equals(row.getStatus()) && !Boolean.TRUE.equals(row.getReleaseRequested()) && (row.getOrderNo()!=null || !row.getCreatedAt().isBefore(now().minusMinutes(15))));
|
||||||
}
|
}
|
||||||
/** 仅开放本轮客户下单业务。 */
|
/** 仅开放本轮客户下单业务。 */
|
||||||
private void requireKind(String kind) { if(!Set.of("procurement","trucking").contains(kind)) throw bad("不支持的预算业务"); }
|
private void requireKind(String kind) { if(!Set.of("procurement","trucking","looseboard").contains(kind)) throw bad("不支持的预算业务"); }
|
||||||
/** 时间按UTC保存以匹配资金事件。 */
|
/** 时间按UTC保存以匹配资金事件。 */
|
||||||
private LocalDateTime now() { return LocalDateTime.now(ZoneOffset.UTC); }
|
private LocalDateTime now() { return LocalDateTime.now(ZoneOffset.UTC); }
|
||||||
/** 返回可恢复的业务校验失败。 */
|
/** 返回可恢复的业务校验失败。 */
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"/api/platform/v1/last-leg/fleet/operations": {
|
"/api/platform/v1/last-leg/fleet/operations": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "platform:fleet:operations:list;一次查询返回分页与全范围汇总",
|
"description": "platform:fleet:operations:list;一次查询返回分页与全范围汇总。纯散板照片直送按LOOSEBOARD_DELIVERING不可变历史的实际发运箱数统计,要求已有实际发车时间且未发生仓内拣货或装车;实托和标准托位保持null,不以申报托数补齐。普通仓内出车仍要求DEPART实载快照。",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "page",
|
"name": "page",
|
||||||
|
|||||||
@@ -2,17 +2,17 @@ openapi: 3.0.3
|
|||||||
info:
|
info:
|
||||||
title: 海外客户下单预估资金校验
|
title: 海外客户下单预估资金校验
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
description: 客户订单先原子落库并进入资金处理中,资金冻结通过Outbox异步执行。冻结成功后进入待审核,失败进入资金失败并可在原订单上重试;平台仅可审核待审核订单。余额优先、授信补足及不足判断由Finance原子执行;本服务不自行修改钱包。预算预占、订单和冻结指令在同一事务内写入,订单终态释放。
|
description: 客户订单先原子落库并进入资金处理中,资金冻结通过Outbox异步执行。冻结成功后进入待审核,失败进入资金失败并可在原订单上重试;平台仅可审核待审核订单。余额优先、授信补足及不足判断由Finance原子执行;本服务不自行修改钱包。预算预占、订单和冻结指令在同一事务内写入,审核通过立即申请释放原预算,取消、驳回或终态同样释放;释放失败自动重试。散板冻结成功进入PENDING_ACCEPTANCE,失败进入FUNDING_FAILED。
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
paths:
|
paths:
|
||||||
/api/merchant/v1/last-leg/order-budgets/{kind}/{key}:
|
/api/merchant/v1/last-leg/order-budgets/{kind}/{key}:
|
||||||
parameters:
|
parameters:
|
||||||
- {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking]}}
|
- {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking, looseboard]}}
|
||||||
- {name: key, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
|
- {name: key, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
|
||||||
post:
|
post:
|
||||||
summary: 兼容接口:发起或读取预算冻结请求
|
summary: 兼容接口:发起或读取预算冻结请求
|
||||||
description: 客户身份仅来自认证。procurement需要merchant:cross-border:procurement:create,trucking需要merchant:cross-border:domestic-trucking:create;权限不能交叉使用。采购金额是预算单价乘采购数量,免费样品零金额不发资金指令;卡车金额必须大于0。重试禁止更改金额或币种。
|
description: 客户身份仅来自认证。procurement需要merchant:cross-border:procurement:create,trucking需要merchant:cross-border:domestic-trucking:create,looseboard需要merchant:cross-border:pallet-shipping:create;权限不能交叉使用。采购金额是预算单价乘采购数量,免费样品零金额不发资金指令;卡车金额必须大于0。重试禁止更改金额或币种。
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -34,16 +34,16 @@ paths:
|
|||||||
'403': {description: 身份或业务创建权限不足}
|
'403': {description: 身份或业务创建权限不足}
|
||||||
/api/merchant/v1/last-leg/order-budgets/{kind}/orders/{orderNo}:
|
/api/merchant/v1/last-leg/order-budgets/{kind}/orders/{orderNo}:
|
||||||
parameters:
|
parameters:
|
||||||
- {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking]}}
|
- {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking, looseboard]}}
|
||||||
- {name: orderNo, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
|
- {name: orderNo, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
|
||||||
get:
|
get:
|
||||||
summary: 读取订单资金状态
|
summary: 读取订单资金状态
|
||||||
description: 仅当前客户可读。FUNDING_PENDING期间客户端可短暂轮询;FUNDING_FAILED展示失败原因并允许重试。只有PENDING_REVIEW允许平台审核。
|
description: 仅当前客户可读。FUNDING_PENDING期间客户端可短暂轮询;FUNDING_FAILED展示失败原因并允许重试。采购和卡车只有PENDING_REVIEW、散板只有PENDING_ACCEPTANCE且资金已冻结才允许平台审核。读取订单资金状态分别要求对应业务list权限,重试要求对应create权限。
|
||||||
responses:
|
responses:
|
||||||
'200': {$ref: '#/components/responses/Result'}
|
'200': {$ref: '#/components/responses/Result'}
|
||||||
/api/merchant/v1/last-leg/order-budgets/{kind}/orders/{orderNo}/retry:
|
/api/merchant/v1/last-leg/order-budgets/{kind}/orders/{orderNo}/retry:
|
||||||
parameters:
|
parameters:
|
||||||
- {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking]}}
|
- {name: kind, in: path, required: true, schema: {type: string, enum: [procurement, trucking, looseboard]}}
|
||||||
- {name: orderNo, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
|
- {name: orderNo, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
|
||||||
post:
|
post:
|
||||||
summary: 重试订单资金冻结
|
summary: 重试订单资金冻结
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ paths:
|
|||||||
/api/platform/v1/last-leg/local-looseboard-orders/{orderNo}/review:
|
/api/platform/v1/last-leg/local-looseboard-orders/{orderNo}/review:
|
||||||
post:
|
post:
|
||||||
summary: 审核散板并创建唯一入仓履约单
|
summary: 审核散板并创建唯一入仓履约单
|
||||||
description: 权限platform:overseas-business:looseboard:status;通过后送货到仓进入PDA收货,仓库上门取货进入取货排车;审核后即可从散板需求添加申报货物;上门取货沿取货排车上传取货与到仓凭证,实收到仓后原单进入PENDING_DISPATCH并交接原排车货量。客户及幂等键严格校验。
|
description: 权限platform:overseas-business:looseboard:status;仅冻结成功可通过审核,通过和驳回均释放原预算预占;通过后送货到仓进入PDA收货,仓库上门取货进入取货排车;审核后即可从散板需求添加申报货物;上门取货沿取货排车上传取货与到仓凭证,实收到仓后原单进入PENDING_DISPATCH并交接原排车货量。客户及幂等键严格校验。
|
||||||
security: [{ bearerAuth: [] }]
|
security: [{ bearerAuth: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: orderNo, in: path, required: true, schema: { type: string } }
|
- { name: orderNo, in: path, required: true, schema: { type: string } }
|
||||||
@@ -2754,20 +2754,20 @@ paths:
|
|||||||
/api/merchant/v1/last-leg/local-looseboard-orders:
|
/api/merchant/v1/last-leg/local-looseboard-orders:
|
||||||
get:
|
get:
|
||||||
summary: 客户分页查询自己的本土散板订单
|
summary: 客户分页查询自己的本土散板订单
|
||||||
description: 权限 merchant:cross-border:pallet-shipping:list;客户范围只取认证 Token,不接收 customerId。
|
description: 权限 merchant:cross-border:pallet-shipping:list;客户范围只取认证 Token,不接收 customerId;状态筛选、分页计数和显示共用真实运输进度。PENDING_ACCEPTANCE 为待审核,审核通过尚未排车为 PENDING_DISPATCH(待排车);已排车待取货为 DISPATCHED;取货到仓车次出发或已取货未到仓为 PICKING_UP(取货中),到仓后等待后续排车;直接取货送货取货完成后为 IN_TRANSIT(派送中)。内部收货与合托状态不作为客户筛选项。
|
||||||
security: [{ bearerAuth: [] }]
|
security: [{ bearerAuth: [] }]
|
||||||
parameters:
|
parameters:
|
||||||
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
|
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
|
||||||
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
|
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
|
||||||
- { name: orderNo, in: query, schema: { type: string } }
|
- { name: orderNo, in: query, schema: { type: string } }
|
||||||
- { name: routeKeyword, in: query, schema: { type: string } }
|
- { name: routeKeyword, in: query, schema: { type: string } }
|
||||||
- { name: status, in: query, schema: { $ref: '#/components/schemas/LooseboardStatus' } }
|
- { name: status, in: query, schema: { $ref: '#/components/schemas/MerchantLooseboardStatus' } }
|
||||||
- { name: requestedFrom, in: query, schema: { type: string, format: date } }
|
- { name: requestedFrom, in: query, schema: { type: string, format: date } }
|
||||||
- { name: requestedTo, in: query, schema: { type: string, format: date } }
|
- { name: requestedTo, in: query, schema: { type: string, format: date } }
|
||||||
responses: { '200': { description: 当前客户本土散板订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardPageResponse' } } } } }
|
responses: { '200': { description: 当前客户本土散板订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardPageResponse' } } } } }
|
||||||
post:
|
post:
|
||||||
summary: 客户创建本土散板订单
|
summary: 客户创建本土散板订单
|
||||||
description: 权限 merchant:cross-border:pallet-shipping:create;新单固定进入 PENDING_ACCEPTANCE,客户不能调用平台状态推进端点。
|
description: 权限 merchant:cross-border:pallet-shipping:create;散板金额必填,预算与订单和冻结指令同事务保存;新单进入 FUNDING_PENDING,Finance按余额优先、授信补足冻结,成功进入 PENDING_ACCEPTANCE,失败进入 FUNDING_FAILED,支持原单重试。审核通过、取消或驳回申请释放预占,不自动扣款;客户不能调用平台状态推进端点。
|
||||||
security: [{ bearerAuth: [] }]
|
security: [{ bearerAuth: [] }]
|
||||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardCreate' } } } }
|
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardCreate' } } } }
|
||||||
responses: { '200': { description: 本土散板订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '409': { description: 幂等冲突 } }
|
responses: { '200': { description: 本土散板订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '409': { description: 幂等冲突 } }
|
||||||
@@ -2775,12 +2775,12 @@ paths:
|
|||||||
parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }]
|
parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }]
|
||||||
get:
|
get:
|
||||||
summary: 客户查询自己的本土散板订单详情
|
summary: 客户查询自己的本土散板订单详情
|
||||||
description: 权限 merchant:cross-border:pallet-shipping:list;不泄露其他客户订单。
|
description: 权限 merchant:cross-border:pallet-shipping:list;不泄露其他客户订单;summary.status 使用 MerchantLooseboardStatus,与列表运输状态及筛选一致。
|
||||||
security: [{ bearerAuth: [] }]
|
security: [{ bearerAuth: [] }]
|
||||||
responses: { '200': { description: 当前客户本土散板订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } }
|
responses: { '200': { description: 当前客户本土散板订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } }
|
||||||
put:
|
put:
|
||||||
summary: 客户编辑待接单的本土散板订单
|
summary: 客户编辑待审核的本土散板订单
|
||||||
description: 权限 merchant:cross-border:pallet-shipping:edit;仅 PENDING_ACCEPTANCE 可编辑,version 必填;不能修改状态。
|
description: 权限 merchant:cross-border:pallet-shipping:edit;仅 PENDING_ACCEPTANCE 可编辑,version 必填;已冻结的预算金额与币种不能通过编辑更改;不能修改状态。
|
||||||
security: [{ bearerAuth: [] }]
|
security: [{ bearerAuth: [] }]
|
||||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardUpdate' } } } }
|
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardUpdate' } } } }
|
||||||
responses: { '200': { description: 更新后的订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '409': { description: 版本或状态冲突 } }
|
responses: { '200': { description: 更新后的订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '409': { description: 版本或状态冲突 } }
|
||||||
@@ -4897,7 +4897,11 @@ components:
|
|||||||
destinationContactPhone: { type: string, nullable: true }
|
destinationContactPhone: { type: string, nullable: true }
|
||||||
pickupAt: { type: string, format: date-time, nullable: true, description: 当地日期时间不带时区;按分钟输入 }
|
pickupAt: { type: string, format: date-time, nullable: true, description: 当地日期时间不带时区;按分钟输入 }
|
||||||
deliveryAt: { type: string, format: date-time, nullable: true, description: 当地日期时间,不能早于取货时间 }
|
deliveryAt: { type: string, format: date-time, nullable: true, description: 当地日期时间,不能早于取货时间 }
|
||||||
status: { $ref: '#/components/schemas/LooseboardStatus' }
|
status:
|
||||||
|
description: 客户读取为运输状态 MerchantLooseboardStatus,平台读取保留内部作业状态 LooseboardStatus。
|
||||||
|
anyOf:
|
||||||
|
- { $ref: '#/components/schemas/LooseboardStatus' }
|
||||||
|
- { $ref: '#/components/schemas/MerchantLooseboardStatus' }
|
||||||
version: { type: integer, format: int64 }
|
version: { type: integer, format: int64 }
|
||||||
createdAt: { type: string, format: date-time }
|
createdAt: { type: string, format: date-time }
|
||||||
updatedAt: { type: string, format: date-time }
|
updatedAt: { type: string, format: date-time }
|
||||||
@@ -5292,7 +5296,11 @@ components:
|
|||||||
pickupAt: { type: string, format: date-time, description: '取货日期时分,使用无偏移量的本地时间,不转换时区' }
|
pickupAt: { type: string, format: date-time, description: '取货日期时分,使用无偏移量的本地时间,不转换时区' }
|
||||||
deliveryAt: { type: string, format: date-time, description: '送货日期时分,不能早于取货时间,不转换时区' }
|
deliveryAt: { type: string, format: date-time, description: '送货日期时分,不能早于取货时间,不转换时区' }
|
||||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||||
LooseboardStatus: { type: string, enum: [PENDING_ACCEPTANCE, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED] }
|
LooseboardStatus: { type: string, enum: [FUNDING_PENDING, FUNDING_FAILED, PENDING_ACCEPTANCE, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED] }
|
||||||
|
MerchantLooseboardStatus:
|
||||||
|
type: string
|
||||||
|
description: 订单受理中、订单处理失败、待审核、待排车、待取货、取货中、派送中、已完成、已取消;只读运输进度,不作为平台状态推进命令。
|
||||||
|
enum: [FUNDING_PENDING, FUNDING_FAILED, PENDING_ACCEPTANCE, PENDING_DISPATCH, DISPATCHED, PICKING_UP, IN_TRANSIT, COMPLETED, CANCELLED]
|
||||||
LooseboardStatusChange:
|
LooseboardStatusChange:
|
||||||
type: object
|
type: object
|
||||||
required: [version, status, idempotencyKey]
|
required: [version, status, idempotencyKey]
|
||||||
@@ -5303,7 +5311,7 @@ components:
|
|||||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||||
LooseboardCreate:
|
LooseboardCreate:
|
||||||
type: object
|
type: object
|
||||||
required: [operatingWarehouseId, deliveryMode, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm, idempotencyKey]
|
required: [operatingWarehouseId, deliveryMode, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm, budgetAmount, idempotencyKey]
|
||||||
properties:
|
properties:
|
||||||
deliveryMode: { type: string, enum: [DELIVERY_TO_WAREHOUSE, WAREHOUSE_PICKUP], description: 送货到仓或仓库上门取货 }
|
deliveryMode: { type: string, enum: [DELIVERY_TO_WAREHOUSE, WAREHOUSE_PICKUP], description: 送货到仓或仓库上门取货 }
|
||||||
operatingWarehouseId: { type: string, description: 实际收货海外仓ID }
|
operatingWarehouseId: { type: string, description: 实际收货海外仓ID }
|
||||||
@@ -5322,7 +5330,7 @@ components:
|
|||||||
packageCount: { type: integer, minimum: 0 }
|
packageCount: { type: integer, minimum: 0 }
|
||||||
totalWeightKg: { type: number, minimum: 0 }
|
totalWeightKg: { type: number, minimum: 0 }
|
||||||
totalVolumeCbm: { type: number, minimum: 0 }
|
totalVolumeCbm: { type: number, minimum: 0 }
|
||||||
budgetAmount: { type: number, nullable: true, minimum: 0 }
|
budgetAmount: { type: number, minimum: 0, multipleOf: 0.01 }
|
||||||
currency: { type: string, minLength: 3, maxLength: 3, default: USD }
|
currency: { type: string, minLength: 3, maxLength: 3, default: USD }
|
||||||
originContactName: { type: string, minLength: 1, maxLength: 128 }
|
originContactName: { type: string, minLength: 1, maxLength: 128 }
|
||||||
originContactPhone: { type: string, minLength: 1, maxLength: 128 }
|
originContactPhone: { type: string, minLength: 1, maxLength: 128 }
|
||||||
|
|||||||
+27
@@ -11,6 +11,33 @@ import static org.assertj.core.api.Assertions.*;
|
|||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
class TripOperationsServiceTest {
|
class TripOperationsServiceTest {
|
||||||
|
/** 照片直送没有仓内装载;与普通出库一起查询时仍保留真实车次及箱数。 */
|
||||||
|
@Test void photoLooseboardDepartureDoesNotBreakTheWholeReport() {
|
||||||
|
var repo=mock(TripOperationsRepository.class);
|
||||||
|
var service=new TripOperationsService(repo,new ObjectMapper().registerModule(new JavaTimeModule()),mock(FleetTripCostService.class));
|
||||||
|
String snapshot="""
|
||||||
|
{"trip":{"tripNo":"TR-PHOTO","tripPurpose":"DELIVERY","departedAt":"2026-10-01T00:00:00Z",
|
||||||
|
"vehiclePlate":"PHOTO-PLATE","stops":[{"name":"取货地"},{"name":"目的仓","destinationKey":"FBA:ONT8"}]}}
|
||||||
|
""";
|
||||||
|
var query=new TripOperationsModels.Query(1,20,Instant.parse("2026-10-01T00:00:00Z"),Instant.parse("2026-10-03T00:00:00Z"),null,null,null,null);
|
||||||
|
var photo=new TripOperationsRepository.Departure("TR-PHOTO","IN_TRANSIT",null,snapshot,null,"{\"taskType\":\"PHOTO_LOOSEBOARD\",\"packages\":6}");
|
||||||
|
when(repo.departures(query)).thenReturn(List.of(photo,new TripOperationsRepository.Departure("TR-A","COMPLETED",null,DEPARTURE,null)));
|
||||||
|
var result=service.report(query,false);
|
||||||
|
assertThat(result.page().records()).hasSize(2);
|
||||||
|
assertThat(result.summary().trips()).isEqualTo(2);
|
||||||
|
assertThat(result.summary().ownPackages()).isEqualTo(17);
|
||||||
|
assertThat(result.summary().physicalPallets()).isEqualTo(1);
|
||||||
|
var row=result.page().records().get(0);
|
||||||
|
assertThat(row.physicalPallets()).isNull();assertThat(row.palletSpaces()).isNull();
|
||||||
|
assertThat(row.destinations().get(0).label()).isEqualTo("目的仓");
|
||||||
|
// 普通车次仍不能因缺少装载而被静默当成照片直送。
|
||||||
|
assertThatThrownBy(()->service.project(new TripOperationsRepository.Departure("TR-PHOTO","IN_TRANSIT",null,snapshot,null)))
|
||||||
|
.hasMessageContaining("缺少装载");
|
||||||
|
for(String quantity:List.of("null","0","-1","1.5","\"6\"")) {
|
||||||
|
assertThatThrownBy(()->service.project(new TripOperationsRepository.Departure("TR-PHOTO","IN_TRANSIT",null,snapshot,null,
|
||||||
|
"{\"taskType\":\"PHOTO_LOOSEBOARD\",\"packages\":"+quantity+"}"))).hasMessageContaining("实际箱数");
|
||||||
|
}
|
||||||
|
}
|
||||||
static final String DEPARTURE="""
|
static final String DEPARTURE="""
|
||||||
{"trip":{"tripNo":"TR-A","departedAt":"2026-10-01T00:00:00Z","vehiclePlate":"OLD-PLATE","driverName":"发车司机","warehouseName":"起运仓"},
|
{"trip":{"tripNo":"TR-A","departedAt":"2026-10-01T00:00:00Z","vehiclePlate":"OLD-PLATE","driverName":"发车司机","warehouseName":"起运仓"},
|
||||||
"confirmedLoad":{"cargo":[
|
"confirmedLoad":{"cargo":[
|
||||||
|
|||||||
+220
@@ -0,0 +1,220 @@
|
|||||||
|
package com.baiyue.lastleg.looseboard;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||||
|
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||||
|
import com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries;
|
||||||
|
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
|
||||||
|
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
|
||||||
|
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper;
|
||||||
|
import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper;
|
||||||
|
import com.baiyue.lastleg.looseboard.port.LooseboardDispatches;
|
||||||
|
import com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository;
|
||||||
|
import com.baiyue.lastleg.looseboard.service.LocalLooseboardService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mybatis.spring.SqlSessionTemplate;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
class LocalLooseboardMerchantStatusTest {
|
||||||
|
private JdbcTemplate jdbc;
|
||||||
|
private LocalLooseboardRepository repository;
|
||||||
|
private LocalLooseboardService service;
|
||||||
|
|
||||||
|
/** 在隔离库中执行真实 Mapper、分页计数和客户服务,不依赖运行环境数据。 */
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() throws Exception {
|
||||||
|
var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
|
||||||
|
jdbc = new JdbcTemplate(ds);
|
||||||
|
String baseline;
|
||||||
|
try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) {
|
||||||
|
baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
int start = baseline.indexOf("CREATE TABLE IF NOT EXISTS ll_local_looseboard_order (");
|
||||||
|
String ddl = baseline.substring(start, baseline.indexOf(';', start))
|
||||||
|
.replaceAll("(?m)^\\s*(?:UNIQUE KEY|KEY) [^\\n]*\\n?", "");
|
||||||
|
ddl = ddl.substring(0, ddl.indexOf(") ENGINE=")) + ")";
|
||||||
|
jdbc.execute(ddl.replaceAll(",\\s*\\)$", ")"));
|
||||||
|
jdbc.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY, source_order_id BIGINT, customer_id VARCHAR(64))");
|
||||||
|
jdbc.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY, plan_id BIGINT, customer_id VARCHAR(64))");
|
||||||
|
jdbc.execute("CREATE TABLE ll_dispatch_item(plan_pallet_id BIGINT, dispatch_order_id BIGINT, customer_id VARCHAR(64))");
|
||||||
|
jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY, status VARCHAR(32), stage VARCHAR(32))");
|
||||||
|
jdbc.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT PRIMARY KEY, transfer_no VARCHAR(64), customer_id VARCHAR(64))");
|
||||||
|
jdbc.execute("CREATE TABLE ll_fleet_pickup_task(transfer_order_id BIGINT, trip_no VARCHAR(64), customer_id VARCHAR(64), status VARCHAR(32))");
|
||||||
|
jdbc.execute("CREATE TABLE ll_fleet_trip(trip_no VARCHAR(64) PRIMARY KEY, status VARCHAR(32))");
|
||||||
|
var config = new MybatisConfiguration();
|
||||||
|
config.setMapUnderscoreToCamelCase(true);
|
||||||
|
config.addMapper(LocalLooseboardOrderMapper.class);
|
||||||
|
var pagination = new MybatisPlusInterceptor();
|
||||||
|
pagination.addInnerInterceptor(new PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2));
|
||||||
|
config.addInterceptor(pagination);
|
||||||
|
var factory = new MybatisSqlSessionFactoryBean();
|
||||||
|
factory.setDataSource(ds); factory.setConfiguration(config);
|
||||||
|
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LocalLooseboardOrderMapper.class);
|
||||||
|
repository = new LocalLooseboardRepository();
|
||||||
|
ReflectionTestUtils.setField(repository, "orders", mapper);
|
||||||
|
ReflectionTestUtils.setField(repository, "histories", mock(LocalLooseboardHistoryMapper.class));
|
||||||
|
service = new LocalLooseboardService();
|
||||||
|
ReflectionTestUtils.setField(service, "repository", repository);
|
||||||
|
ReflectionTestUtils.setField(service, "fleetPool", mock(FleetPoolOrderQueries.class));
|
||||||
|
ReflectionTestUtils.setField(service, "dispatches", mock(LooseboardDispatches.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审核及资金状态优先,审核通过后内部仓储阶段统一等待排车。 */
|
||||||
|
@Test
|
||||||
|
void approvedOrdersWaitForDispatchWhileFundingReviewAndTerminalStatesRemainDistinct() {
|
||||||
|
String[] statuses = {"FUNDING_PENDING", "FUNDING_FAILED", "PENDING_ACCEPTANCE", "WAITING_INBOUND",
|
||||||
|
"RECEIVING", "WAITING_CONSOLIDATION", "PENDING_DISPATCH", "DISPATCHED", "IN_TRANSIT", "COMPLETED", "CANCELLED"};
|
||||||
|
for (int index = 0; index < statuses.length; index++) order(index + 1, "C1", statuses[index]);
|
||||||
|
var waiting = service.page("C1", query(1, 20, "PENDING_DISPATCH"));
|
||||||
|
assertThat(waiting.total()).isEqualTo(4);
|
||||||
|
assertThat(waiting.records()).allMatch(row -> row.status().equals("PENDING_DISPATCH"));
|
||||||
|
for (long id : List.of(1L, 2L, 3L, 8L, 9L, 10L, 11L)) {
|
||||||
|
assertThat(service.merchantDetail("C1", "LB" + id).summary().status()).isEqualTo(statuses[(int) id - 1]);
|
||||||
|
}
|
||||||
|
assertThat(service.detail("C1", "LB4").summary().status()).isEqualTo("WAITING_INBOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上门取货到仓依次等待排车、等待取货和取货中,到仓后等待下一段排车。 */
|
||||||
|
@Test
|
||||||
|
void warehousePickupUsesTripAndPickupFactsWithoutCompletingTheDeliveryEarly() {
|
||||||
|
order(1, "C1", "WAITING_INBOUND");
|
||||||
|
assertStatus(1, "PENDING_DISPATCH");
|
||||||
|
jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES(1,'W1','C1')");
|
||||||
|
jdbc.update("INSERT INTO ll_fleet_trip VALUES('T1','PLANNED')");
|
||||||
|
jdbc.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'T1','C1','ASSIGNED')");
|
||||||
|
assertStatus(1, "DISPATCHED");
|
||||||
|
jdbc.update("UPDATE ll_fleet_trip SET status='CANCELLED'");
|
||||||
|
assertStatus(1, "PENDING_DISPATCH");
|
||||||
|
jdbc.update("UPDATE ll_fleet_trip SET status='PLANNED'");
|
||||||
|
jdbc.update("UPDATE ll_fleet_pickup_task SET trip_no=NULL,status='WAITING'");
|
||||||
|
assertStatus(1, "PENDING_DISPATCH");
|
||||||
|
jdbc.update("UPDATE ll_fleet_pickup_task SET trip_no='T1',status='ASSIGNED'");
|
||||||
|
jdbc.update("UPDATE ll_fleet_trip SET status='IN_TRANSIT'");
|
||||||
|
assertStatus(1, "PICKING_UP");
|
||||||
|
jdbc.update("UPDATE ll_fleet_pickup_task SET status='PICKED'");
|
||||||
|
assertStatus(1, "PICKING_UP");
|
||||||
|
jdbc.update("UPDATE ll_fleet_pickup_task SET status='ARRIVED'");
|
||||||
|
assertStatus(1, "PENDING_DISPATCH");
|
||||||
|
plan(1, "C1"); dispatch(1, 11, "C1", "ACTIVE", "BEFORE_LOADING");
|
||||||
|
assertStatus(1, "DISPATCHED");
|
||||||
|
jdbc.update("UPDATE ll_dispatch_order SET stage='DELIVERING'");
|
||||||
|
assertStatus(1, "IN_TRANSIT");
|
||||||
|
assertThat(repository.findNo("C1", "LB1").getStatus()).isEqualTo("WAITING_INBOUND");
|
||||||
|
jdbc.update("UPDATE ll_local_looseboard_order SET status='COMPLETED'");
|
||||||
|
assertStatus(1, "COMPLETED");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取货直送及分车派送按有效排车判断,取消排车和重复托盘关联不污染状态或总数。 */
|
||||||
|
@Test
|
||||||
|
void directDeliveryIgnoresCancelledDispatchesAndKeepsSplitDeliveryInTransit() {
|
||||||
|
order(1, "C1", "WAITING_INBOUND"); plan(1, "C1");
|
||||||
|
dispatch(1, 11, "C1", "CANCELLED", "DELIVERING");
|
||||||
|
assertStatus(1, "PENDING_DISPATCH");
|
||||||
|
dispatch(1, 12, "C1", "ACTIVE", "LOADING");
|
||||||
|
jdbc.update("INSERT INTO ll_dispatch_item VALUES(1,12,'C1')");
|
||||||
|
assertStatus(1, "DISPATCHED");
|
||||||
|
jdbc.update("UPDATE ll_dispatch_order SET stage='DELIVERED' WHERE id=12");
|
||||||
|
dispatch(1, 13, "C1", "ACTIVE", "BEFORE_LOADING");
|
||||||
|
assertStatus(1, "IN_TRANSIT");
|
||||||
|
jdbc.update("UPDATE ll_dispatch_order SET status='CANCELLED'");
|
||||||
|
assertStatus(1, "PENDING_DISPATCH");
|
||||||
|
jdbc.update("UPDATE ll_local_looseboard_order SET status='CANCELLED'");
|
||||||
|
jdbc.update("UPDATE ll_dispatch_order SET status='ACTIVE'");
|
||||||
|
assertStatus(1, "CANCELLED");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 原单、来源计划、托盘、排车项及取货任务均保持客户隔离。 */
|
||||||
|
@Test
|
||||||
|
void foreignSourceAndPickupFactsCannotAdvanceAnotherCustomersOrder() {
|
||||||
|
order(1, "C1", "WAITING_INBOUND"); order(2, "C2", "WAITING_INBOUND");
|
||||||
|
plan(1, "C2"); dispatch(1, 11, "C2", "ACTIVE", "DELIVERING");
|
||||||
|
assertStatus(1, "PENDING_DISPATCH");
|
||||||
|
jdbc.update("UPDATE ll_inbound_plan SET customer_id='C1'");
|
||||||
|
assertStatus(1, "PENDING_DISPATCH");
|
||||||
|
jdbc.update("UPDATE ll_inbound_plan_pallet SET customer_id='C1'");
|
||||||
|
assertStatus(1, "PENDING_DISPATCH");
|
||||||
|
jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES(1,'W1','C2')");
|
||||||
|
jdbc.update("INSERT INTO ll_fleet_trip VALUES('T1','IN_TRANSIT')");
|
||||||
|
jdbc.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'T1','C2','PICKED')");
|
||||||
|
assertStatus(1, "PENDING_DISPATCH");
|
||||||
|
assertThat(repository.merchantStatuses("C2", List.of(1L))).isEmpty();
|
||||||
|
assertThatThrownBy(() -> service.merchantDetail("C2", "LB1"))
|
||||||
|
.isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
|
||||||
|
jdbc.update("UPDATE ll_dispatch_item SET customer_id='C1'");
|
||||||
|
assertStatus(1, "IN_TRANSIT");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 筛选发生在分页之前;筛选总数、列表状态及详情一致并拒绝废弃筛选值。 */
|
||||||
|
@Test
|
||||||
|
void merchantPaginationCountsProjectedStatusAndRejectsUnusedFilters() {
|
||||||
|
order(1, "C1", "WAITING_INBOUND"); order(2, "C1", "RECEIVING");
|
||||||
|
order(3, "C1", "PENDING_ACCEPTANCE"); order(4, "C2", "WAITING_INBOUND");
|
||||||
|
var first = service.page("C1", query(1, 1, " pending_dispatch "));
|
||||||
|
var second = service.page("C1", query(2, 1, "PENDING_DISPATCH"));
|
||||||
|
assertThat(first.total()).isEqualTo(2); assertThat(first.pages()).isEqualTo(2);
|
||||||
|
assertThat(second.total()).isEqualTo(2);
|
||||||
|
assertThat(first.records()).hasSize(1); assertThat(second.records()).hasSize(1);
|
||||||
|
assertThat(first.records().get(0).id()).isNotEqualTo(second.records().get(0).id());
|
||||||
|
for (var row : List.of(first.records().get(0), second.records().get(0))) {
|
||||||
|
assertThat(row.customerId()).isEqualTo("C1");
|
||||||
|
assertThat(row.status()).isEqualTo(service.merchantDetail("C1", row.orderNo()).summary().status());
|
||||||
|
}
|
||||||
|
for (var status : List.of("APPROVED", "REJECTED", "PROCESSING", "WAITING_INBOUND", "PENDING_REVIEW")) {
|
||||||
|
assertThatThrownBy(() -> service.page("C1", query(1, 20, status)))
|
||||||
|
.isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构造有正式来源标识的原单,取送时间保持本地业务时间。 */
|
||||||
|
private void order(long id, String customer, String status) {
|
||||||
|
var order = new LocalLooseboardOrder();
|
||||||
|
order.setId(id); order.setCustomerId(customer); order.setOrderNo("LB" + id);
|
||||||
|
order.setTransferNo("W" + id); order.setLastlegOrderId(id + 1000);
|
||||||
|
order.setOrigin("LA"); order.setDestination("ONT"); order.setDeliveryMode("WAREHOUSE_PICKUP");
|
||||||
|
order.setPickupAt(LocalDateTime.of(2026, 11, 6, 9, 0)); order.setDeliveryAt(order.getPickupAt().plusHours(5));
|
||||||
|
order.setTotalWeightKg(BigDecimal.ZERO); order.setTotalVolumeCbm(BigDecimal.ZERO);
|
||||||
|
order.setCurrency("USD"); order.setStatus(status); order.setIdempotencyKey("K" + id); order.setRequestHash("0".repeat(64));
|
||||||
|
order.setCreatedAt(LocalDateTime.of(2026, 11, 6, 8, 0));
|
||||||
|
repository.insertOrder(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 建立原单到申报托盘的正式链路。 */
|
||||||
|
private void plan(long id, String customer) {
|
||||||
|
jdbc.update("INSERT INTO ll_inbound_plan VALUES(?,?,?)", id, id + 1000, customer);
|
||||||
|
jdbc.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,?,?)", id, id, customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 建立可独立取消或推进的分车排车事实。 */
|
||||||
|
private void dispatch(long pallet, long id, String customer, String status, String stage) {
|
||||||
|
jdbc.update("INSERT INTO ll_dispatch_order VALUES(?,?,?)", id, status, stage);
|
||||||
|
jdbc.update("INSERT INTO ll_dispatch_item VALUES(?,?,?)", pallet, id, customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每个节点同时核对列表筛选总数和客户详情。 */
|
||||||
|
private void assertStatus(long id, String status) {
|
||||||
|
assertThat(service.merchantDetail("C1", "LB" + id).summary().status()).isEqualTo(status);
|
||||||
|
var page = service.page("C1", query(1, 20, status));
|
||||||
|
assertThat(page.total()).isEqualTo(1);
|
||||||
|
assertThat(page.records().get(0).orderNo()).isEqualTo("LB" + id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用同一路线和日期边界查询客户运输进度。 */
|
||||||
|
private LocalLooseboardRequests.MerchantQuery query(long page, long size, String status) {
|
||||||
|
return new LocalLooseboardRequests.MerchantQuery(page, size, null, "LA", status,
|
||||||
|
LocalDate.of(2026, 11, 6), LocalDate.of(2026, 11, 6));
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-1
@@ -48,6 +48,7 @@ class LocalLooseboardServiceTest {
|
|||||||
@Mock LastlegOrderMapper lastlegOrders;
|
@Mock LastlegOrderMapper lastlegOrders;
|
||||||
@Mock LastlegOrderStatusHistoryMapper lastlegHistories;
|
@Mock LastlegOrderStatusHistoryMapper lastlegHistories;
|
||||||
@Mock com.baiyue.lastleg.looseboard.port.LooseboardDispatches dispatches;
|
@Mock com.baiyue.lastleg.looseboard.port.LooseboardDispatches dispatches;
|
||||||
|
@Mock com.baiyue.lastleg.wallet.budget.OrderBudgetService budgets;
|
||||||
@Spy LastlegOrderNoGenerator lastlegOrderNoGenerator = com.baiyue.lastleg.service.TestDocumentNumbers.orders();
|
@Spy LastlegOrderNoGenerator lastlegOrderNoGenerator = com.baiyue.lastleg.service.TestDocumentNumbers.orders();
|
||||||
private LocalLooseboardService service;
|
private LocalLooseboardService service;
|
||||||
private final Map<String, LocalLooseboardOperation> operationRows = new HashMap<>();
|
private final Map<String, LocalLooseboardOperation> operationRows = new HashMap<>();
|
||||||
@@ -56,6 +57,13 @@ class LocalLooseboardServiceTest {
|
|||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
service = new LocalLooseboardService();
|
service = new LocalLooseboardService();
|
||||||
|
ReflectionTestUtils.setField(service, "orderBudget", budgets);
|
||||||
|
org.mockito.Mockito.lenient().when(budgets.submit(anyString(),anyString(),anyString(),any(),anyString(),any(),any()))
|
||||||
|
.thenAnswer(call -> ((java.util.function.Supplier<?>)call.getArgument(5)).get());
|
||||||
|
org.mockito.Mockito.lenient().when(budgets.update(anyString(),anyString(),anyString(),any(),anyString(),any()))
|
||||||
|
.thenAnswer(call -> ((java.util.function.Supplier<?>)call.getArgument(5)).get());
|
||||||
|
org.mockito.Mockito.lenient().when(budgets.withOrder(anyString(),anyString(),anyString(),any()))
|
||||||
|
.thenAnswer(call -> ((java.util.function.Supplier<?>)call.getArgument(3)).get());
|
||||||
ReflectionTestUtils.setField(service, "dispatches", dispatches);
|
ReflectionTestUtils.setField(service, "dispatches", dispatches);
|
||||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class));
|
org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class));
|
||||||
var repository = new LocalLooseboardRepository();
|
var repository = new LocalLooseboardRepository();
|
||||||
@@ -106,7 +114,7 @@ class LocalLooseboardServiceTest {
|
|||||||
var detail = service.create("100", "7", create("key-1"));
|
var detail = service.create("100", "7", create("key-1"));
|
||||||
|
|
||||||
assertEquals("100", detail.summary().customerId());
|
assertEquals("100", detail.summary().customerId());
|
||||||
assertEquals(LocalLooseboardService.PENDING_ACCEPTANCE, detail.summary().status());
|
assertEquals(LocalLooseboardService.FUNDING_PENDING, detail.summary().status());
|
||||||
assertEquals("Alice", detail.summary().originContactName());
|
assertEquals("Alice", detail.summary().originContactName());
|
||||||
assertEquals("Bob", detail.summary().destinationContactName());
|
assertEquals("Bob", detail.summary().destinationContactName());
|
||||||
assertEquals("家居用品", detail.summary().productName());
|
assertEquals("家居用品", detail.summary().productName());
|
||||||
|
|||||||
+12
@@ -53,6 +53,18 @@ class OrderBudgetAuthorizationTest {
|
|||||||
verify(service).retryOrder("9","trucking","KC1");
|
verify(service).retryOrder("9","trucking","KC1");
|
||||||
assertThatThrownBy(()->controller.retry(writer,"procurement","CG1")).isInstanceOf(AccessDeniedException.class);
|
assertThatThrownBy(()->controller.retry(writer,"procurement","CG1")).isInstanceOf(AccessDeniedException.class);
|
||||||
}
|
}
|
||||||
|
@Test void looseboardFundingUsesOnlyItsOwnCustomerAndPermissions() {
|
||||||
|
var controller=context.getBean(MerchantOrderBudgetController.class);
|
||||||
|
var service=context.getBean(OrderBudgetService.class);
|
||||||
|
var reader=login(9,"MERCHANT","merchant:cross-border:pallet-shipping:list");
|
||||||
|
controller.readOrder(reader,"looseboard","LB1");verify(service).readOrder("9","looseboard","LB1");
|
||||||
|
assertThatThrownBy(()->controller.retry(reader,"looseboard","LB1")).isInstanceOf(AccessDeniedException.class);
|
||||||
|
var writer=login(9,"MERCHANT","merchant:cross-border:pallet-shipping:create");
|
||||||
|
controller.retry(writer,"looseboard","LB1");verify(service).retryOrder("9","looseboard","LB1");
|
||||||
|
assertThatThrownBy(()->controller.retry(writer,"trucking","LB1")).isInstanceOf(AccessDeniedException.class);
|
||||||
|
assertThatThrownBy(()->controller.readOrder(writer,"procurement","LB1")).isInstanceOf(AccessDeniedException.class);
|
||||||
|
}
|
||||||
|
|
||||||
private AuthenticatedUser login(long customer,String app,String permission) {
|
private AuthenticatedUser login(long customer,String app,String permission) {
|
||||||
var user=new AuthenticatedUser(7L,customer,customer,8L,"operator",app,"WEB",Set.of(permission));
|
var user=new AuthenticatedUser(7L,customer,customer,8L,"operator",app,"WEB",Set.of(permission));
|
||||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",List.of(new SimpleGrantedAuthority(permission))));return user;
|
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",List.of(new SimpleGrantedAuthority(permission))));return user;
|
||||||
|
|||||||
+120
-6
@@ -12,6 +12,11 @@ import java.time.LocalDateTime;
|
|||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
import com.baiyue.lastleg.looseboard.mapper.*;
|
||||||
|
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
|
||||||
|
import com.baiyue.lastleg.looseboard.service.LocalLooseboardService;
|
||||||
|
import com.baiyue.lastleg.looseboard.port.LooseboardReceiving;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
import static org.assertj.core.api.Assertions.*;
|
import static org.assertj.core.api.Assertions.*;
|
||||||
|
|
||||||
/** 用真实 Mapper 验证客户隔离、持久化及到期/终态释放候选。 */
|
/** 用真实 Mapper 验证客户隔离、持久化及到期/终态释放候选。 */
|
||||||
@@ -31,7 +36,7 @@ class OrderBudgetPersistenceTest {
|
|||||||
var outbox = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_event_outbox \\(.*?;").matcher(sql);
|
var outbox = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_event_outbox \\(.*?;").matcher(sql);
|
||||||
assertThat(outbox.find()).isTrue();jdbc.execute(outbox.group());
|
assertThat(outbox.find()).isTrue();jdbc.execute(outbox.group());
|
||||||
jdbc.execute("CREATE TABLE ll_overseas_procurement_order(customer_id VARCHAR(64),order_no VARCHAR(64),status VARCHAR(32))");
|
jdbc.execute("CREATE TABLE ll_overseas_procurement_order(customer_id VARCHAR(64),order_no VARCHAR(64),status VARCHAR(32))");
|
||||||
for (String table : new String[]{"ll_us_truck_order", "ll_us_truck_order_pallet", "ll_us_truck_order_operation", "ll_us_truck_order_status_history"}) {
|
for (String table : new String[]{"ll_us_truck_order", "ll_us_truck_order_pallet", "ll_us_truck_order_operation", "ll_us_truck_order_status_history", "ll_local_looseboard_order", "ll_local_looseboard_operation", "ll_local_looseboard_history"}) {
|
||||||
var ddl = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS " + table + " \\(.*?;").matcher(sql);
|
var ddl = Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS " + table + " \\(.*?;").matcher(sql);
|
||||||
assertThat(ddl.find()).isTrue(); jdbc.execute(ddl.group());
|
assertThat(ddl.find()).isTrue(); jdbc.execute(ddl.group());
|
||||||
}
|
}
|
||||||
@@ -41,6 +46,9 @@ class OrderBudgetPersistenceTest {
|
|||||||
config.addMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper.class);
|
config.addMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper.class);
|
||||||
config.addMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderOperationMapper.class);
|
config.addMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderOperationMapper.class);
|
||||||
config.addMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper.class);
|
config.addMapper(com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper.class);
|
||||||
|
config.addMapper(LocalLooseboardOrderMapper.class);
|
||||||
|
config.addMapper(LocalLooseboardOperationMapper.class);
|
||||||
|
config.addMapper(LocalLooseboardHistoryMapper.class);
|
||||||
var factory = new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
|
var factory = new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
|
||||||
sessions = new SqlSessionTemplate(factory.getObject());
|
sessions = new SqlSessionTemplate(factory.getObject());
|
||||||
repository = new OrderBudgetRepository(sessions.getMapper(OrderBudgetMapper.class));
|
repository = new OrderBudgetRepository(sessions.getMapper(OrderBudgetMapper.class));
|
||||||
@@ -60,10 +68,11 @@ class OrderBudgetPersistenceTest {
|
|||||||
}
|
}
|
||||||
/** 两类业务创建失败时,真实预算、订单及Outbox必须整体回滚。 */
|
/** 两类业务创建失败时,真实预算、订单及Outbox必须整体回滚。 */
|
||||||
@Test void bothBusinessesRollbackOrderBudgetAndOutboxTogether() {
|
@Test void bothBusinessesRollbackOrderBudgetAndOutboxTogether() {
|
||||||
for (String kind : new String[]{"procurement","trucking"}) {
|
for (String kind : new String[]{"procurement","trucking","looseboard"}) {
|
||||||
String table = "procurement".equals(kind) ? "ll_overseas_procurement_order" : "ll_us_truck_order";
|
String table = switch(kind) { case "procurement" -> "ll_overseas_procurement_order"; case "trucking" -> "ll_us_truck_order"; default -> "ll_local_looseboard_order"; };
|
||||||
assertThatThrownBy(() -> service.submit("9",kind,"rollback",BigDecimal.TEN,"CNY",() -> {
|
assertThatThrownBy(() -> service.submit("9",kind,"rollback",BigDecimal.TEN,"CNY",() -> {
|
||||||
if ("trucking".equals(kind)) jdbc.update("INSERT INTO ll_us_truck_order(id,customer_id,order_no,origin,destination,status,idempotency_key,request_hash) VALUES(1,'9','FAIL','A','B','FUNDING_PENDING','rollback',?)", "a".repeat(64));
|
if ("trucking".equals(kind)) jdbc.update("INSERT INTO ll_us_truck_order(id,customer_id,order_no,origin,destination,status,idempotency_key,request_hash) VALUES(1,'9','FAIL','A','B','FUNDING_PENDING','rollback',?)", "a".repeat(64));
|
||||||
|
else if ("looseboard".equals(kind)) jdbc.update("INSERT INTO ll_local_looseboard_order(id,customer_id,order_no,origin,destination,status,idempotency_key,request_hash) VALUES(1,'9','FAIL','A','B','FUNDING_PENDING','rollback',?)", "a".repeat(64));
|
||||||
else jdbc.update("INSERT INTO " + table + " VALUES('9','FAIL','FUNDING_PENDING')");
|
else jdbc.update("INSERT INTO " + table + " VALUES('9','FAIL','FUNDING_PENDING')");
|
||||||
throw new IllegalStateException("订单明细写入失败");
|
throw new IllegalStateException("订单明细写入失败");
|
||||||
}, value -> String.valueOf(value))).hasMessageContaining("订单明细写入失败");
|
}, value -> String.valueOf(value))).hasMessageContaining("订单明细写入失败");
|
||||||
@@ -74,12 +83,113 @@ class OrderBudgetPersistenceTest {
|
|||||||
}
|
}
|
||||||
/** 成功受理原子提交并可同键重放,资金指令不能重复。 */
|
/** 成功受理原子提交并可同键重放,资金指令不能重复。 */
|
||||||
@Test void acceptsAndReplaysWithoutDuplicateFreeze() {
|
@Test void acceptsAndReplaysWithoutDuplicateFreeze() {
|
||||||
for (String kind : new String[]{"procurement","trucking"}) {
|
for (String kind : new String[]{"procurement","trucking","looseboard"}) {
|
||||||
service.submit("9",kind,"accept",BigDecimal.TEN,"CNY",()->"ORDER1",v->v);
|
service.submit("9",kind,"accept",BigDecimal.TEN,"CNY",()->"ORDER1",v->v);
|
||||||
service.submit("9",kind,"accept",BigDecimal.TEN,"CNY",()->"ORDER1",v->v);
|
service.submit("9",kind,"accept",BigDecimal.TEN,"CNY",()->"ORDER1",v->v);
|
||||||
assertThat(repository.lockOrder("9",kind,"ORDER1").getStatus()).isEqualTo("FREEZE_PENDING");
|
assertThat(repository.lockOrder("9",kind,"ORDER1").getStatus()).isEqualTo("FREEZE_PENDING");
|
||||||
}
|
}
|
||||||
|
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 散板原单、资金回执、审核和释放使用真实Mapper,重复请求不重复占款或建履约。 */
|
||||||
|
@Test void looseboardCreationFreezesAndApprovalReleasesTheSameBudget() {
|
||||||
|
var harness=looseboards();var looseboards=harness.orders();var request=looseboardRequest();
|
||||||
|
var created=looseboards.create("9","customer",request);
|
||||||
|
String number=created.summary().orderNo();
|
||||||
|
assertThat(created.summary().status()).isEqualTo("FUNDING_PENDING");
|
||||||
|
assertThatThrownBy(()->looseboards.review("9",number,"reviewer",new LocalLooseboardRequests.Review(0L,"APPROVE","early-review",null)))
|
||||||
|
.hasMessageContaining("冻结成功");
|
||||||
|
looseboards.create("9","customer",request);
|
||||||
|
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(1);
|
||||||
|
var budget=repository.lockOrder("9","looseboard",number);
|
||||||
|
service.handle(truckReceipt(budget,"FREEZE","FROZEN"));
|
||||||
|
var ready=looseboards.detail("9",number);
|
||||||
|
assertThat(ready.summary().status()).isEqualTo("PENDING_ACCEPTANCE");
|
||||||
|
var review=new LocalLooseboardRequests.Review(ready.summary().version(),"APPROVE","review-key",null);
|
||||||
|
assertThat(looseboards.review("9",number,"reviewer",review).summary().status()).isEqualTo("WAITING_INBOUND");
|
||||||
|
looseboards.review("9",number,"reviewer",review);
|
||||||
|
org.mockito.Mockito.verify(harness.receiving(),org.mockito.Mockito.times(1)).approve(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.eq("reviewer"));
|
||||||
|
budget=repository.lockOrder("9","looseboard",number);
|
||||||
|
assertThat(budget.getStatus()).isEqualTo("RELEASE_PENDING");assertThat(budget.getReleaseRequested()).isTrue();
|
||||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(2);
|
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(2);
|
||||||
|
service.handle(truckReceipt(budget,"RELEASE","RELEASED"));
|
||||||
|
assertThat(repository.lockOrder("9","looseboard",number).getStatus()).isEqualTo("RELEASED");
|
||||||
|
assertThat(looseboards.create("9","customer",request).summary().orderNo()).isEqualTo(number);
|
||||||
|
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 冻结失败不能审核,重试仍绑定原单;驳回同键重放只释放一次。 */
|
||||||
|
@Test void looseboardInsufficientFundsCanRetryAndRejectionReleasesOnce() {
|
||||||
|
var harness=looseboards();var looseboards=harness.orders();
|
||||||
|
String number=looseboards.create("9","customer",looseboardRequest()).summary().orderNo();
|
||||||
|
var budget=repository.lockOrder("9","looseboard",number);
|
||||||
|
service.handle(new com.baiyue.common.event.WalletCommandCompletedV1(budget.getCommandEventId(),"FREEZE",9L,OrderBudgetService.BUSINESS_TYPE,
|
||||||
|
budget.getId().toString(),"FAILED",null,budget.getAmountCny(),null,"可用余额和授信额度不足",java.time.Instant.now(),null,null));
|
||||||
|
assertThat(looseboards.detail("9",number).summary().status()).isEqualTo("FUNDING_FAILED");
|
||||||
|
assertThatThrownBy(()->looseboards.review("9",number,"reviewer",new LocalLooseboardRequests.Review(1L,"APPROVE","early",null)))
|
||||||
|
.hasMessageContaining("冻结成功");
|
||||||
|
assertThat(service.readOrder("9","looseboard",number).message()).contains("授信额度不足");
|
||||||
|
service.retryOrder("9","looseboard",number);
|
||||||
|
budget=repository.lockOrder("9","looseboard",number);service.handle(truckReceipt(budget,"FREEZE","FROZEN"));
|
||||||
|
var review=new LocalLooseboardRequests.Review(looseboards.detail("9",number).summary().version(),"REJECT","reject-key","路线不可承运");
|
||||||
|
looseboards.review("9",number,"reviewer",review);looseboards.review("9",number,"reviewer",review);
|
||||||
|
assertThat(looseboards.detail("9",number).summary().status()).isEqualTo("CANCELLED");
|
||||||
|
assertThat(repository.lockOrder("9","looseboard",number).getStatus()).isEqualTo("RELEASE_PENDING");
|
||||||
|
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(3);
|
||||||
|
org.mockito.Mockito.verifyNoInteractions(harness.receiving());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消时冻结在途,成功回执只能触发释放,不能将原单重新改成待审核。 */
|
||||||
|
@Test void looseboardCancellationReleasesLateFreezeWithoutReopeningOrder() {
|
||||||
|
var looseboards=looseboards().orders();String number=looseboards.create("9","customer",looseboardRequest()).summary().orderNo();
|
||||||
|
var cancel=new LocalLooseboardRequests.ChangeStatus(0L,"CANCELLED","cancel-key",null);
|
||||||
|
looseboards.changeStatus("9",number,"operator",cancel);looseboards.changeStatus("9",number,"operator",cancel);
|
||||||
|
var budget=repository.lockOrder("9","looseboard",number);assertThat(budget.getReleaseRequested()).isTrue();
|
||||||
|
service.handle(truckReceipt(budget,"FREEZE","FROZEN"));
|
||||||
|
assertThat(repository.lockOrder("9","looseboard",number).getStatus()).isEqualTo("RELEASE_PENDING");
|
||||||
|
assertThat(looseboards.detail("9",number).summary().status()).isEqualTo("CANCELLED");
|
||||||
|
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审核履约创建失败回滚,不提前释放预算或保存已审核状态。 */
|
||||||
|
@Test void looseboardApprovalFailureKeepsOriginalHold() {
|
||||||
|
var harness=looseboards();var looseboards=harness.orders();String number=looseboards.create("9","customer",looseboardRequest()).summary().orderNo();
|
||||||
|
var budget=repository.lockOrder("9","looseboard",number);service.handle(truckReceipt(budget,"FREEZE","FROZEN"));
|
||||||
|
org.mockito.Mockito.when(harness.receiving().approve(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.anyString())).thenThrow(new IllegalStateException("履约创建失败"));
|
||||||
|
assertThatThrownBy(()->looseboards.review("9",number,"reviewer",new LocalLooseboardRequests.Review(1L,"APPROVE","failed-review",null)))
|
||||||
|
.hasMessageContaining("履约创建失败");
|
||||||
|
assertThat(looseboards.detail("9",number).summary().status()).isEqualTo("PENDING_ACCEPTANCE");
|
||||||
|
assertThat(repository.lockOrder("9","looseboard",number).getStatus()).isEqualTo("FROZEN");
|
||||||
|
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox",Integer.class)).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Looseboards(LocalLooseboardService orders,LooseboardReceiving receiving) {}
|
||||||
|
/** 散板原单及历史使用生产持久化,仅隔离真正的收货履约入口。 */
|
||||||
|
private Looseboards looseboards() {
|
||||||
|
var orders=sessions.getMapper(LocalLooseboardOrderMapper.class);var history=sessions.getMapper(LocalLooseboardHistoryMapper.class);
|
||||||
|
var repo=new com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository();
|
||||||
|
ReflectionTestUtils.setField(repo,"orders",orders);ReflectionTestUtils.setField(repo,"histories",history);
|
||||||
|
ReflectionTestUtils.setField(repo,"operations",sessions.getMapper(LocalLooseboardOperationMapper.class));
|
||||||
|
var looseboards=new LocalLooseboardService();ReflectionTestUtils.setField(looseboards,"repository",repo);
|
||||||
|
ReflectionTestUtils.setField(looseboards,"objectMapper",new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules());
|
||||||
|
ReflectionTestUtils.setField(looseboards,"orderBudget",service);
|
||||||
|
ReflectionTestUtils.setField(looseboards,"dispatches",org.mockito.Mockito.mock(com.baiyue.lastleg.looseboard.port.LooseboardDispatches.class));
|
||||||
|
ReflectionTestUtils.setField(looseboards,"fleetPool",org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class));
|
||||||
|
var numbers=org.mockito.Mockito.mock(com.baiyue.lastleg.service.LastlegOrderNoGenerator.class);
|
||||||
|
org.mockito.Mockito.when(numbers.next("LOOSEBOARD")).thenReturn("LB-BUDGET-1");ReflectionTestUtils.setField(looseboards,"lastlegOrderNoGenerator",numbers);
|
||||||
|
var receiving=org.mockito.Mockito.mock(LooseboardReceiving.class);ReflectionTestUtils.setField(looseboards,"receiving",receiving);
|
||||||
|
org.mockito.Mockito.when(receiving.approve(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.anyString())).thenReturn(new LooseboardReceiving.Created("TRANSFER1",99L));
|
||||||
|
var actualFunding=new com.baiyue.lastleg.procurement.funding.OrderFundingService(
|
||||||
|
new com.baiyue.lastleg.procurement.funding.OrderFundingRepository(null,null,null,null,orders,history));
|
||||||
|
org.mockito.Mockito.doAnswer(call->{actualFunding.apply(call.getArgument(0),call.getArgument(1),call.getArgument(2));return null;})
|
||||||
|
.when(funding).apply(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.anyString(),org.mockito.ArgumentMatchers.any());
|
||||||
|
return new Looseboards(looseboards,receiving);
|
||||||
|
}
|
||||||
|
/** 明确金额和币种,避免测试把未填金额冒充零价。 */
|
||||||
|
private LocalLooseboardRequests.Create looseboardRequest() {
|
||||||
|
return new LocalLooseboardRequests.Create("9","FBA",null,"WAREHOUSE_PICKUP",null,null,null,null,"LA","Street A","ONT8",null,
|
||||||
|
"货物",2,10,BigDecimal.TEN,BigDecimal.ONE,new BigDecimal("100.00"),"CNY","发货人","10086","收货人","10010",
|
||||||
|
LocalDateTime.of(2026,11,2,9,0),LocalDateTime.of(2026,11,3,9,0),"原始备注","looseboard-budget-key");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 本地真实建卡车单,核对冻结、审核释放、回执和重复审核的持久化结果。 */
|
/** 本地真实建卡车单,核对冻结、审核释放、回执和重复审核的持久化结果。 */
|
||||||
@@ -99,7 +209,7 @@ class OrderBudgetPersistenceTest {
|
|||||||
org.mockito.Mockito.when(numbers.next("TRUCK")).thenReturn("TRUCK-LOCAL-BUDGET-1");
|
org.mockito.Mockito.when(numbers.next("TRUCK")).thenReturn("TRUCK-LOCAL-BUDGET-1");
|
||||||
org.springframework.test.util.ReflectionTestUtils.setField(trucks, "orderNoGenerator", numbers);
|
org.springframework.test.util.ReflectionTestUtils.setField(trucks, "orderNoGenerator", numbers);
|
||||||
var actualFunding = new com.baiyue.lastleg.procurement.funding.OrderFundingService(
|
var actualFunding = new com.baiyue.lastleg.procurement.funding.OrderFundingService(
|
||||||
new com.baiyue.lastleg.procurement.funding.OrderFundingRepository(null, null, orders, histories));
|
new com.baiyue.lastleg.procurement.funding.OrderFundingRepository(null, null, orders, histories, null, null));
|
||||||
org.mockito.Mockito.doAnswer(call -> {
|
org.mockito.Mockito.doAnswer(call -> {
|
||||||
actualFunding.apply(call.getArgument(0), call.getArgument(1), call.getArgument(2)); return null;
|
actualFunding.apply(call.getArgument(0), call.getArgument(1), call.getArgument(2)); return null;
|
||||||
}).when(funding).apply(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any());
|
}).when(funding).apply(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any());
|
||||||
@@ -158,7 +268,11 @@ class OrderBudgetPersistenceTest {
|
|||||||
repository.insert(row(4,"FROZEN"));
|
repository.insert(row(4,"FROZEN"));
|
||||||
repository.insert(row(5,"FREEZE_PENDING"));
|
repository.insert(row(5,"FREEZE_PENDING"));
|
||||||
var recentFailure = row(6,"RELEASE_FAILED");recentFailure.setReleaseRequested(true);repository.insert(recentFailure);
|
var recentFailure = row(6,"RELEASE_FAILED");recentFailure.setReleaseRequested(true);repository.insert(recentFailure);
|
||||||
assertThat(repository.releasable()).extracting(OrderBudget::getId).containsExactly(1L,2L,3L);
|
var acceptedLooseboard=row(7,"FROZEN");acceptedLooseboard.setKind("looseboard");acceptedLooseboard.setOrderNo("LB1");repository.insert(acceptedLooseboard);
|
||||||
|
jdbc.update("INSERT INTO ll_local_looseboard_order(id,customer_id,order_no,origin,destination,status,idempotency_key,request_hash) VALUES(7,'9','LB1','A','B','WAITING_INBOUND','accepted',?)", "a".repeat(64));
|
||||||
|
var pendingLooseboard=row(8,"FROZEN");pendingLooseboard.setKind("looseboard");pendingLooseboard.setOrderNo("LB2");repository.insert(pendingLooseboard);
|
||||||
|
jdbc.update("INSERT INTO ll_local_looseboard_order(id,customer_id,order_no,origin,destination,status,idempotency_key,request_hash) VALUES(8,'9','LB2','A','B','PENDING_ACCEPTANCE','pending',?)", "a".repeat(64));
|
||||||
|
assertThat(repository.releasable()).extracting(OrderBudget::getId).containsExactly(1L,2L,3L,7L);
|
||||||
}
|
}
|
||||||
/** 测试数据与生产预占使用同一 UTC 时间,数据库会话保持东八区以覆盖时区差异。 */
|
/** 测试数据与生产预占使用同一 UTC 时间,数据库会话保持东八区以覆盖时区差异。 */
|
||||||
private OrderBudget row(long id,String status) {
|
private OrderBudget row(long id,String status) {
|
||||||
|
|||||||
+1
-1
@@ -68,7 +68,7 @@ class OrderBudgetServiceTest {
|
|||||||
assertThat(service.read("9","procurement","key").usable()).isFalse();
|
assertThat(service.read("9","procurement","key").usable()).isFalse();
|
||||||
assertThat(service.read("9","procurement","key").message()).contains("过期");
|
assertThat(service.read("9","procurement","key").message()).contains("过期");
|
||||||
}
|
}
|
||||||
@ParameterizedTest @ValueSource(strings={"procurement","trucking"})
|
@ParameterizedTest @ValueSource(strings={"procurement","trucking","looseboard"})
|
||||||
void reviewReleaseImmediatelyReleasesFrozenBudget(String kind) {
|
void reviewReleaseImmediatelyReleasesFrozenBudget(String kind) {
|
||||||
row.setKind(kind);
|
row.setKind(kind);
|
||||||
row.setOrderNo("CG-1"); row.setStatus("FROZEN");
|
row.setOrderNo("CG-1"); row.setStatus("FROZEN");
|
||||||
|
|||||||
+18
@@ -15,6 +15,24 @@ class OrderFundingServiceTest {
|
|||||||
OrderFundingRepository repository = mock(OrderFundingRepository.class);
|
OrderFundingRepository repository = mock(OrderFundingRepository.class);
|
||||||
OrderFundingService service = new OrderFundingService(repository);
|
OrderFundingService service = new OrderFundingService(repository);
|
||||||
|
|
||||||
|
/** 散板成功后进入原待审核状态,失败可重试;重复或取消后的回执不复活原单。 */
|
||||||
|
@Test void looseboardFundingPreservesAuditGateAndIgnoresLateResults() {
|
||||||
|
var budget = budget("looseboard");
|
||||||
|
var order = new com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder();
|
||||||
|
order.setId(1L);order.setCustomerId("9");order.setStatus("FUNDING_PENDING");order.setVersion(0L);order.setRemark("客户备注");
|
||||||
|
when(repository.looseboard("9","NO1")).thenReturn(order);
|
||||||
|
service.apply(budget,"FUNDING_FAILED","可用余额和授信额度不足");
|
||||||
|
assertThat(order.getStatus()).isEqualTo("FUNDING_FAILED");
|
||||||
|
service.apply(budget,"FUNDING_PENDING",null);
|
||||||
|
service.apply(budget,"PENDING_REVIEW",null);
|
||||||
|
service.apply(budget,"PENDING_REVIEW",null);
|
||||||
|
assertThat(order.getStatus()).isEqualTo("PENDING_ACCEPTANCE");
|
||||||
|
assertThat(order.getVersion()).isEqualTo(3L);assertThat(order.getRemark()).isEqualTo("客户备注");
|
||||||
|
order.setStatus("CANCELLED");service.apply(budget,"PENDING_REVIEW",null);
|
||||||
|
assertThat(order.getStatus()).isEqualTo("CANCELLED");
|
||||||
|
verify(repository,times(3)).history(eq(order),anyString(),anyString(),nullable(String.class),any());
|
||||||
|
}
|
||||||
|
|
||||||
/** 采购冻结成功后才待审核,重复成功无第二条历史。 */
|
/** 采购冻结成功后才待审核,重复成功无第二条历史。 */
|
||||||
@Test void purchaseSuccessIsIdempotentAndPreservesRemark() {
|
@Test void purchaseSuccessIsIdempotentAndPreservesRemark() {
|
||||||
var budget = budget("procurement");
|
var budget = budget("procurement");
|
||||||
|
|||||||
@@ -136,7 +136,8 @@ public class UnifiedOrderQueryService {
|
|||||||
OrderEntity order = authority.get(row.orderId());
|
OrderEntity order = authority.get(row.orderId());
|
||||||
if (order == null || !Objects.equals(order.getTenantId(), row.tenantId())
|
if (order == null || !Objects.equals(order.getTenantId(), row.tenantId())
|
||||||
|| !ACTIVE_ORDER_STATUSES.contains(order.getStatus())) continue;
|
|| !ACTIVE_ORDER_STATUSES.contains(order.getStatus())) continue;
|
||||||
String status = order.getStatus() == OrderStatus.SUBMITTED ? "PENDING_REVIEW"
|
// 待审核主状态仍由 Order 决定,提前测量提交产生的独立审核任务须保留。
|
||||||
|
String status = order.getStatus() == OrderStatus.SUBMITTED && !"MEASUREMENT_REVIEW".equals(row.mainStatus()) ? "PENDING_REVIEW"
|
||||||
: order.getStatus() == OrderStatus.COMPLETED && !WORKFLOW_STATUSES.contains(row.mainStatus()) ? "COMPLETED" : row.mainStatus();
|
: order.getStatus() == OrderStatus.COMPLETED && !WORKFLOW_STATUSES.contains(row.mainStatus()) ? "COMPLETED" : row.mainStatus();
|
||||||
if (status != null) result.add(new FirstlegUnifiedOrderClient.StatusRow(row.tenantId(), row.orderId(), status));
|
if (status != null) result.add(new FirstlegUnifiedOrderClient.StatusRow(row.tenantId(), row.orderId(), status));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -421,7 +421,7 @@ paths:
|
|||||||
需要平台身份和 platform:order:list;tenantId 省略时按授权平台范围查询全部客户。
|
需要平台身份和 platform:order:list;tenantId 省略时按授权平台范围查询全部客户。
|
||||||
按统一列表非分页、非 mainStatus 筛选全量去重;忽略主状态和分页,不返回全部数量。
|
按统一列表非分页、非 mainStatus 筛选全量去重;忽略主状态和分页,不返回全部数量。
|
||||||
订单审核与完成状态优先于履约投影,依赖异常返回业务错误,不能以零代替失败。
|
订单审核与完成状态优先于履约投影,依赖异常返回业务错误,不能以零代替失败。
|
||||||
MEASUREMENT_REVIEW 为独立任务,暂不返回该 key。
|
MEASUREMENT_REVIEW 返回独立测量审核任务数量;待审核订单提前测量并提交审核后可同时计入 PENDING_REVIEW 与 MEASUREMENT_REVIEW。
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: '#/components/parameters/UnifiedTaxMode'
|
- $ref: '#/components/parameters/UnifiedTaxMode'
|
||||||
- {name: tenantId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
- {name: tenantId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||||
@@ -1743,7 +1743,7 @@ components:
|
|||||||
name: mainStatus
|
name: mainStatus
|
||||||
in: query
|
in: query
|
||||||
required: false
|
required: false
|
||||||
description: 统一订单主状态及业务页签筛选;WAIT_PALLETIZE、WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询。WAIT_CUSTOMS_DOCS 为实际装柜后待上传客户报关资料;EXPORT_CUSTOMS 为本票报关资料已上传且实际装柜完成、尚未报关完成或实际开船的汇总,买单票无需客户退税资料,不以点击开始报关为前提;SAILING_CLEARANCE 在整柜报关完成后进入,也接受实际离港事实;清关资料保存成功后才进入 WAIT_UNLOAD/WAIT_DISPATCH。WAIT_UNLOAD 持续到实际卸柜或生成有效出库单;生成出库单后进入 OUTBOUND_DOCUMENT,实际出库后进入 DELIVERING,派送完成进入 SIGNED。
|
description: 统一订单主状态及业务页签筛选;WAIT_PALLETIZE、WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询。WAIT_CUSTOMS_DOCS 为实际装柜后待上传客户报关资料;EXPORT_CUSTOMS 为本票报关资料已上传且实际装柜完成、尚未报关完成或实际开船的汇总,买单票无需客户退税资料,不以点击开始报关为前提;SAILING_CLEARANCE 在整柜报关完成后进入,也接受实际离港事实;清关资料保存成功后才进入 WAIT_UNLOAD/WAIT_DISPATCH。WAIT_UNLOAD 持续到实际卸柜或生成有效出库单;尾程排车理货审核通过并生成有效出库单后进入 OUTBOUND_DOCUMENT,不以前置开船节点或清关资料为条件;取消或实际出库后移出,实际出库并发车后进入 DELIVERING,派送完成进入 SIGNED。
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED, COMPLETED, WAIT_WEIGHT_CONFIRM, WEIGHT_CONFIRMED, WAIT_CUSTOMS_DOCS, EXPORT_CUSTOMS, SAILING_CLEARANCE, WAIT_UNLOAD, WAIT_DISPATCH, OUTBOUND_DOCUMENT, DELIVERING, SIGNED, MEASUREMENT_REVIEW]
|
enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED, COMPLETED, WAIT_WEIGHT_CONFIRM, WEIGHT_CONFIRMED, WAIT_CUSTOMS_DOCS, EXPORT_CUSTOMS, SAILING_CLEARANCE, WAIT_UNLOAD, WAIT_DISPATCH, OUTBOUND_DOCUMENT, DELIVERING, SIGNED, MEASUREMENT_REVIEW]
|
||||||
|
|||||||
@@ -1012,6 +1012,35 @@ class UnifiedOrderQueryServiceTest {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 提前测量提交后同时保留订单待审核和测量审核待办,统计与列表一致。 */
|
||||||
|
@Test
|
||||||
|
void submittedOrderRetainsIndependentMeasurementReviewTask() {
|
||||||
|
var pending = order(OrderStatus.SUBMITTED);
|
||||||
|
when(orders.unifiedStatusOrders(eq(10001L), any(), any())).thenReturn(List.of(pending));
|
||||||
|
when(orders.statusAuthorities(eq(10001L), any())).thenReturn(List.of(pending));
|
||||||
|
when(orders.findByIds(eq(10001L), any())).thenReturn(List.of(pending));
|
||||||
|
when(firstleg.statusRows(eq("internal-token"), any())).thenReturn(ApiResponse.success(List.of(
|
||||||
|
new FirstlegUnifiedOrderClient.StatusRow(10001L, 77L, "WAIT_MEASURE"),
|
||||||
|
new FirstlegUnifiedOrderClient.StatusRow(10001L, 77L, "MEASUREMENT_REVIEW"),
|
||||||
|
new FirstlegUnifiedOrderClient.StatusRow(10001L, 77L, "MEASUREMENT_REVIEW"))));
|
||||||
|
var projected = summary("WAIT_MEASURE", null);
|
||||||
|
when(projected.inbound().processStatus()).thenReturn("MEASURE_PENDING");
|
||||||
|
when(firstleg.statusSummaries(eq("internal-token"), any()))
|
||||||
|
.thenReturn(ApiResponse.success(List.of(projected)));
|
||||||
|
|
||||||
|
var counts = service.statusCounts(10001L, query(null));
|
||||||
|
var page = service.page(10001L, query("MEASUREMENT_REVIEW"));
|
||||||
|
|
||||||
|
assertThat(counts.counts()).containsEntry("PENDING_REVIEW", 1L)
|
||||||
|
.containsEntry("MEASUREMENT_REVIEW", 1L).containsEntry("WAIT_MEASURE", 0L);
|
||||||
|
assertThat(page.total()).isEqualTo(1L);
|
||||||
|
assertThat(page.records()).singleElement().satisfies(row -> {
|
||||||
|
assertThat(row.mainStatus()).isEqualTo("PENDING_REVIEW");
|
||||||
|
assertThat(row.nextAction()).isEqualTo("APPROVE_ORDER");
|
||||||
|
assertThat(row.inboundId()).isEqualTo(88L);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** 错误响应和跨客户投影均失败关闭,不能返回看似可信的零。 */
|
/** 错误响应和跨客户投影均失败关闭,不能返回看似可信的零。 */
|
||||||
@Test
|
@Test
|
||||||
void statusCountsRejectDependencyFailuresAndForeignCustomerRows() {
|
void statusCountsRejectDependencyFailuresAndForeignCustomerRows() {
|
||||||
|
|||||||
Reference in New Issue
Block a user