提交
This commit is contained in:
+18
@@ -157,6 +157,24 @@ public class InternalInboundOrderController {
|
||||
return ApiResponse.success(workbenchFilters.read(request));
|
||||
}
|
||||
|
||||
/** 当前页摘要继续应用原筛选,防止父子或分片入库投影错配。 */
|
||||
@PostMapping("/unified-orders/status-summaries")
|
||||
public ApiResponse<List<UnifiedInboundSummary>> unifiedStatusSummaries(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@Valid @RequestBody UnifiedInboundQuery request) {
|
||||
requireToken(token);
|
||||
return ApiResponse.success(unifiedQueryService.statusSummaries(request));
|
||||
}
|
||||
|
||||
/** 服务令牌保护的轻量状态投影,供 Order 统计和分页校正。 */
|
||||
@PostMapping("/unified-orders/status-rows")
|
||||
public ApiResponse<List<UnifiedInboundQueryService.StatusRow>> unifiedStatusRows(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@Valid @RequestBody UnifiedInboundQuery request) {
|
||||
requireToken(token);
|
||||
return ApiResponse.success(unifiedQueryService.statusRows(request));
|
||||
}
|
||||
|
||||
@PostMapping("/unified-orders/query")
|
||||
public ApiResponse<PageResult<UnifiedInboundSummary>> unifiedPage(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
|
||||
+5
-5
@@ -140,7 +140,7 @@ public class PlatformContainerController {
|
||||
public ApiResponse<BusinessDocument> clearance(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long id) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
service.requireOverseas(id);
|
||||
service.requireClearanceAccess(id);
|
||||
return ApiResponse.success(loadingLists.containerClearance(id));
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ public class PlatformContainerController {
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
|
||||
@PathVariable @Positive long fileId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
service.requireOverseas(id);
|
||||
service.requireClearanceAccess(id);
|
||||
var download = loadingLists.downloadContainerClearance(id, fileId);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.attachment()
|
||||
@@ -168,7 +168,7 @@ public class PlatformContainerController {
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody DocumentAttachRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
service.requireOverseas(id);
|
||||
service.requireClearanceAccess(id);
|
||||
return ApiResponse.success(loadingLists.attachContainerClearance(
|
||||
id, user.userId(), user.username(), key, request));
|
||||
}
|
||||
@@ -183,7 +183,7 @@ public class PlatformContainerController {
|
||||
@RequestParam("expectedVersion") @Min(0) int expectedVersion,
|
||||
@RequestParam(value = "operationReason", required = false) @Size(max = 500) String operationReason) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
service.requireOverseas(id);
|
||||
service.requireClearanceAccess(id);
|
||||
return ApiResponse.success(loadingLists.uploadContainerClearance(
|
||||
id, user.userId(), user.username(), key, expectedVersion, operationReason, file));
|
||||
}
|
||||
@@ -196,7 +196,7 @@ public class PlatformContainerController {
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody DocumentCompleteRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
service.requireOverseas(id);
|
||||
service.requireClearanceAccess(id);
|
||||
return ApiResponse.success(loadingLists.completeContainerClearance(
|
||||
id, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ public record UnifiedInboundQuery(
|
||||
@Size(max = 64) String keyword,
|
||||
@Size(max = 64) String orderNo,
|
||||
@Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "WAIT_SPLIT",
|
||||
"LOADING", "LOADED", "DELIVERED"})
|
||||
"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"})
|
||||
@Size(max = 32) String mainStatus,
|
||||
Long customerId,
|
||||
Long receivingWarehouseId,
|
||||
|
||||
@@ -158,6 +158,14 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
|
||||
""")
|
||||
int hasActiveOutboundDeparture(@Param("id") long id);
|
||||
|
||||
/** 手动或拉取的实际开船节点均可作为清关资料入口依据。 */
|
||||
@Select("""
|
||||
SELECT COUNT(1) FROM fl_carrier_schedule_node_fact
|
||||
WHERE container_booking_id=#{id} AND standard_node='DEPARTED_ORIGIN_PORT'
|
||||
AND actual_at IS NOT NULL
|
||||
""")
|
||||
int hasActualVesselDeparture(@Param("id") long id);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_container_booking SET pickup_code=#{pickupCode},pickup_code_updated_by=#{operatorId},
|
||||
pickup_code_updated_by_name=#{operatorName},pickup_code_updated_at=CURRENT_TIMESTAMP(3),
|
||||
|
||||
@@ -25,6 +25,8 @@ public class ContainerRepository {
|
||||
public ContainerBookingEntity findForUpdate(long id) { return mapper.selectBookingForUpdate(id); }
|
||||
public ContainerBookingEntity findActiveForUpdate(long id) { return mapper.selectActiveBookingForUpdate(id); }
|
||||
public boolean departed(long id) { return mapper.hasActiveOutboundDeparture(id) > 0; }
|
||||
/** 读取本服务维护的实际开船事实,不依赖第三方查询任务是否建立。 */
|
||||
public boolean sailed(long id) { return mapper.hasActualVesselDeparture(id) > 0; }
|
||||
public boolean containerNoExists(long oceanBookingId, String no, Long excludedId) {
|
||||
if (no == null) return false;
|
||||
return mapper.selectCount(new LambdaQueryWrapper<ContainerBookingEntity>()
|
||||
|
||||
@@ -114,9 +114,27 @@ public class InboundOrderRepository {
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
/** Internal unified-query accessors. They always retain the tenant predicate when one is supplied. */
|
||||
/** 统一订单列表按共享筛选分页。 */
|
||||
public Page<InboundOrderEntity> pageUnifiedEntities(UnifiedInboundQuery query, List<String> processStatuses) {
|
||||
LambdaQueryWrapper<InboundOrderEntity> wrapper = new LambdaQueryWrapper<InboundOrderEntity>()
|
||||
return orders.selectPage(new Page<>(query.page(), query.size()), unifiedFilter(query, processStatuses));
|
||||
}
|
||||
|
||||
/** 只读取身份和流程状态,顺序与列表一致;空候选不能扩大查询范围。 */
|
||||
public List<InboundOrderEntity> unifiedStatusEntities(UnifiedInboundQuery query) {
|
||||
if (query.candidateOrderIds() != null && query.candidateOrderIds().isEmpty()) return List.of();
|
||||
return orders.selectList(unifiedFilter(query, List.of()).select(InboundOrderEntity::getId,
|
||||
InboundOrderEntity::getTenantId, InboundOrderEntity::getOrderId, InboundOrderEntity::getProcessStatus));
|
||||
}
|
||||
|
||||
/** 当前页候选订单沿原筛选读取匹配的入库记录,避免读到另一条不匹配投影。 */
|
||||
public List<InboundOrderEntity> unifiedMatchedEntities(UnifiedInboundQuery query, List<String> processStatuses) {
|
||||
if (query.candidateOrderIds() == null || query.candidateOrderIds().isEmpty()) return List.of();
|
||||
return orders.selectList(unifiedFilter(query, processStatuses));
|
||||
}
|
||||
|
||||
/** 列表与统计共享客户隔离、费用、测量及船期等筛选语义。 */
|
||||
private LambdaQueryWrapper<InboundOrderEntity> unifiedFilter(UnifiedInboundQuery query, List<String> processStatuses) {
|
||||
return new LambdaQueryWrapper<InboundOrderEntity>()
|
||||
.eq(query.tenantId() != null, InboundOrderEntity::getTenantId, query.tenantId())
|
||||
.eq(query.taxMode() != null, InboundOrderEntity::getTaxMode, query.taxMode())
|
||||
.in(processStatuses != null && !processStatuses.isEmpty(), InboundOrderEntity::getProcessStatus, processStatuses)
|
||||
@@ -187,7 +205,6 @@ public class InboundOrderRepository {
|
||||
.ge(query.createdFrom() != null, InboundOrderEntity::getCreatedAt, query.createdFrom())
|
||||
.le(query.createdTo() != null, InboundOrderEntity::getCreatedAt, query.createdTo())
|
||||
.orderByDesc(InboundOrderEntity::getCreatedAt).orderByDesc(InboundOrderEntity::getId);
|
||||
return orders.selectPage(new Page<>(query.page(), query.size()), wrapper);
|
||||
}
|
||||
|
||||
public List<InboundOrderEntity> findByOrderIds(Long tenantId, List<Long> orderIds) {
|
||||
|
||||
@@ -45,6 +45,13 @@ public class ContainerService {
|
||||
if (!repository.departed(id)) throw new BizException(ResultCode.NOT_FOUND, "国外工作台货柜不存在");
|
||||
}
|
||||
|
||||
/** 目的港清关资料允许已离仓或已实际开船货柜维护,不改写国内出仓事实。 */
|
||||
public void requireClearanceAccess(long id) {
|
||||
required(id);
|
||||
if (!repository.departed(id) && !repository.sailed(id))
|
||||
throw new BizException(ResultCode.NOT_FOUND, "货柜尚未离仓或实际开船,不能维护目的港清关资料");
|
||||
}
|
||||
|
||||
public void requireDomestic(long id) {
|
||||
required(id);
|
||||
if (repository.departed(id)) throw new BizException(ResultCode.NOT_FOUND, "国内工作台货柜不存在");
|
||||
|
||||
+3
-1
@@ -9,7 +9,9 @@ import org.springframework.stereotype.Service;
|
||||
public class MerchantWorkbenchFilterService {
|
||||
@Resource private MerchantWorkbenchFilterMapper mapper;
|
||||
@Resource private UnifiedInboundQueryService unified;
|
||||
@Resource private com.baiyue.firstleg.workflow.ChargeableWeightService weights;
|
||||
|
||||
/** 精确详情只读进度;工作台待确认队列使用计费重事实,金额确认不能代替。 */
|
||||
@org.springframework.transaction.annotation.Transactional(readOnly=true)
|
||||
public MerchantWorkbenchFilters.Response read(MerchantWorkbenchFilters.Request request) {
|
||||
if (request.orderIds() != null) {
|
||||
@@ -19,7 +21,7 @@ public class MerchantWorkbenchFilterService {
|
||||
return new MerchantWorkbenchFilters.Response(
|
||||
request.mainStatus() == null ? null : mapper.byProcessStatuses(request.tenantId(),
|
||||
unified.internalStatuses(request.mainStatus())),
|
||||
mapper.pendingFees(request.tenantId()), mapper.pendingCustoms(request.tenantId()),
|
||||
weights.pendingOrders(request.tenantId()), mapper.pendingCustoms(request.tenantId()),
|
||||
progress(mapper.progressFragments(request.tenantId(), null)));
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,38 @@ public class UnifiedInboundQueryService {
|
||||
@Resource private FbaPricingRepository pricing;
|
||||
@Resource private UnifiedInboundReadRepository operations;
|
||||
@Resource private ObjectMapper json;
|
||||
@Resource private com.baiyue.firstleg.workflow.WorkflowTabService workflowTabs;
|
||||
|
||||
/** 以稳定列表顺序返回去重履约状态,不触发逐票摘要及费用查询。 */
|
||||
public List<StatusRow> statusRows(UnifiedInboundQuery query) {
|
||||
validate(query);
|
||||
Map<String, StatusRow> byOrder = new LinkedHashMap<>();
|
||||
List<InboundOrderEntity> matched = inbounds.unifiedStatusEntities(query);
|
||||
for (InboundOrderEntity inbound : matched) {
|
||||
String status = mainStatus(inbound.getProcessStatus());
|
||||
// 分片订单可同时命中不同履约页签;同一页签内每个订单只统计一次。
|
||||
byOrder.putIfAbsent(inbound.getOrderId() + ":" + status, new StatusRow(inbound.getTenantId(),
|
||||
inbound.getOrderId(), status));
|
||||
}
|
||||
workflowTabs.rows(matched).forEach(row -> byOrder.putIfAbsent(row.orderId() + ":" + row.mainStatus(), row));
|
||||
return List.copyOf(byOrder.values());
|
||||
}
|
||||
|
||||
/** 只对当前页最多200个订单读取匹配摘要,选取规则与状态投影一致。 */
|
||||
public List<UnifiedInboundSummary> statusSummaries(UnifiedInboundQuery query) {
|
||||
validate(query);
|
||||
if (query.candidateOrderIds() == null || query.candidateOrderIds().size() > 200) {
|
||||
throw new com.baiyue.common.core.exception.BizException(
|
||||
com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "状态摘要必须提供最多200个候选订单");
|
||||
}
|
||||
Map<Long, InboundOrderEntity> byOrder = new LinkedHashMap<>();
|
||||
for (InboundOrderEntity inbound : inbounds.unifiedMatchedEntities(query, internalStatuses(query.mainStatus()))) {
|
||||
byOrder.putIfAbsent(inbound.getOrderId(), inbound);
|
||||
}
|
||||
return summaries(List.copyOf(byOrder.values()));
|
||||
}
|
||||
|
||||
public record StatusRow(Long tenantId, Long orderId, String mainStatus) {}
|
||||
|
||||
public PageResult<UnifiedInboundSummary> page(UnifiedInboundQuery query) {
|
||||
validate(query);
|
||||
@@ -43,11 +75,45 @@ public class UnifiedInboundQueryService {
|
||||
|| query.candidateOrderIds() != null && query.candidateOrderIds().isEmpty()) {
|
||||
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
|
||||
}
|
||||
if (StringUtils.hasText(query.mainStatus())
|
||||
&& com.baiyue.firstleg.workflow.WorkflowTabService.KEYS.contains(query.mainStatus())) {
|
||||
return workflowPage(query);
|
||||
}
|
||||
Page<InboundOrderEntity> source = inbounds.pageUnifiedEntities(query, internalStatuses(query.mainStatus()));
|
||||
List<UnifiedInboundSummary> records = summaries(source.getRecords());
|
||||
return new PageResult<>(records, source.getCurrent(), source.getSize(), source.getTotal(), source.getPages());
|
||||
}
|
||||
|
||||
/** 独立事实页签先筛选完整来源再分页,内部查询也不能把业务页签解释成全部状态。 */
|
||||
private PageResult<UnifiedInboundSummary> workflowPage(UnifiedInboundQuery query) {
|
||||
List<InboundOrderEntity> matched = inbounds.unifiedStatusEntities(query);
|
||||
Set<Long> matchingOrders = workflowTabs.rows(matched).stream()
|
||||
.filter(row -> query.mainStatus().equals(row.mainStatus())).map(StatusRow::orderId)
|
||||
.collect(Collectors.toSet());
|
||||
Map<Long, InboundOrderEntity> unique = new LinkedHashMap<>();
|
||||
// 轻量事实不能用于摘要,重新读取当前条件下的完整来源。
|
||||
for (InboundOrderEntity inbound : inbounds.unifiedMatchedEntities(
|
||||
withCandidates(query, matchingOrders.stream().toList()), List.of())) {
|
||||
if (matchingOrders.contains(inbound.getOrderId())) unique.putIfAbsent(inbound.getOrderId(), inbound);
|
||||
}
|
||||
List<InboundOrderEntity> records = List.copyOf(unique.values());
|
||||
long total = records.size();
|
||||
long start = query.page() - 1 > Long.MAX_VALUE / query.size()
|
||||
? total : Math.min((query.page() - 1) * query.size(), total);
|
||||
var current = records.subList((int) start, (int) Math.min(start + query.size(), total));
|
||||
return new PageResult<>(summaries(current), query.page(), query.size(), total,
|
||||
total / query.size() + (total % query.size() == 0 ? 0 : 1));
|
||||
}
|
||||
|
||||
/** 保留全部过滤条件,只收窄为业务事实已命中的订单。 */
|
||||
private UnifiedInboundQuery withCandidates(UnifiedInboundQuery q, List<Long> candidates) {
|
||||
return new UnifiedInboundQuery(q.tenantId(), q.keyword(), q.orderNo(), q.mainStatus(), q.customerId(),
|
||||
q.receivingWarehouseId(), q.destinationWarehouseId(), q.inboundCode(), q.internalOrderStatus(),
|
||||
q.expectedArrivalDate(), q.feeReviewStatus(), q.channelName(), q.containerNo(), q.cabinetNo(),
|
||||
q.sailingDate(), q.loaded(), q.fullContainer(), q.measurementReviewStatus(), candidates,
|
||||
q.createdFrom(), q.createdTo(), q.page(), q.size(), q.taxMode());
|
||||
}
|
||||
|
||||
public List<UnifiedInboundSummary> summaries(Long tenantId, List<Long> orderIds) {
|
||||
Map<Long, UnifiedInboundSummary> byOrder = new LinkedHashMap<>();
|
||||
for (UnifiedInboundSummary item : summaries(inbounds.findByOrderIds(tenantId, orderIds)))
|
||||
@@ -203,7 +269,7 @@ public class UnifiedInboundQueryService {
|
||||
}
|
||||
|
||||
List<String> internalStatuses(String mainStatus) {
|
||||
if (!StringUtils.hasText(mainStatus)) return List.of();
|
||||
if (!StringUtils.hasText(mainStatus) || com.baiyue.firstleg.workflow.WorkflowTabService.KEYS.contains(mainStatus)) return List.of();
|
||||
return switch (mainStatus) {
|
||||
case "WAIT_MEASURE" -> List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
|
||||
"WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING");
|
||||
@@ -231,6 +297,7 @@ public class UnifiedInboundQueryService {
|
||||
}
|
||||
if (StringUtils.hasText(query.mainStatus())
|
||||
&& !MAIN_STATUSES.contains(query.mainStatus())
|
||||
&& !com.baiyue.firstleg.workflow.WorkflowTabService.KEYS.contains(query.mainStatus())
|
||||
) {
|
||||
throw new com.baiyue.common.core.exception.BizException(
|
||||
com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "统一订单主状态不合法");
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.baiyue.firstleg.workflow;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import java.util.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
/** 客户独立确认计费重,金额确认和主流程不受影响。 */
|
||||
@Service
|
||||
public class ChargeableWeightService {
|
||||
private final WorkflowFactRepository repository;
|
||||
/** 注入当前事实和确认历史。 */
|
||||
public ChargeableWeightService(WorkflowFactRepository repository) { this.repository=repository; }
|
||||
/** 客户仅能读取本订单已审核的计费重。 */
|
||||
public List<WorkflowFacts.Weight> read(long tenant,long orderId) {
|
||||
return repository.facts(repository.orderInbounds(tenant,orderId)).stream().filter(f->f.tenantId()==tenant && eligible(f)).map(ChargeableWeightService::weight).toList();
|
||||
}
|
||||
/** 客户工作台按独立确认事实计算待办,不借用费用确认。 */
|
||||
public List<Long> pendingOrders(long tenant) {
|
||||
var ids=repository.weightCandidates(tenant);var orders=new LinkedHashSet<Long>();
|
||||
for(int offset=0;offset<ids.size();offset+=500) for(var f:repository.facts(ids.subList(offset,Math.min(offset+500,ids.size()))))
|
||||
if(f.tenantId()==tenant && eligible(f) && !weight(f).confirmed()) orders.add(f.orderId());
|
||||
return List.copyOf(orders);
|
||||
}
|
||||
/** 值和测量轮次变化使旧确认失效,金额变化不影响计费重。 */
|
||||
public static String fingerprint(WorkflowFacts.Fact f) {
|
||||
try { String value=f.inboundId()+"|"+f.chargeableWeightKg().stripTrailingZeros().toPlainString()+"|"+f.measureCompletedAt();
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch(java.security.NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
|
||||
}
|
||||
/** 未审核或非正数不允许确认。 */
|
||||
public static boolean eligible(WorkflowFacts.Fact f) { return "APPROVED".equals(f.reviewStatus()) && f.chargeableWeightKg()!=null && f.chargeableWeightKg().signum()>0; }
|
||||
/** 只认可当前版本的确认。 */
|
||||
public static WorkflowFacts.Weight weight(WorkflowFacts.Fact f) {
|
||||
String hash=fingerprint(f);boolean confirmed=hash.equals(f.confirmationFingerprint());
|
||||
return new WorkflowFacts.Weight(f.inboundId(),f.orderId(),f.chargeableWeightKg(),hash,confirmed,confirmed?f.confirmedAt():null);
|
||||
}
|
||||
/** 入仓锁、预期版本和幂等键防止确认旧值或异参重放。 */
|
||||
@Transactional
|
||||
public WorkflowFacts.Weight confirm(long tenant,long operator,long id,String key,WorkflowFacts.Confirm request) {
|
||||
if(key==null || key.isBlank() || key.length()>128) throw new BizException(ResultCode.BAD_REQUEST,"确认幂等键不能为空且最多128字符");
|
||||
var inbound=repository.lock(tenant,id);
|
||||
if(inbound==null || "CANCELLED".equals(inbound.getProcessStatus())) throw new BizException(ResultCode.NOT_FOUND,"入仓单不存在");
|
||||
var replay=repository.command(tenant,operator,key);
|
||||
if(replay!=null && (replay.inboundId()!=id || !replay.fingerprint().equals(request.expectedFingerprint()))) throw new BizException(ResultCode.CONFLICT,"同一幂等键不能确认不同计费重");
|
||||
var facts=repository.facts(List.of(id));
|
||||
if(facts.isEmpty()) throw new BizException(ResultCode.CONFLICT,"请分别确认分柜后的独立订单计费重");
|
||||
var f=facts.get(0);
|
||||
if(!eligible(f)) throw new BizException(ResultCode.CONFLICT,"计费重尚未审核通过");
|
||||
var current=weight(f);
|
||||
if(!current.fingerprint().equals(request.expectedFingerprint())) throw new BizException(ResultCode.CONFLICT,"计费重已变化,请刷新后重新确认");
|
||||
if(replay==null) repository.insert(new WorkflowFacts.Confirmation(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId(),tenant,id,operator,key,current.fingerprint(),current.chargeableWeightKg(),LocalDateTime.now()));
|
||||
return weight(repository.facts(List.of(id)).get(0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.baiyue.firstleg.workflow;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.List;
|
||||
/** 经内部接口读取尾程,禁止跨库查询。 */
|
||||
@FeignClient(name="lastleg-service",contextId="firstlegWorkflowFacts",url="${LASTLEG_SERVICE_URL:${baiyue.clients.lastleg.url:}}")
|
||||
public interface LastlegWorkflowClient {
|
||||
/** 最多500个精确来源,不转发客户JWT。 */
|
||||
@PostMapping("/internal/lastleg/v1/order-workflow-facts")
|
||||
ApiResponse<List<WorkflowFacts.Overseas>> read(@RequestHeader("X-Internal-Service-Token") String token,@RequestBody WorkflowFacts.Request request);
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.baiyue.firstleg.workflow;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
/** 客户计费重确认入口,独立于费用确认。 */
|
||||
@RestController
|
||||
@RequestMapping("/api/merchant/v1/first-leg")
|
||||
public class MerchantChargeableWeightController {
|
||||
private final ChargeableWeightService service;
|
||||
/** 注入独立确认服务。 */
|
||||
public MerchantChargeableWeightController(ChargeableWeightService service) { this.service=service; }
|
||||
/** 客户范围取认证上下文。 */
|
||||
@GetMapping("/orders/{orderId}/chargeable-weights")
|
||||
@PreAuthorize("hasAuthority('merchant:order:view')")
|
||||
public ApiResponse<List<WorkflowFacts.Weight>> read(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long orderId) { return ApiResponse.success(service.read(FirstlegScopeGuard.merchant(user),orderId)); }
|
||||
/** 不触发费用确认与金额锁定。 */
|
||||
@com.baiyue.common.audit.BusinessAudit(event="CUSTOMER_CHARGEABLE_WEIGHT_CONFIRM",resourceType="INBOUND_ORDER",resourceId="#inboundId",description="客户独立确认计费重")
|
||||
@PostMapping("/chargeable-weights/{inboundId}/confirm")
|
||||
@PreAuthorize("hasAuthority('merchant:order:edit')")
|
||||
public ApiResponse<WorkflowFacts.Weight> confirm(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long inboundId,@RequestHeader("Idempotency-Key") String key,@Valid @RequestBody WorkflowFacts.Confirm request) { return ApiResponse.success(service.confirm(FirstlegScopeGuard.merchant(user),user.userId(),inboundId,key,request)); }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.baiyue.firstleg.workflow;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.List;
|
||||
/** 柜事实和独立确认SQL入口。 */
|
||||
@Mapper
|
||||
public interface WorkflowFactMapper {
|
||||
String ACTUAL="""
|
||||
FROM fl_loading_plan_item li JOIN fl_loading_plan p ON p.id=li.loading_plan_id AND p.plan_status='COMPLETED'
|
||||
JOIN fl_loading_pick_task t ON t.loading_plan_id=p.id AND t.task_status='COMPLETED'
|
||||
JOIN fl_loading_pick_item pk ON pk.pick_task_id=t.id AND pk.loading_plan_item_id=li.id
|
||||
JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE'
|
||||
WHERE li.inbound_id=i.id AND li.account_id=i.tenant_id AND li.deleted=0
|
||||
AND pk.item_status IN ('PICKED','PARTIAL') AND pk.picked_package_count>0
|
||||
""";
|
||||
/** 按独立实装票核验资料;来源单承接实装时,余量子单不能将来源票排除。 */
|
||||
@Select("<script>SELECT i.tenant_id,i.id inbound_id,i.order_id,i.measure_review_status review_status,i.chargeable_weight_kg,i.measure_completed_at,"
|
||||
+"EXISTS(SELECT 1 "+ACTUAL+" AND EXISTS(SELECT 1 FROM fl_carrier_schedule_node_fact n WHERE n.container_booking_id=b.id AND n.standard_node='DEPARTED_ORIGIN_PORT' AND n.actual_at IS NOT NULL)) departed,"
|
||||
+"EXISTS(SELECT 1 "+ACTUAL+" AND EXISTS(SELECT 1 FROM fl_container_document d WHERE d.container_booking_id=b.id AND d.document_type='DESTINATION_CLEARANCE' AND d.file_id IS NOT NULL)) clearance_uploaded,"
|
||||
+"EXISTS(SELECT 1 "+ACTUAL+" AND i.tax_mode='TAX_REFUND' AND NOT EXISTS(SELECT 1 FROM fl_customs_customer_document_inbound s JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.tenant_id=i.tenant_id AND d.status='ACTIVE' JOIN fl_customs_container c ON c.id=d.customs_container_id AND c.loading_plan_id=p.id WHERE s.inbound_id=i.id AND s.tenant_id=i.tenant_id)) missing_customs,"
|
||||
+"EXISTS(SELECT 1 "+ACTUAL+" AND (i.tax_mode='BUY_DECLARATION' OR EXISTS(SELECT 1 FROM fl_customs_customer_document_inbound s JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.tenant_id=i.tenant_id AND d.status='ACTIVE' JOIN fl_customs_container c ON c.id=d.customs_container_id AND c.loading_plan_id=p.id WHERE s.inbound_id=i.id AND s.tenant_id=i.tenant_id))) export_customs_ready,"
|
||||
+"(SELECT w.fingerprint FROM fl_chargeable_weight_confirmation w WHERE w.tenant_id=i.tenant_id AND w.inbound_id=i.id ORDER BY w.id DESC LIMIT 1) confirmation_fingerprint,"
|
||||
+"(SELECT w.confirmed_at FROM fl_chargeable_weight_confirmation w WHERE w.tenant_id=i.tenant_id AND w.inbound_id=i.id ORDER BY w.id DESC LIMIT 1) confirmed_at "
|
||||
+"FROM fl_inbound_order i WHERE i.deleted=0 AND NOT EXISTS(SELECT 1 FROM fl_inbound_order child WHERE child.tenant_id=i.tenant_id AND child.parent_inbound_id=i.id AND child.inbound_role='CHILD' AND (child.split_purpose IS NULL OR child.split_purpose != 'REMAINDER') AND child.deleted=0) AND i.process_status != 'CANCELLED' AND i.id IN "
|
||||
+"<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
|
||||
List<WorkflowFacts.Fact> facts(@Param("ids") List<Long> ids);
|
||||
/** 锁定客户自己的入仓单,串行确认与测量写入。 */
|
||||
@Select("SELECT * FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{id} AND deleted=0 FOR UPDATE")
|
||||
com.baiyue.firstleg.entity.InboundOrderEntity lock(@Param("tenantId") long tenantId,@Param("id") long id);
|
||||
/** 客户订单下全部独立入仓单。 */
|
||||
@Select("SELECT id FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND order_id=#{orderId} AND deleted=0 AND process_status != 'CANCELLED' ORDER BY id")
|
||||
List<Long> orderInbounds(@Param("tenantId") long tenantId,@Param("orderId") long orderId);
|
||||
/** 客户工作台只读取审核通过且有正计费重的来源。 */
|
||||
@Select("SELECT id FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND deleted=0 AND process_status != 'CANCELLED' AND measure_review_status='APPROVED' AND chargeable_weight_kg>0 ORDER BY id")
|
||||
List<Long> weightCandidates(@Param("tenantId") long tenantId);
|
||||
/** 成员幂等键不允许跨确认对象复用。 */
|
||||
@Select("SELECT * FROM fl_chargeable_weight_confirmation WHERE tenant_id=#{tenantId} AND operator_id=#{operatorId} AND command_key=#{key}")
|
||||
WorkflowFacts.Confirmation command(@Param("tenantId") long tenantId,@Param("operatorId") long operatorId,@Param("key") String key);
|
||||
/** 独立记录,不修改费用或物流主状态。 */
|
||||
@Insert("INSERT INTO fl_chargeable_weight_confirmation(id,tenant_id,inbound_id,operator_id,command_key,fingerprint,chargeable_weight_kg,confirmed_at) VALUES(#{id},#{tenantId},#{inboundId},#{operatorId},#{commandKey},#{fingerprint},#{chargeableWeightKg},#{confirmedAt})")
|
||||
int insert(WorkflowFacts.Confirmation value);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.firstleg.workflow;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import java.util.List;
|
||||
/** 本地柜事实与确认记录的持久化入口。 */
|
||||
@Repository
|
||||
public class WorkflowFactRepository {
|
||||
private final WorkflowFactMapper mapper;
|
||||
/** 注入SQL提供者。 */
|
||||
public WorkflowFactRepository(WorkflowFactMapper mapper) { this.mapper=mapper; }
|
||||
/** 空候选不读取数据。 */
|
||||
public List<WorkflowFacts.Fact> facts(List<Long> ids) { return ids.isEmpty()?List.of():mapper.facts(ids); }
|
||||
/** 锁定所属客户来源。 */
|
||||
public com.baiyue.firstleg.entity.InboundOrderEntity lock(long tenant,long id) { return mapper.lock(tenant,id); }
|
||||
/** 查询订单下独立来源。 */
|
||||
public List<Long> orderInbounds(long tenant,long order) { return mapper.orderInbounds(tenant,order); }
|
||||
/** 客户计费重待确认候选。 */
|
||||
public List<Long> weightCandidates(long tenant) { return mapper.weightCandidates(tenant); }
|
||||
/** 读取成员确认命令。 */
|
||||
public WorkflowFacts.Confirmation command(long tenant,long operator,String key) { return mapper.command(tenant,operator,key); }
|
||||
/** 追加确认历史。 */
|
||||
public void insert(WorkflowFacts.Confirmation value) { mapper.insert(value); }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.baiyue.firstleg.workflow;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
/** 页签事实与独立计费重确认契约。 */
|
||||
public final class WorkflowFacts {
|
||||
/** 仅承载查询及确认契约,不允许实例化。 */
|
||||
private WorkflowFacts() {}
|
||||
public record Fact(long tenantId,long inboundId,long orderId,String reviewStatus,BigDecimal chargeableWeightKg,LocalDateTime measureCompletedAt,
|
||||
boolean departed,boolean clearanceUploaded,boolean missingCustoms,boolean exportCustomsReady,String confirmationFingerprint,LocalDateTime confirmedAt) {}
|
||||
public record Source(long tenantId,long inboundId) {}
|
||||
public record Request(List<Source> sources) {}
|
||||
public record Overseas(long tenantId,long inboundId,boolean outboundExists,boolean pendingOutbound,boolean delivering,boolean unloaded) {}
|
||||
public record Weight(long inboundId,long orderId,BigDecimal chargeableWeightKg,String fingerprint,boolean confirmed,LocalDateTime confirmedAt) {}
|
||||
public record Confirm(@jakarta.validation.constraints.Pattern(regexp="[a-f0-9]{64}") @jakarta.validation.constraints.NotNull String expectedFingerprint) {}
|
||||
public record Confirmation(long id,long tenantId,long inboundId,long operatorId,String commandKey,String fingerprint,BigDecimal chargeableWeightKg,LocalDateTime confirmedAt) {}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.baiyue.firstleg.workflow;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.service.UnifiedInboundQueryService.StatusRow;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import java.util.*;
|
||||
/** 将已确认的独立业务维度映射为页签,不改物流主状态。 */
|
||||
@Service
|
||||
public class WorkflowTabService {
|
||||
public static final Set<String> KEYS=Set.of("WAIT_WEIGHT_CONFIRM","WEIGHT_CONFIRMED","WAIT_CUSTOMS_DOCS","EXPORT_CUSTOMS","SAILING_CLEARANCE","WAIT_UNLOAD","WAIT_DISPATCH","OUTBOUND_DOCUMENT","DELIVERING","SIGNED","MEASUREMENT_REVIEW");
|
||||
private final WorkflowFactRepository repository;
|
||||
private final LastlegWorkflowClient lastleg;
|
||||
@Value("${baiyue.security.internal-service-token}") private String token;
|
||||
/** 注入本地事实与尾程内部契约。 */
|
||||
public WorkflowTabService(WorkflowFactRepository repository,LastlegWorkflowClient lastleg) { this.repository=repository;this.lastleg=lastleg; }
|
||||
/** 分批取事实,失败整体关闭,不能把服务异常伪装成零。 */
|
||||
public List<StatusRow> rows(List<InboundOrderEntity> inbounds) {
|
||||
var result=new LinkedHashMap<String,StatusRow>();
|
||||
for(int offset=0;offset<inbounds.size();offset+=500) {
|
||||
var batch=inbounds.subList(offset,Math.min(offset+500,inbounds.size()));
|
||||
var expected=new HashMap<Long,InboundOrderEntity>();batch.forEach(i->expected.put(i.getId(),i));
|
||||
var facts=repository.facts(batch.stream().map(InboundOrderEntity::getId).toList());
|
||||
var sources=facts.stream().filter(WorkflowFacts.Fact::departed).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
|
||||
Map<String,WorkflowFacts.Overseas> overseas=new HashMap<>();
|
||||
if(!sources.isEmpty()) {
|
||||
try {
|
||||
var response=lastleg.read(token,new WorkflowFacts.Request(sources));
|
||||
if(response==null || !response.successful() || response.data()==null) throw unavailable();
|
||||
var allowed=new HashSet<>(sources);
|
||||
for(var fact:response.data()) {
|
||||
if(fact==null || !allowed.contains(new WorkflowFacts.Source(fact.tenantId(),fact.inboundId()))) throw unavailable();
|
||||
overseas.merge(fact.tenantId()+":"+fact.inboundId(),fact,(a,b)->new WorkflowFacts.Overseas(a.tenantId(),a.inboundId(),a.outboundExists()||b.outboundExists(),a.pendingOutbound()||b.pendingOutbound(),a.delivering()||b.delivering(),a.unloaded()&&b.unloaded()));
|
||||
}
|
||||
if(overseas.size()!=sources.size()) throw unavailable();
|
||||
} catch(BizException e) { throw e; } catch(Exception e) { throw unavailable(); }
|
||||
}
|
||||
// SQL排除仅作汇总的父票;保留承接实际装柜数量的来源票及独立余量票。
|
||||
for(var fact:facts) {
|
||||
var inbound=expected.get(fact.inboundId());
|
||||
if(inbound==null || !Objects.equals(inbound.getTenantId(),fact.tenantId()) || !Objects.equals(inbound.getOrderId(),fact.orderId())) throw unavailable();
|
||||
var o=overseas.get(fact.tenantId()+":"+fact.inboundId());
|
||||
for(String key:classify(fact,inbound.getProcessStatus(),o)) result.putIfAbsent(fact.orderId()+":"+key,new StatusRow(fact.tenantId(),fact.orderId(),key));
|
||||
}
|
||||
}
|
||||
return List.copyOf(result.values());
|
||||
}
|
||||
/** 分类为可重叠的待办/证据集合,计费重确认独立于费用确认。 */
|
||||
public static Set<String> classify(WorkflowFacts.Fact f,String process,WorkflowFacts.Overseas o) {
|
||||
Set<String> keys=new LinkedHashSet<>();
|
||||
if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW");
|
||||
if(ChargeableWeightService.eligible(f)) keys.add(ChargeableWeightService.weight(f).confirmed()?"WEIGHT_CONFIRMED":"WAIT_WEIGHT_CONFIRM");
|
||||
boolean signed="DELIVERED".equals(process);
|
||||
// 报关页签按资料与实装事实汇总,不依赖点击开始报关;实际开船后转入清关链路。
|
||||
if(!f.departed() && !signed) {
|
||||
if(f.missingCustoms()) keys.add("WAIT_CUSTOMS_DOCS");
|
||||
if(f.exportCustomsReady()) keys.add("EXPORT_CUSTOMS");
|
||||
}
|
||||
if(signed) keys.add("SIGNED");
|
||||
if(f.departed() && !signed) {
|
||||
if(!f.clearanceUploaded()) keys.add("SAILING_CLEARANCE");
|
||||
if(o!=null) {
|
||||
if(f.clearanceUploaded() && !o.unloaded()) keys.add("WAIT_UNLOAD");
|
||||
// 取消的出库不构成有效覆盖,读取事实后自动回显,不能保存永久隐藏标志。
|
||||
if(!o.outboundExists()) keys.add("WAIT_DISPATCH");
|
||||
if(o.pendingOutbound()) keys.add("OUTBOUND_DOCUMENT");
|
||||
if(o.delivering()) keys.add("DELIVERING");
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
/** 下游缺失或归属异常不能生成部分数量。 */
|
||||
private static BizException unavailable() { return new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试"); }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
-- 客户独立确认计费重,不回填既有费用确认。
|
||||
CREATE TABLE IF NOT EXISTS fl_chargeable_weight_confirmation (
|
||||
id BIGINT NOT NULL COMMENT '确认记录ID',
|
||||
tenant_id BIGINT NOT NULL COMMENT '客户隔离ID',
|
||||
inbound_id BIGINT NOT NULL COMMENT '独立来源入仓单ID',
|
||||
operator_id BIGINT NOT NULL COMMENT '确认客户成员ID',
|
||||
command_key VARCHAR(128) NOT NULL COMMENT '成员确认幂等键',
|
||||
fingerprint CHAR(64) NOT NULL COMMENT '计费重及测量轮次摘要',
|
||||
chargeable_weight_kg DECIMAL(18,4) NOT NULL COMMENT '客户确认计费重千克',
|
||||
confirmed_at DATETIME(3) NOT NULL COMMENT '客户确认时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_weight_confirmation_command(tenant_id,operator_id,command_key),
|
||||
KEY idx_weight_confirmation_inbound(tenant_id,inbound_id,id)
|
||||
) ENGINE=InnoDB COMMENT='客户计费重独立确认历史';
|
||||
@@ -134,8 +134,11 @@ BEGIN
|
||||
OR NOT (NEW.cabinet_no <=> OLD.cabinet_no) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务稳定归属不可修改';
|
||||
END IF;
|
||||
IF NOT (OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING', 'COMPLETED')) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态只允许完成';
|
||||
-- 与装柜主基线一致,允许持久化完成命令的处理中、失败重试及最终完成。
|
||||
IF NOT ((OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING','COMPLETING'))
|
||||
OR (OLD.task_status = 'COMPLETING' AND NEW.task_status IN ('COMPLETING','COMPLETION_FAILED','COMPLETED'))
|
||||
OR (OLD.task_status = 'COMPLETION_FAILED' AND NEW.task_status = 'COMPLETING')) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态流转非法';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
@@ -355,7 +355,7 @@ paths:
|
||||
/internal/firstleg/v1/inbounds/unified-orders/workbench-filters:
|
||||
post:
|
||||
summary: 查询单一客户工作台履约筛选标识(Order内部)
|
||||
description: 只读返回命中状态、最新待确认费用及待上传资料的订单标识。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。传入orderIds为精确进度模式,仅计算progress,matchingStatusOrderIds为null,feeOrderIds和customsOrderIds为空;mainStatus在该模式不参与查询。
|
||||
description: 只读返回命中状态、待独立确认计费重及待上传报关资料的订单标识。feeOrderIds沿用既有字段名,值为尚未确认当前计费重的订单,不代表金额确认。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。传入orderIds为精确进度模式,仅计算progress,matchingStatusOrderIds为null,feeOrderIds和customsOrderIds为空;mainStatus在该模式不参与查询。
|
||||
security: [{internalServiceToken: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -366,7 +366,7 @@ paths:
|
||||
required: [tenantId]
|
||||
properties:
|
||||
tenantId: {type: integer, format: int64, minimum: 1}
|
||||
mainStatus: {type: string, nullable: true, enum: [WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED]}
|
||||
mainStatus: {type: string, nullable: true, enum: [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]}
|
||||
orderIds:
|
||||
type: array
|
||||
nullable: true
|
||||
@@ -388,7 +388,7 @@ paths:
|
||||
required: [feeOrderIds, customsOrderIds, progress]
|
||||
properties:
|
||||
matchingStatusOrderIds: {type: array, nullable: true, items: {type: integer, format: int64}}
|
||||
feeOrderIds: {type: array, items: {type: integer, format: int64}}
|
||||
feeOrderIds: {type: array, description: 待客户独立确认当前计费重的订单ID, items: {type: integer, format: int64}}
|
||||
customsOrderIds: {type: array, items: {type: integer, format: int64}}
|
||||
progress:
|
||||
type: array
|
||||
@@ -401,6 +401,47 @@ paths:
|
||||
stage: {type: string, enum: [loaded, declared, cleared]}
|
||||
partial: {type: boolean}
|
||||
|
||||
/internal/firstleg/v1/inbounds/unified-orders/status-summaries:
|
||||
post:
|
||||
summary: 当前页订单按原筛选读取履约摘要(Order 内部)
|
||||
description: candidateOrderIds 必须提供且最多200项;保留统一筛选,避免读到不匹配的其他入库分片。
|
||||
security: [{internalServiceToken: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/UnifiedInboundQuery'}
|
||||
responses:
|
||||
'200':
|
||||
description: 标准 ApiResponse,data 为 UnifiedInboundSummary 数组
|
||||
/internal/firstleg/v1/inbounds/unified-orders/status-rows:
|
||||
post:
|
||||
summary: 统一订单筛选后的轻量状态投影(Order 内部)
|
||||
description: 非分页查询,共享统一履约筛选;按创建时间和ID降序排列,同一订单同一主状态去重,保留不同分片主状态,不查询逐票详情。调用方不传mainStatus。
|
||||
security: [{internalServiceToken: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/UnifiedInboundQuery'}
|
||||
responses:
|
||||
'200':
|
||||
description: 标准 ApiResponse;依赖失败不能返回空成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string}
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [tenantId, orderId]
|
||||
properties:
|
||||
tenantId: {type: string}
|
||||
orderId: {type: string}
|
||||
mainStatus: {type: string, nullable: true}
|
||||
/internal/firstleg/v1/inbounds/unified-orders/query:
|
||||
post:
|
||||
summary: 按统一订单条件分页查询履约摘要(Order 内部)
|
||||
@@ -594,13 +635,13 @@ paths:
|
||||
/api/platform/v1/containers/{id}/clearance:
|
||||
get:
|
||||
summary: 查询货柜目的港清关资料
|
||||
description: 需要 platform:firstleg:terminal-drayage:list 权限;仅国外工作台货柜
|
||||
description: 需要 platform:firstleg:terminal-drayage:list 权限;有效货柜已确认国内离仓,或已记录 DEPARTED_ORIGIN_PORT 且 actual_at 非空时可读取;人工与拉取节点使用相同事实
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||
responses: {'200': {description: 成功或货柜不存在}}
|
||||
/api/platform/v1/containers/{id}/clearance/document:
|
||||
post:
|
||||
summary: 上传或替换货柜目的港清关资料
|
||||
description: 需要 platform:firstleg:terminal-drayage:document:edit 权限,fileId 必须严格绑定当前货柜
|
||||
description: 需要 platform:firstleg:terminal-drayage:document:edit 权限;有效货柜已离仓或已实际开船,fileId 必须严格绑定当前货柜
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -609,7 +650,7 @@ paths:
|
||||
/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:
|
||||
get:
|
||||
summary: 下载货柜目的港清关附件
|
||||
description: 需要 platform:firstleg:terminal-drayage:list 权限;服务端校验当前 DESTINATION_CLEARANCE fileId 及 File 业务归属,完成态仍可下载,响应禁止缓存
|
||||
description: 需要 platform:firstleg:terminal-drayage:list 权限;有效货柜已离仓或已实际开船;服务端校验当前 DESTINATION_CLEARANCE fileId 及 File 业务归属,完成态仍可下载,响应禁止缓存
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/ContainerBookingId'}
|
||||
- {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
@@ -619,7 +660,7 @@ paths:
|
||||
/api/platform/v1/containers/{id}/clearance/document/upload:
|
||||
post:
|
||||
summary: 直接选择文件上传或替换货柜目的港清关资料
|
||||
description: 需要 platform:firstleg:terminal-drayage:document:edit;Firstleg 使用内部服务令牌上传 File,浏览器不得传递 fileId 或内部令牌。
|
||||
description: 需要 platform:firstleg:terminal-drayage:document:edit;有效货柜已离仓或已实际开船;Firstleg 使用内部服务令牌上传 File,浏览器不得传递 fileId 或内部令牌。上传成功即作为待拆柜的资料事实,不要求另行确认清关完成。
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -630,7 +671,7 @@ paths:
|
||||
/api/platform/v1/containers/{id}/clearance/complete:
|
||||
post:
|
||||
summary: 点击完成目的港清关
|
||||
description: 需要 platform:firstleg:terminal-drayage:document:edit 权限
|
||||
description: 需要 platform:firstleg:terminal-drayage:document:edit 权限;有效货柜已离仓或已实际开船;此完成动作不作为上传后进入待拆柜的附加条件
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -1631,9 +1672,9 @@ components:
|
||||
orderNo: {type: string, maxLength: 64, nullable: true}
|
||||
mainStatus:
|
||||
type: string
|
||||
enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED]
|
||||
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
|
||||
description: WAIT_SPLIT 为唯一待分柜筛选值
|
||||
description: WAIT_SPLIT 为唯一待分柜筛选值;WAIT_CUSTOMS_DOCS 为实际装柜后缺少本票客户报关资料;EXPORT_CUSTOMS 按本票资料已上传且实际装柜完成汇总,买单无需客户退税资料,不依赖点击开始报关,实际开船后移出;SAILING_CLEARANCE 按手动或拉取的实际离港节点进入,清关资料上传后进入 WAIT_UNLOAD,实际卸柜完成后移出。
|
||||
customerId: {type: integer, format: int64, nullable: true}
|
||||
receivingWarehouseId: {type: integer, format: int64, nullable: true}
|
||||
destinationWarehouseId: {type: integer, format: int64, nullable: true}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
openapi: 3.0.3
|
||||
info: {title: 客户独立计费重确认, version: 1.0.0}
|
||||
security: [{bearerAuth: []}]
|
||||
paths:
|
||||
/api/merchant/v1/first-leg/orders/{orderId}/chargeable-weights:
|
||||
get:
|
||||
summary: 客户读取本票全部独立入仓计费重
|
||||
description: 认证客户隔离,要求merchant:order:view;仅返回测量审核通过且计费重为正的分片。
|
||||
parameters:
|
||||
- {name: orderId, in: path, required: true, schema: {type: integer, format: int64}}
|
||||
responses:
|
||||
'200':
|
||||
description: 标准ApiResponse,认证客户无可确认来源时返回空数组
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string, example: '200'}
|
||||
message: {type: string}
|
||||
data:
|
||||
type: array
|
||||
items: {$ref: '#/components/schemas/Weight'}
|
||||
/api/merchant/v1/first-leg/chargeable-weights/{inboundId}/confirm:
|
||||
post:
|
||||
summary: 客户独立确认当前计费重
|
||||
description: 要求merchant:order:edit。与费用金额确认独立;值或测量轮次变化后旧确认失效。客户范围取认证主体。
|
||||
parameters:
|
||||
- {name: inboundId, in: path, required: true, schema: {type: integer, format: int64}}
|
||||
- {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [expectedFingerprint]
|
||||
properties:
|
||||
expectedFingerprint: {type: string, pattern: '^[a-f0-9]{64}$'}
|
||||
responses:
|
||||
'200':
|
||||
description: 标准ApiResponse;版本或异参冲突返回业务冲突码,不改变费用状态
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string, example: '200'}
|
||||
message: {type: string}
|
||||
data: {$ref: '#/components/schemas/Weight'}
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
|
||||
schemas:
|
||||
Weight:
|
||||
type: object
|
||||
required: [inboundId, orderId, chargeableWeightKg, fingerprint, confirmed]
|
||||
properties:
|
||||
inboundId: {type: string, description: 独立来源入仓单ID}
|
||||
orderId: {type: string, description: 独立订单ID}
|
||||
chargeableWeightKg: {type: number}
|
||||
fingerprint: {type: string}
|
||||
confirmed: {type: boolean}
|
||||
confirmedAt: {type: string, format: date-time, nullable: true}
|
||||
+14
@@ -44,6 +44,20 @@ class InternalInboundOrderReferenceControllerTest {
|
||||
@Resource
|
||||
private InternalInboundOrderController controller;
|
||||
|
||||
/** 新增状态查询和摘要入口都必须校验内部服务身份。 */
|
||||
@Test
|
||||
void statusEndpointsRequireInternalTokenBeforeDelegation() {
|
||||
var request = new UnifiedInboundQuery(10001L, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, null, List.of(77L), null, null, 1, 20, null);
|
||||
assertThatThrownBy(() -> controller.unifiedStatusRows(null, request)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> controller.unifiedStatusSummaries("wrong", request)).isInstanceOf(BizException.class);
|
||||
org.mockito.Mockito.verifyNoInteractions(unifiedQueryService);
|
||||
when(unifiedQueryService.statusRows(request)).thenReturn(List.of());
|
||||
when(unifiedQueryService.statusSummaries(request)).thenReturn(List.of());
|
||||
assertThat(controller.unifiedStatusRows("internal-token", request).data()).isEmpty();
|
||||
assertThat(controller.unifiedStatusSummaries("internal-token", request).data()).isEmpty();
|
||||
}
|
||||
|
||||
@Test void workbenchFiltersRejectMissingAndWrongInternalIdentity() {
|
||||
var request = new com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Request(11L, "LOADED", null);
|
||||
assertThatThrownBy(() -> controller.workbenchFilters(null, request)).isInstanceOf(BizException.class);
|
||||
|
||||
+10
@@ -9,6 +9,16 @@ import java.nio.file.Path;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class LoadingCompletionSqlContractTest {
|
||||
/** 后执行的渠道柜次脚本不能恢复旧触发器并阻断持久化完成命令。 */
|
||||
@Test
|
||||
void channelMigrationKeepsTheDurableCompletionTransitions() throws Exception {
|
||||
String sql = Files.readString(Path.of("src", "main", "resources", "db",
|
||||
"phase3-channel-container-migration.sql"), StandardCharsets.UTF_8);
|
||||
assertThat(sql).contains("OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING','COMPLETING')")
|
||||
.contains("OLD.task_status = 'COMPLETING' AND NEW.task_status IN ('COMPLETING','COMPLETION_FAILED','COMPLETED')")
|
||||
.contains("OLD.task_status = 'COMPLETION_FAILED' AND NEW.task_status = 'COMPLETING'")
|
||||
.doesNotContain("任务状态只允许完成");
|
||||
}
|
||||
@Test
|
||||
void baselineAndUnifiedSqlDefineDurableCompletionStateMachine() throws Exception {
|
||||
assertContract(Files.readString(Path.of("src", "main", "resources", "db",
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ class Phase3FinalUnifiedSqlContractTest {
|
||||
@Test
|
||||
void initializationPassIsUniqueAndFinal() throws Exception {
|
||||
String sql = sql();
|
||||
int expectedTables = 227;
|
||||
int expectedTables = 228;
|
||||
long actualTables = java.util.regex.Pattern.compile("(?m)^CREATE TABLE\\s+")
|
||||
.matcher(sql).results().count();
|
||||
assertThat(actualTables).isEqualTo(expectedTables);
|
||||
|
||||
+11
@@ -69,6 +69,17 @@ class UnifiedInboundTaxModePaginationTest {
|
||||
assertPage(null, 1, 4, 6);
|
||||
assertPage(null, 2, 4, 4);
|
||||
}
|
||||
/** 轻量统计读取全量状态,不能把第二页当作全量,也不能越过客户和软删除条件。 */
|
||||
@Test
|
||||
void statusProjectionUsesFullFilteredSetAndEmptyCandidatesStayEmpty() {
|
||||
var query = new UnifiedInboundQuery(11L, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, null, null, 2, 1, "TAX_REFUND");
|
||||
assertThat(repository.unifiedStatusEntities(query)).extracting(InboundOrderEntity::getId).containsExactly(6L, 2L);
|
||||
var empty = new UnifiedInboundQuery(11L, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, List.of(), null, null, 2, 1, "TAX_REFUND");
|
||||
assertThat(repository.unifiedStatusEntities(empty)).isEmpty();
|
||||
}
|
||||
|
||||
private void assertPage(String taxMode, long page, long total, long expectedId) {
|
||||
var query = new UnifiedInboundQuery(11L, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, page, 1, taxMode);
|
||||
var result = repository.pageUnifiedEntities(query, List.of("LOADED"));
|
||||
|
||||
@@ -50,6 +50,18 @@ class ContainerServiceTest {
|
||||
@MockBean private SmartLoadingService smartLoadingService;
|
||||
@Resource private ContainerService service;
|
||||
|
||||
/** 实际开船可独立打开清关资料,预估节点与缺失货柜不能绕过校验。 */
|
||||
@Test void clearanceAcceptsActualSailingWithoutInventingOutboundConfirmation() {
|
||||
when(repository.find(91L)).thenReturn(new ContainerBookingEntity());
|
||||
assertThatThrownBy(() -> service.requireClearanceAccess(91L)).isInstanceOf(BizException.class);
|
||||
when(repository.sailed(91L)).thenReturn(true);
|
||||
service.requireClearanceAccess(91L);
|
||||
assertThatThrownBy(() -> service.requireClearanceAccess(92L)).isInstanceOf(BizException.class);
|
||||
when(repository.sailed(91L)).thenReturn(false);
|
||||
when(repository.departed(91L)).thenReturn(true);
|
||||
service.requireClearanceAccess(91L);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUpWarehouse() {
|
||||
when(documentNumbers.nextCabinetNo()).thenReturn("D0001");
|
||||
|
||||
+14
@@ -9,6 +9,20 @@ import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class MerchantWorkbenchFilterServiceTest {
|
||||
/** 金额待确认与计费重待确认不同,客户工作台只能使用后者。 */
|
||||
@Test void customerQueueUsesIndependentWeightConfirmation() {
|
||||
var mapper=mock(MerchantWorkbenchFilterMapper.class);
|
||||
var weights=mock(com.baiyue.firstleg.workflow.ChargeableWeightService.class);
|
||||
var service=new MerchantWorkbenchFilterService();
|
||||
ReflectionTestUtils.setField(service,"mapper",mapper);
|
||||
ReflectionTestUtils.setField(service,"weights",weights);
|
||||
when(weights.pendingOrders(11)).thenReturn(List.of(101L));
|
||||
var response=service.read(new MerchantWorkbenchFilters.Request(11L,null,null));
|
||||
assertThat(response.feeOrderIds()).containsExactly(101L);
|
||||
verify(weights).pendingOrders(11);
|
||||
verify(mapper,never()).pendingFees(anyLong());
|
||||
}
|
||||
|
||||
@Test void exactDetailProgressSkipsUnrelatedCustomerQueues() {
|
||||
var mapper=mock(MerchantWorkbenchFilterMapper.class);
|
||||
var unified=mock(UnifiedInboundQueryService.class);
|
||||
|
||||
+67
@@ -24,11 +24,77 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class UnifiedInboundQueryServiceTest {
|
||||
/** 直接内部查询同样先按业务事实筛选再分页,不能返回未命中的订单。 */
|
||||
@Test
|
||||
void workflowPageFiltersBeforePagination() {
|
||||
var repository = mock(InboundOrderRepository.class);
|
||||
var workflow = mock(com.baiyue.firstleg.workflow.WorkflowTabService.class);
|
||||
var service = new UnifiedInboundQueryService();
|
||||
ReflectionTestUtils.setField(service, "inbounds", repository);
|
||||
ReflectionTestUtils.setField(service, "workflowTabs", workflow);
|
||||
ReflectionTestUtils.setField(service, "operations", mock(UnifiedInboundReadRepository.class));
|
||||
ReflectionTestUtils.setField(service, "pricing", mock(FbaPricingRepository.class));
|
||||
var query = new UnifiedInboundQuery(10001L, null, null, "WAIT_WEIGHT_CONFIRM", null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, null, null, null, null, 2, 1, null);
|
||||
var first = new InboundOrderEntity(); first.setId(1L); first.setTenantId(10001L); first.setOrderId(77L); first.setProcessStatus("WAIT_SPLIT");
|
||||
var second = new InboundOrderEntity(); second.setId(2L); second.setTenantId(10001L); second.setOrderId(78L); second.setProcessStatus("WAIT_SPLIT");
|
||||
var other = new InboundOrderEntity(); other.setId(3L); other.setTenantId(10001L); other.setOrderId(79L); other.setProcessStatus("WAIT_SPLIT");
|
||||
var sources = List.of(first, second, other);
|
||||
when(repository.unifiedStatusEntities(query)).thenReturn(sources);
|
||||
when(workflow.rows(sources)).thenReturn(List.of(
|
||||
new UnifiedInboundQueryService.StatusRow(10001L,77L,"WAIT_WEIGHT_CONFIRM"),
|
||||
new UnifiedInboundQueryService.StatusRow(10001L,78L,"WAIT_WEIGHT_CONFIRM"),
|
||||
new UnifiedInboundQueryService.StatusRow(10001L,79L,"WEIGHT_CONFIRMED")));
|
||||
when(repository.unifiedMatchedEntities(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.eq(List.of())))
|
||||
.thenReturn(sources);
|
||||
var result = service.page(query);
|
||||
assertThat(result.total()).isEqualTo(2);
|
||||
assertThat(result.records()).singleElement().satisfies(row -> assertThat(row.inbound().orderId()).isEqualTo(78));
|
||||
}
|
||||
|
||||
/** 分片订单保留各状态命中,同一状态内去重;不触发逐票富化查询。 */
|
||||
@Test
|
||||
void statusRowsDeduplicateLatestMatchingInboundWithoutEnrichment() {
|
||||
var repository = mock(InboundOrderRepository.class);
|
||||
var operations = mock(UnifiedInboundReadRepository.class);
|
||||
var pricing = mock(FbaPricingRepository.class);
|
||||
var service = new UnifiedInboundQueryService();
|
||||
ReflectionTestUtils.setField(service, "workflowTabs", mock(com.baiyue.firstleg.workflow.WorkflowTabService.class));
|
||||
ReflectionTestUtils.setField(service, "inbounds", repository);
|
||||
ReflectionTestUtils.setField(service, "operations", operations);
|
||||
ReflectionTestUtils.setField(service, "pricing", pricing);
|
||||
var query = new UnifiedInboundQuery(10001L, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, null, null, null, null, 9, 1, null);
|
||||
var newest = new InboundOrderEntity(); newest.setTenantId(10001L); newest.setOrderId(77L);
|
||||
newest.setProcessStatus("MEASURE_PENDING");
|
||||
var older = new InboundOrderEntity(); older.setTenantId(10001L); older.setOrderId(77L);
|
||||
older.setProcessStatus("LOADED");
|
||||
when(repository.unifiedStatusEntities(query)).thenReturn(List.of(newest, newest, older));
|
||||
assertThat(service.statusRows(query)).containsExactly(
|
||||
new UnifiedInboundQueryService.StatusRow(10001L, 77L, "WAIT_MEASURE"),
|
||||
new UnifiedInboundQueryService.StatusRow(10001L, 77L, "LOADED"));
|
||||
verifyNoInteractions(operations, pricing);
|
||||
}
|
||||
|
||||
/** 摘要入口必须提供当前页候选,不能误触发全量明细读取。 */
|
||||
@Test
|
||||
void statusSummariesRejectUnboundedCandidateRequest() {
|
||||
var repository = mock(InboundOrderRepository.class);
|
||||
var service = new UnifiedInboundQueryService();
|
||||
ReflectionTestUtils.setField(service, "workflowTabs", mock(com.baiyue.firstleg.workflow.WorkflowTabService.class));
|
||||
ReflectionTestUtils.setField(service, "inbounds", repository);
|
||||
var query = new UnifiedInboundQuery(10001L, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, null, null, null, null, 1, 20, null);
|
||||
assertThatThrownBy(() -> service.statusSummaries(query)).isInstanceOf(BizException.class);
|
||||
verifyNoInteractions(repository);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"", " ", "tax_refund", "TAX_REFUND ", "OTHER", "TAX_INCLUDED"})
|
||||
void illegalTaxModeFailsBeforeRepository(String taxMode) {
|
||||
var repository = mock(InboundOrderRepository.class);
|
||||
var service = new UnifiedInboundQueryService();
|
||||
ReflectionTestUtils.setField(service, "workflowTabs", mock(com.baiyue.firstleg.workflow.WorkflowTabService.class));
|
||||
ReflectionTestUtils.setField(service, "inbounds", repository);
|
||||
var query = new UnifiedInboundQuery(10001L, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, null, null, null, null, 1, 20, taxMode);
|
||||
@@ -144,6 +210,7 @@ class UnifiedInboundQueryServiceTest {
|
||||
var operations = mock(UnifiedInboundReadRepository.class);
|
||||
var pricing = mock(FbaPricingRepository.class);
|
||||
var service = new UnifiedInboundQueryService();
|
||||
ReflectionTestUtils.setField(service, "workflowTabs", mock(com.baiyue.firstleg.workflow.WorkflowTabService.class));
|
||||
ReflectionTestUtils.setField(service,"inbounds",inbounds);
|
||||
ReflectionTestUtils.setField(service,"operations",operations);
|
||||
ReflectionTestUtils.setField(service,"pricing",pricing);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.baiyue.firstleg.workflow;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/** 跨服务缺项和客户归属异常不能生成看似成功的部分统计。 */
|
||||
class WorkflowBoundaryTest {
|
||||
/** 真实开船来源的测试事实。 */
|
||||
private WorkflowFacts.Fact fact() {
|
||||
return new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,
|
||||
LocalDateTime.of(2026,10,19,10,0),true,false,false,false,null,null);
|
||||
}
|
||||
|
||||
/** 下游漏来源或返回其他客户均整体失败。 */
|
||||
@Test void rejectsMissingAndForeignDownstreamFacts() {
|
||||
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(fact()));
|
||||
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of()));
|
||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
|
||||
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of(
|
||||
new WorkflowFacts.Overseas(22,1,true,true,false,true))));
|
||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
|
||||
when(downstream.read(any(),any())).thenThrow(new IllegalStateException("unavailable"));
|
||||
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
/** 重测同值也要求重新确认,金额没有参与摘要。 */
|
||||
@Test void repeatedMeasurementRequiresNewConfirmation() {
|
||||
var original=fact();
|
||||
var measuredAgain=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,
|
||||
original.measureCompletedAt().plusSeconds(1),true,false,false,false,
|
||||
ChargeableWeightService.fingerprint(original),LocalDateTime.now());
|
||||
assertThat(ChargeableWeightService.weight(measuredAgain).confirmed()).isFalse();
|
||||
}
|
||||
|
||||
/** 平台主体不得冒充客户,客户和操作者只取认证上下文。 */
|
||||
@Test void confirmationUsesAuthenticatedCustomerAndOperator() {
|
||||
var service=mock(ChargeableWeightService.class);
|
||||
var controller=new MerchantChargeableWeightController(service);
|
||||
var platform=new com.baiyue.common.security.AuthenticatedUser(7L,0L,0L,9L,"platform","PLATFORM","WEB",java.util.Set.of());
|
||||
var request=new WorkflowFacts.Confirm("a".repeat(64));
|
||||
assertThatThrownBy(()->controller.read(platform,101)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(()->controller.confirm(platform,1,"key",request)).isInstanceOf(BizException.class);
|
||||
verifyNoInteractions(service);
|
||||
var merchant=new com.baiyue.common.security.AuthenticatedUser(7L,11L,0L,9L,"member","MERCHANT","WEB",java.util.Set.of());
|
||||
controller.read(merchant,101);
|
||||
controller.confirm(merchant,1,"key",request);
|
||||
verify(service).read(11,101);
|
||||
verify(service).confirm(11,7,1,"key",request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.baiyue.firstleg.workflow;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
/** 取消回显、分柜隔离和独立计费重版本回归。 */
|
||||
class WorkflowBusinessTest {
|
||||
/** 报关资料齐备进入汇总,实际开船及清关资料分别驱动后续页签。 */
|
||||
@Test void customsDocumentsDepartureAndClearanceDriveTabs() {
|
||||
var time=LocalDateTime.of(2026,10,19,10,0);
|
||||
var missing=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,false,true,false,null,null);
|
||||
var uploaded=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,false,false,true,null,null);
|
||||
var departed=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,true,false,false,true,null,null);
|
||||
var cleared=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,true,true,false,true,null,null);
|
||||
var uploadedBeforeDeparture=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,true,false,true,null,null);
|
||||
var pendingUnload=new WorkflowFacts.Overseas(11,1,false,false,false,false);
|
||||
var unloaded=new WorkflowFacts.Overseas(11,1,false,false,false,true);
|
||||
assertThat(WorkflowTabService.classify(missing,"LOADED",null))
|
||||
.contains("WAIT_CUSTOMS_DOCS").doesNotContain("EXPORT_CUSTOMS","SAILING_CLEARANCE","WAIT_UNLOAD");
|
||||
assertThat(WorkflowTabService.classify(uploaded,"LOADED",null))
|
||||
.contains("EXPORT_CUSTOMS").doesNotContain("WAIT_CUSTOMS_DOCS","SAILING_CLEARANCE","WAIT_UNLOAD");
|
||||
assertThat(WorkflowTabService.classify(uploadedBeforeDeparture,"LOADED",pendingUnload))
|
||||
.contains("EXPORT_CUSTOMS").doesNotContain("SAILING_CLEARANCE","WAIT_UNLOAD");
|
||||
assertThat(WorkflowTabService.classify(departed,"LOADED",pendingUnload))
|
||||
.contains("SAILING_CLEARANCE").doesNotContain("WAIT_CUSTOMS_DOCS","EXPORT_CUSTOMS","WAIT_UNLOAD");
|
||||
assertThat(WorkflowTabService.classify(cleared,"LOADED",pendingUnload))
|
||||
.contains("WAIT_UNLOAD").doesNotContain("WAIT_CUSTOMS_DOCS","EXPORT_CUSTOMS","SAILING_CLEARANCE");
|
||||
assertThat(WorkflowTabService.classify(cleared,"LOADED",unloaded))
|
||||
.doesNotContain("WAIT_UNLOAD","SAILING_CLEARANCE","EXPORT_CUSTOMS");
|
||||
assertThat(WorkflowTabService.classify(uploaded,"DELIVERED",unloaded))
|
||||
.contains("SIGNED").doesNotContain("EXPORT_CUSTOMS","WAIT_CUSTOMS_DOCS");
|
||||
}
|
||||
/** 构造独立分柜来源。 */
|
||||
private WorkflowFacts.Fact fact(long id,String kg,String confirmed,boolean uploaded) {
|
||||
return new WorkflowFacts.Fact(11,id,100+id,"APPROVED",new BigDecimal(kg),LocalDateTime.of(2026,10,19,10,0),true,uploaded,false,false,confirmed,null);
|
||||
}
|
||||
/** 有效建单移出待排车,取消后恢复;实际出库不计未出库单。 */
|
||||
@Test void cancellationRestoresDispatchQueue() {
|
||||
var f=fact(1,"100",null,true);
|
||||
var waiting=new WorkflowFacts.Overseas(11,1,false,false,false,true);
|
||||
var created=new WorkflowFacts.Overseas(11,1,true,true,false,true);
|
||||
var shipped=new WorkflowFacts.Overseas(11,1,true,false,true,true);
|
||||
assertThat(WorkflowTabService.classify(f,"LOADED",waiting)).contains("WAIT_DISPATCH").doesNotContain("OUTBOUND_DOCUMENT");
|
||||
assertThat(WorkflowTabService.classify(f,"LOADED",created)).contains("OUTBOUND_DOCUMENT").doesNotContain("WAIT_DISPATCH");
|
||||
assertThat(WorkflowTabService.classify(f,"LOADED",shipped)).contains("DELIVERING").doesNotContain("WAIT_DISPATCH","OUTBOUND_DOCUMENT");
|
||||
assertThat(WorkflowTabService.classify(f,"LOADED",waiting)).contains("WAIT_DISPATCH");
|
||||
}
|
||||
/** 一柜资料上传不覆盖另一柜;签收不再进入待排。 */
|
||||
@Test void independentSlicesAndUploadedClearance() {
|
||||
assertThat(WorkflowTabService.classify(fact(1,"100",null,false),"LOADED",new WorkflowFacts.Overseas(11,1,false,false,false,false)))
|
||||
.contains("SAILING_CLEARANCE","WAIT_DISPATCH").doesNotContain("WAIT_UNLOAD");
|
||||
assertThat(WorkflowTabService.classify(fact(2,"200",null,true),"LOADED",new WorkflowFacts.Overseas(11,2,true,true,false,false)))
|
||||
.contains("WAIT_UNLOAD","OUTBOUND_DOCUMENT").doesNotContain("SAILING_CLEARANCE","WAIT_DISPATCH");
|
||||
assertThat(WorkflowTabService.classify(fact(1,"100",null,false),"DELIVERED",null)).contains("SIGNED").doesNotContain("WAIT_DISPATCH");
|
||||
}
|
||||
/** 数值格式不改变版本,值或来源变化必须重新确认。 */
|
||||
@Test void independentWeightVersion() {
|
||||
var hash=ChargeableWeightService.fingerprint(fact(1,"100.0000",null,false));
|
||||
assertThat(ChargeableWeightService.weight(fact(1,"100",hash,false)).confirmed()).isTrue();
|
||||
assertThat(ChargeableWeightService.weight(fact(1,"101",hash,false)).confirmed()).isFalse();
|
||||
assertThat(ChargeableWeightService.weight(fact(2,"100",hash,false)).confirmed()).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.baiyue.firstleg.workflow;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.mybatis.spring.SqlSessionTemplate;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
|
||||
import java.nio.file.*;
|
||||
import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
/** 真实Mapper和隔离内存库验证资料归属、计费重确认和历史失效。 */
|
||||
class WorkflowFactPersistenceTest {
|
||||
/** 测试建表不连接业务数据库。 */
|
||||
@Test void clearanceAndWeightConfirmationPersistIndependently() throws Exception {
|
||||
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:weight-"+java.util.UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
|
||||
var db=new JdbcTemplate(ds);
|
||||
db.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT,order_id BIGINT,measure_review_status VARCHAR(30),chargeable_weight_kg DECIMAL(18,4),measure_completed_at TIMESTAMP,deleted INT,process_status VARCHAR(30))");
|
||||
db.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,inbound_id BIGINT,account_id BIGINT,loading_plan_id BIGINT,deleted INT)");
|
||||
db.execute("CREATE TABLE fl_loading_plan(id BIGINT,plan_status VARCHAR(30),container_booking_id BIGINT)");
|
||||
db.execute("CREATE TABLE fl_loading_pick_task(id BIGINT,loading_plan_id BIGINT,task_status VARCHAR(30))");
|
||||
db.execute("CREATE TABLE fl_loading_pick_item(pick_task_id BIGINT,loading_plan_item_id BIGINT,item_status VARCHAR(30),picked_package_count INT)");
|
||||
db.execute("CREATE TABLE fl_container_booking(id BIGINT,record_status VARCHAR(30))");
|
||||
db.execute("CREATE TABLE fl_carrier_schedule_node_fact(container_booking_id BIGINT,standard_node VARCHAR(40),actual_at TIMESTAMP)");
|
||||
db.execute("CREATE TABLE fl_container_document(container_booking_id BIGINT,document_type VARCHAR(40),file_id BIGINT)");
|
||||
db.execute("CREATE TABLE fl_customs_customer_document_inbound(inbound_id BIGINT,tenant_id BIGINT,document_id BIGINT)");
|
||||
db.execute("CREATE TABLE fl_customs_customer_document(id BIGINT,tenant_id BIGINT,status VARCHAR(30),customs_container_id BIGINT)");
|
||||
db.execute("CREATE TABLE fl_customs_container(id BIGINT,loading_plan_id BIGINT,status VARCHAR(40))");
|
||||
db.execute("ALTER TABLE fl_inbound_order ADD tax_mode VARCHAR(30)");
|
||||
db.execute("ALTER TABLE fl_inbound_order ADD parent_inbound_id BIGINT");
|
||||
db.execute("ALTER TABLE fl_inbound_order ADD inbound_role VARCHAR(30)");
|
||||
db.execute("ALTER TABLE fl_inbound_order ADD split_purpose VARCHAR(30)");
|
||||
db.execute(Files.readString(Path.of("src/main/resources/db/chargeable-weight-confirmation.sql")));
|
||||
db.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,measure_review_status,chargeable_weight_kg,measure_completed_at,deleted,process_status,tax_mode) VALUES(1,11,101,'APPROVED',100,CURRENT_TIMESTAMP,0,'LOADED','TAX_REFUND'),(2,22,102,'APPROVED',200,CURRENT_TIMESTAMP,0,'LOADED','BUY_DECLARATION')");
|
||||
db.update("INSERT INTO fl_loading_plan_item VALUES(3,1,11,4,0)");
|
||||
db.update("INSERT INTO fl_loading_plan VALUES(4,'COMPLETED',5)");
|
||||
db.update("INSERT INTO fl_container_booking VALUES(5,'ACTIVE')");
|
||||
db.update("INSERT INTO fl_loading_pick_task VALUES(6,4,'COMPLETED')");
|
||||
db.update("INSERT INTO fl_loading_pick_item VALUES(6,3,'PICKED',2)");
|
||||
db.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(5,'DEPARTED_ORIGIN_PORT',CURRENT_TIMESTAMP)");
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(WorkflowFactMapper.class);
|
||||
config.addMapper(com.baiyue.firstleg.mapper.ContainerBookingMapper.class);
|
||||
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
|
||||
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(WorkflowFactMapper.class);
|
||||
var containers=new SqlSessionTemplate(factory.getObject()).getMapper(com.baiyue.firstleg.mapper.ContainerBookingMapper.class);
|
||||
assertThat(containers.hasActualVesselDeparture(5)).isOne();
|
||||
assertThat(containers.hasActualVesselDeparture(6)).isZero();
|
||||
db.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(6,'DEPARTED_ORIGIN_PORT',NULL),(6,'ARRIVED_DESTINATION_PORT',CURRENT_TIMESTAMP)");
|
||||
assertThat(containers.hasActualVesselDeparture(6)).isZero();
|
||||
var repository=new WorkflowFactRepository(mapper);var service=new ChargeableWeightService(repository);
|
||||
assertThat(service.read(22,101)).isEmpty();
|
||||
assertThat(mapper.facts(List.of(1L))).singleElement().satisfies(f->{assertThat(f.departed()).isTrue();assertThat(f.clearanceUploaded()).isFalse();assertThat(f.missingCustoms()).isTrue();});
|
||||
db.update("INSERT INTO fl_container_document VALUES(5,'DESTINATION_CLEARANCE',99)");
|
||||
assertThat(mapper.facts(List.of(1L)).get(0).clearanceUploaded()).isTrue();
|
||||
db.update("INSERT INTO fl_customs_container VALUES(8,4,'WAITING_CUSTOMER_DOCUMENTS')");
|
||||
db.update("INSERT INTO fl_customs_customer_document VALUES(9,22,'ACTIVE',8)");
|
||||
db.update("INSERT INTO fl_customs_customer_document_inbound VALUES(1,22,9)");
|
||||
assertThat(mapper.facts(List.of(1L))).singleElement().satisfies(f->{
|
||||
assertThat(f.missingCustoms()).isTrue();assertThat(f.exportCustomsReady()).isFalse();
|
||||
});
|
||||
db.update("INSERT INTO fl_customs_customer_document VALUES(10,11,'ACTIVE',8)");
|
||||
db.update("INSERT INTO fl_customs_customer_document_inbound VALUES(1,11,10)");
|
||||
assertThat(mapper.facts(List.of(1L))).singleElement().satisfies(f->{assertThat(f.missingCustoms()).isFalse();assertThat(f.exportCustomsReady()).isTrue();});
|
||||
// 本票上传后即可汇总,不必等同柜其他票或点击开始报关;完成报关后仍等待实际开船。
|
||||
for (String status : List.of("READY_TO_START", "DECLARING", "COMPLETED")) {
|
||||
db.update("UPDATE fl_customs_container SET status=? WHERE id=8", status);
|
||||
assertThat(mapper.facts(List.of(1L)).get(0).exportCustomsReady()).isTrue();
|
||||
}
|
||||
db.update("UPDATE fl_customs_customer_document SET status='DELETED' WHERE id=10");
|
||||
assertThat(mapper.facts(List.of(1L))).singleElement().satisfies(f->{
|
||||
assertThat(f.missingCustoms()).isTrue();assertThat(f.exportCustomsReady()).isFalse();
|
||||
});
|
||||
db.update("UPDATE fl_customs_customer_document SET status='ACTIVE' WHERE id=10");
|
||||
db.update("INSERT INTO fl_customs_container VALUES(18,14,'READY_TO_START')");
|
||||
db.update("UPDATE fl_customs_customer_document SET customs_container_id=18 WHERE id=10");
|
||||
assertThat(mapper.facts(List.of(1L)).get(0).exportCustomsReady()).isFalse();
|
||||
db.update("UPDATE fl_customs_customer_document SET customs_container_id=8 WHERE id=10");
|
||||
db.update("UPDATE fl_loading_plan SET plan_status='LOADING' WHERE id=4");
|
||||
assertThat(mapper.facts(List.of(1L)).get(0).exportCustomsReady()).isFalse();
|
||||
db.update("UPDATE fl_loading_plan SET plan_status='COMPLETED' WHERE id=4");
|
||||
db.update("UPDATE fl_loading_pick_item SET picked_package_count=0");
|
||||
assertThat(mapper.facts(List.of(1L)).get(0).exportCustomsReady()).isFalse();
|
||||
db.update("UPDATE fl_loading_pick_item SET picked_package_count=2");
|
||||
// 不把预计开船当成实际开船;手动或拉取写入的实际节点都读同一事实表。
|
||||
db.update("UPDATE fl_carrier_schedule_node_fact SET actual_at=NULL");
|
||||
assertThat(mapper.facts(List.of(1L)).get(0).departed()).isFalse();
|
||||
db.update("UPDATE fl_carrier_schedule_node_fact SET actual_at=CURRENT_TIMESTAMP");
|
||||
assertThat(mapper.facts(List.of(1L)).get(0).departed()).isTrue();
|
||||
// 买单票无需客户上传退税资料,但同样必须有实际装柜证据。
|
||||
assertThat(mapper.facts(List.of(2L)).get(0).exportCustomsReady()).isFalse();
|
||||
db.update("INSERT INTO fl_loading_plan_item VALUES(13,2,22,4,0)");
|
||||
db.update("INSERT INTO fl_loading_pick_item VALUES(6,13,'PICKED',1)");
|
||||
assertThat(mapper.facts(List.of(2L))).singleElement().satisfies(f->{
|
||||
assertThat(f.missingCustoms()).isFalse();assertThat(f.exportCustomsReady()).isTrue();
|
||||
});
|
||||
var current=service.read(11,101).get(0);
|
||||
assertThat(service.confirm(11,7,1,"cmd",new WorkflowFacts.Confirm(current.fingerprint())).confirmed()).isTrue();
|
||||
assertThat(service.confirm(11,7,1,"cmd",new WorkflowFacts.Confirm(current.fingerprint())).confirmed()).isTrue();
|
||||
assertThat(db.queryForObject("SELECT COUNT(*) FROM fl_chargeable_weight_confirmation",Integer.class)).isEqualTo(1);
|
||||
assertThatThrownBy(()->service.confirm(11,7,1,"cmd",new WorkflowFacts.Confirm("0".repeat(64)))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
|
||||
db.update("UPDATE fl_inbound_order SET chargeable_weight_kg=101 WHERE id=1");
|
||||
assertThat(service.read(11,101).get(0).confirmed()).isFalse();
|
||||
assertThatThrownBy(()->service.confirm(11,7,1,"old",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
|
||||
assertThatThrownBy(()->service.confirm(22,7,1,"foreign",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
|
||||
// 现行部分装柜由来源单承接实装,余量子单独立继续分柜,两票均应保留。
|
||||
db.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,measure_review_status,chargeable_weight_kg,measure_completed_at,deleted,process_status,tax_mode,parent_inbound_id,inbound_role,split_purpose) VALUES(3,11,103,'APPROVED',50,CURRENT_TIMESTAMP,0,'WAIT_SPLIT','BUY_DECLARATION',1,'CHILD','REMAINDER')");
|
||||
assertThat(mapper.facts(List.of(1L,3L))).hasSize(2);
|
||||
assertThat(mapper.facts(List.of(1L)).get(0).departed()).isTrue();
|
||||
assertThat(mapper.facts(List.of(3L)).get(0).departed()).isFalse();
|
||||
assertThat(service.read(11,101)).hasSize(1);
|
||||
assertThat(service.read(11,103)).hasSize(1);
|
||||
// 旧模型已将实装移至独立子票时,父汇总不能重复进入页签或确认。
|
||||
db.update("UPDATE fl_inbound_order SET process_status='LOADED',split_purpose='LOADED' WHERE id=3");
|
||||
assertThat(service.read(11,101)).isEmpty();
|
||||
assertThatThrownBy(()->service.confirm(11,7,1,"parent",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
|
||||
var child=service.read(11,103).get(0);
|
||||
assertThat(child.confirmed()).isFalse();
|
||||
assertThatThrownBy(()->service.confirm(11,7,3,"cmd",new WorkflowFacts.Confirm(child.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
|
||||
assertThat(service.confirm(11,7,3,"child",new WorkflowFacts.Confirm(child.fingerprint())).confirmed()).isTrue();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user