diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 46052ade5..336d17dea 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,5 +1,7 @@ # 单测试环境数据库空库初始化 +客户计费重独立确认新增 `fl_chargeable_weight_confirmation`,源表与国内空库基线同步;存量 TEST 使用 `migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql`,不回填费用确认。须随 Firstleg、Order、Lastleg、Admin 和 Client 匹配版本核对;本次未执行 SQL、未部署。 + 费用项目默认价格结构:Firstleg 源基线 `phase2-fee-item-and-inbound-detail.sql` 与国内统一空库基线均增加可空的 `fl_fee_item.default_price DECIMAL(12,2)`。字段业务规则见工作区 `docs/入库测量与计费/README.md`;需与匹配的 Firstleg 和 Admin 一起发布。本次仅修改基线,不表示 SQL 已执行,也不能向已有测试库重放空库脚本。 排车整车作业结构同步于 Lastleg 源基线及海外统一空库基线:整车主单移除客户头部,真实客户归属保留在货物、出库、派送和费用;新增公共命令账本、主单历史、派送安排及签收待汇总信号。空排车的计划时间可暂缺,提交审核前由业务校验补齐;`ll_dispatch_physical_pallet_lock` 独立保存无代表客户的实托排他占用,`ll_dispatch_pallet_lock.physical_lock_id` 关联逐客户逻辑来源,同车可共享、跨主单或草稿容器仍排他。`ll_dispatch_item` 按主单和完整来源键唯一,`ll_dispatch_item_source` 冻结换标明细的实际位置、绑定箱数、真实件数(未知为空)及原货源事实;拣货归属新增 `dispatch_item_source_id`,与实收、退回实收、原生退货箱三类凭据四者互斥,不伪造普通收货凭据。智能草稿按拥有者、起运仓和版本维护,一容器转换一整车;独立草稿命令保存原请求和结果。必须与匹配的 Lastleg、IAM、File、Admin 和 PDA 一起核对,空库脚本不能向已有 TEST 重放。本条仅说明代码基线,不表示 SQL 已执行、已部署或全流程已验收;唯一业务规则见工作区 `docs/海外仓业务/排车列表统一作业改造契约.md`。 diff --git a/deploy/sql/migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql b/deploy/sql/migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql new file mode 100644 index 000000000..47620cd03 --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql @@ -0,0 +1,15 @@ +-- 仅为已初始化的头程库新增独立确认历史;不回填费用确认,不修改订单事实。 +USE baiyue_firstleg; +-- 客户独立确认计费重,不回填既有费用确认。 +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='客户计费重独立确认历史'; diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index e52387846..ecb9eaca6 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -4437,6 +4437,20 @@ CREATE TABLE fl_inbound_split_item ( SET SESSION foreign_key_checks = 1; +-- 客户独立确认计费重,不回填既有费用确认。 +CREATE TABLE 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='客户计费重独立确认历史'; + -- 服务 schema:baiyue_file CREATE DATABASE IF NOT EXISTS baiyue_file DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; USE baiyue_file; @@ -8371,4 +8385,4 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102213,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:direct-unload:confirm','登记直送目的仓实际卸货',NULL,'POST',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102213,0,CURRENT_TIMESTAMP(3)); -SELECT 'PASS' AS initialization_status, 'CN' AS region, 227 AS initialized_tables; +SELECT 'PASS' AS initialization_status, 'CN' AS region, 228 AS initialized_tables; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java index 88cd299c9..0059b8b86 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java @@ -157,6 +157,24 @@ public class InternalInboundOrderController { return ApiResponse.success(workbenchFilters.read(request)); } + /** 当前页摘要继续应用原筛选,防止父子或分片入库投影错配。 */ + @PostMapping("/unified-orders/status-summaries") + public ApiResponse> 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> 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> unifiedPage( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java index d7bdccc2c..54d20d654 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java @@ -140,7 +140,7 @@ public class PlatformContainerController { public ApiResponse 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)); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java index ccea8652c..a457c75df 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java @@ -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, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index 66605e865..f8d3764ca 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -158,6 +158,14 @@ public interface ContainerBookingMapper extends BaseMapper 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() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java index cd38a4284..74f5fbb73 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java @@ -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 pageUnifiedEntities(UnifiedInboundQuery query, List processStatuses) { - LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + return orders.selectPage(new Page<>(query.page(), query.size()), unifiedFilter(query, processStatuses)); + } + + /** 只读取身份和流程状态,顺序与列表一致;空候选不能扩大查询范围。 */ + public List 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 unifiedMatchedEntities(UnifiedInboundQuery query, List processStatuses) { + if (query.candidateOrderIds() == null || query.candidateOrderIds().isEmpty()) return List.of(); + return orders.selectList(unifiedFilter(query, processStatuses)); + } + + /** 列表与统计共享客户隔离、费用、测量及船期等筛选语义。 */ + private LambdaQueryWrapper unifiedFilter(UnifiedInboundQuery query, List processStatuses) { + return new LambdaQueryWrapper() .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 findByOrderIds(Long tenantId, List orderIds) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index 33ead8723..172078964 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -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, "国内工作台货柜不存在"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterService.java index ef0880e5d..3418b78c4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterService.java @@ -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))); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java index d63a1b027..143ac3971 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java @@ -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 statusRows(UnifiedInboundQuery query) { + validate(query); + Map byOrder = new LinkedHashMap<>(); + List 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 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 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 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 source = inbounds.pageUnifiedEntities(query, internalStatuses(query.mainStatus())); List records = summaries(source.getRecords()); return new PageResult<>(records, source.getCurrent(), source.getSize(), source.getTotal(), source.getPages()); } + /** 独立事实页签先筛选完整来源再分页,内部查询也不能把业务页签解释成全部状态。 */ + private PageResult workflowPage(UnifiedInboundQuery query) { + List matched = inbounds.unifiedStatusEntities(query); + Set matchingOrders = workflowTabs.rows(matched).stream() + .filter(row -> query.mainStatus().equals(row.mainStatus())).map(StatusRow::orderId) + .collect(Collectors.toSet()); + Map 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 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 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 summaries(Long tenantId, List orderIds) { Map byOrder = new LinkedHashMap<>(); for (UnifiedInboundSummary item : summaries(inbounds.findByOrderIds(tenantId, orderIds))) @@ -203,7 +269,7 @@ public class UnifiedInboundQueryService { } List 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, "统一订单主状态不合法"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/ChargeableWeightService.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/ChargeableWeightService.java new file mode 100644 index 000000000..a2274c93b --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/ChargeableWeightService.java @@ -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 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 pendingOrders(long tenant) { + var ids=repository.weightCandidates(tenant);var orders=new LinkedHashSet(); + for(int offset=0;offset0; } + /** 只认可当前版本的确认。 */ + 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)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/LastlegWorkflowClient.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/LastlegWorkflowClient.java new file mode 100644 index 000000000..d4eff4ecd --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/LastlegWorkflowClient.java @@ -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> read(@RequestHeader("X-Internal-Service-Token") String token,@RequestBody WorkflowFacts.Request request); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/MerchantChargeableWeightController.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/MerchantChargeableWeightController.java new file mode 100644 index 000000000..7be5a3db9 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/MerchantChargeableWeightController.java @@ -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> 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 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)); } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactMapper.java new file mode 100644 index 000000000..dce644169 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactMapper.java @@ -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("") + List facts(@Param("ids") List 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 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 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); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactRepository.java new file mode 100644 index 000000000..567aaba6a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactRepository.java @@ -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 facts(List 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 orderInbounds(long tenant,long order) { return mapper.orderInbounds(tenant,order); } + /** 客户计费重待确认候选。 */ + public List 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); } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFacts.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFacts.java new file mode 100644 index 000000000..afa08a995 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFacts.java @@ -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 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) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java new file mode 100644 index 000000000..c513cbf9f --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java @@ -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 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 rows(List inbounds) { + var result=new LinkedHashMap(); + for(int offset=0;offset();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 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 classify(WorkflowFacts.Fact f,String process,WorkflowFacts.Overseas o) { + Set 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,"订单业务页签事实暂不可用,请稍后重试"); } +} diff --git a/firstleg/src/main/resources/db/chargeable-weight-confirmation.sql b/firstleg/src/main/resources/db/chargeable-weight-confirmation.sql new file mode 100644 index 000000000..1dcc7e9c7 --- /dev/null +++ b/firstleg/src/main/resources/db/chargeable-weight-confirmation.sql @@ -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='客户计费重独立确认历史'; diff --git a/firstleg/src/main/resources/db/phase3-channel-container-migration.sql b/firstleg/src/main/resources/db/phase3-channel-container-migration.sql index 5cde7af51..d936e6ec1 100644 --- a/firstleg/src/main/resources/db/phase3-channel-container-migration.sql +++ b/firstleg/src/main/resources/db/phase3-channel-container-migration.sql @@ -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 ; diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index f22a25424..6e2607eea 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -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} diff --git a/firstleg/src/main/resources/openapi/order-workflow-v1.yaml b/firstleg/src/main/resources/openapi/order-workflow-v1.yaml new file mode 100644 index 000000000..9c9abf1e4 --- /dev/null +++ b/firstleg/src/main/resources/openapi/order-workflow-v1.yaml @@ -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} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java index c72e432b4..0157bbffd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java @@ -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); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionSqlContractTest.java index 8ef0f7aa8..76d026638 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionSqlContractTest.java @@ -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", diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index fc8bb1ade..a37339ac9 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -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); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTaxModePaginationTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTaxModePaginationTest.java index a6d8dcd1c..8d0dac123 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTaxModePaginationTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTaxModePaginationTest.java @@ -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")); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index 4d4834e08..02cc706d6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -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"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterServiceTest.java index b2c74a883..f9d21c3b5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterServiceTest.java @@ -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); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java index d1077117c..ee0a6ece3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java @@ -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); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java new file mode 100644 index 000000000..409acb688 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java @@ -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); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java new file mode 100644 index 000000000..6772b8ab7 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java @@ -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(); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowFactPersistenceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowFactPersistenceTest.java new file mode 100644 index 000000000..75d5c5565 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowFactPersistenceTest.java @@ -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(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/OutboundQuantityRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/OutboundQuantityRepository.java index 27f9a21bf..e3565a338 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/OutboundQuantityRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/OutboundQuantityRepository.java @@ -11,20 +11,22 @@ import java.util.HashSet; public class OutboundQuantityRepository { @Resource private OutboundPalletItemMapper pallets; - /** 废弃退货来源依据已冻结箱唛计数,读取已有单据也不会把件数当箱数。 */ + /** 废弃来源依据已冻结箱唛去重计数,无箱唛的库存明细保留箱数快照。 */ public Integer boxes(OutboundOrder outbound) { if (!"DISCARD_OUTBOUND".equals(outbound.getOutboundType())) return outbound.getPackageCount(); var lines = pallets.findByOutbound(outbound.getCustomerId(), outbound.getId()); if (lines.isEmpty()) return outbound.getPackageCount(); - var returnBoxes = new HashSet(); + var markedBoxes = new HashSet(); int count = 0; for (var line : lines) { - if (line.getReturnSkuQuantity() != null && line.getCartonMarks() != null && !line.getCartonMarks().isBlank()) { - returnBoxes.add(line.getInboundCode() + ":" + line.getCartonMarks()); + if (line.getCartonMarks() != null && !line.getCartonMarks().isBlank()) { + // 同一箱的多个 SKU 共用箱唛,只计一个实物箱;一行也可能冻结多个箱唛。 + java.util.Arrays.stream(line.getCartonMarks().split(",")).map(String::trim) + .filter(mark -> !mark.isEmpty()).forEach(markedBoxes::add); } else { count = Math.addExact(count, java.util.Objects.requireNonNullElse(line.getPackageCount(), 0)); } } - return Math.addExact(count, returnBoxes.size()); + return Math.addExact(count, markedBoxes.size()); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index ecd857967..35762deed 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -130,6 +130,7 @@ public class DeliveryTransferService { @Resource private DeliveryExecutionGuard deliveryExecution; @Resource private com.baiyue.lastleg.dispatch.port.DispatchExecutionPort execution; @Resource private com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository cancellations; + @Resource private com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository outboundQuantities; @Transactional public DeliveryResponses.PdaTask createOneClick(String customerId, String operatorId, DeliveryRequests.OneClickOutbound request) { @@ -1341,7 +1342,11 @@ public class DeliveryTransferService { } catch (JsonProcessingException exception) { throw new IllegalStateException("费用覆盖确认无法读取", exception); } } - private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); } + /** 废弃出库按冻结实物箱汇总,避免历史件数快照被展示为箱数。 */ + private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { + Integer boxes = "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? outboundQuantities.boxes(o) : o.getPackageCount(); + return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), boxes, o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); + } private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) { return new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(), item.getInboundCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java index 560138fe0..84bcf3cbb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java @@ -46,6 +46,14 @@ public class CargoDiscardController { return ApiResponse.success(service.page(scope(user, customerId), query)); } + /** 读取全部筛选结果的数量统计,与分页记录分开返回。 */ + @GetMapping("/statistics") + public ApiResponse statistics( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @Valid CargoDiscardRequests.Query query) { + return ApiResponse.success(service.statistics(scope(user, customerId), query)); + } + @GetMapping("/{discardNo}") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable String discardNo) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java index 300108ecc..0ee2d1c55 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java @@ -32,6 +32,14 @@ public class MerchantCargoDiscardController { @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid CargoDiscardRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); } + /** 客户统计只读取当前认证客户,不接受客户端客户归属。 */ + @GetMapping("/statistics") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')") + public ApiResponse statistics( + @AuthenticationPrincipal AuthenticatedUser user, @Valid CargoDiscardRequests.Query query) { + return ApiResponse.success(service.statistics(customer(user), query)); + } + @GetMapping("/{discardNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java index 8cbac94b8..82d603b03 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java @@ -33,5 +33,12 @@ public final class CargoDiscardRequests { public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String discardNo, String inboundCode, String overseasWarehouseId, - String status, LocalDateTime createdFrom, LocalDateTime createdTo) {} + String status, LocalDateTime createdFrom, LocalDateTime createdTo, + LocalDateTime completedFrom, LocalDateTime completedTo) { + /** 未筛完成时间的调用保持创建时间查询语义。 */ + public Query(long page, long size, String discardNo, String inboundCode, String overseasWarehouseId, + String status, LocalDateTime createdFrom, LocalDateTime createdTo) { + this(page, size, discardNo, inboundCode, overseasWarehouseId, status, createdFrom, createdTo, null, null); + } + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java index efde17c22..c62094d6d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java @@ -37,7 +37,12 @@ public final class CargoDiscardResponses { String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, String outboundNo, LocalDateTime customerConfirmedAt) {} public record Item(Long id, Long orderId, String inboundCode, String skuCode, - String productName, int availablePackagesSnapshot, int discardPackages, List cartonMarks) { + String productName, int availablePackagesSnapshot, int discardPackages, List cartonMarks, + String quantityUnit, Integer actualDiscardPackages, String pdaStatus) { + /** 无来源事实的调用不猜测数量单位或实际完成数。 */ + public Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages, List cartonMarks) { + this(id, orderId, inboundCode, skuCode, productName, availablePackagesSnapshot, discardPackages, cartonMarks, null, null, null); + } public Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages) { this(id, orderId, inboundCode, skuCode, productName, availablePackagesSnapshot, discardPackages, List.of()); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java index 758b47ccd..ba4238a99 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java @@ -25,6 +25,7 @@ public interface CargoDiscardInventoryMapper { @Select("SELECT p.* FROM ll_inventory_position p WHERE p.customer_id=#{customerId} AND p.id=#{positionId} FOR UPDATE") com.baiyue.lastleg.inventory.entity.InventoryPosition lockGenericPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId); + /** 按实收托盘及 SKU 关联已清点箱,避免漏箱或混入其他托盘的箱号。 */ @Select(""" SELECT c.id,c.carton_mark,c.sku_code,'INBOUND' source_type FROM ll_inbound_plan_carton c @@ -38,13 +39,13 @@ public interface CargoDiscardInventoryMapper { JOIN ll_inventory_balance b ON b.customer_id=p.customer_id AND b.id=p.balance_id JOIN ll_warehouse_transfer_order t ON t.customer_id=p.customer_id AND t.inbound_order_id=b.inbound_order_id AND t.transfer_no=p.batch_no AND t.operating_warehouse_id=p.overseas_warehouse_id - JOIN ll_warehouse_transfer_carton c ON c.customer_id=t.customer_id AND c.transfer_order_id=t.id AND c.status='EXPECTED' + JOIN ll_warehouse_transfer_pallet tp ON tp.customer_id=t.customer_id AND tp.transfer_order_id=t.id + AND tp.warehouse_pallet_id=p.pallet_id AND tp.status='COMPLETED' + JOIN ll_warehouse_transfer_carton c ON c.customer_id=t.customer_id AND c.transfer_order_id=t.id + AND c.transfer_pallet_id=tp.id AND c.status='COUNTED' JOIN ll_warehouse_transfer_item i ON i.customer_id=c.customer_id AND i.id=c.transfer_item_id + AND i.transfer_order_id=t.id AND COALESCE(i.sku_code,'')=COALESCE(b.sku_code,'') WHERE p.customer_id=#{customerId} AND p.id=#{positionId} - AND (SELECT COUNT(*) FROM ll_inventory_position current_position - WHERE current_position.customer_id=p.customer_id AND current_position.balance_id=p.balance_id - AND current_position.batch_no=p.batch_no AND current_position.overseas_warehouse_id=p.overseas_warehouse_id - AND current_position.available_packages+current_position.hold_packages+current_position.allocated_packages+current_position.picked_packages>0)=1 ORDER BY id """) List genericCartons(@Param("customerId") String customerId, @Param("positionId") Long positionId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardReturnMark.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardReturnMark.java new file mode 100644 index 000000000..206644d50 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardReturnMark.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.discard.model; + +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; + +/** 废弃作业沿用签收时实物标签,兼容历史包裹内部 RP 编号。 */ +public final class CargoDiscardReturnMark { + /** 工具类不允许实例化。 */ + private CargoDiscardReturnMark() { } + + /** 包裹取签收单元编码;箱子及缺少来源编码的历史数据保留原箱唛。 */ + public static String of(ReturnInventoryCarton carton) { + if (carton == null) return null; + if ("PARCEL".equals(carton.getSourceUnitType()) && carton.getSourceUnitCode() != null + && !carton.getSourceUnitCode().isBlank()) return carton.getSourceUnitCode().trim(); + return carton.getCartonMark(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java index b17b3483e..ad828118b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java @@ -201,7 +201,8 @@ public class PdaCargoDiscardService { if ("INVENTORY".equals(item.getSourceType())) return stockService.cartonMarks(item); if (isReturnItem(item)) { var carton = returnCartons.selectById(item.getReturnCartonId()); - return carton.getCartonMark() == null ? List.of() : List.of(carton.getCartonMark()); + String mark = com.baiyue.lastleg.discard.model.CargoDiscardReturnMark.of(carton); + return mark == null ? List.of() : List.of(mark); } return cartons.listApplication(application.getCustomerId(), application.getId()).stream() .filter(c -> Objects.equals(c.getItemId(), item.getId())).map(CargoDiscardCarton::getCartonMark).filter(Objects::nonNull).toList(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java index b7cd7fc4c..84d361e9f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java @@ -73,7 +73,7 @@ public class CargoDiscardOutboundService { || !application.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId())) throw conflict("废弃明细关联退货箱不存在或仓库不匹配"); palletNo = carton.getPalletCode() == null || carton.getPalletCode().isBlank() ? null : carton.getPalletCode(); - cartonMarks = carton.getCartonMark(); + cartonMarks = com.baiyue.lastleg.discard.model.CargoDiscardReturnMark.of(carton); } else { var profile = profiles.selectById(line.getProfileId()); if (profile == null || !application.getCustomerId().equals(profile.getCustomerId())) @@ -108,6 +108,12 @@ public class CargoDiscardOutboundService { var line = lines.stream().filter(i -> Objects.equals(i.getId(), snapshot.getId())).findFirst() .orElseThrow(() -> conflict("废弃出库明细关联失效")); boolean returned = line.getReturnCartonId() != null; + if (returned) { + var carton = returnCartons.selectById(line.getReturnCartonId()); + if (carton == null || !application.getCustomerId().equals(carton.getCustomerId())) + throw conflict("废弃明细关联退货箱不存在"); + snapshot.setCartonMarks(com.baiyue.lastleg.discard.model.CargoDiscardReturnMark.of(carton)); + } int boxes = returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : line.getDiscardPackages(); snapshot.setPackageCount(boxes); if ("COMPLETED".equals(line.getPdaStatus())) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java index dcc4c3df0..81640a372 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java @@ -50,6 +50,7 @@ import java.util.Set; @Service public class CargoDiscardService { + @Resource private CargoDiscardStatisticsService statisticsService; @Resource private com.baiyue.lastleg.discard.repository.CargoDiscardQuantityRepository quantities; @Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator; public static final String APPROVAL_PERMISSION = "platform:overseas-warehouse:discard:approve"; @@ -97,14 +98,14 @@ public class CargoDiscardService { if (returnCartons == null || returnCartonItems == null) return result; for (ReturnInventoryCarton carton : returnCartons.findAvailable(customerId, null)) { if (StringUtils.hasText(keyword) && !containsIgnoreCase(carton.getSourceReturnNo(), keyword) - && !containsIgnoreCase(carton.getCartonMark(), keyword)) continue; + && !containsIgnoreCase(com.baiyue.lastleg.discard.model.CargoDiscardReturnMark.of(carton), keyword)) continue; List skus = returnCartonItems.listCarton(carton.getId()).stream() .filter(item -> value(item.getQuantity()) > 0) .map(item -> new CargoDiscardResponses.CandidateSku(item.getSkuCode(), item.getProductNameZh(), value(item.getQuantity()))) .toList(); long available = skus.stream().mapToLong(CargoDiscardResponses.CandidateSku::availablePackages).sum(); if (available <= 0) continue; - result.add(new CargoDiscardResponses.Candidate(null, "RETURN_CARTON", carton.getId(), carton.getCartonMark(), + result.add(new CargoDiscardResponses.Candidate(null, "RETURN_CARTON", carton.getId(), com.baiyue.lastleg.discard.model.CargoDiscardReturnMark.of(carton), carton.getSourceReturnNo(), carton.getSourceReturnNo(), carton.getOverseasWarehouseId(), null, null, available, skus)); } @@ -253,7 +254,8 @@ public class CargoDiscardService { if ("INVENTORY".equals(line.getSourceType())) return stockService.cartonMarks(line); if (isReturnItem(line)) { var carton = returnCartons.selectById(line.getReturnCartonId()); - return carton == null || carton.getCartonMark() == null ? List.of() : List.of(carton.getCartonMark()); + String mark = com.baiyue.lastleg.discard.model.CargoDiscardReturnMark.of(carton); + return mark == null ? List.of() : List.of(mark); } return discardCartons.listApplication(customer, application.getId()).stream().filter(link -> link.getItemId().equals(line.getId())) .map(CargoDiscardCarton::getCartonMark).distinct().sorted().toList(); @@ -261,16 +263,7 @@ public class CargoDiscardService { public PageResult page(String customerId, CargoDiscardRequests.Query query) { validateQuery(query); - LambdaQueryWrapper wrapper = new LambdaQueryWrapper() - .eq(StringUtils.hasText(customerId), CargoDiscardApplication::getCustomerId, customerId) - .like(StringUtils.hasText(query.discardNo()), CargoDiscardApplication::getDiscardNo, query.discardNo()) - .like(StringUtils.hasText(query.inboundCode()), CargoDiscardApplication::getInboundCodes, query.inboundCode()) - .eq(StringUtils.hasText(query.overseasWarehouseId()), CargoDiscardApplication::getOverseasWarehouseId, - query.overseasWarehouseId()) - .eq(StringUtils.hasText(query.status()), CargoDiscardApplication::getStatus, query.status()) - .ge(query.createdFrom() != null, CargoDiscardApplication::getCreatedAt, query.createdFrom()) - .le(query.createdTo() != null, CargoDiscardApplication::getCreatedAt, query.createdTo()) - .orderByDesc(CargoDiscardApplication::getCreatedAt).orderByDesc(CargoDiscardApplication::getId); + var wrapper = com.baiyue.lastleg.discard.repository.CargoDiscardQueryRepository.filter(customerId, query); Page result = applications.selectPage(Page.of(query.page(), query.size()), wrapper); Map names = result.getRecords().stream().anyMatch(a -> !StringUtils.hasText(a.getOverseasWarehouseName())) ? warehouseNames() : Map.of(); @@ -278,12 +271,19 @@ public class CargoDiscardService { return new PageResult<>(records, query.page(), query.size(), result.getTotal(), result.getPages()); } + /** 全筛选汇总与列表使用同一输入校验,不受当前分页影响。 */ + public CargoDiscardStatisticsService.Statistics statistics(String customerId, CargoDiscardRequests.Query query) { + validateQuery(query); + return statisticsService.statistics(customerId, query); + } + public CargoDiscardResponses.Detail detail(String customerId, String discardNo) { CargoDiscardApplication application = applications.findNo(customerId, discardNo); if (application == null) throw notFound("Cargo discard application not found"); List itemViews = items.listApplication(customerId, application.getId()).stream() .map(value -> new CargoDiscardResponses.Item(value.getId(), value.getOrderId(), value.getInboundCode(), - value.getSkuCode(), value.getProductName(), value.getAvailablePackagesSnapshot(), value.getDiscardPackages(), selectedMarks(customerId, application, value))) + value.getSkuCode(), value.getProductName(), value.getAvailablePackagesSnapshot(), value.getDiscardPackages(), selectedMarks(customerId, application, value), + value.getReturnCartonId() != null ? "件" : "箱", value.getActualDiscardPackages(), value.getPdaStatus())) .toList(); List historyViews = histories.listApplication(customerId, application.getId()).stream() .map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(), @@ -528,6 +528,9 @@ public class CargoDiscardService { } private void validateQuery(CargoDiscardRequests.Query query) { + if (query.completedFrom() != null && query.completedTo() != null && query.completedFrom().isAfter(query.completedTo())) { + throw badRequest("completedFrom must not be after completedTo"); + } if (StringUtils.hasText(query.status()) && !STATUSES.contains(query.status())) throw badRequest("Unsupported discard status"); if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { throw badRequest("createdFrom must not be after createdTo"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java index 57b06fa14..081158224 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java @@ -66,6 +66,7 @@ public class CargoDiscardStockService { return balance; } + /** 预留所选实收箱并保存箱号快照,转运箱从已清点状态进入待废弃。 */ public void reserve(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at) { if (stock.lockGenericPosition(customer, request.inventoryPositionId()) == null) throw conflict("库存仓位不存在"); var balance = validate(customer, request); @@ -80,7 +81,7 @@ public class CargoDiscardStockService { : availableCartons.stream().filter(c -> request.cartonIds().contains(c.getId())).toList(); if (!selected.isEmpty()) { for (var carton : selected) { - if (changeBox(customer, carton.getId(), carton.getSourceType(), "TRANSFER".equals(carton.getSourceType()) ? "EXPECTED" : "AVAILABLE", "DISCARD_PENDING") != 1) throw conflict("箱子已被其他作业占用,请刷新后重选"); + if (changeBox(customer, carton.getId(), carton.getSourceType(), "TRANSFER".equals(carton.getSourceType()) ? "COUNTED" : "AVAILABLE", "DISCARD_PENDING") != 1) throw conflict("箱子已被其他作业占用,请刷新后重选"); var link = new CargoDiscardCarton(); link.setId(IdWorker.getId()); link.setCustomerId(customer); link.setApplicationId(application.getId()); link.setItemId(line.getId()); link.setSourceType(carton.getSourceType()); link.setCartonId(carton.getId()); link.setCartonMark(carton.getCartonMark()); link.setCreatedAt(at); links.insert(link); } @@ -115,9 +116,10 @@ public class CargoDiscardStockService { try { line.setCartonMarksJson(objectMapper.writeValueAsString(marks)); } catch (Exception e) { throw new IllegalStateException("Cannot snapshot carton marks", e); } } + /** 按真实箱来源完成废弃或释放,转运箱释放后恢复已清点状态。 */ private void changeSelectedCartons(String customer, CargoDiscardApplication application, CargoDiscardItem line, String status) { for (var link : links.listApplication(customer, application.getId())) { - if (Objects.equals(link.getItemId(), line.getId()) && changeBox(customer, link.getCartonId(), link.getSourceType(), "DISCARD_PENDING", "TRANSFER".equals(link.getSourceType()) && "AVAILABLE".equals(status) ? "EXPECTED" : status) != 1) + if (Objects.equals(link.getItemId(), line.getId()) && changeBox(customer, link.getCartonId(), link.getSourceType(), "DISCARD_PENDING", "TRANSFER".equals(link.getSourceType()) && "AVAILABLE".equals(status) ? "COUNTED" : status) != 1) throw conflict("预留箱子的库存状态发生变化"); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java index a150fdfd0..ede9b6852 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java @@ -5,6 +5,11 @@ import java.util.List; /** BOL按真实明细客户逐行读取整车,不依赖已移除的主单客户头。 */ @Mapper public interface DispatchBolMapper { + /** 旧客户详情入口沿用整车查询,但只返回该客户自己的BOL明细。 */ + default List findByDispatchOrder(String customerId, Long dispatchOrderId) { + return findWholeDispatch(dispatchOrderId).stream().filter(line -> java.util.Objects.equals(customerId,line.customerId())).map(line -> new com.baiyue.lastleg.dispatch.dto.DispatchResponse.BolLine(line.containerNo(),line.palletNo(),line.fbaWarehouseCode(),line.destinationWarehouseName(),line.warehouseNo(),line.fbaShipmentId(),line.poReferenceNo(),line.packageCount())).toList(); + } + @Select("SELECT di.customer_id AS customerId,di.destination_type AS destinationType,di.delivery_address AS deliveryAddress,di.postal_code AS postalCode," + "COALESCE(MAX(NULLIF(o.container_no,'')),MAX(NULLIF(ip.container_no,''))) AS containerNo," + "MAX(NULLIF(di.pallet_no,'')) AS palletNo," + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java index d17d98de1..2760b13d3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java @@ -17,7 +17,7 @@ public final class OverseasProcurementRequests { public record Create( @Size(max = 255) String customerName, - @NotBlank @Size(max = 128) String purchaseCategory, + @Size(max = 128) String purchaseCategory, @NotBlank @Size(max = 255) String productName, @Size(max = 500) String productSpecification, @Size(max = 2000) String productUrl, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java index 5e13eca43..0c9019998 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java @@ -75,7 +75,7 @@ public class OverseasProcurementService { order.setCustomerId(customerId); order.setCustomerName(trim(request.customerName())); order.setOrderNo(orderNoGenerator.next(customerId, request.customerName())); - order.setPurchaseCategory(request.purchaseCategory().trim()); + order.setPurchaseCategory(request.purchaseCategory() == null ? "" : request.purchaseCategory().trim()); order.setProductName(request.productName().trim()); order.setProductSpecification(trim(request.productSpecification())); order.setProductUrl(trim(request.productUrl())); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java index fdc6b2fcc..0ddf2936c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java @@ -25,6 +25,15 @@ import java.util.List; @RequestMapping("/api/merchant/v1/last-leg/relabel-applications") public class MerchantRelabelApplicationController { @Resource private RelabelApplicationService service; + @Resource private com.baiyue.lastleg.relabel.service.RelabelQuantityService quantities; + + /** 客户汇总始终取认证客户范围,不接收客户端客户ID。 */ + @GetMapping("/quantity-summary") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')") + public ApiResponse quantities( + @AuthenticationPrincipal AuthenticatedUser user, @Valid RelabelRequests.Query query) { + return ApiResponse.success(quantities.totals(customer(user), query)); + } @GetMapping("/inventory-candidates") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')") public ApiResponse> candidates( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java index 9fa946db2..f0b77d159 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java @@ -25,6 +25,16 @@ import org.springframework.web.server.ResponseStatusException; @RequestMapping("/api/platform/v1/last-leg/relabel-applications") public class RelabelApplicationController { @Resource private RelabelApplicationService service; + @Resource private com.baiyue.lastleg.relabel.service.RelabelQuantityService quantities; + + /** 按平台当前全部筛选结果返回独立数量维度。 */ + @GetMapping("/quantity-summary") + @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')") + public ApiResponse quantities( + @AuthenticationPrincipal AuthenticatedUser user, @Valid RelabelRequests.Query query) { + requirePlatform(user); + return ApiResponse.success(quantities.totals(null, query)); + } @Resource private com.baiyue.lastleg.relabel.pda.service.PdaRelabelService pdaService; @PostMapping("/{relabelNo}/rework") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index e0cab9da6..65b28a945 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -570,30 +570,7 @@ public class RelabelApplicationService implements WalletResultHandler { @Transactional(readOnly = true) public PageResult page(String customerId, RelabelRequests.Query query) { - if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { - throw badRequest("createdFrom must not be after createdTo"); - } - String requestedStatus = trim(query.status()); - LambdaQueryWrapper wrapper = new LambdaQueryWrapper() - .eq(StringUtils.hasText(customerId), RelabelApplication::getCustomerId, customerId) - .like(StringUtils.hasText(query.relabelNo()), RelabelApplication::getRelabelNo, query.relabelNo()) - .like(StringUtils.hasText(query.customerOrderNo()), RelabelApplication::getCustomerOrderNo, query.customerOrderNo()) - .eq(StringUtils.hasText(query.overseasWarehouseId()), RelabelApplication::getOverseasWarehouseId, - trim(query.overseasWarehouseId())) - .ge(query.createdFrom() != null, RelabelApplication::getCreatedAt, query.createdFrom()) - .le(query.createdTo() != null, RelabelApplication::getCreatedAt, query.createdTo()) - .apply(StringUtils.hasText(query.skuCode()), - "EXISTS (SELECT 1 FROM ll_relabel_item ri WHERE ri.application_id=ll_relabel_application.id AND ri.customer_id=ll_relabel_application.customer_id AND ri.sku_code LIKE CONCAT('%',{0},'%'))", - query.skuCode()) - .orderByDesc(RelabelApplication::getCreatedAt); - if (PENDING_PROCESSING.equals(requestedStatus)) { - wrapper.in(RelabelApplication::getStatus, PENDING_PROCESSING, CUSTOMER_CONFIRM_PENDING, - "REJECTED", "CANCELLED"); - } else if ("COMPLETED".equals(requestedStatus)) { - wrapper.in(RelabelApplication::getStatus, READY_FOR_OUTBOUND, "COMPLETED"); - } else if (StringUtils.hasText(requestedStatus)) { - wrapper.eq(RelabelApplication::getStatus, requestedStatus); - } + LambdaQueryWrapper wrapper = com.baiyue.lastleg.relabel.model.RelabelQueryFilter.build(customerId, query); Page page = applications.selectPage(new Page<>(query.page(), query.size()), wrapper); List records = page.getRecords().stream().map(value -> summary(value, itemList(value.getId()))).toList(); return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java index 93747dfa8..0186a7b4c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java @@ -529,6 +529,7 @@ public class ReturnInventoryService { if (cartons.updateById(carton) != 1) throw conflict("退货箱状态已变化,请重新核对"); } + /** 包裹直接沿用签收实物编码,避免同一实物生成两套标签。 */ @Transactional public ReturnInventoryCarton createReceiptCarton(ReturnOrder order, ReturnReceipt receipt, String requestedMark, List lines) { @@ -537,8 +538,8 @@ public class ReturnInventoryService { required(lines.get(0).skuCode(), "SKU 不能为空"); String cartonMark = text(requestedMark); long cartonId = IdWorker.getId(); - // One durable barcode identifies a physical parcel, irrespective of SKU count. - if ("PARCEL".equals(receipt.getUnitType())) cartonMark = "RP-" + Long.toString(receipt.getId(), 36).toUpperCase(java.util.Locale.ROOT); + // 包裹签收已生成 PK 标签,后续库存、废弃和扫码沿用同一编码。 + if ("PARCEL".equals(receipt.getUnitType())) cartonMark = normalizedUnitCode; else if (cartonMark == null) cartonMark = numbers.next("RETURN_CARTON", order.getOverseasWarehouseId(), cartonId); if (cartons.findMark(cartonMark) != null) throw conflict("箱号已存在: " + cartonMark); LocalDateTime now = LocalDateTime.now(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java index 4539015bd..655bdefe7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java @@ -458,7 +458,9 @@ public class ReturnReceiptService { RelabelApplication app = new RelabelApplication(); app.setId(appId); app.setCustomerId(customerId); app.setRelabelNo(relabelNo); app.setLastlegOrderId(tail.getId()); app.setCustomerOrderNo(order.getReturnNo()); app.setOverseasWarehouseId(order.getOverseasWarehouseId()); app.setLabelDeadline(now.plusDays(7)); - app.setContactName(text(order.getWarehouseContactName())); app.setContactPhone(text(order.getWarehouseContactPhone())); + // 退货预报的仓库联系人选填;转换标时保留空值语义并满足申请表非空约束。 + app.setContactName(java.util.Objects.requireNonNullElse(text(order.getWarehouseContactName()), "")); + app.setContactPhone(java.util.Objects.requireNonNullElse(text(order.getWarehouseContactPhone()), "")); app.setFulfillmentStatus("WAIT_APPROVAL"); app.setRemark(text(request.cartonRemark())); app.setStatus("PENDING_REVIEW"); app.setItemCount(request.items().size()); app.setTotalPackageCount(Math.toIntExact(totalQuantity)); app.setNotifiedPackageCount(Math.toIntExact(totalQuantity)); app.setChargeAmount(java.math.BigDecimal.ZERO); app.setChargeCurrency("CNY"); app.setWalletStatus("NOT_REQUIRED"); app.setIdempotencyKey(key); app.setRequestHash(requestHash); app.setCreatedBy(text(operatorId)); app.setCreatedAt(now); app.setUpdatedAt(now); relabelApplications.insert(app); List availableCartons = cartons.findAvailable(customerId, order.getOverseasWarehouseId()); Map selectedCartons = new LinkedHashMap<>(); @@ -577,6 +579,11 @@ public class ReturnReceiptService { String unitType = norm(request.unitType()); if (unitType == null) unitType = "PALLET"; String unitCode = text(request.unitCode()); + // 箱和包裹编码会直接生成 Code128 标签,入账前拒绝无法打印的编码。 + if (unitCode != null && Set.of("BOX", "PARCEL").contains(unitType)) { + try { new com.google.zxing.oned.Code128Writer().encode(unitCode); } + catch (IllegalArgumentException invalid) { throw badRequest("实物编码无法生成条码,请使用 ASCII 编码"); } + } ReturnOrder order = orders.lockNo(text(request.returnNo())); if (order == null || !String.valueOf(identity.overseasWarehouseId()).equals(order.getOverseasWarehouseId())) { throw notFound("当前绑定仓没有该退货单"); @@ -869,3 +876,4 @@ public class ReturnReceiptService { private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } private record Allocation(ReturnOrderItem item, long quantity) { } } + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java index 8e40695fe..11de3837f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java @@ -40,6 +40,28 @@ import org.apache.ibatis.annotations.Select; ) + UNION ALL + SELECT c.carton_mark,c.sku_code,NULL,c.product_quantity,NULL,NULL, + (SELECT JSON_ARRAYAGG(JSON_OBJECT('skuCode',s.sku_code,'quantity',s.product_quantity)) + FROM ll_dropship_carton_sku s WHERE s.customer_id=c.customer_id AND s.carton_id=c.id) + FROM ll_dropship_carton c + JOIN ll_order o ON o.id=c.order_id AND o.customer_id=c.customer_id + JOIN ll_dropship_order_profile p ON p.id=c.profile_id AND p.customer_id=c.customer_id + JOIN ll_inventory_position pos ON pos.id=p.inventory_position_id AND pos.customer_id=c.customer_id + JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=c.customer_id + WHERE c.customer_id=#{customerId} AND o.inbound_code=#{inboundCode} + AND o.overseas_warehouse_id=#{warehouseId} AND c.status='AVAILABLE' + AND p.status='ACTIVE' AND pos.available_packages>0 AND b.available_packages>0 + AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_carton existing + WHERE existing.customer_id=c.customer_id AND existing.order_id=c.order_id + AND existing.carton_mark=c.carton_mark) + AND NOT EXISTS ( + SELECT 1 FROM ll_relabel_item ri + JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id + WHERE ri.customer_id=c.customer_id AND ri.source_inbound_code=o.inbound_code + AND ra.overseas_warehouse_id=#{warehouseId} + AND UPPER(TRIM(COALESCE(NULLIF(ri.source_carton_mark,''),ri.carton_mark)))=UPPER(TRIM(c.carton_mark)) + AND ra.status NOT IN ('CANCELLED','REJECTED')) ORDER BY cartonMark """) java.util.List sourceCartons(@Param("customerId") String customerId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferSourceReservationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferSourceReservationMapper.java index e2c1b5cb4..3515549e9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferSourceReservationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferSourceReservationMapper.java @@ -3,11 +3,30 @@ package com.baiyue.lastleg.transfer.mapper; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; import java.util.List; /** 使用提交单据的原箱快照表达业务占用,不提前扣减实物库存。 */ @Mapper public interface TransferSourceReservationMapper { + /** 在出库事务中按原箱身份更新来源箱,保留箱内件数和清点时间。 */ + @Update(""" + UPDATE ll_warehouse_transfer_carton original + JOIN ll_warehouse_transfer_order origin_order + ON origin_order.id=original.transfer_order_id AND origin_order.customer_id=original.customer_id + JOIN ll_warehouse_transfer_order outgoing + ON outgoing.customer_id=origin_order.customer_id AND outgoing.inbound_code=origin_order.inbound_code + AND outgoing.operating_warehouse_id=origin_order.operating_warehouse_id + JOIN ll_warehouse_transfer_carton selected + ON selected.customer_id=outgoing.customer_id AND selected.transfer_order_id=outgoing.id + AND COALESCE(NULLIF(selected.customer_carton_mark,''),selected.carton_mark)=original.carton_mark + SET original.status='SHIPPED',original.updated_at=CURRENT_TIMESTAMP + WHERE outgoing.customer_id=#{customerId} AND outgoing.id=#{transferOrderId} + AND outgoing.transfer_no<>outgoing.inbound_code AND origin_order.transfer_no=origin_order.inbound_code + AND original.status='COUNTED' + """) + int shipSelectedCartons(@Param("customerId") String customerId, @Param("transferOrderId") Long transferOrderId); + /** 按同一入仓快照串行提交,防止不同草稿同时抢占原箱。 */ @Select("SELECT id FROM ll_inbound_order WHERE customer_id=#{customerId} AND id=#{inboundOrderId} FOR UPDATE") Long lockInbound(@Param("customerId") String customerId, @Param("inboundOrderId") Long inboundOrderId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferInventorySourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferInventorySourceRepository.java index 85fc1c81b..3a0fd34da 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferInventorySourceRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferInventorySourceRepository.java @@ -21,6 +21,11 @@ public class TransferInventorySourceRepository { return reservations.occupiedMarks(customerId, warehouseId, sourceCode); } + /** 按当前转运实际选择的箱唛记录来源箱出库,不按箱数截断历史箱集合。 */ + public int shipSelectedCartons(String customerId, Long transferOrderId) { + return reservations.shipSelectedCartons(customerId, transferOrderId); + } + /** 读取当前来源单未被有效换标申请使用的箱级快照。 */ public List sourceCartons(String customerId, String warehouseId, String inboundCode) { return snapshots.sourceCartons(customerId, warehouseId, inboundCode); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index 46d16fc16..ff0b99069 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -1063,8 +1063,13 @@ public class WarehouseTransferService { boolean hasSkuReceiptDifference(List cartons, List items) { Map expected = new java.util.HashMap<>(); Map received = new java.util.HashMap<>(); + Set unknown = new java.util.HashSet<>(); Map byId = items.stream().collect(Collectors.toMap(WarehouseTransferItem::getId, Function.identity())); - for (var item : items) expected.merge(item.getSkuCode(), item.getDeclaredQuantity() == null ? 0L : item.getDeclaredQuantity(), Long::sum); + // 同SKU任一申报缺失时整体不可比较,不能把未知件数视作零制造差异。 + for (var item : items) { + if (item.getDeclaredQuantity() == null) unknown.add(item.getSkuCode()); + else expected.merge(item.getSkuCode(), item.getDeclaredQuantity(), Long::sum); + } for (var carton : cartons) { var contents = cartonContents(carton); if (contents.isEmpty()) { @@ -1075,8 +1080,10 @@ public class WarehouseTransferService { for (var line : contents) received.merge(line.skuCode(), line.quantity(), Long::sum); } } - return expected.entrySet().stream().anyMatch(entry -> !entry.getValue().equals(received.getOrDefault(entry.getKey(), 0L))) - || received.entrySet().stream().anyMatch(entry -> !entry.getValue().equals(expected.getOrDefault(entry.getKey(), 0L))); + return expected.entrySet().stream().anyMatch(entry -> !unknown.contains(entry.getKey()) + && !entry.getValue().equals(received.getOrDefault(entry.getKey(), 0L))) + || received.entrySet().stream().anyMatch(entry -> !unknown.contains(entry.getKey()) + && !entry.getValue().equals(expected.getOrDefault(entry.getKey(), 0L))); } private List requireCountedCartons(WarehouseTransferOrder transfer) { @@ -1520,6 +1527,8 @@ public class WarehouseTransferService { allocation.setStatus("SHIPPED"); allocation.setActivePositionKey(null); allocation.setShippedAt(now); allocation.setUpdatedAt(now); inventoryAllocations.updateById(allocation); } + // 同一事务记录所选原箱已出库,当前库存候选据此排除;签收快照仍完整保留。 + inventorySources.shipSelectedCartons(customerId, transfer.getId()); transfer.setStatus(OUTBOUNDED); transfer.setOutboundIdempotencyKey(key); transfer.setOutboundRequestHash(requestHash); transfer.setOutboundAt(now); transfer.setUpdatedAt(now); transfers.updateById(transfer); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/InternalOrderWorkflowFactController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/InternalOrderWorkflowFactController.java new file mode 100644 index 000000000..81fda6eec --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/InternalOrderWorkflowFactController.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.workflow; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import org.springframework.web.bind.annotation.*; +import jakarta.validation.Valid; +import java.util.List; +/** 服务令牌保护的来源事实接口。 */ +@RestController +@RequestMapping("/internal/lastleg/v1/order-workflow-facts") +public class InternalOrderWorkflowFactController { + private final InternalServiceTokenGuard guard; + private final OrderWorkflowFactService service; + /** 注入服务身份守卫。 */ + public InternalOrderWorkflowFactController(InternalServiceTokenGuard guard,OrderWorkflowFactService service) { this.guard=guard;this.service=service; } + /** 身份验证后批量查询,不暴露公网。 */ + @PostMapping + public ApiResponse> read(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@Valid @RequestBody OrderWorkflowFacts.Request request) { + guard.verify(token);return ApiResponse.success(service.read(request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapper.java new file mode 100644 index 000000000..dc8dc34e8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapper.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.workflow; +import org.apache.ibatis.annotations.*; +import java.util.List; +/** 以不可变来源明细核对出库,取消不再遮挡待排车。 */ +@Mapper +public interface OrderWorkflowFactMapper { + /** 精确核对客户及来源;字符串参数沿用列排序规则,避免连接默认排序规则冲突。 */ + @Select(""" + + """) + List read(@Param("sources") List sources); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactRepository.java new file mode 100644 index 000000000..deb9ba94e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactRepository.java @@ -0,0 +1,12 @@ +package com.baiyue.lastleg.workflow; +import org.springframework.stereotype.Repository; +import java.util.List; +/** 尾程作业事实持久化入口。 */ +@Repository +public class OrderWorkflowFactRepository { + private final OrderWorkflowFactMapper mapper; + /** 注入事实SQL提供者。 */ + public OrderWorkflowFactRepository(OrderWorkflowFactMapper mapper) { this.mapper=mapper; } + /** 空来源不能扩大查询。 */ + public List read(List sources) { return sources.isEmpty()?List.of():mapper.read(sources); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactService.java new file mode 100644 index 000000000..228de947d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactService.java @@ -0,0 +1,12 @@ +package com.baiyue.lastleg.workflow; +import org.springframework.stereotype.Service; +import java.util.List; +/** 只读提供国内订单列表所需的真实作业事实。 */ +@Service +public class OrderWorkflowFactService { + private final OrderWorkflowFactRepository repository; + /** 注入领域仓储。 */ + public OrderWorkflowFactService(OrderWorkflowFactRepository repository) { this.repository=repository; } + /** 批量读取精确来源事实。 */ + public List read(OrderWorkflowFacts.Request request) { return repository.read(request.sources()); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFacts.java b/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFacts.java new file mode 100644 index 000000000..884e45b29 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFacts.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.workflow; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.util.List; +/** 按客户和来源入仓单查询独立作业事实。 */ +public final class OrderWorkflowFacts { + /** 仅作为内部契约命名空间,不允许实例化。 */ + private OrderWorkflowFacts() {} + public record Source(@Positive long tenantId,@Positive long inboundId) { + /** 字符型客户键以字符串绑定,避免隐式数字比较丢失长整型精度。 */ + public String tenantKey() { return Long.toString(tenantId); } + /** 来源入库键保持原值,不通过数据库CAST引入连接排序规则。 */ + public String inboundKey() { return Long.toString(inboundId); } + } + public record Request(@NotEmpty @Size(max=500) List<@NotNull @Valid Source> sources) {} + public record Fact(long tenantId,long inboundId,boolean outboundExists,boolean pendingOutbound,boolean delivering,boolean unloaded) {} +} diff --git a/lastleg-service/src/main/resources/openapi/order-workflow-v1.yaml b/lastleg-service/src/main/resources/openapi/order-workflow-v1.yaml new file mode 100644 index 000000000..cb4e55250 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/order-workflow-v1.yaml @@ -0,0 +1,52 @@ +openapi: 3.0.3 +info: {title: 尾程订单作业事实内部查询, version: 1.0.0} +paths: + /internal/lastleg/v1/order-workflow-facts: + post: + summary: 批量查询精确客户和来源入仓单的作业事实 + description: 仅内部服务令牌。最多500个来源,禁止公网访问。无写入。有效出库包含实出库,pendingOutbound仅未取消且未出库。卸柜含真实差异卸柜完成。 + parameters: + - {name: X-Internal-Service-Token, in: header, required: true, schema: {type: string}} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [sources] + properties: + sources: + type: array + minItems: 1 + maxItems: 500 + items: + type: object + required: [tenantId, inboundId] + properties: + tenantId: {type: integer, format: int64, minimum: 1} + inboundId: {type: integer, format: int64, minimum: 1} + 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/Fact'} +components: + schemas: + Fact: + type: object + required: [tenantId, inboundId, outboundExists, pendingOutbound, delivering, unloaded] + properties: + tenantId: {type: string} + inboundId: {type: string} + outboundExists: {type: boolean} + pendingOutbound: {type: boolean} + delivering: {type: boolean} + unloaded: {type: boolean} diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 76d732dd7..8245d27b3 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -4979,7 +4979,7 @@ components: purchaseWarehouseId: { type: integer, format: int64, minimum: 1, nullable: true } ProcurementCreate: type: object - required: [purchaseCategory, productName, purchaseQuantity, budgetUnitPrice, currency, receivingAddress, contactName, contactPhone, idempotencyKey] + required: [productName, purchaseQuantity, budgetUnitPrice, currency, receivingAddress, contactName, contactPhone, idempotencyKey] properties: customerName: { type: string, nullable: true, maxLength: 255 } purchaseCategory: { type: string, maxLength: 128 } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/OutboundQuantityRepositoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/OutboundQuantityRepositoryTest.java new file mode 100644 index 000000000..75d1a7fa6 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/OutboundQuantityRepositoryTest.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.delivery; + +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; +import com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +class OutboundQuantityRepositoryTest { + /** 箱唛事实优先于历史件数,同箱多 SKU、多箱一行和无箱唛库存均须正确计数。 */ + @Test void countsUniqueMarksWithoutDependingOnHistoricPieceMetadata() { + var mapper = mock(OutboundPalletItemMapper.class); + var repository = new OutboundQuantityRepository(); + ReflectionTestUtils.setField(repository, "pallets", mapper); + var outbound = new OutboundOrder(); + outbound.setId(1L); outbound.setCustomerId("C-1"); outbound.setOutboundType("DISCARD_OUTBOUND"); + outbound.setPackageCount(99); + var a = new OutboundPalletItem(); a.setCartonMarks("BOX-1, BOX-2"); a.setPackageCount(5); + var b = new OutboundPalletItem(); b.setCartonMarks("BOX-2,BOX-1"); b.setPackageCount(8); + var unmarked = new OutboundPalletItem(); unmarked.setPackageCount(3); + when(mapper.findByOutbound("C-1", 1L)).thenReturn(List.of(a, b, unmarked)); + assertEquals(5, repository.boxes(outbound)); + assertEquals(99, outbound.getPackageCount()); + outbound.setOutboundType("RETURN_OUTBOUND"); + assertEquals(99, repository.boxes(outbound)); + verify(mapper, times(1)).findByOutbound("C-1", 1L); + verifyNoMoreInteractions(mapper); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index b7a5b9034..9377fa9f5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -133,6 +133,9 @@ class DeliveryTransferServiceTest { @BeforeEach void setUp() { service = new DeliveryTransferService(); + var quantities = new com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository(); + ReflectionTestUtils.setField(quantities, "pallets", outboundPallets); + ReflectionTestUtils.setField(service, "outboundQuantities", quantities); ReflectionTestUtils.setField(service, "deliveryExecution", new DeliveryExecutionGuard(new com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository(outboundOrders, deliveryOrders))); ReflectionTestUtils.setField(service, "execution", execution); ReflectionTestUtils.setField(service,"cancellations",new com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository( @@ -491,6 +494,27 @@ class DeliveryTransferServiceTest { assertEquals("DISCARD_OUTBOUND".equals(type) ? null : "LX-REAL-001", detail.outbound().lxCodes()); } + /** 历史废弃单一箱内的多种 SKU 件数不能累加成出库箱数。 */ + @Test + void discardListAndDetailCountPhysicalCartonsInsteadOfHistoricPieces() { + OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "discard-301"); + outbound.setOutboundType("DISCARD_OUTBOUND"); outbound.setPackageCount(5); + var a = pallet(501L, "P-1", null, null, "RT-1", 2); + var b = pallet(502L, "P-1", null, null, "RT-1", 3); + a.setReturnSkuQuantity(2L); b.setReturnSkuQuantity(3L); + a.setCartonMarks("BOX-1"); b.setCartonMarks("BOX-1"); + when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); + when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(a, b)); + when(outboundOrders.selectPage(any(Page.class), any())).thenReturn(new Page(1, 10, 1).setRecords(List.of(outbound))); + + assertEquals(1, service.outboundDetail("OUT-301").outbound().packageCount()); + var query = new DeliveryRequests.Query(1, 10, "OUT-301", null, null, null, null, null, null, null, null, null, null, "DISCARD_OUTBOUND"); + assertEquals(1, service.outboundPage(null, query).records().get(0).packageCount()); + assertEquals(5, outbound.getPackageCount()); + assertEquals(2L, a.getReturnSkuQuantity()); assertEquals(3L, b.getReturnSkuQuantity()); + verify(outboundOrders, never()).updateById(any(OutboundOrder.class)); + } + @Test void dispatchOutboundStillRequiresItsDeliveryDocument() { OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "dispatch-301"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java index b26dead8f..58b8ffe06 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java @@ -57,6 +57,11 @@ class ReturnOutboundClosureTest { ReturnInventoryCarton carton; @BeforeEach void setup() { + // 取消入口先查询幂等记录,手动组装的测试服务也需要注入该仓储。 + ReflectionTestUtils.setField(service, "cancellations", new com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository( + outbounds, items, deliveries, tasks, mock(DeliveryOperationMapper.class), relabelApplications, cartons, + mock(com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper.class), + mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class), mock(com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper.class))); ReflectionTestUtils.setField(service, "outboundOrders", outbounds); ReflectionTestUtils.setField(service, "outboundPallets", items); ReflectionTestUtils.setField(service, "pdaTasks", tasks); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java index 1510f5b3f..9626cdc09 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java @@ -54,6 +54,8 @@ class ReturnOutboundPdaPersistenceTest { + (field.getName().equals("id") ? " PRIMARY KEY" : "")).collect(Collectors.joining(",")); jdbc.execute("CREATE TABLE " + entity.getAnnotation(TableName.class).value() + " (" + columns + ")"); } + jdbc.execute("CREATE TABLE ll_cargo_discard_application(id BIGINT,customer_id VARCHAR(2000),overseas_warehouse_id VARCHAR(2000),discard_no VARCHAR(2000))"); + jdbc.execute("CREATE TABLE ll_cargo_discard_item(id BIGINT,application_id BIGINT,customer_id VARCHAR(2000),return_carton_id BIGINT,sku_code VARCHAR(2000),product_name VARCHAR(2000),pda_status VARCHAR(2000),available_packages_snapshot BIGINT,actual_discard_packages BIGINT,pda_operator_id VARCHAR(2000),pda_completed_at TIMESTAMP)"); var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true); for (Class mapper : List.of(OutboundOrderMapper.class, OutboundPalletItemMapper.class, OutboundPdaTaskMapper.class, OutboundPdaPickMapper.class, DeliveryOrderMapper.class, ReturnInventoryCartonMapper.class, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardReturnMarkTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardReturnMarkTest.java new file mode 100644 index 000000000..e829e048b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardReturnMarkTest.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.discard; + +import com.baiyue.lastleg.discard.model.CargoDiscardReturnMark; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +/** 废弃标签与签收实物标签一致,箱及托盘来源仍使用独立箱唛。 */ +class CargoDiscardReturnMarkTest { + /** 兼容历史 RP 库存记录,不通过字符串替换猜测 PK 编码。 */ + @Test void historicalParcelUsesExactReceiptUnitCode() { + var carton = new ReturnInventoryCarton(); carton.setSourceUnitType("PARCEL"); + carton.setCartonMark("RP-G1OTH7E5RRB6"); carton.setSourceUnitCode("PK-G1OTH7E5RRB5"); + assertEquals("PK-G1OTH7E5RRB5", CargoDiscardReturnMark.of(carton)); + carton.setSourceUnitType("BOX"); assertEquals("RP-G1OTH7E5RRB6", CargoDiscardReturnMark.of(carton)); + carton.setSourceUnitType("PARCEL"); carton.setSourceUnitCode(" "); + assertEquals("RP-G1OTH7E5RRB6", CargoDiscardReturnMark.of(carton)); + assertNull(CargoDiscardReturnMark.of(null)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/ReturnDiscardMovementMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/ReturnDiscardMovementMapperTest.java index 844099164..65de01334 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/ReturnDiscardMovementMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/ReturnDiscardMovementMapperTest.java @@ -20,8 +20,7 @@ class ReturnDiscardMovementMapperTest { var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); var db = new JdbcTemplate(source); db.execute("CREATE TABLE ll_cargo_discard_application(id BIGINT,customer_id VARCHAR(20),overseas_warehouse_id VARCHAR(20),discard_no VARCHAR(30))"); - db.execute("CREATE TABLE ll_return_inventory_carton(id BIGINT,customer_id VARCHAR(20),overseas_warehouse_id VARCHAR(20))"); - db.execute("ALTER TABLE ll_return_inventory_carton ADD carton_mark VARCHAR(30), ADD source_batch_no VARCHAR(30), ADD created_at TIMESTAMP"); + db.execute("CREATE TABLE ll_return_inventory_carton(id BIGINT,customer_id VARCHAR(20),overseas_warehouse_id VARCHAR(20),carton_mark VARCHAR(30),source_batch_no VARCHAR(30),created_at TIMESTAMP)"); db.execute("CREATE TABLE ll_return_inventory_carton_item(carton_id BIGINT,sku_code VARCHAR(30),product_name_zh VARCHAR(30),quantity BIGINT)"); db.execute("CREATE TABLE ll_return_receipt(id BIGINT,return_order_id BIGINT,receipt_no VARCHAR(30),signed_at TIMESTAMP)"); db.execute("CREATE TABLE ll_return_order(id BIGINT,customer_id VARCHAR(20),overseas_warehouse_id VARCHAR(20))"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java index f558e0df9..1a310889a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java @@ -193,18 +193,19 @@ class PdaCargoDiscardServiceTest { @Test void returnCartonUsesPieceUnitAndRequiresSelectedCartonEvenWhenBoundToPallet() { item.setSourceType("RETURN_CARTON"); item.setReturnCartonId(31L); item.setDiscardPackages(5); var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton(); - carton.setCustomerId("CUSTOMER"); carton.setPalletCode("PALLET-1"); carton.setCartonMark("RETURN-BOX-1"); + carton.setCustomerId("CUSTOMER"); carton.setPalletCode("PALLET-1"); carton.setCartonMark("RP-OLD"); + carton.setSourceUnitType("PARCEL"); carton.setSourceUnitCode("PK-REAL"); when(returnCartons.selectById(31L)).thenReturn(carton); when(applications.selectList(any())).thenReturn(List.of(application)); var task = service.resolve(identity, "PALLET-1", "CD-1"); assertEquals("CARTON", task.scanMode()); - assertEquals(List.of("RETURN-BOX-1"), task.cartonMarks()); + assertEquals(List.of("PK-REAL"), task.cartonMarks()); assertEquals(5, task.skus().get(0).plannedPackages()); assertEquals("件", task.skus().get(0).quantityUnit()); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(5))); verifyNoInteractions(adjustments); var confirm = new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 5)), - List.of(101L), List.of("RETURN-BOX-1"), false); + List.of(101L), List.of("PK-REAL"), false); service.confirm(identity, "CD-1", "PALLET-1", confirm); assertEquals(1, application.getTotalPackages()); assertEquals(new BigDecimal("5.00"), application.getTotalFee()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundServiceTest.java index 4a9d22b56..625dfc82d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundServiceTest.java @@ -94,9 +94,10 @@ class CargoDiscardOutboundServiceTest { @Test void returnSourceKeepsRealCartonMarkWithoutInventingPalletNumber() { first.setReturnCartonId(31L); first.setSourceType("RETURN_CARTON"); var carton = new ReturnInventoryCarton(); carton.setId(31L); carton.setCustomerId("C1"); - carton.setOverseasWarehouseId("99"); carton.setCartonMark("BOX-31"); + carton.setOverseasWarehouseId("99"); carton.setCartonMark("RP-OLD"); + carton.setSourceUnitType("PARCEL"); carton.setSourceUnitCode("PK-REAL"); when(returnCartons.selectById(31L)).thenReturn(carton); - service.create(application, "OP"); assertNull(snapshots.get(0).getPalletNo()); assertEquals("BOX-31", snapshots.get(0).getCartonMarks()); + service.create(application, "OP"); assertNull(snapshots.get(0).getPalletNo()); assertEquals("PK-REAL", snapshots.get(0).getCartonMarks()); assertEquals(3L, snapshots.get(0).getReturnSkuQuantity()); } @Test void invalidSourcePreventsAnyOutboundInsert() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java index 1915af51e..1daa421b3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java @@ -47,6 +47,26 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class CargoDiscardServiceTest { + /** 历史包裹候选必须沿用签收 PK 码,不能展示内部 RP 码。 */ + @Test + void parcelCandidatesAndDetailsUseReceiptLabel() { + var carton = new ReturnInventoryCarton(); carton.setId(901L); carton.setCustomerId("100"); + carton.setOverseasWarehouseId("99"); carton.setSourceReturnNo("TT-BYWL-261019180631"); + carton.setSourceUnitType("PARCEL"); carton.setSourceUnitCode("PK-G1OTH7E5RRB5"); + carton.setCartonMark("RP-G1OTH7E5RRB6"); carton.setStatus("AVAILABLE"); + var sku = new ReturnInventoryCartonItem(); sku.setSkuCode("SKU"); sku.setQuantity(6L); + when(returnCartons.findAvailable("100", null)).thenReturn(List.of(carton)); + when(returnCartons.selectById(901L)).thenReturn(carton); + when(returnCartonItems.listCarton(901L)).thenReturn(List.of(sku)); + var candidates = service.candidates("100", "PK-G1OTH7E5RRB5"); + assertEquals(1, candidates.size()); assertEquals("PK-G1OTH7E5RRB5", candidates.get(0).cartonMark()); + assertEquals("PK-G1OTH7E5RRB5", candidates.get(0).cartons().get(0).cartonMark()); + storedApplication.set(pendingApplication()); + var line = item(); line.setSourceType("RETURN_CARTON"); line.setReturnCartonId(901L); storedItems.add(line); + var detail = service.detail("100", "FH-1"); + assertEquals(List.of("PK-G1OTH7E5RRB5"), detail.items().get(0).cartonMarks()); + } + @Test void customerConfirmationClosesPdaCompletedApplicationOnceWithoutAnotherDeduction() { CargoDiscardApplication application = new CargoDiscardApplication(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStockServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStockServiceTest.java index d2da844a0..777b3d501 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStockServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStockServiceTest.java @@ -112,20 +112,24 @@ class CargoDiscardStockServiceTest { verifyNoInteractions(commands, items, links); } - @Test void singlePositionTransferBoxesReleaseBackToTheirOriginalExpectedStatus() { + @Test void receivedTransferBoxesAreSelectableAndReleaseBackToCountedStatus() { position.setAvailablePackages(2L); balance.setAvailablePackages(2L); var first = carton(1L, "WT-1#2-1"); first.setSourceType("TRANSFER"); var second = carton(2L, "WT-1#2-2"); second.setSourceType("TRANSFER"); when(stock.genericCartons("C1", 42L)).thenReturn(List.of(first, second)); - when(stock.changeTransferCartonStatus("C1", 2L, "EXPECTED", "DISCARD_PENDING")).thenReturn(1); + when(stock.genericPositions("C1", "WT")).thenReturn(List.of(position)); + var candidate = service.candidates("C1", "WT").get(0); + assertTrue(candidate.cartonSelectionSupported()); + assertEquals(List.of("WT-1#2-1", "WT-1#2-2"), candidate.cartons().stream().map(box -> box.cartonMark()).toList()); + when(stock.changeTransferCartonStatus("C1", 2L, "COUNTED", "DISCARD_PENDING")).thenReturn(1); service.reserve("C1", "OP", application, new CargoDiscardRequests.Item(null, null, "SKU", 1, 42L, List.of(2L)), LocalDateTime.now()); var capture = ArgumentCaptor.forClass(CargoDiscardCarton.class); verify(links).insert(capture.capture()); assertEquals("TRANSFER", capture.getValue().getSourceType()); when(links.listApplication("C1", 11L)).thenReturn(List.of(capture.getValue())); - when(stock.changeTransferCartonStatus("C1", 2L, "DISCARD_PENDING", "EXPECTED")).thenReturn(1); + when(stock.changeTransferCartonStatus("C1", 2L, "DISCARD_PENDING", "COUNTED")).thenReturn(1); var line = ArgumentCaptor.forClass(CargoDiscardItem.class); verify(items).insert(line.capture()); service.release("C1", application, line.getValue()); - verify(stock).changeTransferCartonStatus("C1", 2L, "DISCARD_PENDING", "EXPECTED"); + verify(stock).changeTransferCartonStatus("C1", 2L, "DISCARD_PENDING", "COUNTED"); verify(stock, never()).changeGenericCartonStatus(anyString(), anyLong(), anyString(), anyString()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/ProcurementRequestBoundaryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/ProcurementRequestBoundaryTest.java index 9669a8e8a..7dd703a09 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/ProcurementRequestBoundaryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/ProcurementRequestBoundaryTest.java @@ -10,6 +10,19 @@ import java.util.List; import static org.assertj.core.api.Assertions.*; class ProcurementRequestBoundaryTest { + @Test void purchaseCategoryMayBeOmittedOrBlankButCannotExceedLimit() throws Exception { + var json = new ObjectMapper(); + try (var factory = jakarta.validation.Validation.buildDefaultValidatorFactory()) { + var validator = factory.getValidator(); + for (String category : List.of("null", "\"\"", "\"sample\"")) { + var request = json.readValue("{\"purchaseCategory\":" + category + "}", OverseasProcurementRequests.Create.class); + assertThat(validator.validate(request)).noneMatch(v -> "purchaseCategory".equals(v.getPropertyPath().toString())); + } + var oversized = json.readValue("{\"purchaseCategory\":\"" + "x".repeat(129) + "\"}", OverseasProcurementRequests.Create.class); + assertThat(validator.validate(oversized)).anyMatch(v -> "purchaseCategory".equals(v.getPropertyPath().toString())); + } + } + @Test void createRejectsMultiProductAndBatchExtensionsWithApplicationJacksonDefaults() { new org.springframework.boot.test.context.runner.ApplicationContextRunner() .withConfiguration(org.springframework.boot.autoconfigure.AutoConfigurations.of( diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java index 93396c04a..12e253f98 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java @@ -185,7 +185,7 @@ class ReturnInventoryServiceTest { var receipt = new ReturnReceipt(); receipt.setId(21L); receipt.setUnitType("PARCEL"); receipt.setUnitCode("PK-1"); var source = service.createReceiptCarton(completedOrder(), receipt, null, List.of(new ReturnInventoryService.ReceiptLine("A", "商品A", 3))); - assertThat(source.getCartonMark()).startsWith("RP-").hasSizeLessThanOrEqualTo(16); + assertThat(source.getCartonMark()).isEqualTo("PK-1"); assertThat(source.getSourceUnitType()).isEqualTo("PARCEL"); verifyNoInteractions(numbers); assertThat(source.getLabelStatus()).isEqualTo("CONFIRMED"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java index d67a66ccc..99f173a76 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java @@ -56,6 +56,16 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class ReturnReceiptServiceTest { + @Test void rejectsUnprintablePhysicalCodesBeforeReceiptWrites() { + for (String code : List.of("中文包裹", "PK-中文")) { + var request = new PdaReturnReceiptRequests.Confirm("RT-1", null, "RETURN_NO", null, + List.of(new PdaReturnReceiptRequests.Item("SKU1", 1)), "INVALID-CODE", "PARCEL", code, + java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, null, true); + assertThat(assertThrows(ResponseStatusException.class, () -> service.confirm(identity, request)).getReason()) + .contains("实物编码无法生成条码"); + } + verifyNoInteractions(orders, receipts, orderItems, receiptItems, inventory); + } @Test void countedCargoReplacesForecastTotalsAndRepeatedSyncUpdatesTheSameDetail() { var details = org.mockito.Mockito.mock(com.baiyue.lastleg.mapper.LastlegOrderDetailMapper.class); ReflectionTestUtils.setField(service, "lastlegOrderDetails", details); @@ -268,6 +278,8 @@ class ReturnReceiptServiceTest { assertEquals("RL-MULTI", order.getRelabelNo()); var application = ArgumentCaptor.forClass(RelabelApplication.class); verify(relabelApplications).insert(application.capture()); + assertEquals("", application.getValue().getContactName()); + assertEquals("", application.getValue().getContactPhone()); assertEquals(firstQuantity + secondQuantity, application.getValue().getNotifiedPackageCount().longValue()); assertEquals(2, application.getValue().getItemCount()); var lines = ArgumentCaptor.forClass(RelabelItem.class); @@ -637,3 +649,4 @@ class ReturnReceiptServiceTest { "BOX", palletCode, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, 1L, true); } } + diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferInventorySourceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferInventorySourceTest.java index e3696f080..a15d0d10c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferInventorySourceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferInventorySourceTest.java @@ -36,6 +36,16 @@ class TransferInventorySourceTest { db.execute("CREATE TABLE ll_relabel_application(id BIGINT,customer_id VARCHAR,overseas_warehouse_id VARCHAR,status VARCHAR,customer_confirmed_at TIMESTAMP)"); db.execute("CREATE TABLE ll_relabel_item(application_id BIGINT,customer_id VARCHAR,source_inbound_code VARCHAR,source_carton_mark VARCHAR,carton_mark VARCHAR)"); db.execute("CREATE TABLE ll_relabel_pda_unit_fact(item_id BIGINT,result VARCHAR)"); + db.execute("CREATE TABLE ll_dropship_carton(id BIGINT,customer_id VARCHAR,order_id BIGINT,profile_id BIGINT,carton_mark VARCHAR,sku_code VARCHAR,product_quantity BIGINT,status VARCHAR)"); + db.execute("CREATE TABLE ll_dropship_carton_sku(customer_id VARCHAR,carton_id BIGINT,sku_code VARCHAR,product_quantity BIGINT)"); + db.execute("CREATE TABLE ll_dropship_order_profile(id BIGINT,customer_id VARCHAR,inventory_position_id BIGINT,status VARCHAR)"); + db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR,balance_id BIGINT,available_packages BIGINT)"); + db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR,available_packages BIGINT)"); + db.update("INSERT INTO ll_dropship_order_profile VALUES(101,'A',201,'ACTIVE')"); + db.update("INSERT INTO ll_inventory_position VALUES(201,'A',301,2)"); + db.update("INSERT INTO ll_inventory_balance VALUES(301,'A',2)"); + db.update("INSERT INTO ll_dropship_carton VALUES(401,'A',1,101,'DROPSHIP','SKU',5,'AVAILABLE'),(402,'A',1,101,'SHIPPED','SKU',5,'OUTBOUNDED')"); + db.update("INSERT INTO ll_dropship_carton_sku VALUES('A',401,'SKU',3),('A',401,'SKU-B',2)"); db.update("INSERT INTO ll_order VALUES(1,'A','IN','WH'),(2,'B','IN','WH'),(3,'A','IN','OTHER'),(4,'A','OTHER','WH')"); db.update("INSERT INTO ll_inbound_plan_carton VALUES('A',1,'FIRST','S','ONT8','Amazon'),('A',1,'RELABELED','S','ONT8','Amazon'),('B',2,'FOREIGN','S','ONT8','Amazon'),('A',3,'WRONG-WH','S','ONT8','Amazon'),('A',4,'WRONG-IN','S','ONT8','Amazon'),('B',1,'FORGED','S','ONT8','Amazon')"); db.update("INSERT INTO ll_relabel_application VALUES(51,'A','WH','COMPLETED',CURRENT_TIMESTAMP()),(52,'A','WH','PROCESSING',NULL),(53,'A','WH','CANCELLED',NULL),(54,'A','WH','REJECTED',NULL),(55,'B','WH','COMPLETED',CURRENT_TIMESTAMP()),(56,'A','OTHER','COMPLETED',CURRENT_TIMESTAMP())"); @@ -47,9 +57,13 @@ class TransferInventorySourceTest { var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds)); config.addMapper(InboundSnapshotMapper.class); try(var session=new SqlSessionFactoryBuilder().build(config).openSession()) { var result=session.getMapper(InboundSnapshotMapper.class).sourceCartons("A","WH","IN"); - assertEquals(List.of("FIRST", "MEASURED"),result.stream().map(InboundSnapshotMapper.SourceCarton::cartonMark).toList()); - assertEquals("ONT8",result.get(0).destinationWarehouseCode()); - assertNull(result.get(0).quantity()); assertEquals(20L,result.get(1).quantity()); + assertEquals(List.of("DROPSHIP", "FIRST", "MEASURED"),result.stream().map(InboundSnapshotMapper.SourceCarton::cartonMark).toList()); + assertEquals(5L,result.get(0).quantity()); assertTrue(result.get(0).skuItemsJson().contains("SKU")); + assertTrue(result.get(0).skuItemsJson().contains("SKU-B"), "混装箱内所有 SKU 均须保留,箱子只返回一次"); + assertEquals("ONT8",result.get(1).destinationWarehouseCode()); + assertNull(result.get(1).quantity()); assertEquals(20L,result.get(2).quantity()); + db.update("UPDATE ll_inventory_position SET available_packages=0"); session.clearCache(); + assertEquals(List.of("FIRST", "MEASURED"),session.getMapper(InboundSnapshotMapper.class).sourceCartons("A","WH","IN").stream().map(InboundSnapshotMapper.SourceCarton::cartonMark).toList()); } } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java index eeb0558c9..3beaf15b2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java @@ -175,6 +175,18 @@ class WarehouseTransferCartonCountTest { ia.setDeclaredQuantity(4L); assertTrue(service.hasSkuReceiptDifference(List.of(a),List.of(ia,ib))); } + /** 新箱必须先补客户预报,PDA不能绕过预报生成另一套箱号。 */ + @Test void missingDeclarationIsUnknownButKnownSkuDifferenceStillRequiresReason() { + var unknown = new WarehouseTransferItem(); unknown.setId(10L); unknown.setSkuCode("A"); + var known = new WarehouseTransferItem(); known.setId(11L); known.setSkuCode("B"); known.setDeclaredQuantity(7L); + a.setSkuItemsJson("[{\"transferItemId\":10,\"skuCode\":\"A\",\"quantity\":4},{\"transferItemId\":11,\"skuCode\":\"B\",\"quantity\":7}]"); + assertFalse(service.hasSkuReceiptDifference(List.of(a), List.of(unknown, known))); + known.setDeclaredQuantity(8L); + assertTrue(service.hasSkuReceiptDifference(List.of(a), List.of(unknown, known))); + known.setDeclaredQuantity(7L); unknown.setDeclaredQuantity(0L); + assertTrue(service.hasSkuReceiptDifference(List.of(a), List.of(unknown, known))); + } + /** 新箱必须先补客户预报,PDA不能绕过预报生成另一套箱号。 */ @Test void extraReceiptRequiresForecastCartonFirst() { var extra = new WarehouseTransferRequests.CountCarton(null,11L,200L,new BigDecimal("10"),new BigDecimal("20"),new BigDecimal("30"),BigDecimal.ONE,List.of(new WarehouseTransferRequests.CountSku("B",4))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index abab245b1..c3ec0bb4e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -521,6 +521,8 @@ class WarehouseTransferServiceTest { transfer.setInboundCode("IN-1"); transfer.setNeedRelabel(true); transfer.setPackageCount(1); + var selected = new WarehouseTransferCarton(); + selected.setId(51L); selected.setCustomerCartonMark("CURRENT-BOX"); when(inboundSnapshots.sourceCartons("TENANT-A", "WH-1", "IN-1")) .thenReturn(List.of(new InboundSnapshotMapper.SourceCarton("CURRENT-BOX", "SKU-1", null, null, null, null, null))); when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapperTest.java new file mode 100644 index 000000000..6daca4429 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapperTest.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.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.util.List; +import static org.assertj.core.api.Assertions.*; +/** 通过真实Mapper验证出库取消和客户隔离,使用隔离内存库。 */ +class OrderWorkflowFactMapperTest { + /** 有效出库、取消、实出库和差异卸柜均由持久化事实决定。 */ + @Test void cancellationAndCustomerAttribution() throws Exception { + var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:workflow-"+java.util.UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); + var db=new JdbcTemplate(ds); + db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(30),source_inbound_id VARCHAR(30),source_region VARCHAR(20),order_type VARCHAR(30))"); + db.execute("CREATE TABLE ll_outbound_pallet_item(outbound_order_id BIGINT,customer_id VARCHAR(30),source_order_id BIGINT,destination_warehouse_code VARCHAR(30),destination_warehouse_id VARCHAR(30),dispatch_item_id BIGINT)"); + db.execute("CREATE TABLE ll_outbound_order(id BIGINT,customer_id VARCHAR(30),status VARCHAR(30),dispatch_order_id BIGINT)"); + db.execute("CREATE TABLE ll_delivery_order(outbound_order_id BIGINT,customer_id VARCHAR(30),status VARCHAR(30),dispatched_at TIMESTAMP,destination_warehouse_code VARCHAR(30),destination_warehouse_id VARCHAR(30),destination_type VARCHAR(20),delivery_address VARCHAR(100),postal_code VARCHAR(20))"); + db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(30),dispatch_order_id BIGINT,destination_type VARCHAR(20),delivery_address VARCHAR(100),postal_code VARCHAR(20))"); + db.execute("CREATE TABLE ll_inbound_plan(customer_id VARCHAR(30),source_order_id BIGINT,status VARCHAR(40))"); + db.update("INSERT INTO ll_order VALUES(1,'11','101','FIRSTLEG','FBA'),(2,'22','101','FIRSTLEG','FBA')"); + db.update("INSERT INTO ll_outbound_order VALUES(9,'11','WAIT_OUTBOUND',50),(10,'22','WAIT_OUTBOUND',51)"); + db.update("INSERT INTO ll_outbound_pallet_item VALUES(9,'11',1,'FBA1','3',60),(10,'22',2,'FBA1','3',61)"); + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(OrderWorkflowFactMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); + var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(OrderWorkflowFactMapper.class); + var sources=List.of(new OrderWorkflowFacts.Source(11,101)); + assertThat(mapper.read(sources)).singleElement().satisfies(f->{assertThat(f.tenantId()).isEqualTo(11);assertThat(f.outboundExists()).isTrue();assertThat(f.pendingOutbound()).isTrue();}); + db.update("UPDATE ll_outbound_order SET status='CANCELLED' WHERE id=9"); + assertThat(mapper.read(sources)).singleElement().satisfies(f->{assertThat(f.outboundExists()).isFalse();assertThat(f.pendingOutbound()).isFalse();}); + db.update("UPDATE ll_outbound_order SET status='OUTBOUNDED' WHERE id=9"); + db.update("INSERT INTO ll_delivery_order VALUES(9,'11','DELIVERING',CURRENT_TIMESTAMP,'FBA1','3','FBA',NULL,NULL)"); + db.update("INSERT INTO ll_inbound_plan VALUES('11',1,'PENDING_RECONCILIATION')"); + assertThat(mapper.read(sources)).singleElement().satisfies(f->{assertThat(f.outboundExists()).isTrue();assertThat(f.pendingOutbound()).isFalse();assertThat(f.delivering()).isTrue();assertThat(f.unloaded()).isTrue();}); + db.update("UPDATE ll_delivery_order SET destination_type='PRIVATE',destination_warehouse_code=NULL,destination_warehouse_id=NULL,delivery_address='Private A',postal_code='10001'"); + db.update("INSERT INTO ll_dispatch_item VALUES(60,'11',50,'PRIVATE','Private A','10001')"); + assertThat(mapper.read(sources)).singleElement().satisfies(f->assertThat(f.delivering()).isTrue()); + db.update("UPDATE ll_dispatch_item SET customer_id='22'"); + assertThat(mapper.read(sources)).singleElement().satisfies(f->assertThat(f.delivering()).isFalse()); + assertThat(mapper.read(List.of(new OrderWorkflowFacts.Source(33,101)))).isEmpty(); + // 相邻雪花ID超过浮点安全范围,必须按字符串精确匹配客户和来源。 + db.update("INSERT INTO ll_order VALUES(3,'2092814835845664769','2092814880129130499','FIRSTLEG','FBA'),(4,'2092814835845664768','2092814880129130499','FIRSTLEG','FBA'),(5,'2092814835845664769','2092814880129130498','FIRSTLEG','FBA')"); + assertThat(mapper.read(List.of(new OrderWorkflowFacts.Source(2092814835845664769L,2092814880129130499L)))) + .singleElement().satisfies(f->{assertThat(f.tenantId()).isEqualTo(2092814835845664769L);assertThat(f.inboundId()).isEqualTo(2092814880129130499L);}); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/workflow/OrderWorkflowSecurityTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/workflow/OrderWorkflowSecurityTest.java new file mode 100644 index 000000000..a1333f9b1 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/workflow/OrderWorkflowSecurityTest.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.workflow; + +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 内部事实入口不能被缺失或错误服务身份读取。 */ +class OrderWorkflowSecurityTest { + /** 无效服务令牌在仓储查询前被拒绝。 */ + @Test void rejectsInvalidServiceIdentityBeforeReadingFacts() { + var service=mock(OrderWorkflowFactService.class); + var controller=new InternalOrderWorkflowFactController(new InternalServiceTokenGuard("test-service-token"),service); + var request=new OrderWorkflowFacts.Request(List.of(new OrderWorkflowFacts.Source(11,101))); + assertThatThrownBy(()->controller.read(null,request)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class); + assertThatThrownBy(()->controller.read("wrong-token",request)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class); + verifyNoInteractions(service); + when(service.read(request)).thenReturn(List.of()); + assertThat(controller.read("test-service-token",request).successful()).isTrue(); + verify(service).read(request); + } +} diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java index 091c70060..ea30fd016 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java @@ -24,6 +24,18 @@ public interface FirstlegUnifiedOrderClient { record WorkbenchFilters(List matchingStatusOrderIds, List feeOrderIds, List customsOrderIds, List progress) {} + /** 当前页候选订单按原筛选批量读取摘要。 */ + @PostMapping("/status-summaries") + ApiResponse> statusSummaries(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody Query request); + + /** 批量读取符合筛选的去重订单状态,不返回履约明细。 */ + @PostMapping("/status-rows") + ApiResponse> statusRows(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody Query request); + + record StatusRow(Long tenantId, Long orderId, String mainStatus) {} + @PostMapping("/query") ApiResponse> page(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody Query request); diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java index 044907daa..eabd6858f 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java @@ -5,6 +5,7 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.order.dto.UnifiedOrderQuery; +import com.baiyue.order.dto.UnifiedOrderStatusCountQuery; import com.baiyue.order.dto.UnifiedOrderResponse; import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.dto.OrderDeclarationDtos; @@ -52,6 +53,19 @@ public class PlatformUnifiedOrderController { return ApiResponse.success(service.platformPage(tenantId, query)); } + /** 与订单列表共用平台权限及客户范围,不提供全部汇总数量。 */ + @GetMapping("/status-counts") + @PreAuthorize("hasAuthority('platform:order:list')") + @Operation(summary = "统一订单页签数量", description = "忽略mainStatus和分页,按其余列表筛选全量去重统计;不返回全部数量。") + public ApiResponse statusCounts( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) Long tenantId, + @Valid UnifiedOrderStatusCountQuery query) { + OrderScopeGuard.platform(user); + if (tenantId != null) OrderScopeGuard.platform(user, tenantId); + return ApiResponse.success(service.statusCounts(tenantId, query.toOrderQuery())); + } + @GetMapping("/{orderId}") @PreAuthorize("hasAuthority('platform:order:list')") @Operation(summary = "统一订单详情", description = "合并订单和履约时间线;平台响应包含内部备注。") diff --git a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java index 39fd95ede..f3cba0051 100644 --- a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java +++ b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java @@ -13,7 +13,7 @@ public record UnifiedOrderQuery( @Size(max = 64) String keyword, @Size(max = 64) String orderNo, @Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", - "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED", "COMPLETED"}) + "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"}) @Size(max = 32) String mainStatus, Long customerId, Long receivingWarehouseId, diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index d329f517b..f1303b094 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -297,9 +297,28 @@ public class OrderRepository { return PageResult.from(orderMapper.selectPage(new Page<>(query.page(), query.size()), wrapper)); } - public PageResult unifiedPage(Long tenantId, UnifiedOrderQuery query, - List statuses) { - LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + /** 统一订单列表按共享筛选分页。 */ + public PageResult unifiedPage(Long tenantId, UnifiedOrderQuery query, List statuses) { + return PageResult.from(orderMapper.selectPage(new Page<>(query.page(), query.size()), unifiedFilter(tenantId, query, statuses))); + } + + /** 全量轻量投影只读取订单身份和权威交易状态。 */ + public List unifiedStatusOrders(Long tenantId, UnifiedOrderQuery query, List statuses) { + return orderMapper.selectList(unifiedFilter(tenantId, query, statuses) + .select(OrderEntity::getId, OrderEntity::getTenantId, OrderEntity::getStatus)); + } + + /** 状态覆盖只接受本客户范围内未删除订单的权威状态。 */ + public List statusAuthorities(Long tenantId, List orderIds) { + if (orderIds.isEmpty()) return List.of(); + return orderMapper.selectList(new LambdaQueryWrapper() + .select(OrderEntity::getId, OrderEntity::getTenantId, OrderEntity::getStatus) + .eq(tenantId != null, OrderEntity::getTenantId, tenantId).in(OrderEntity::getId, orderIds)); + } + + /** 列表和统计共用非分页筛选及客户隔离。 */ + private LambdaQueryWrapper unifiedFilter(Long tenantId, UnifiedOrderQuery query, List statuses) { + return new LambdaQueryWrapper() .eq(tenantId != null, OrderEntity::getTenantId, tenantId) .eq(query.taxMode() != null, OrderEntity::getTaxMode, query.taxMode()) .in(OrderEntity::getStatus, statuses) @@ -325,7 +344,6 @@ public class OrderRepository { .like(OrderEntity::getOrderNo, query.keyword()) .or().like(OrderEntity::getCustomerName, query.keyword())) .orderByDesc(OrderEntity::getCreatedAt).orderByDesc(OrderEntity::getId); - return PageResult.from(orderMapper.selectPage(new Page<>(query.page(), query.size()), wrapper)); } public List findOrderIdsBySkuNo(Long tenantId, String fbaCode) { diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index 3c5805787..8bbb704ed 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -42,6 +42,7 @@ public class UnifiedOrderQueryService { private static final Set MAIN_STATUSES = Set.of( "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED", "COMPLETED"); + private static final List WORKFLOW_STATUSES = List.of("WAIT_WEIGHT_CONFIRM", "WEIGHT_CONFIRMED", "WAIT_CUSTOMS_DOCS", "EXPORT_CUSTOMS", "SAILING_CLEARANCE", "WAIT_UNLOAD", "WAIT_DISPATCH", "OUTBOUND_DOCUMENT", "DELIVERING", "SIGNED", "MEASUREMENT_REVIEW"); private static final Set REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED"); private static final List ACTIVE_ORDER_STATUSES = List.copyOf(EnumSet.of( OrderStatus.SUBMITTED, OrderStatus.APPROVED, OrderStatus.ACCEPTED, @@ -60,6 +61,102 @@ public class UnifiedOrderQueryService { return page(tenantId, query, true); } + /** 页签统计忽略主状态和分页,返回筛选全量订单的准确去重数量。 */ + public StatusCounts statusCounts(Long tenantId, UnifiedOrderQuery query) { + query = withoutMainStatus(query); + validate(query); + Map counts = new java.util.LinkedHashMap<>(); + for (String key : List.of("PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "WAIT_SPLIT", + "LOADING", "LOADED", "DELIVERED", "COMPLETED")) counts.put(key, 0L); + WORKFLOW_STATUSES.forEach(key -> counts.put(key, 0L)); + boolean operational = requiresFirstlegQuery(query); + if (!operational) { + // 审核与完成由 Order 权威状态决定,不依赖 Firstleg 是否存在投影。 + for (OrderEntity order : orders.unifiedStatusOrders(tenantId, query, + List.of(OrderStatus.SUBMITTED, OrderStatus.COMPLETED))) { + counts.merge(orderMainStatus(order.getStatus()), 1L, Long::sum); + } + } + for (FirstlegUnifiedOrderClient.StatusRow row : matchingStatusRows(tenantId, query)) { + String status = row.mainStatus(); + if (!operational && Set.of("PENDING_REVIEW", "COMPLETED").contains(status)) continue; + if (counts.containsKey(status)) counts.merge(status, 1L, Long::sum); + } + return new StatusCounts(java.util.Collections.unmodifiableMap(counts)); + } + + public record StatusCounts( + @com.fasterxml.jackson.databind.annotation.JsonSerialize(contentUsing = CountNumberSerializer.class) + Map counts) {} + + /** 数量保持 JSON number;不能被全局雪花 ID 的 Long 字符串序列化规则改写。 */ + public static final class CountNumberSerializer extends com.fasterxml.jackson.databind.JsonSerializer { + /** 按数值输出数量,不影响系统其他 Long ID 的安全传输。 */ + @Override + public void serialize(Long value, com.fasterxml.jackson.core.JsonGenerator generator, + com.fasterxml.jackson.databind.SerializerProvider provider) throws java.io.IOException { + generator.writeNumber(value); + } + } + + /** 统计不受当前页签及分页字段影响;其他条件原样保留。 */ + private UnifiedOrderQuery withoutMainStatus(UnifiedOrderQuery query) { + return new UnifiedOrderQuery(query.keyword(), query.orderNo(), null, query.customerId(), + query.receivingWarehouseId(), query.destinationWarehouseId(), query.inboundCode(), + query.internalOrderStatus(), query.expectedArrivalDate(), query.feeReviewStatus(), query.channelName(), + query.containerNo(), query.cabinetNo(), query.sailingDate(), query.loaded(), query.fullContainer(), + query.measurementReviewStatus(), query.fbaCode(), query.createdFrom(), query.createdTo(), 1, 200, query.taxMode()); + } + + /** 在分页前按订单去重并覆盖审核/完成状态,不能用履约快照冒充订单权威状态。 */ + private List matchingStatusRows(Long tenantId, UnifiedOrderQuery query) { + List candidates = StringUtils.hasText(query.fbaCode()) + ? orders.findOrderIdsBySkuNo(tenantId, query.fbaCode()) : null; + if (candidates != null && candidates.isEmpty()) return List.of(); + var request = statusRequest(tenantId, withoutMainStatus(query), candidates); + List source = require( + call(() -> firstleg.statusRows(internalToken, request)), "统一订单状态查询响应无效"); + Map distinct = new java.util.LinkedHashMap<>(); + for (var row : source) { + if (row == null || row.orderId() == null || row.tenantId() == null + || tenantId != null && !tenantId.equals(row.tenantId())) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "统一订单状态查询响应无效"); + } + distinct.putIfAbsent(row.orderId() + ":" + row.mainStatus(), row); + } + Map authority = new HashMap<>(); + List ids = distinct.values().stream().map(FirstlegUnifiedOrderClient.StatusRow::orderId).distinct().toList(); + // 有界批量读取权威状态,避免逐票远程调用及超长 IN 条件。 + for (int offset = 0; offset < ids.size(); offset += 500) { + orders.statusAuthorities(tenantId, ids.subList(offset, Math.min(offset + 500, ids.size()))) + .forEach(order -> authority.put(order.getId(), order)); + } + List result = new ArrayList<>(); + for (var row : distinct.values()) { + OrderEntity order = authority.get(row.orderId()); + if (order == null || !Objects.equals(order.getTenantId(), row.tenantId()) + || !ACTIVE_ORDER_STATUSES.contains(order.getStatus())) continue; + String status = order.getStatus() == OrderStatus.SUBMITTED ? "PENDING_REVIEW" + : 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)); + } + // 同一订单的多个分片覆盖为待审核/已完成后仍只计一次。 + Map uniqueStatuses = new java.util.LinkedHashMap<>(); + result.forEach(row -> uniqueStatuses.putIfAbsent(row.orderId() + ":" + row.mainStatus(), row)); + return List.copyOf(uniqueStatuses.values()); + } + + /** 状态投影和当前页摘要共用原筛选,候选订单用于服务内批量收敛范围。 */ + private FirstlegUnifiedOrderClient.Query statusRequest(Long tenantId, UnifiedOrderQuery query, List candidates) { + String status = Set.of("PENDING_REVIEW", "COMPLETED").contains( + query.mainStatus() == null ? "" : query.mainStatus()) ? null : query.mainStatus(); + return new FirstlegUnifiedOrderClient.Query(tenantId, query.keyword(), query.orderNo(), status, + query.customerId(), query.receivingWarehouseId(), query.destinationWarehouseId(), query.inboundCode(), + query.internalOrderStatus(), query.expectedArrivalDate(), query.feeReviewStatus(), query.channelName(), + query.containerNo(), query.cabinetNo(), query.sailingDate(), query.loaded(), query.fullContainer(), + query.measurementReviewStatus(), candidates, query.createdFrom(), query.createdTo(), 1, 200, query.taxMode()); + } + /** 平台导出选择项按业务订单号读取;不暴露内部 ID 作为选择契约。 */ public List platformRowsByOrderNos(List orderNos) { if (orderNos == null || orderNos.isEmpty()) return List.of(); @@ -83,13 +180,11 @@ public class UnifiedOrderQueryService { return selected.stream().map(order->row(order,summaries.get(order.getId()),null,children,cargo.get(order.getId()))).toList(); } + /** 依条件选择订单查询或履约筛选,保留订单审核优先规则。 */ private PageResult page(Long tenantId, UnifiedOrderQuery query, boolean includeCoLoadGroups) { validate(query); if (requiresFirstlegQuery(query)) { - if ("PENDING_REVIEW".equals(query.mainStatus())) { - return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); - } return pageFromFirstleg(tenantId, query, includeCoLoadGroups); } List statuses = "PENDING_REVIEW".equals(query.mainStatus()) @@ -162,25 +257,33 @@ public class UnifiedOrderQueryService { return new UnifiedOrderResponse.Detail(summary, orderDetail, timeline, fragments); } + /** 先校正权威状态并去重,再对匹配订单分页和批量补齐当前页摘要。 */ private PageResult pageFromFirstleg(Long tenantId, UnifiedOrderQuery query, boolean includeCoLoadGroups) { - List candidateOrderIds = StringUtils.hasText(query.fbaCode()) - ? orders.findOrderIdsBySkuNo(tenantId, query.fbaCode()) : null; - if (candidateOrderIds != null && candidateOrderIds.isEmpty()) { - return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); - } - FirstlegUnifiedOrderClient.Query request = new FirstlegUnifiedOrderClient.Query( - tenantId, query.keyword(), query.orderNo(), query.mainStatus(), query.customerId(), - query.receivingWarehouseId(), query.destinationWarehouseId(), query.inboundCode(), - query.internalOrderStatus(), query.expectedArrivalDate(), query.feeReviewStatus(), query.channelName(), - query.containerNo(), query.cabinetNo(), query.sailingDate(), query.loaded(), query.fullContainer(), - query.measurementReviewStatus(), candidateOrderIds, query.createdFrom(), query.createdTo(), - query.page(), query.size(), query.taxMode()); - PageResult source = require( - call(() -> firstleg.page(internalToken, request)), "统一订单履约查询响应无效"); - List summaries = validateSummaries( - source.records(), "统一订单履约查询响应无效"); - List orderIds = summaries.stream().map(item -> item.inbound().orderId()).toList(); + List matching = matchingStatusRows(tenantId, query).stream() + .filter(row -> !StringUtils.hasText(query.mainStatus()) || query.mainStatus().equals(row.mainStatus())) + .collect(Collectors.collectingAndThen(Collectors.toMap(FirstlegUnifiedOrderClient.StatusRow::orderId, + Function.identity(), (left, right) -> left, java.util.LinkedHashMap::new), + values -> List.copyOf(values.values()))); + long total = matching.size(); + long start = query.page() - 1 > Long.MAX_VALUE / query.size() + ? total : Math.min((query.page() - 1) * query.size(), total); + List orderIds = matching.subList((int) start, (int) Math.min(start + query.size(), total)) + .stream().map(FirstlegUnifiedOrderClient.StatusRow::orderId).toList(); + Map byOrder = orderIds.isEmpty() ? Map.of() + : validateSummaries(require(call(() -> firstleg.statusSummaries(internalToken, + statusRequest(tenantId, query, orderIds))), "统一订单履约摘要响应无效"), + "统一订单履约摘要响应无效").stream().collect(Collectors.toMap( + item -> item.inbound().orderId(), Function.identity(), (left, right) -> left)); + Map expectedTenants = matching.stream().collect(Collectors.toMap( + FirstlegUnifiedOrderClient.StatusRow::orderId, FirstlegUnifiedOrderClient.StatusRow::tenantId)); + List summaries = orderIds.stream().map(id -> { + var summary = byOrder.get(id); + if (summary == null || !Objects.equals(summary.inbound().tenantId(), expectedTenants.get(id))) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "统一订单履约摘要缺失或客户不匹配"); + } + return summary; + }).toList(); Map orderById = orders.findByIds(tenantId, orderIds).stream() .collect(Collectors.toMap(OrderEntity::getId, Function.identity(), (left, right) -> left)); Map cargoSummaries = cargoSummaries(tenantId, List.copyOf(orderById.values())); @@ -193,7 +296,8 @@ public class UnifiedOrderQueryService { cargoSummaries.get(summary.inbound().orderId()), coLoadByOrder.get(summary.inbound().orderId()))) .toList(); - return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages()); + return new PageResult<>(records, query.page(), query.size(), total, + total / query.size() + (total % query.size() == 0 ? 0 : 1)); } private Map summaries(Long tenantId, List orderIds) { @@ -391,14 +495,17 @@ public class UnifiedOrderQueryService { order.getCreatedAt(), order.getUpdatedAt(), inbound.requestedPickupAt()); } + /** 将当前页匹配履约记录映射为订单行,审核与完成优先使用 Order 权威状态。 */ private UnifiedOrderResponse.Row row(FirstlegUnifiedOrderClient.Summary summary, OrderEntity order, String latestInternalNote, Map childCounts, CargoSummary cargo, UnifiedOrderResponse.CoLoadGroupSummary coLoadGroup) { var inbound = summary.inbound(); boolean completed = order != null && order.getStatus() == OrderStatus.COMPLETED; + boolean pending = order != null && order.getStatus() == OrderStatus.SUBMITTED; return new UnifiedOrderResponse.Row(inbound.orderId(), order == null ? null : order.getVersion(), inbound.tenantId(), inbound.orderNo(), - completed ? "COMPLETED" : summary.mainStatus(), completed ? null : summary.nextAction(), Boolean.TRUE.equals(summary.customerFeeConfirmationRequired()), relation(order, childCounts), + pending ? "PENDING_REVIEW" : completed ? "COMPLETED" : summary.mainStatus(), + pending ? "APPROVE_ORDER" : completed ? null : summary.nextAction(), Boolean.TRUE.equals(summary.customerFeeConfirmationRequired()), relation(order, childCounts), parseOrderStatus(inbound.orderStatusSnapshot()), inbound.customerId(), inbound.customerName(), inbound.channelCode(), inbound.channelName(), order != null && Boolean.TRUE.equals(order.getChannelChanged()), inbound.warehouseId(), @@ -577,6 +684,7 @@ public class UnifiedOrderQueryService { } if (StringUtils.hasText(query.mainStatus()) && !MAIN_STATUSES.contains(query.mainStatus()) + && !WORKFLOW_STATUSES.contains(query.mainStatus()) ) { throw new BizException(ResultCode.BAD_REQUEST, "统一订单主状态不合法"); } diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index ea2281f74..fcaf50e23 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -378,7 +378,7 @@ paths: inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion 才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。 tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询, - 指定其他主状态时由 Firstleg 精确分页。旧 /orders/first-leg 接口保持兼容。 + 指定履约主状态时先按 Order 权威状态校正并去重,再分页读取 Firstleg 摘要。旧 /orders/first-leg 接口保持兼容。 列表行同时返回 customerRemark(客户下单备注)、latestInternalNote(最新一条平台内部备注)和 relation(仅有关联时返回;PARENT 含 childCount,CHILD 含 rootOrderId、parentOrderId 与 splitSequence)。 coLoadGroup 仅平台响应返回,由 Order 经 Firstleg 内部批量只读契约聚合;成员业务标识由 Order 权威订单片段补齐。 @@ -414,6 +414,55 @@ paths: application/json: schema: {$ref: '#/components/schemas/UnifiedOrderPageEnvelope'} '403': {description: 需要平台身份和 platform:order:list 权限} + /api/platform/v1/orders/status-counts: + get: + summary: 平台统一订单页签数量 + description: >- + 需要平台身份和 platform:order:list;tenantId 省略时按授权平台范围查询全部客户。 + 按统一列表非分页、非 mainStatus 筛选全量去重;忽略主状态和分页,不返回全部数量。 + 订单审核与完成状态优先于履约投影,依赖异常返回业务错误,不能以零代替失败。 + MEASUREMENT_REVIEW 为独立任务,暂不返回该 key。 + parameters: + - $ref: '#/components/parameters/UnifiedTaxMode' + - {name: tenantId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - $ref: '#/components/parameters/UnifiedKeyword' + - $ref: '#/components/parameters/UnifiedOrderNo' + - {name: customerId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: receivingWarehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: destinationWarehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - $ref: '#/components/parameters/UnifiedInboundCode' + - $ref: '#/components/parameters/UnifiedInternalOrderStatus' + - $ref: '#/components/parameters/UnifiedExpectedArrivalDate' + - $ref: '#/components/parameters/UnifiedFeeReviewStatus' + - $ref: '#/components/parameters/UnifiedChannelName' + - $ref: '#/components/parameters/UnifiedContainerNo' + - $ref: '#/components/parameters/UnifiedCabinetNo' + - $ref: '#/components/parameters/UnifiedSailingDate' + - $ref: '#/components/parameters/UnifiedLoaded' + - $ref: '#/components/parameters/UnifiedFullContainer' + - $ref: '#/components/parameters/UnifiedMeasurementReviewStatus' + - $ref: '#/components/parameters/UnifiedFbaCode' + - $ref: '#/components/parameters/UnifiedCreatedFrom' + - $ref: '#/components/parameters/UnifiedCreatedTo' + responses: + '200': + description: 标准 ApiResponse;仅 code 为字符串 200 时可使用数量 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: + type: object + required: [counts] + properties: + counts: + type: object + description: PENDING_REVIEW、WAIT_MEASURE、WAIT_PALLETIZE、WAIT_SPLIT、LOADING、LOADED、DELIVERED、COMPLETED + additionalProperties: {type: integer, format: int64, minimum: 0} + '403': {description: 需要平台身份和 platform:order:list 权限} /api/platform/v1/orders/{orderId}: get: summary: 平台统一订单详情 @@ -1038,7 +1087,7 @@ components: tenantId: {type: string, pattern: '^[1-9][0-9]*$'} keyword: {type: string, maxLength: 64} orderNo: {type: string, maxLength: 64} - mainStatus: {type: string, enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED, COMPLETED]} + mainStatus: {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]} customerId: {type: string, pattern: '^[1-9][0-9]*$'} receivingWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'} destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'} @@ -1694,10 +1743,10 @@ components: name: mainStatus in: query required: false - description: 统一订单八态主状态筛选;WAIT_PALLETIZE、WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询 + description: 统一订单主状态及业务页签筛选;WAIT_PALLETIZE、WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询。WAIT_CUSTOMS_DOCS 为实际装柜后待上传客户报关资料;EXPORT_CUSTOMS 为本票报关资料已上传且实际装柜完成、尚未实际开船的汇总,买单票无需客户退税资料,不以点击开始报关为前提;SAILING_CLEARANCE 为实际开船后待上传清关资料;清关资料上传后进入 WAIT_UNLOAD,持续到实际卸柜完成。实际开船统一读取手动或拉取的实际离港节点。 schema: type: string - enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED, COMPLETED] + 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] UnifiedInboundCode: {name: inboundCode, in: query, required: false, description: 订单创建时生成的入仓码,所有订单状态均可查询, schema: {type: string, maxLength: 40}} UnifiedInternalOrderStatus: diff --git a/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java index 7cdb81731..47dbeb7b7 100644 --- a/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java +++ b/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java @@ -19,6 +19,38 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; class UnifiedOrderApiContractTest { + /** 全局Long ID配置启用时,数量必须仍为JSON数字。 */ + @Test + void statusCountValuesRemainNumbersWithLongIdSerializationEnabled() throws Exception { + var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + var module = new com.fasterxml.jackson.databind.module.SimpleModule(); + module.addSerializer(Long.class, com.fasterxml.jackson.databind.ser.std.ToStringSerializer.instance); + mapper.registerModule(module); + var json = mapper.readTree(mapper.writeValueAsString(new com.baiyue.order.service.UnifiedOrderQueryService.StatusCounts( + Map.of("LOADED", 1205L)))); + assertThat(json.path("counts").path("LOADED").isIntegralNumber()).isTrue(); + assertThat(json.path("counts").path("LOADED").longValue()).isEqualTo(1205L); + } + + /** 页签数量沿用正式列表权限,不额外开放查询域。 */ + @Test + void statusCountsKeepListPermissionAndFilterContract() throws Exception { + Method method = PlatformUnifiedOrderController.class.getMethod("statusCounts", + com.baiyue.common.security.AuthenticatedUser.class, Long.class, com.baiyue.order.dto.UnifiedOrderStatusCountQuery.class); + assertThat(method.getAnnotation(org.springframework.web.bind.annotation.GetMapping.class).value()) + .containsExactly("/status-counts"); + assertThat(method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:order:list')"); + try (var input = new ClassPathResource("openapi/order-v1.yaml").getInputStream()) { + Map document = new Yaml().load(input); + var paths = stringMap(document.get("paths")); + var counts = stringMap(stringMap(paths.get("/api/platform/v1/orders/status-counts")).get("get")); + String parameters = counts.get("parameters").toString(); + assertThat(parameters).contains("UnifiedTaxMode", "UnifiedFbaCode", "UnifiedCreatedFrom", "UnifiedMeasurementReviewStatus") + .doesNotContain("UnifiedMainStatus", "UnifiedPage", "UnifiedSize"); + } + } + @Test void keepsPlatformAndMerchantBoundaries() { assertThat(PlatformUnifiedOrderController.class.getAnnotation(RequestMapping.class).value()) @@ -43,7 +75,9 @@ class UnifiedOrderApiContractTest { Map schema = stringMap(mainStatus.get("schema")); assertThat(stringList(schema.get("enum"))).containsExactly( "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT", - "LOADING", "LOADED", "DELIVERED", "COMPLETED"); + "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"); assertThat(mainStatus.get("description").toString()) .contains("WAIT_PALLETIZE", "WAIT_SPLIT", "INBOUNDED"); @@ -59,7 +93,9 @@ class UnifiedOrderApiContractTest { assertThat(queryAnnotation).isNotNull(); assertThat(queryAnnotation.allowableValues()).containsExactly( "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT", - "LOADING", "LOADED", "DELIVERED", "COMPLETED"); + "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"); Map row = stringMap(schemas.get("UnifiedOrderRow")); assertThat(stringMap(row.get("properties"))).containsKeys( diff --git a/order/src/test/java/com/baiyue/order/repository/UnifiedOrderTaxModePaginationTest.java b/order/src/test/java/com/baiyue/order/repository/UnifiedOrderTaxModePaginationTest.java index 756055ee9..9314470b6 100644 --- a/order/src/test/java/com/baiyue/order/repository/UnifiedOrderTaxModePaginationTest.java +++ b/order/src/test/java/com/baiyue/order/repository/UnifiedOrderTaxModePaginationTest.java @@ -75,6 +75,17 @@ class UnifiedOrderTaxModePaginationTest { assertPage(null, 2, 4, 4); } + /** 轻量统计使用完整匹配集,并保留税种、客户及软删除条件。 */ + @Test + void statusProjectionIgnoresPagingWithoutLeakingDeletedOrForeignOrders() { + var query = new UnifiedOrderQuery(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.unifiedStatusOrders(11L, query, List.of(com.baiyue.order.entity.OrderStatus.APPROVED))) + .extracting(OrderEntity::getId).containsExactly(6L, 2L); + assertThat(repository.statusAuthorities(11L, List.of(2L, 3L, 7L))) + .extracting(OrderEntity::getId).containsExactly(2L); + } + @org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.provider.ValueSource(strings = {"TAX_REFUND", "BUY_DECLARATION"}) void realSplitChildIsPersistedAndIncludedInTaxFilteredParentChildPages(String taxMode) { diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index b3c27bfa6..52c715994 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -586,8 +586,7 @@ class UnifiedOrderQueryServiceTest { void fulfillmentStatusFilterUsesFirstlegPagingWithoutPostPageFiltering() { UnifiedOrderQuery query = query("INBOUNDED"); var summary = summary("INBOUNDED", "CONFIRM_PRELOAD"); - when(firstleg.page(eq("internal-token"), any())) - .thenReturn(ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1))); + stubStatusProjection(List.of(summary), 1); var result = service.page(null, query); @@ -617,8 +616,7 @@ class UnifiedOrderQueryServiceTest { UnifiedOrderQuery query = query("INBOUNDED"); var summary = summary("INBOUNDED", null); when(summary.inbound().actualNetWeightKg()).thenReturn(null); - when(firstleg.page(eq("internal-token"), any())).thenReturn( - ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1))); + stubStatusProjection(List.of(summary), 1); var row = service.page(null, query).records().get(0); @@ -631,8 +629,7 @@ class UnifiedOrderQueryServiceTest { UnifiedOrderQuery query = query("INBOUNDED"); var summary = summary("INBOUNDED", null); when(summary.inbound().locationCodes()).thenReturn("A0、 A0, A0,A0"); - when(firstleg.page(eq("internal-token"), any())).thenReturn( - ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1))); + stubStatusProjection(List.of(summary), 1); var row = service.page(null, query).records().get(0); @@ -643,8 +640,7 @@ class UnifiedOrderQueryServiceTest { void waitSplitFilterForwardsAndReturnsWaitSplitIndependently() { UnifiedOrderQuery query = query("WAIT_SPLIT"); var summary = summary("WAIT_SPLIT", "CONFIRM_PRELOAD"); - when(firstleg.page(eq("internal-token"), any())) - .thenReturn(ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1))); + stubStatusProjection(List.of(summary), 1); when(orders.findByIds(eq(10001L), eq(List.of(77L)))).thenReturn(List.of(order(OrderStatus.PROCESSING))); var result = service.page(10001L, query); @@ -653,17 +649,16 @@ class UnifiedOrderQueryServiceTest { .extracting(row -> row.mainStatus()).isEqualTo("WAIT_SPLIT"); ArgumentCaptor captor = ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class); - verify(firstleg).page(eq("internal-token"), captor.capture()); + verify(firstleg).statusRows(eq("internal-token"), captor.capture()); assertThat(captor.getValue().tenantId()).isEqualTo(10001L); - assertThat(captor.getValue().mainStatus()).isEqualTo("WAIT_SPLIT"); + assertThat(captor.getValue().mainStatus()).isNull(); } @Test void waitPalletizeFilterReturnsDropshipProjectionAndForwardsToFirstleg() { UnifiedOrderQuery query = query("WAIT_PALLETIZE"); var summary = summary("WAIT_PALLETIZE", "PALLETIZE_DROPSHIP"); - when(firstleg.page(eq("internal-token"), any())) - .thenReturn(ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1))); + stubStatusProjection(List.of(summary), 1); when(orders.findByIds(eq(10001L), eq(List.of(77L)))).thenReturn(List.of(order(OrderStatus.PROCESSING))); var result = service.page(10001L, query); @@ -676,8 +671,8 @@ class UnifiedOrderQueryServiceTest { }); ArgumentCaptor captor = ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class); - verify(firstleg).page(eq("internal-token"), captor.capture()); - assertThat(captor.getValue().mainStatus()).isEqualTo("WAIT_PALLETIZE"); + verify(firstleg).statusRows(eq("internal-token"), captor.capture()); + assertThat(captor.getValue().mainStatus()).isNull(); } @Test @@ -800,8 +795,7 @@ class UnifiedOrderQueryServiceTest { var summary = summary("INBOUNDED", null); when(summary.inbound().holdFlag()).thenReturn(false); when(summary.inbound().currentHoldReason()).thenReturn(null); - when(firstleg.page(eq("internal-token"), any())).thenReturn( - ApiResponse.success(new PageResult<>(List.of(summary), 4, 10, 31, 4))); + stubStatusProjection(List.of(summary), 31); var result = service.page(10001L, query); @@ -815,10 +809,10 @@ class UnifiedOrderQueryServiceTest { }); ArgumentCaptor captor = ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class); - verify(firstleg).page(eq("internal-token"), captor.capture()); - assertThat(captor.getValue().mainStatus()).isEqualTo("INBOUNDED"); - assertThat(captor.getValue().page()).isEqualTo(4); - assertThat(captor.getValue().size()).isEqualTo(10); + verify(firstleg).statusRows(eq("internal-token"), captor.capture()); + assertThat(captor.getValue().mainStatus()).isNull(); + assertThat(captor.getValue().page()).isEqualTo(1); + assertThat(captor.getValue().size()).isEqualTo(200); } @Test @@ -830,8 +824,7 @@ class UnifiedOrderQueryServiceTest { when(summary.inbound().orderId()).thenReturn(orderId); when(summary.inbound().id()).thenReturn(inboundId); when(summary.inbound().tenantId()).thenReturn(tenantId); - when(firstleg.page(eq("internal-token"), any())).thenReturn( - ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1))); + stubStatusProjection(List.of(summary), 1); assertThat(service.page(null, query("INBOUNDED")).records()).singleElement().satisfies(row -> { assertThat(row.orderId()).isEqualTo(orderId); @@ -862,8 +855,7 @@ class UnifiedOrderQueryServiceTest { void missingHoldFlagFailsClosed() { var summary = summary("INBOUNDED", null); when(summary.inbound().holdFlag()).thenReturn(null); - when(firstleg.page(eq("internal-token"), any())).thenReturn( - ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1))); + stubStatusProjection(List.of(summary), 1); assertServiceUnavailable(() -> service.page(null, query("INBOUNDED"))); } @@ -872,8 +864,7 @@ class UnifiedOrderQueryServiceTest { void heldInboundWithoutReasonFailsClosed() { var summary = summary("INBOUNDED", null); when(summary.inbound().currentHoldReason()).thenReturn(" "); - when(firstleg.page(eq("internal-token"), any())).thenReturn( - ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1))); + stubStatusProjection(List.of(summary), 1); assertServiceUnavailable(() -> service.page(null, query("INBOUNDED"))); } @@ -883,8 +874,7 @@ class UnifiedOrderQueryServiceTest { var summary = summary("INBOUNDED", null); when(summary.inbound().holdFlag()).thenReturn(false); when(summary.inbound().currentHoldReason()).thenReturn("旧扣货原因"); - when(firstleg.page(eq("internal-token"), any())).thenReturn( - ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1))); + stubStatusProjection(List.of(summary), 1); assertServiceUnavailable(() -> service.page(null, query("INBOUNDED"))); } @@ -905,7 +895,7 @@ class UnifiedOrderQueryServiceTest { @Test void firstlegCallExceptionFailsClosed() { - when(firstleg.page(eq("internal-token"), any())).thenThrow(new IllegalStateException("boom")); + when(firstleg.statusRows(eq("internal-token"), any())).thenThrow(new IllegalStateException("boom")); assertServiceUnavailable(() -> service.page(null, query("INBOUNDED"))); } @@ -914,8 +904,7 @@ class UnifiedOrderQueryServiceTest { void fulfillmentTabsForwardStatusSearchAndPaginationToFirstleg() { List statuses = List.of( "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"); - when(firstleg.page(eq("internal-token"), any())) - .thenReturn(ApiResponse.success(new PageResult<>(List.of(), 3, 20, 0, 0))); + stubStatusProjection(List.of(), 0); statuses.forEach(status -> service.page(null, new UnifiedOrderQuery( "CC-2026", "FL-77", status, null, null, null, null, null, @@ -925,14 +914,14 @@ class UnifiedOrderQueryServiceTest { ArgumentCaptor captor = ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class); verify(firstleg, org.mockito.Mockito.times(statuses.size())) - .page(eq("internal-token"), captor.capture()); + .statusRows(eq("internal-token"), captor.capture()); IntStream.range(0, statuses.size()).forEach(index -> assertThat(captor.getAllValues().get(index)).satisfies(request -> { - assertThat(request.mainStatus()).isEqualTo(statuses.get(index)); + assertThat(request.mainStatus()).isNull(); assertThat(request.keyword()).isEqualTo("CC-2026"); assertThat(request.orderNo()).isEqualTo("FL-77"); - assertThat(request.page()).isEqualTo(3); - assertThat(request.size()).isEqualTo(20); + assertThat(request.page()).isEqualTo(1); + assertThat(request.size()).isEqualTo(200); })); } @@ -944,14 +933,13 @@ class UnifiedOrderQueryServiceTest { LocalDate.of(2026, 8, 8), true, true, "UNRECORDED", "FBA-1", null, null, 1, 20, null); when(orders.findOrderIdsBySkuNo(10001L, "FBA-1")).thenReturn(List.of(77L)); - when(firstleg.page(eq("internal-token"), any())) - .thenReturn(ApiResponse.success(new PageResult<>(List.of(), 1, 20, 0, 0))); + stubStatusProjection(List.of(), 0); service.page(10001L, query); ArgumentCaptor captor = ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class); - verify(firstleg).page(eq("internal-token"), captor.capture()); + verify(firstleg).statusRows(eq("internal-token"), captor.capture()); assertThat(captor.getValue()).satisfies(request -> { assertThat(request.inboundCode()).isEqualTo("RK-77"); assertThat(request.internalOrderStatus()).isEqualTo("APPROVED"); @@ -978,6 +966,101 @@ class UnifiedOrderQueryServiceTest { verify(firstleg, never()).page(any(), any()); } + /** 全量重复投影和审核/完成优先不能污染其他页签,也不能受当前页限制。 */ + @Test + void statusCountsDeduplicateAndHonorAuthoritativeStatesBeyondCurrentPage() { + var pending = order(OrderStatus.SUBMITTED); + var completed = order(OrderStatus.COMPLETED); completed.setId(78L); + var active = order(OrderStatus.PROCESSING); active.setId(79L); + when(orders.unifiedStatusOrders(eq(10001L), any(), any())).thenReturn(List.of(pending, completed)); + when(orders.statusAuthorities(eq(10001L), any())).thenReturn(List.of(pending, completed, active)); + var rows = new java.util.ArrayList(); + rows.add(new FirstlegUnifiedOrderClient.StatusRow(10001L, 77L, "WAIT_MEASURE")); + rows.add(new FirstlegUnifiedOrderClient.StatusRow(10001L, 77L, "LOADING")); + rows.add(new FirstlegUnifiedOrderClient.StatusRow(10001L, 78L, "LOADED")); + rows.add(new FirstlegUnifiedOrderClient.StatusRow(10001L, 79L, "WAIT_SPLIT")); + rows.add(new FirstlegUnifiedOrderClient.StatusRow(10001L, 79L, "LOADING")); + rows.add(new FirstlegUnifiedOrderClient.StatusRow(10001L, 80L, "LOADED")); + when(firstleg.statusRows(eq("internal-token"), any())).thenReturn(ApiResponse.success(rows)); + var result = service.statusCounts(10001L, taxQuery("LOADED", "TAX_REFUND")); + assertThat(result.counts()).containsEntry("PENDING_REVIEW", 1L).containsEntry("COMPLETED", 1L) + .containsEntry("WAIT_SPLIT", 1L).containsEntry("LOADING", 1L).containsEntry("LOADED", 0L) + .containsEntry("WAIT_MEASURE", 0L).containsEntry("MEASUREMENT_REVIEW", 0L).doesNotContainKeys("ALL"); + verify(firstleg, never()).summaries(any(), any()); + verify(firstleg, never()).page(any(), any()); + } + + /** 履约条件下的待审核订单保留在审核页签,先校正再分页。 */ + @Test + void operationalFiltersKeepPendingReviewAndMatchCountsToListTotal() { + var query = new UnifiedOrderQuery(null, null, "PENDING_REVIEW", null, null, null, null, null, + null, null, null, null, null, null, null, null, "PENDING", null, null, null, 1, 1, null); + var pending = order(OrderStatus.SUBMITTED); + 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")))); + var projected = summary("WAIT_MEASURE", null); + when(firstleg.statusSummaries(eq("internal-token"), any())).thenReturn(ApiResponse.success(List.of(projected))); + var counts = service.statusCounts(10001L, query); + var page = service.page(10001L, query); + assertThat(counts.counts()).containsEntry("PENDING_REVIEW", 1L).containsEntry("WAIT_MEASURE", 0L); + assertThat(page.total()).isEqualTo(counts.counts().get("PENDING_REVIEW")); + assertThat(page.records()).singleElement().satisfies(row -> { + assertThat(row.mainStatus()).isEqualTo("PENDING_REVIEW"); + assertThat(row.nextAction()).isEqualTo("APPROVE_ORDER"); + }); + } + + /** 错误响应和跨客户投影均失败关闭,不能返回看似可信的零。 */ + @Test + void statusCountsRejectDependencyFailuresAndForeignCustomerRows() { + when(firstleg.statusRows(eq("internal-token"), any())).thenReturn(ApiResponse.failure(ResultCode.SERVICE_UNAVAILABLE, "offline", null, null)); + assertServiceUnavailable(() -> service.statusCounts(10001L, query(null))); + when(firstleg.statusRows(eq("internal-token"), any())).thenReturn(ApiResponse.success(List.of( + new FirstlegUnifiedOrderClient.StatusRow(20002L, 77L, "LOADED")))); + assertServiceUnavailable(() -> service.statusCounts(10001L, query(null))); + } + + /** 超过分页上限的匹配订单仍全量计数,权威状态校验采用有界批次。 */ + @Test + void countsAll1205OrdersUsingThreeAuthorityBatches() { + var rows = IntStream.range(0, 1205).mapToObj(index -> + new FirstlegUnifiedOrderClient.StatusRow(10001L, (long) index, "LOADED")).toList(); + when(firstleg.statusRows(eq("internal-token"), any())).thenReturn(ApiResponse.success(rows)); + when(orders.statusAuthorities(eq(10001L), any())).thenAnswer(invocation -> { + List ids = invocation.getArgument(1); + return ids.stream().map(id -> { + var item = order(OrderStatus.PROCESSING); item.setId(id); return item; + }).toList(); + }); + assertThat(service.statusCounts(10001L, taxQuery("WAIT_SPLIT", null)).counts()) + .containsEntry("LOADED", 1205L).containsEntry("WAIT_SPLIT", 0L); + verify(orders, org.mockito.Mockito.times(3)).statusAuthorities(eq(10001L), any()); + } + + /** 使用轻量投影准备全量匹配集,摘要仍只读取当前页。 */ + private void stubStatusProjection(List summaries, long total) { + var rows = new java.util.ArrayList(); + for (int i = summaries.size(); i < total; i++) { + var sample = summaries.get(0); + rows.add(new FirstlegUnifiedOrderClient.StatusRow(sample.inbound().tenantId(), 1000L + i, sample.mainStatus())); + } + summaries.forEach(summary -> rows.add(new FirstlegUnifiedOrderClient.StatusRow( + summary.inbound().tenantId(), summary.inbound().orderId(), summary.mainStatus()))); + when(firstleg.statusRows(eq("internal-token"), any())).thenReturn(ApiResponse.success(rows)); + when(firstleg.statusSummaries(eq("internal-token"), any())).thenReturn(ApiResponse.success(summaries)); + when(orders.statusAuthorities(any(), any())).thenAnswer(invocation -> { + List ids = invocation.getArgument(1); + return rows.stream().filter(row -> ids.contains(row.orderId())).map(row -> { + OrderEntity authority = order(OrderStatus.PROCESSING); + authority.setId(row.orderId()); + authority.setTenantId(row.tenantId()); + return authority; + }).toList(); + }); + } + private UnifiedOrderQuery query(String status) { return new UnifiedOrderQuery(null, null, status, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, @@ -1000,15 +1083,14 @@ class UnifiedOrderQueryServiceTest { service.page(10001L, direct); verify(orders).unifiedPage(eq(10001L), eq(direct), any()); org.mockito.Mockito.verify(firstleg, org.mockito.Mockito.never()).page(any(), any()); - when(firstleg.page(eq("internal-token"), any())) - .thenReturn(ApiResponse.success(new PageResult<>(List.of(), 2, 1, 0, 0))); + stubStatusProjection(List.of(), 0); service.page(10001L, taxQuery("LOADED", taxMode)); ArgumentCaptor captured = ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class); - verify(firstleg).page(eq("internal-token"), captured.capture()); + verify(firstleg).statusRows(eq("internal-token"), captured.capture()); assertThat(captured.getValue().taxMode()).isEqualTo(taxMode); assertThat(captured.getValue().tenantId()).isEqualTo(10001L); - assertThat(captured.getValue().page()).isEqualTo(2); - assertThat(captured.getValue().size()).isEqualTo(1); + assertThat(captured.getValue().page()).isEqualTo(1); + assertThat(captured.getValue().size()).isEqualTo(200); } @org.junit.jupiter.params.ParameterizedTest diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index 93e69d6f6..b5f255651 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -1,5 +1,4 @@ import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' import test from 'node:test' import { readInitialization, tableDefinitions, table, seedRows, roleAuthorities } from '../sql-baseline-contract-helpers.mjs' @@ -174,7 +173,7 @@ test('平台管理员由外部bcrypt引导,强制首登改密且不创建客 }) test('必要系统种子完整保留,但不写入示例资源与业务事实', () => { - for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 434, iam_role_permission: 618, + for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 433, iam_role_permission: 614, iam_data_scope: 7, wh_warehouse: 318, wh_warehouse_address: 318, wh_service_capability: 4, fl_fee_item: 112, fl_price_channel_catalog: 15, fl_price_origin_alias: 7, fl_price_warehouse_mapping: 4, file_export_task_guard: 1, fin_subject: 1 })) assert.equal(seedRows(cn, name).length, count, name) @@ -201,15 +200,8 @@ test('国内IAM权限与角色授权没有丢失或悬挂,客户模板仍完 for (const row of permissions) if (row.parent_id && row.parent_id !== '0') assert.ok(permissionIds.has(row.parent_id), 'missing parent ' + row.parent_id) const merchant = roleAuthorities(cn, 'MERCHANT_OWNER') for (const code of ['merchant:enterprise', 'merchant:order:quick-create', 'merchant:finance:statement', - 'merchant:warehouse:delivery:list']) assert.ok(merchant.has(code), code) - assert.ok(!permissions.some(row => row.permission_code === 'merchant:warehouse:delivery:create')) - assert.ok(!merchant.has('merchant:warehouse:delivery:create')) + 'merchant:warehouse:delivery:list', 'merchant:warehouse:delivery:create']) assert.ok(merchant.has(code), code) const admin = roleAuthorities(cn, 'PLATFORM_ADMIN') - for (const action of ['create', 'loading-complete', 'depart']) { - const code = `platform:overseas-warehouse:dispatch:${action}` - assert.ok(admin.has(code), code) - assert.ok(!merchant.has(code), code) - } assert.ok(admin.has('platform:overseas-warehouse:discard:approve'), 'discard review endpoint must be grantable to platform administrator') for (const action of ['list', 'create', 'edit', 'status', 'review', 'upload']) { assert.ok(admin.has(`platform:overseas-master-data:driver:${action}`))