This commit is contained in:
2026-10-20 11:30:34 +08:00
parent c13175bf77
commit 16ac817524
92 changed files with 1916 additions and 190 deletions
+2
View File
@@ -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 已执行,也不能向已有测试库重放空库脚本。 费用项目默认价格结构: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`。 排车整车作业结构同步于 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`。
@@ -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='客户计费重独立确认历史';
@@ -4437,6 +4437,20 @@ CREATE TABLE fl_inbound_split_item (
SET SESSION foreign_key_checks = 1; 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 -- 服务 schema:baiyue_file
CREATE DATABASE IF NOT EXISTS baiyue_file DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; CREATE DATABASE IF NOT EXISTS baiyue_file DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_file; 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_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)); 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;
@@ -157,6 +157,24 @@ public class InternalInboundOrderController {
return ApiResponse.success(workbenchFilters.read(request)); return ApiResponse.success(workbenchFilters.read(request));
} }
/** 当前页摘要继续应用原筛选,防止父子或分片入库投影错配。 */
@PostMapping("/unified-orders/status-summaries")
public ApiResponse<List<UnifiedInboundSummary>> unifiedStatusSummaries(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody UnifiedInboundQuery request) {
requireToken(token);
return ApiResponse.success(unifiedQueryService.statusSummaries(request));
}
/** 服务令牌保护的轻量状态投影,供 Order 统计和分页校正。 */
@PostMapping("/unified-orders/status-rows")
public ApiResponse<List<UnifiedInboundQueryService.StatusRow>> unifiedStatusRows(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody UnifiedInboundQuery request) {
requireToken(token);
return ApiResponse.success(unifiedQueryService.statusRows(request));
}
@PostMapping("/unified-orders/query") @PostMapping("/unified-orders/query")
public ApiResponse<PageResult<UnifiedInboundSummary>> unifiedPage( public ApiResponse<PageResult<UnifiedInboundSummary>> unifiedPage(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -140,7 +140,7 @@ public class PlatformContainerController {
public ApiResponse<BusinessDocument> clearance(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<BusinessDocument> clearance(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Positive long id) { @PathVariable @Positive long id) {
FirstlegScopeGuard.platform(user); FirstlegScopeGuard.platform(user);
service.requireOverseas(id); service.requireClearanceAccess(id);
return ApiResponse.success(loadingLists.containerClearance(id)); return ApiResponse.success(loadingLists.containerClearance(id));
} }
@@ -151,7 +151,7 @@ public class PlatformContainerController {
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
@PathVariable @Positive long fileId) { @PathVariable @Positive long fileId) {
FirstlegScopeGuard.platform(user); FirstlegScopeGuard.platform(user);
service.requireOverseas(id); service.requireClearanceAccess(id);
var download = loadingLists.downloadContainerClearance(id, fileId); var download = loadingLists.downloadContainerClearance(id, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()) return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.attachment() .header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.attachment()
@@ -168,7 +168,7 @@ public class PlatformContainerController {
@RequestHeader("Idempotency-Key") String key, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody DocumentAttachRequest request) { @Valid @RequestBody DocumentAttachRequest request) {
FirstlegScopeGuard.platform(user); FirstlegScopeGuard.platform(user);
service.requireOverseas(id); service.requireClearanceAccess(id);
return ApiResponse.success(loadingLists.attachContainerClearance( return ApiResponse.success(loadingLists.attachContainerClearance(
id, user.userId(), user.username(), key, request)); id, user.userId(), user.username(), key, request));
} }
@@ -183,7 +183,7 @@ public class PlatformContainerController {
@RequestParam("expectedVersion") @Min(0) int expectedVersion, @RequestParam("expectedVersion") @Min(0) int expectedVersion,
@RequestParam(value = "operationReason", required = false) @Size(max = 500) String operationReason) { @RequestParam(value = "operationReason", required = false) @Size(max = 500) String operationReason) {
FirstlegScopeGuard.platform(user); FirstlegScopeGuard.platform(user);
service.requireOverseas(id); service.requireClearanceAccess(id);
return ApiResponse.success(loadingLists.uploadContainerClearance( return ApiResponse.success(loadingLists.uploadContainerClearance(
id, user.userId(), user.username(), key, expectedVersion, operationReason, file)); id, user.userId(), user.username(), key, expectedVersion, operationReason, file));
} }
@@ -196,7 +196,7 @@ public class PlatformContainerController {
@RequestHeader("Idempotency-Key") String key, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody DocumentCompleteRequest request) { @Valid @RequestBody DocumentCompleteRequest request) {
FirstlegScopeGuard.platform(user); FirstlegScopeGuard.platform(user);
service.requireOverseas(id); service.requireClearanceAccess(id);
return ApiResponse.success(loadingLists.completeContainerClearance( return ApiResponse.success(loadingLists.completeContainerClearance(
id, user.userId(), user.username(), key, request)); id, user.userId(), user.username(), key, request));
} }
@@ -15,7 +15,7 @@ public record UnifiedInboundQuery(
@Size(max = 64) String keyword, @Size(max = 64) String keyword,
@Size(max = 64) String orderNo, @Size(max = 64) String orderNo,
@Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "WAIT_SPLIT", @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, @Size(max = 32) String mainStatus,
Long customerId, Long customerId,
Long receivingWarehouseId, Long receivingWarehouseId,
@@ -158,6 +158,14 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
""") """)
int hasActiveOutboundDeparture(@Param("id") long id); int hasActiveOutboundDeparture(@Param("id") long id);
/** 手动或拉取的实际开船节点均可作为清关资料入口依据。 */
@Select("""
SELECT COUNT(1) FROM fl_carrier_schedule_node_fact
WHERE container_booking_id=#{id} AND standard_node='DEPARTED_ORIGIN_PORT'
AND actual_at IS NOT NULL
""")
int hasActualVesselDeparture(@Param("id") long id);
@Update(""" @Update("""
UPDATE fl_container_booking SET pickup_code=#{pickupCode},pickup_code_updated_by=#{operatorId}, UPDATE fl_container_booking SET pickup_code=#{pickupCode},pickup_code_updated_by=#{operatorId},
pickup_code_updated_by_name=#{operatorName},pickup_code_updated_at=CURRENT_TIMESTAMP(3), pickup_code_updated_by_name=#{operatorName},pickup_code_updated_at=CURRENT_TIMESTAMP(3),
@@ -25,6 +25,8 @@ public class ContainerRepository {
public ContainerBookingEntity findForUpdate(long id) { return mapper.selectBookingForUpdate(id); } public ContainerBookingEntity findForUpdate(long id) { return mapper.selectBookingForUpdate(id); }
public ContainerBookingEntity findActiveForUpdate(long id) { return mapper.selectActiveBookingForUpdate(id); } public ContainerBookingEntity findActiveForUpdate(long id) { return mapper.selectActiveBookingForUpdate(id); }
public boolean departed(long id) { return mapper.hasActiveOutboundDeparture(id) > 0; } public boolean departed(long id) { return mapper.hasActiveOutboundDeparture(id) > 0; }
/** 读取本服务维护的实际开船事实,不依赖第三方查询任务是否建立。 */
public boolean sailed(long id) { return mapper.hasActualVesselDeparture(id) > 0; }
public boolean containerNoExists(long oceanBookingId, String no, Long excludedId) { public boolean containerNoExists(long oceanBookingId, String no, Long excludedId) {
if (no == null) return false; if (no == null) return false;
return mapper.selectCount(new LambdaQueryWrapper<ContainerBookingEntity>() return mapper.selectCount(new LambdaQueryWrapper<ContainerBookingEntity>()
@@ -114,9 +114,27 @@ public class InboundOrderRepository {
.last("LIMIT 1")); .last("LIMIT 1"));
} }
/** Internal unified-query accessors. They always retain the tenant predicate when one is supplied. */ /** 统一订单列表按共享筛选分页。 */
public Page<InboundOrderEntity> pageUnifiedEntities(UnifiedInboundQuery query, List<String> processStatuses) { public Page<InboundOrderEntity> pageUnifiedEntities(UnifiedInboundQuery query, List<String> processStatuses) {
LambdaQueryWrapper<InboundOrderEntity> wrapper = new LambdaQueryWrapper<InboundOrderEntity>() return orders.selectPage(new Page<>(query.page(), query.size()), unifiedFilter(query, processStatuses));
}
/** 只读取身份和流程状态,顺序与列表一致;空候选不能扩大查询范围。 */
public List<InboundOrderEntity> unifiedStatusEntities(UnifiedInboundQuery query) {
if (query.candidateOrderIds() != null && query.candidateOrderIds().isEmpty()) return List.of();
return orders.selectList(unifiedFilter(query, List.of()).select(InboundOrderEntity::getId,
InboundOrderEntity::getTenantId, InboundOrderEntity::getOrderId, InboundOrderEntity::getProcessStatus));
}
/** 当前页候选订单沿原筛选读取匹配的入库记录,避免读到另一条不匹配投影。 */
public List<InboundOrderEntity> unifiedMatchedEntities(UnifiedInboundQuery query, List<String> processStatuses) {
if (query.candidateOrderIds() == null || query.candidateOrderIds().isEmpty()) return List.of();
return orders.selectList(unifiedFilter(query, processStatuses));
}
/** 列表与统计共享客户隔离、费用、测量及船期等筛选语义。 */
private LambdaQueryWrapper<InboundOrderEntity> unifiedFilter(UnifiedInboundQuery query, List<String> processStatuses) {
return new LambdaQueryWrapper<InboundOrderEntity>()
.eq(query.tenantId() != null, InboundOrderEntity::getTenantId, query.tenantId()) .eq(query.tenantId() != null, InboundOrderEntity::getTenantId, query.tenantId())
.eq(query.taxMode() != null, InboundOrderEntity::getTaxMode, query.taxMode()) .eq(query.taxMode() != null, InboundOrderEntity::getTaxMode, query.taxMode())
.in(processStatuses != null && !processStatuses.isEmpty(), InboundOrderEntity::getProcessStatus, processStatuses) .in(processStatuses != null && !processStatuses.isEmpty(), InboundOrderEntity::getProcessStatus, processStatuses)
@@ -187,7 +205,6 @@ public class InboundOrderRepository {
.ge(query.createdFrom() != null, InboundOrderEntity::getCreatedAt, query.createdFrom()) .ge(query.createdFrom() != null, InboundOrderEntity::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, InboundOrderEntity::getCreatedAt, query.createdTo()) .le(query.createdTo() != null, InboundOrderEntity::getCreatedAt, query.createdTo())
.orderByDesc(InboundOrderEntity::getCreatedAt).orderByDesc(InboundOrderEntity::getId); .orderByDesc(InboundOrderEntity::getCreatedAt).orderByDesc(InboundOrderEntity::getId);
return orders.selectPage(new Page<>(query.page(), query.size()), wrapper);
} }
public List<InboundOrderEntity> findByOrderIds(Long tenantId, List<Long> orderIds) { public List<InboundOrderEntity> findByOrderIds(Long tenantId, List<Long> orderIds) {
@@ -45,6 +45,13 @@ public class ContainerService {
if (!repository.departed(id)) throw new BizException(ResultCode.NOT_FOUND, "国外工作台货柜不存在"); 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) { public void requireDomestic(long id) {
required(id); required(id);
if (repository.departed(id)) throw new BizException(ResultCode.NOT_FOUND, "国内工作台货柜不存在"); if (repository.departed(id)) throw new BizException(ResultCode.NOT_FOUND, "国内工作台货柜不存在");
@@ -9,7 +9,9 @@ import org.springframework.stereotype.Service;
public class MerchantWorkbenchFilterService { public class MerchantWorkbenchFilterService {
@Resource private MerchantWorkbenchFilterMapper mapper; @Resource private MerchantWorkbenchFilterMapper mapper;
@Resource private UnifiedInboundQueryService unified; @Resource private UnifiedInboundQueryService unified;
@Resource private com.baiyue.firstleg.workflow.ChargeableWeightService weights;
/** 精确详情只读进度;工作台待确认队列使用计费重事实,金额确认不能代替。 */
@org.springframework.transaction.annotation.Transactional(readOnly=true) @org.springframework.transaction.annotation.Transactional(readOnly=true)
public MerchantWorkbenchFilters.Response read(MerchantWorkbenchFilters.Request request) { public MerchantWorkbenchFilters.Response read(MerchantWorkbenchFilters.Request request) {
if (request.orderIds() != null) { if (request.orderIds() != null) {
@@ -19,7 +21,7 @@ public class MerchantWorkbenchFilterService {
return new MerchantWorkbenchFilters.Response( return new MerchantWorkbenchFilters.Response(
request.mainStatus() == null ? null : mapper.byProcessStatuses(request.tenantId(), request.mainStatus() == null ? null : mapper.byProcessStatuses(request.tenantId(),
unified.internalStatuses(request.mainStatus())), 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))); progress(mapper.progressFragments(request.tenantId(), null)));
} }
@@ -36,6 +36,38 @@ public class UnifiedInboundQueryService {
@Resource private FbaPricingRepository pricing; @Resource private FbaPricingRepository pricing;
@Resource private UnifiedInboundReadRepository operations; @Resource private UnifiedInboundReadRepository operations;
@Resource private ObjectMapper json; @Resource private ObjectMapper json;
@Resource private com.baiyue.firstleg.workflow.WorkflowTabService workflowTabs;
/** 以稳定列表顺序返回去重履约状态,不触发逐票摘要及费用查询。 */
public List<StatusRow> statusRows(UnifiedInboundQuery query) {
validate(query);
Map<String, StatusRow> byOrder = new LinkedHashMap<>();
List<InboundOrderEntity> matched = inbounds.unifiedStatusEntities(query);
for (InboundOrderEntity inbound : matched) {
String status = mainStatus(inbound.getProcessStatus());
// 分片订单可同时命中不同履约页签;同一页签内每个订单只统计一次。
byOrder.putIfAbsent(inbound.getOrderId() + ":" + status, new StatusRow(inbound.getTenantId(),
inbound.getOrderId(), status));
}
workflowTabs.rows(matched).forEach(row -> byOrder.putIfAbsent(row.orderId() + ":" + row.mainStatus(), row));
return List.copyOf(byOrder.values());
}
/** 只对当前页最多200个订单读取匹配摘要,选取规则与状态投影一致。 */
public List<UnifiedInboundSummary> statusSummaries(UnifiedInboundQuery query) {
validate(query);
if (query.candidateOrderIds() == null || query.candidateOrderIds().size() > 200) {
throw new com.baiyue.common.core.exception.BizException(
com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "状态摘要必须提供最多200个候选订单");
}
Map<Long, InboundOrderEntity> byOrder = new LinkedHashMap<>();
for (InboundOrderEntity inbound : inbounds.unifiedMatchedEntities(query, internalStatuses(query.mainStatus()))) {
byOrder.putIfAbsent(inbound.getOrderId(), inbound);
}
return summaries(List.copyOf(byOrder.values()));
}
public record StatusRow(Long tenantId, Long orderId, String mainStatus) {}
public PageResult<UnifiedInboundSummary> page(UnifiedInboundQuery query) { public PageResult<UnifiedInboundSummary> page(UnifiedInboundQuery query) {
validate(query); validate(query);
@@ -43,11 +75,45 @@ public class UnifiedInboundQueryService {
|| query.candidateOrderIds() != null && query.candidateOrderIds().isEmpty()) { || query.candidateOrderIds() != null && query.candidateOrderIds().isEmpty()) {
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
} }
if (StringUtils.hasText(query.mainStatus())
&& com.baiyue.firstleg.workflow.WorkflowTabService.KEYS.contains(query.mainStatus())) {
return workflowPage(query);
}
Page<InboundOrderEntity> source = inbounds.pageUnifiedEntities(query, internalStatuses(query.mainStatus())); Page<InboundOrderEntity> source = inbounds.pageUnifiedEntities(query, internalStatuses(query.mainStatus()));
List<UnifiedInboundSummary> records = summaries(source.getRecords()); List<UnifiedInboundSummary> records = summaries(source.getRecords());
return new PageResult<>(records, source.getCurrent(), source.getSize(), source.getTotal(), source.getPages()); return new PageResult<>(records, source.getCurrent(), source.getSize(), source.getTotal(), source.getPages());
} }
/** 独立事实页签先筛选完整来源再分页,内部查询也不能把业务页签解释成全部状态。 */
private PageResult<UnifiedInboundSummary> workflowPage(UnifiedInboundQuery query) {
List<InboundOrderEntity> matched = inbounds.unifiedStatusEntities(query);
Set<Long> matchingOrders = workflowTabs.rows(matched).stream()
.filter(row -> query.mainStatus().equals(row.mainStatus())).map(StatusRow::orderId)
.collect(Collectors.toSet());
Map<Long, InboundOrderEntity> unique = new LinkedHashMap<>();
// 轻量事实不能用于摘要,重新读取当前条件下的完整来源。
for (InboundOrderEntity inbound : inbounds.unifiedMatchedEntities(
withCandidates(query, matchingOrders.stream().toList()), List.of())) {
if (matchingOrders.contains(inbound.getOrderId())) unique.putIfAbsent(inbound.getOrderId(), inbound);
}
List<InboundOrderEntity> records = List.copyOf(unique.values());
long total = records.size();
long start = query.page() - 1 > Long.MAX_VALUE / query.size()
? total : Math.min((query.page() - 1) * query.size(), total);
var current = records.subList((int) start, (int) Math.min(start + query.size(), total));
return new PageResult<>(summaries(current), query.page(), query.size(), total,
total / query.size() + (total % query.size() == 0 ? 0 : 1));
}
/** 保留全部过滤条件,只收窄为业务事实已命中的订单。 */
private UnifiedInboundQuery withCandidates(UnifiedInboundQuery q, List<Long> candidates) {
return new UnifiedInboundQuery(q.tenantId(), q.keyword(), q.orderNo(), q.mainStatus(), q.customerId(),
q.receivingWarehouseId(), q.destinationWarehouseId(), q.inboundCode(), q.internalOrderStatus(),
q.expectedArrivalDate(), q.feeReviewStatus(), q.channelName(), q.containerNo(), q.cabinetNo(),
q.sailingDate(), q.loaded(), q.fullContainer(), q.measurementReviewStatus(), candidates,
q.createdFrom(), q.createdTo(), q.page(), q.size(), q.taxMode());
}
public List<UnifiedInboundSummary> summaries(Long tenantId, List<Long> orderIds) { public List<UnifiedInboundSummary> summaries(Long tenantId, List<Long> orderIds) {
Map<Long, UnifiedInboundSummary> byOrder = new LinkedHashMap<>(); Map<Long, UnifiedInboundSummary> byOrder = new LinkedHashMap<>();
for (UnifiedInboundSummary item : summaries(inbounds.findByOrderIds(tenantId, orderIds))) for (UnifiedInboundSummary item : summaries(inbounds.findByOrderIds(tenantId, orderIds)))
@@ -203,7 +269,7 @@ public class UnifiedInboundQueryService {
} }
List<String> internalStatuses(String mainStatus) { List<String> internalStatuses(String mainStatus) {
if (!StringUtils.hasText(mainStatus)) return List.of(); if (!StringUtils.hasText(mainStatus) || com.baiyue.firstleg.workflow.WorkflowTabService.KEYS.contains(mainStatus)) return List.of();
return switch (mainStatus) { return switch (mainStatus) {
case "WAIT_MEASURE" -> List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", case "WAIT_MEASURE" -> List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED",
"WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING"); "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING");
@@ -231,6 +297,7 @@ public class UnifiedInboundQueryService {
} }
if (StringUtils.hasText(query.mainStatus()) if (StringUtils.hasText(query.mainStatus())
&& !MAIN_STATUSES.contains(query.mainStatus()) && !MAIN_STATUSES.contains(query.mainStatus())
&& !com.baiyue.firstleg.workflow.WorkflowTabService.KEYS.contains(query.mainStatus())
) { ) {
throw new com.baiyue.common.core.exception.BizException( throw new com.baiyue.common.core.exception.BizException(
com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "统一订单主状态不合法"); com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "统一订单主状态不合法");
@@ -0,0 +1,57 @@
package com.baiyue.firstleg.workflow;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import java.util.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
/** 客户独立确认计费重,金额确认和主流程不受影响。 */
@Service
public class ChargeableWeightService {
private final WorkflowFactRepository repository;
/** 注入当前事实和确认历史。 */
public ChargeableWeightService(WorkflowFactRepository repository) { this.repository=repository; }
/** 客户仅能读取本订单已审核的计费重。 */
public List<WorkflowFacts.Weight> read(long tenant,long orderId) {
return repository.facts(repository.orderInbounds(tenant,orderId)).stream().filter(f->f.tenantId()==tenant && eligible(f)).map(ChargeableWeightService::weight).toList();
}
/** 客户工作台按独立确认事实计算待办,不借用费用确认。 */
public List<Long> pendingOrders(long tenant) {
var ids=repository.weightCandidates(tenant);var orders=new LinkedHashSet<Long>();
for(int offset=0;offset<ids.size();offset+=500) for(var f:repository.facts(ids.subList(offset,Math.min(offset+500,ids.size()))))
if(f.tenantId()==tenant && eligible(f) && !weight(f).confirmed()) orders.add(f.orderId());
return List.copyOf(orders);
}
/** 值和测量轮次变化使旧确认失效,金额变化不影响计费重。 */
public static String fingerprint(WorkflowFacts.Fact f) {
try { String value=f.inboundId()+"|"+f.chargeableWeightKg().stripTrailingZeros().toPlainString()+"|"+f.measureCompletedAt();
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
} catch(java.security.NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
/** 未审核或非正数不允许确认。 */
public static boolean eligible(WorkflowFacts.Fact f) { return "APPROVED".equals(f.reviewStatus()) && f.chargeableWeightKg()!=null && f.chargeableWeightKg().signum()>0; }
/** 只认可当前版本的确认。 */
public static WorkflowFacts.Weight weight(WorkflowFacts.Fact f) {
String hash=fingerprint(f);boolean confirmed=hash.equals(f.confirmationFingerprint());
return new WorkflowFacts.Weight(f.inboundId(),f.orderId(),f.chargeableWeightKg(),hash,confirmed,confirmed?f.confirmedAt():null);
}
/** 入仓锁、预期版本和幂等键防止确认旧值或异参重放。 */
@Transactional
public WorkflowFacts.Weight confirm(long tenant,long operator,long id,String key,WorkflowFacts.Confirm request) {
if(key==null || key.isBlank() || key.length()>128) throw new BizException(ResultCode.BAD_REQUEST,"确认幂等键不能为空且最多128字符");
var inbound=repository.lock(tenant,id);
if(inbound==null || "CANCELLED".equals(inbound.getProcessStatus())) throw new BizException(ResultCode.NOT_FOUND,"入仓单不存在");
var replay=repository.command(tenant,operator,key);
if(replay!=null && (replay.inboundId()!=id || !replay.fingerprint().equals(request.expectedFingerprint()))) throw new BizException(ResultCode.CONFLICT,"同一幂等键不能确认不同计费重");
var facts=repository.facts(List.of(id));
if(facts.isEmpty()) throw new BizException(ResultCode.CONFLICT,"请分别确认分柜后的独立订单计费重");
var f=facts.get(0);
if(!eligible(f)) throw new BizException(ResultCode.CONFLICT,"计费重尚未审核通过");
var current=weight(f);
if(!current.fingerprint().equals(request.expectedFingerprint())) throw new BizException(ResultCode.CONFLICT,"计费重已变化,请刷新后重新确认");
if(replay==null) repository.insert(new WorkflowFacts.Confirmation(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId(),tenant,id,operator,key,current.fingerprint(),current.chargeableWeightKg(),LocalDateTime.now()));
return weight(repository.facts(List.of(id)).get(0));
}
}
@@ -0,0 +1,12 @@
package com.baiyue.firstleg.workflow;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/** 经内部接口读取尾程,禁止跨库查询。 */
@FeignClient(name="lastleg-service",contextId="firstlegWorkflowFacts",url="${LASTLEG_SERVICE_URL:${baiyue.clients.lastleg.url:}}")
public interface LastlegWorkflowClient {
/** 最多500个精确来源,不转发客户JWT。 */
@PostMapping("/internal/lastleg/v1/order-workflow-facts")
ApiResponse<List<WorkflowFacts.Overseas>> read(@RequestHeader("X-Internal-Service-Token") String token,@RequestBody WorkflowFacts.Request request);
}
@@ -0,0 +1,26 @@
package com.baiyue.firstleg.workflow;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import org.springframework.web.bind.annotation.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import jakarta.validation.Valid;
import java.util.List;
/** 客户计费重确认入口,独立于费用确认。 */
@RestController
@RequestMapping("/api/merchant/v1/first-leg")
public class MerchantChargeableWeightController {
private final ChargeableWeightService service;
/** 注入独立确认服务。 */
public MerchantChargeableWeightController(ChargeableWeightService service) { this.service=service; }
/** 客户范围取认证上下文。 */
@GetMapping("/orders/{orderId}/chargeable-weights")
@PreAuthorize("hasAuthority('merchant:order:view')")
public ApiResponse<List<WorkflowFacts.Weight>> read(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long orderId) { return ApiResponse.success(service.read(FirstlegScopeGuard.merchant(user),orderId)); }
/** 不触发费用确认与金额锁定。 */
@com.baiyue.common.audit.BusinessAudit(event="CUSTOMER_CHARGEABLE_WEIGHT_CONFIRM",resourceType="INBOUND_ORDER",resourceId="#inboundId",description="客户独立确认计费重")
@PostMapping("/chargeable-weights/{inboundId}/confirm")
@PreAuthorize("hasAuthority('merchant:order:edit')")
public ApiResponse<WorkflowFacts.Weight> confirm(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long inboundId,@RequestHeader("Idempotency-Key") String key,@Valid @RequestBody WorkflowFacts.Confirm request) { return ApiResponse.success(service.confirm(FirstlegScopeGuard.merchant(user),user.userId(),inboundId,key,request)); }
}
@@ -0,0 +1,41 @@
package com.baiyue.firstleg.workflow;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 柜事实和独立确认SQL入口。 */
@Mapper
public interface WorkflowFactMapper {
String ACTUAL="""
FROM fl_loading_plan_item li JOIN fl_loading_plan p ON p.id=li.loading_plan_id AND p.plan_status='COMPLETED'
JOIN fl_loading_pick_task t ON t.loading_plan_id=p.id AND t.task_status='COMPLETED'
JOIN fl_loading_pick_item pk ON pk.pick_task_id=t.id AND pk.loading_plan_item_id=li.id
JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE'
WHERE li.inbound_id=i.id AND li.account_id=i.tenant_id AND li.deleted=0
AND pk.item_status IN ('PICKED','PARTIAL') AND pk.picked_package_count>0
""";
/** 按独立实装票核验资料;来源单承接实装时,余量子单不能将来源票排除。 */
@Select("<script>SELECT i.tenant_id,i.id inbound_id,i.order_id,i.measure_review_status review_status,i.chargeable_weight_kg,i.measure_completed_at,"
+"EXISTS(SELECT 1 "+ACTUAL+" AND EXISTS(SELECT 1 FROM fl_carrier_schedule_node_fact n WHERE n.container_booking_id=b.id AND n.standard_node='DEPARTED_ORIGIN_PORT' AND n.actual_at IS NOT NULL)) departed,"
+"EXISTS(SELECT 1 "+ACTUAL+" AND EXISTS(SELECT 1 FROM fl_container_document d WHERE d.container_booking_id=b.id AND d.document_type='DESTINATION_CLEARANCE' AND d.file_id IS NOT NULL)) clearance_uploaded,"
+"EXISTS(SELECT 1 "+ACTUAL+" AND i.tax_mode='TAX_REFUND' AND NOT EXISTS(SELECT 1 FROM fl_customs_customer_document_inbound s JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.tenant_id=i.tenant_id AND d.status='ACTIVE' JOIN fl_customs_container c ON c.id=d.customs_container_id AND c.loading_plan_id=p.id WHERE s.inbound_id=i.id AND s.tenant_id=i.tenant_id)) missing_customs,"
+"EXISTS(SELECT 1 "+ACTUAL+" AND (i.tax_mode='BUY_DECLARATION' OR EXISTS(SELECT 1 FROM fl_customs_customer_document_inbound s JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.tenant_id=i.tenant_id AND d.status='ACTIVE' JOIN fl_customs_container c ON c.id=d.customs_container_id AND c.loading_plan_id=p.id WHERE s.inbound_id=i.id AND s.tenant_id=i.tenant_id))) export_customs_ready,"
+"(SELECT w.fingerprint FROM fl_chargeable_weight_confirmation w WHERE w.tenant_id=i.tenant_id AND w.inbound_id=i.id ORDER BY w.id DESC LIMIT 1) confirmation_fingerprint,"
+"(SELECT w.confirmed_at FROM fl_chargeable_weight_confirmation w WHERE w.tenant_id=i.tenant_id AND w.inbound_id=i.id ORDER BY w.id DESC LIMIT 1) confirmed_at "
+"FROM fl_inbound_order i WHERE i.deleted=0 AND NOT EXISTS(SELECT 1 FROM fl_inbound_order child WHERE child.tenant_id=i.tenant_id AND child.parent_inbound_id=i.id AND child.inbound_role='CHILD' AND (child.split_purpose IS NULL OR child.split_purpose != 'REMAINDER') AND child.deleted=0) AND i.process_status != 'CANCELLED' AND i.id IN "
+"<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach></script>")
List<WorkflowFacts.Fact> facts(@Param("ids") List<Long> ids);
/** 锁定客户自己的入仓单,串行确认与测量写入。 */
@Select("SELECT * FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{id} AND deleted=0 FOR UPDATE")
com.baiyue.firstleg.entity.InboundOrderEntity lock(@Param("tenantId") long tenantId,@Param("id") long id);
/** 客户订单下全部独立入仓单。 */
@Select("SELECT id FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND order_id=#{orderId} AND deleted=0 AND process_status != 'CANCELLED' ORDER BY id")
List<Long> orderInbounds(@Param("tenantId") long tenantId,@Param("orderId") long orderId);
/** 客户工作台只读取审核通过且有正计费重的来源。 */
@Select("SELECT id FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND deleted=0 AND process_status != 'CANCELLED' AND measure_review_status='APPROVED' AND chargeable_weight_kg>0 ORDER BY id")
List<Long> weightCandidates(@Param("tenantId") long tenantId);
/** 成员幂等键不允许跨确认对象复用。 */
@Select("SELECT * FROM fl_chargeable_weight_confirmation WHERE tenant_id=#{tenantId} AND operator_id=#{operatorId} AND command_key=#{key}")
WorkflowFacts.Confirmation command(@Param("tenantId") long tenantId,@Param("operatorId") long operatorId,@Param("key") String key);
/** 独立记录,不修改费用或物流主状态。 */
@Insert("INSERT INTO fl_chargeable_weight_confirmation(id,tenant_id,inbound_id,operator_id,command_key,fingerprint,chargeable_weight_kg,confirmed_at) VALUES(#{id},#{tenantId},#{inboundId},#{operatorId},#{commandKey},#{fingerprint},#{chargeableWeightKg},#{confirmedAt})")
int insert(WorkflowFacts.Confirmation value);
}
@@ -0,0 +1,22 @@
package com.baiyue.firstleg.workflow;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 本地柜事实与确认记录的持久化入口。 */
@Repository
public class WorkflowFactRepository {
private final WorkflowFactMapper mapper;
/** 注入SQL提供者。 */
public WorkflowFactRepository(WorkflowFactMapper mapper) { this.mapper=mapper; }
/** 空候选不读取数据。 */
public List<WorkflowFacts.Fact> facts(List<Long> ids) { return ids.isEmpty()?List.of():mapper.facts(ids); }
/** 锁定所属客户来源。 */
public com.baiyue.firstleg.entity.InboundOrderEntity lock(long tenant,long id) { return mapper.lock(tenant,id); }
/** 查询订单下独立来源。 */
public List<Long> orderInbounds(long tenant,long order) { return mapper.orderInbounds(tenant,order); }
/** 客户计费重待确认候选。 */
public List<Long> weightCandidates(long tenant) { return mapper.weightCandidates(tenant); }
/** 读取成员确认命令。 */
public WorkflowFacts.Confirmation command(long tenant,long operator,String key) { return mapper.command(tenant,operator,key); }
/** 追加确认历史。 */
public void insert(WorkflowFacts.Confirmation value) { mapper.insert(value); }
}
@@ -0,0 +1,17 @@
package com.baiyue.firstleg.workflow;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/** 页签事实与独立计费重确认契约。 */
public final class WorkflowFacts {
/** 仅承载查询及确认契约,不允许实例化。 */
private WorkflowFacts() {}
public record Fact(long tenantId,long inboundId,long orderId,String reviewStatus,BigDecimal chargeableWeightKg,LocalDateTime measureCompletedAt,
boolean departed,boolean clearanceUploaded,boolean missingCustoms,boolean exportCustomsReady,String confirmationFingerprint,LocalDateTime confirmedAt) {}
public record Source(long tenantId,long inboundId) {}
public record Request(List<Source> sources) {}
public record Overseas(long tenantId,long inboundId,boolean outboundExists,boolean pendingOutbound,boolean delivering,boolean unloaded) {}
public record Weight(long inboundId,long orderId,BigDecimal chargeableWeightKg,String fingerprint,boolean confirmed,LocalDateTime confirmedAt) {}
public record Confirm(@jakarta.validation.constraints.Pattern(regexp="[a-f0-9]{64}") @jakarta.validation.constraints.NotNull String expectedFingerprint) {}
public record Confirmation(long id,long tenantId,long inboundId,long operatorId,String commandKey,String fingerprint,BigDecimal chargeableWeightKg,LocalDateTime confirmedAt) {}
}
@@ -0,0 +1,75 @@
package com.baiyue.firstleg.workflow;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Value;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.service.UnifiedInboundQueryService.StatusRow;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import java.util.*;
/** 将已确认的独立业务维度映射为页签,不改物流主状态。 */
@Service
public class WorkflowTabService {
public static final Set<String> KEYS=Set.of("WAIT_WEIGHT_CONFIRM","WEIGHT_CONFIRMED","WAIT_CUSTOMS_DOCS","EXPORT_CUSTOMS","SAILING_CLEARANCE","WAIT_UNLOAD","WAIT_DISPATCH","OUTBOUND_DOCUMENT","DELIVERING","SIGNED","MEASUREMENT_REVIEW");
private final WorkflowFactRepository repository;
private final LastlegWorkflowClient lastleg;
@Value("${baiyue.security.internal-service-token}") private String token;
/** 注入本地事实与尾程内部契约。 */
public WorkflowTabService(WorkflowFactRepository repository,LastlegWorkflowClient lastleg) { this.repository=repository;this.lastleg=lastleg; }
/** 分批取事实,失败整体关闭,不能把服务异常伪装成零。 */
public List<StatusRow> rows(List<InboundOrderEntity> inbounds) {
var result=new LinkedHashMap<String,StatusRow>();
for(int offset=0;offset<inbounds.size();offset+=500) {
var batch=inbounds.subList(offset,Math.min(offset+500,inbounds.size()));
var expected=new HashMap<Long,InboundOrderEntity>();batch.forEach(i->expected.put(i.getId(),i));
var facts=repository.facts(batch.stream().map(InboundOrderEntity::getId).toList());
var sources=facts.stream().filter(WorkflowFacts.Fact::departed).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
Map<String,WorkflowFacts.Overseas> overseas=new HashMap<>();
if(!sources.isEmpty()) {
try {
var response=lastleg.read(token,new WorkflowFacts.Request(sources));
if(response==null || !response.successful() || response.data()==null) throw unavailable();
var allowed=new HashSet<>(sources);
for(var fact:response.data()) {
if(fact==null || !allowed.contains(new WorkflowFacts.Source(fact.tenantId(),fact.inboundId()))) throw unavailable();
overseas.merge(fact.tenantId()+":"+fact.inboundId(),fact,(a,b)->new WorkflowFacts.Overseas(a.tenantId(),a.inboundId(),a.outboundExists()||b.outboundExists(),a.pendingOutbound()||b.pendingOutbound(),a.delivering()||b.delivering(),a.unloaded()&&b.unloaded()));
}
if(overseas.size()!=sources.size()) throw unavailable();
} catch(BizException e) { throw e; } catch(Exception e) { throw unavailable(); }
}
// SQL排除仅作汇总的父票;保留承接实际装柜数量的来源票及独立余量票。
for(var fact:facts) {
var inbound=expected.get(fact.inboundId());
if(inbound==null || !Objects.equals(inbound.getTenantId(),fact.tenantId()) || !Objects.equals(inbound.getOrderId(),fact.orderId())) throw unavailable();
var o=overseas.get(fact.tenantId()+":"+fact.inboundId());
for(String key:classify(fact,inbound.getProcessStatus(),o)) result.putIfAbsent(fact.orderId()+":"+key,new StatusRow(fact.tenantId(),fact.orderId(),key));
}
}
return List.copyOf(result.values());
}
/** 分类为可重叠的待办/证据集合,计费重确认独立于费用确认。 */
public static Set<String> classify(WorkflowFacts.Fact f,String process,WorkflowFacts.Overseas o) {
Set<String> keys=new LinkedHashSet<>();
if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW");
if(ChargeableWeightService.eligible(f)) keys.add(ChargeableWeightService.weight(f).confirmed()?"WEIGHT_CONFIRMED":"WAIT_WEIGHT_CONFIRM");
boolean signed="DELIVERED".equals(process);
// 报关页签按资料与实装事实汇总,不依赖点击开始报关;实际开船后转入清关链路。
if(!f.departed() && !signed) {
if(f.missingCustoms()) keys.add("WAIT_CUSTOMS_DOCS");
if(f.exportCustomsReady()) keys.add("EXPORT_CUSTOMS");
}
if(signed) keys.add("SIGNED");
if(f.departed() && !signed) {
if(!f.clearanceUploaded()) keys.add("SAILING_CLEARANCE");
if(o!=null) {
if(f.clearanceUploaded() && !o.unloaded()) keys.add("WAIT_UNLOAD");
// 取消的出库不构成有效覆盖,读取事实后自动回显,不能保存永久隐藏标志。
if(!o.outboundExists()) keys.add("WAIT_DISPATCH");
if(o.pendingOutbound()) keys.add("OUTBOUND_DOCUMENT");
if(o.delivering()) keys.add("DELIVERING");
}
}
return keys;
}
/** 下游缺失或归属异常不能生成部分数量。 */
private static BizException unavailable() { return new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试"); }
}
@@ -0,0 +1,13 @@
-- 客户独立确认计费重,不回填既有费用确认。
CREATE TABLE IF NOT EXISTS fl_chargeable_weight_confirmation (
id BIGINT NOT NULL COMMENT '确认记录ID',
tenant_id BIGINT NOT NULL COMMENT '客户隔离ID',
inbound_id BIGINT NOT NULL COMMENT '独立来源入仓单ID',
operator_id BIGINT NOT NULL COMMENT '确认客户成员ID',
command_key VARCHAR(128) NOT NULL COMMENT '成员确认幂等键',
fingerprint CHAR(64) NOT NULL COMMENT '计费重及测量轮次摘要',
chargeable_weight_kg DECIMAL(18,4) NOT NULL COMMENT '客户确认计费重千克',
confirmed_at DATETIME(3) NOT NULL COMMENT '客户确认时间',
PRIMARY KEY(id), UNIQUE KEY uk_weight_confirmation_command(tenant_id,operator_id,command_key),
KEY idx_weight_confirmation_inbound(tenant_id,inbound_id,id)
) ENGINE=InnoDB COMMENT='客户计费重独立确认历史';
@@ -134,8 +134,11 @@ BEGIN
OR NOT (NEW.cabinet_no <=> OLD.cabinet_no) THEN OR NOT (NEW.cabinet_no <=> OLD.cabinet_no) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务稳定归属不可修改'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务稳定归属不可修改';
END IF; 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 IF;
END$$ END$$
DELIMITER ; DELIMITER ;
@@ -355,7 +355,7 @@ paths:
/internal/firstleg/v1/inbounds/unified-orders/workbench-filters: /internal/firstleg/v1/inbounds/unified-orders/workbench-filters:
post: post:
summary: 查询单一客户工作台履约筛选标识(Order内部) summary: 查询单一客户工作台履约筛选标识(Order内部)
description: 只读返回命中状态、最新待确认费用及待上传资料的订单标识。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。传入orderIds为精确进度模式,仅计算progress,matchingStatusOrderIds为null,feeOrderIds和customsOrderIds为空;mainStatus在该模式不参与查询。 description: 只读返回命中状态、待独立确认计费重及待上传报关资料的订单标识。feeOrderIds沿用既有字段名,值为尚未确认当前计费重的订单,不代表金额确认。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。传入orderIds为精确进度模式,仅计算progress,matchingStatusOrderIds为null,feeOrderIds和customsOrderIds为空;mainStatus在该模式不参与查询。
security: [{internalServiceToken: []}] security: [{internalServiceToken: []}]
requestBody: requestBody:
required: true required: true
@@ -366,7 +366,7 @@ paths:
required: [tenantId] required: [tenantId]
properties: properties:
tenantId: {type: integer, format: int64, minimum: 1} 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: orderIds:
type: array type: array
nullable: true nullable: true
@@ -388,7 +388,7 @@ paths:
required: [feeOrderIds, customsOrderIds, progress] required: [feeOrderIds, customsOrderIds, progress]
properties: properties:
matchingStatusOrderIds: {type: array, nullable: true, items: {type: integer, format: int64}} 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}} customsOrderIds: {type: array, items: {type: integer, format: int64}}
progress: progress:
type: array type: array
@@ -401,6 +401,47 @@ paths:
stage: {type: string, enum: [loaded, declared, cleared]} stage: {type: string, enum: [loaded, declared, cleared]}
partial: {type: boolean} 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: /internal/firstleg/v1/inbounds/unified-orders/query:
post: post:
summary: 按统一订单条件分页查询履约摘要(Order 内部) summary: 按统一订单条件分页查询履约摘要(Order 内部)
@@ -594,13 +635,13 @@ paths:
/api/platform/v1/containers/{id}/clearance: /api/platform/v1/containers/{id}/clearance:
get: get:
summary: 查询货柜目的港清关资料 summary: 查询货柜目的港清关资料
description: 需要 platform:firstleg:terminal-drayage:list 权限;仅国外工作台货柜 description: 需要 platform:firstleg:terminal-drayage:list 权限;有效货柜已确认国内离仓,或已记录 DEPARTED_ORIGIN_PORT 且 actual_at 非空时可读取;人工与拉取节点使用相同事实
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}] parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
responses: {'200': {description: 成功或货柜不存在}} responses: {'200': {description: 成功或货柜不存在}}
/api/platform/v1/containers/{id}/clearance/document: /api/platform/v1/containers/{id}/clearance/document:
post: post:
summary: 上传或替换货柜目的港清关资料 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'}] parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: requestBody:
required: true required: true
@@ -609,7 +650,7 @@ paths:
/api/platform/v1/containers/{id}/clearance/document/{fileId}/download: /api/platform/v1/containers/{id}/clearance/document/{fileId}/download:
get: get:
summary: 下载货柜目的港清关附件 summary: 下载货柜目的港清关附件
description: 需要 platform:firstleg:terminal-drayage:list 权限;服务端校验当前 DESTINATION_CLEARANCE fileId 及 File 业务归属,完成态仍可下载,响应禁止缓存 description: 需要 platform:firstleg:terminal-drayage:list 权限;有效货柜已离仓或已实际开船;服务端校验当前 DESTINATION_CLEARANCE fileId 及 File 业务归属,完成态仍可下载,响应禁止缓存
parameters: parameters:
- {$ref: '#/components/parameters/ContainerBookingId'} - {$ref: '#/components/parameters/ContainerBookingId'}
- {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {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: /api/platform/v1/containers/{id}/clearance/document/upload:
post: post:
summary: 直接选择文件上传或替换货柜目的港清关资料 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'}] parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: requestBody:
required: true required: true
@@ -630,7 +671,7 @@ paths:
/api/platform/v1/containers/{id}/clearance/complete: /api/platform/v1/containers/{id}/clearance/complete:
post: post:
summary: 点击完成目的港清关 summary: 点击完成目的港清关
description: 需要 platform:firstleg:terminal-drayage:document:edit 权限 description: 需要 platform:firstleg:terminal-drayage:document:edit 权限;有效货柜已离仓或已实际开船;此完成动作不作为上传后进入待拆柜的附加条件
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: requestBody:
required: true required: true
@@ -1631,9 +1672,9 @@ components:
orderNo: {type: string, maxLength: 64, nullable: true} orderNo: {type: string, maxLength: 64, nullable: true}
mainStatus: mainStatus:
type: string 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 nullable: true
description: WAIT_SPLIT 为唯一待分柜筛选值 description: WAIT_SPLIT 为唯一待分柜筛选值;WAIT_CUSTOMS_DOCS 为实际装柜后缺少本票客户报关资料;EXPORT_CUSTOMS 按本票资料已上传且实际装柜完成汇总,买单无需客户退税资料,不依赖点击开始报关,实际开船后移出;SAILING_CLEARANCE 按手动或拉取的实际离港节点进入,清关资料上传后进入 WAIT_UNLOAD,实际卸柜完成后移出。
customerId: {type: integer, format: int64, nullable: true} customerId: {type: integer, format: int64, nullable: true}
receivingWarehouseId: {type: integer, format: int64, nullable: true} receivingWarehouseId: {type: integer, format: int64, nullable: true}
destinationWarehouseId: {type: integer, format: int64, nullable: true} destinationWarehouseId: {type: integer, format: int64, nullable: true}
@@ -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}
@@ -44,6 +44,20 @@ class InternalInboundOrderReferenceControllerTest {
@Resource @Resource
private InternalInboundOrderController controller; 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() { @Test void workbenchFiltersRejectMissingAndWrongInternalIdentity() {
var request = new com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Request(11L, "LOADED", null); var request = new com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Request(11L, "LOADED", null);
assertThatThrownBy(() -> controller.workbenchFilters(null, request)).isInstanceOf(BizException.class); assertThatThrownBy(() -> controller.workbenchFilters(null, request)).isInstanceOf(BizException.class);
@@ -9,6 +9,16 @@ import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
class LoadingCompletionSqlContractTest { 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 @Test
void baselineAndUnifiedSqlDefineDurableCompletionStateMachine() throws Exception { void baselineAndUnifiedSqlDefineDurableCompletionStateMachine() throws Exception {
assertContract(Files.readString(Path.of("src", "main", "resources", "db", assertContract(Files.readString(Path.of("src", "main", "resources", "db",
@@ -80,7 +80,7 @@ class Phase3FinalUnifiedSqlContractTest {
@Test @Test
void initializationPassIsUniqueAndFinal() throws Exception { void initializationPassIsUniqueAndFinal() throws Exception {
String sql = sql(); String sql = sql();
int expectedTables = 227; int expectedTables = 228;
long actualTables = java.util.regex.Pattern.compile("(?m)^CREATE TABLE\\s+") long actualTables = java.util.regex.Pattern.compile("(?m)^CREATE TABLE\\s+")
.matcher(sql).results().count(); .matcher(sql).results().count();
assertThat(actualTables).isEqualTo(expectedTables); assertThat(actualTables).isEqualTo(expectedTables);
@@ -69,6 +69,17 @@ class UnifiedInboundTaxModePaginationTest {
assertPage(null, 1, 4, 6); assertPage(null, 1, 4, 6);
assertPage(null, 2, 4, 4); 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) { 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 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")); var result = repository.pageUnifiedEntities(query, List.of("LOADED"));
@@ -50,6 +50,18 @@ class ContainerServiceTest {
@MockBean private SmartLoadingService smartLoadingService; @MockBean private SmartLoadingService smartLoadingService;
@Resource private ContainerService service; @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 @BeforeEach
void setUpWarehouse() { void setUpWarehouse() {
when(documentNumbers.nextCabinetNo()).thenReturn("D0001"); when(documentNumbers.nextCabinetNo()).thenReturn("D0001");
@@ -9,6 +9,20 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
class MerchantWorkbenchFilterServiceTest { 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() { @Test void exactDetailProgressSkipsUnrelatedCustomerQueues() {
var mapper=mock(MerchantWorkbenchFilterMapper.class); var mapper=mock(MerchantWorkbenchFilterMapper.class);
var unified=mock(UnifiedInboundQueryService.class); var unified=mock(UnifiedInboundQueryService.class);
@@ -24,11 +24,77 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
class UnifiedInboundQueryServiceTest { 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 @ParameterizedTest
@ValueSource(strings = {"", " ", "tax_refund", "TAX_REFUND ", "OTHER", "TAX_INCLUDED"}) @ValueSource(strings = {"", " ", "tax_refund", "TAX_REFUND ", "OTHER", "TAX_INCLUDED"})
void illegalTaxModeFailsBeforeRepository(String taxMode) { void illegalTaxModeFailsBeforeRepository(String taxMode) {
var repository = mock(InboundOrderRepository.class); var repository = mock(InboundOrderRepository.class);
var service = new UnifiedInboundQueryService(); var service = new UnifiedInboundQueryService();
ReflectionTestUtils.setField(service, "workflowTabs", mock(com.baiyue.firstleg.workflow.WorkflowTabService.class));
ReflectionTestUtils.setField(service, "inbounds", repository); ReflectionTestUtils.setField(service, "inbounds", repository);
var query = new UnifiedInboundQuery(10001L, null, null, null, null, null, null, null, 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); 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 operations = mock(UnifiedInboundReadRepository.class);
var pricing = mock(FbaPricingRepository.class); var pricing = mock(FbaPricingRepository.class);
var service = new UnifiedInboundQueryService(); var service = new UnifiedInboundQueryService();
ReflectionTestUtils.setField(service, "workflowTabs", mock(com.baiyue.firstleg.workflow.WorkflowTabService.class));
ReflectionTestUtils.setField(service,"inbounds",inbounds); ReflectionTestUtils.setField(service,"inbounds",inbounds);
ReflectionTestUtils.setField(service,"operations",operations); ReflectionTestUtils.setField(service,"operations",operations);
ReflectionTestUtils.setField(service,"pricing",pricing); ReflectionTestUtils.setField(service,"pricing",pricing);
@@ -0,0 +1,63 @@
package com.baiyue.firstleg.workflow;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** 跨服务缺项和客户归属异常不能生成看似成功的部分统计。 */
class WorkflowBoundaryTest {
/** 真实开船来源的测试事实。 */
private WorkflowFacts.Fact fact() {
return new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,
LocalDateTime.of(2026,10,19,10,0),true,false,false,false,null,null);
}
/** 下游漏来源或返回其他客户均整体失败。 */
@Test void rejectsMissingAndForeignDownstreamFacts() {
var repository=mock(WorkflowFactRepository.class);
var downstream=mock(LastlegWorkflowClient.class);
var service=new WorkflowTabService(repository,downstream);
var inbound=new InboundOrderEntity();inbound.setId(1L);inbound.setTenantId(11L);
inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
when(repository.facts(List.of(1L))).thenReturn(List.of(fact()));
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of()));
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of(
new WorkflowFacts.Overseas(22,1,true,true,false,true))));
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
when(downstream.read(any(),any())).thenThrow(new IllegalStateException("unavailable"));
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
}
/** 重测同值也要求重新确认,金额没有参与摘要。 */
@Test void repeatedMeasurementRequiresNewConfirmation() {
var original=fact();
var measuredAgain=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,
original.measureCompletedAt().plusSeconds(1),true,false,false,false,
ChargeableWeightService.fingerprint(original),LocalDateTime.now());
assertThat(ChargeableWeightService.weight(measuredAgain).confirmed()).isFalse();
}
/** 平台主体不得冒充客户,客户和操作者只取认证上下文。 */
@Test void confirmationUsesAuthenticatedCustomerAndOperator() {
var service=mock(ChargeableWeightService.class);
var controller=new MerchantChargeableWeightController(service);
var platform=new com.baiyue.common.security.AuthenticatedUser(7L,0L,0L,9L,"platform","PLATFORM","WEB",java.util.Set.of());
var request=new WorkflowFacts.Confirm("a".repeat(64));
assertThatThrownBy(()->controller.read(platform,101)).isInstanceOf(BizException.class);
assertThatThrownBy(()->controller.confirm(platform,1,"key",request)).isInstanceOf(BizException.class);
verifyNoInteractions(service);
var merchant=new com.baiyue.common.security.AuthenticatedUser(7L,11L,0L,9L,"member","MERCHANT","WEB",java.util.Set.of());
controller.read(merchant,101);
controller.confirm(merchant,1,"key",request);
verify(service).read(11,101);
verify(service).confirm(11,7,1,"key",request);
}
}
@@ -0,0 +1,63 @@
package com.baiyue.firstleg.workflow;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.*;
/** 取消回显、分柜隔离和独立计费重版本回归。 */
class WorkflowBusinessTest {
/** 报关资料齐备进入汇总,实际开船及清关资料分别驱动后续页签。 */
@Test void customsDocumentsDepartureAndClearanceDriveTabs() {
var time=LocalDateTime.of(2026,10,19,10,0);
var missing=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,false,true,false,null,null);
var uploaded=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,false,false,true,null,null);
var departed=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,true,false,false,true,null,null);
var cleared=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,true,true,false,true,null,null);
var uploadedBeforeDeparture=new WorkflowFacts.Fact(11,1,101,"APPROVED",BigDecimal.TEN,time,false,true,false,true,null,null);
var pendingUnload=new WorkflowFacts.Overseas(11,1,false,false,false,false);
var unloaded=new WorkflowFacts.Overseas(11,1,false,false,false,true);
assertThat(WorkflowTabService.classify(missing,"LOADED",null))
.contains("WAIT_CUSTOMS_DOCS").doesNotContain("EXPORT_CUSTOMS","SAILING_CLEARANCE","WAIT_UNLOAD");
assertThat(WorkflowTabService.classify(uploaded,"LOADED",null))
.contains("EXPORT_CUSTOMS").doesNotContain("WAIT_CUSTOMS_DOCS","SAILING_CLEARANCE","WAIT_UNLOAD");
assertThat(WorkflowTabService.classify(uploadedBeforeDeparture,"LOADED",pendingUnload))
.contains("EXPORT_CUSTOMS").doesNotContain("SAILING_CLEARANCE","WAIT_UNLOAD");
assertThat(WorkflowTabService.classify(departed,"LOADED",pendingUnload))
.contains("SAILING_CLEARANCE").doesNotContain("WAIT_CUSTOMS_DOCS","EXPORT_CUSTOMS","WAIT_UNLOAD");
assertThat(WorkflowTabService.classify(cleared,"LOADED",pendingUnload))
.contains("WAIT_UNLOAD").doesNotContain("WAIT_CUSTOMS_DOCS","EXPORT_CUSTOMS","SAILING_CLEARANCE");
assertThat(WorkflowTabService.classify(cleared,"LOADED",unloaded))
.doesNotContain("WAIT_UNLOAD","SAILING_CLEARANCE","EXPORT_CUSTOMS");
assertThat(WorkflowTabService.classify(uploaded,"DELIVERED",unloaded))
.contains("SIGNED").doesNotContain("EXPORT_CUSTOMS","WAIT_CUSTOMS_DOCS");
}
/** 构造独立分柜来源。 */
private WorkflowFacts.Fact fact(long id,String kg,String confirmed,boolean uploaded) {
return new WorkflowFacts.Fact(11,id,100+id,"APPROVED",new BigDecimal(kg),LocalDateTime.of(2026,10,19,10,0),true,uploaded,false,false,confirmed,null);
}
/** 有效建单移出待排车,取消后恢复;实际出库不计未出库单。 */
@Test void cancellationRestoresDispatchQueue() {
var f=fact(1,"100",null,true);
var waiting=new WorkflowFacts.Overseas(11,1,false,false,false,true);
var created=new WorkflowFacts.Overseas(11,1,true,true,false,true);
var shipped=new WorkflowFacts.Overseas(11,1,true,false,true,true);
assertThat(WorkflowTabService.classify(f,"LOADED",waiting)).contains("WAIT_DISPATCH").doesNotContain("OUTBOUND_DOCUMENT");
assertThat(WorkflowTabService.classify(f,"LOADED",created)).contains("OUTBOUND_DOCUMENT").doesNotContain("WAIT_DISPATCH");
assertThat(WorkflowTabService.classify(f,"LOADED",shipped)).contains("DELIVERING").doesNotContain("WAIT_DISPATCH","OUTBOUND_DOCUMENT");
assertThat(WorkflowTabService.classify(f,"LOADED",waiting)).contains("WAIT_DISPATCH");
}
/** 一柜资料上传不覆盖另一柜;签收不再进入待排。 */
@Test void independentSlicesAndUploadedClearance() {
assertThat(WorkflowTabService.classify(fact(1,"100",null,false),"LOADED",new WorkflowFacts.Overseas(11,1,false,false,false,false)))
.contains("SAILING_CLEARANCE","WAIT_DISPATCH").doesNotContain("WAIT_UNLOAD");
assertThat(WorkflowTabService.classify(fact(2,"200",null,true),"LOADED",new WorkflowFacts.Overseas(11,2,true,true,false,false)))
.contains("WAIT_UNLOAD","OUTBOUND_DOCUMENT").doesNotContain("SAILING_CLEARANCE","WAIT_DISPATCH");
assertThat(WorkflowTabService.classify(fact(1,"100",null,false),"DELIVERED",null)).contains("SIGNED").doesNotContain("WAIT_DISPATCH");
}
/** 数值格式不改变版本,值或来源变化必须重新确认。 */
@Test void independentWeightVersion() {
var hash=ChargeableWeightService.fingerprint(fact(1,"100.0000",null,false));
assertThat(ChargeableWeightService.weight(fact(1,"100",hash,false)).confirmed()).isTrue();
assertThat(ChargeableWeightService.weight(fact(1,"101",hash,false)).confirmed()).isFalse();
assertThat(ChargeableWeightService.weight(fact(2,"100",hash,false)).confirmed()).isFalse();
}
}
@@ -0,0 +1,120 @@
package com.baiyue.firstleg.workflow;
import org.junit.jupiter.api.Test;
import org.h2.jdbcx.JdbcDataSource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.mybatis.spring.SqlSessionTemplate;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import java.nio.file.*;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
/** 真实Mapper和隔离内存库验证资料归属、计费重确认和历史失效。 */
class WorkflowFactPersistenceTest {
/** 测试建表不连接业务数据库。 */
@Test void clearanceAndWeightConfirmationPersistIndependently() throws Exception {
var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:weight-"+java.util.UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
var db=new JdbcTemplate(ds);
db.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT,order_id BIGINT,measure_review_status VARCHAR(30),chargeable_weight_kg DECIMAL(18,4),measure_completed_at TIMESTAMP,deleted INT,process_status VARCHAR(30))");
db.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,inbound_id BIGINT,account_id BIGINT,loading_plan_id BIGINT,deleted INT)");
db.execute("CREATE TABLE fl_loading_plan(id BIGINT,plan_status VARCHAR(30),container_booking_id BIGINT)");
db.execute("CREATE TABLE fl_loading_pick_task(id BIGINT,loading_plan_id BIGINT,task_status VARCHAR(30))");
db.execute("CREATE TABLE fl_loading_pick_item(pick_task_id BIGINT,loading_plan_item_id BIGINT,item_status VARCHAR(30),picked_package_count INT)");
db.execute("CREATE TABLE fl_container_booking(id BIGINT,record_status VARCHAR(30))");
db.execute("CREATE TABLE fl_carrier_schedule_node_fact(container_booking_id BIGINT,standard_node VARCHAR(40),actual_at TIMESTAMP)");
db.execute("CREATE TABLE fl_container_document(container_booking_id BIGINT,document_type VARCHAR(40),file_id BIGINT)");
db.execute("CREATE TABLE fl_customs_customer_document_inbound(inbound_id BIGINT,tenant_id BIGINT,document_id BIGINT)");
db.execute("CREATE TABLE fl_customs_customer_document(id BIGINT,tenant_id BIGINT,status VARCHAR(30),customs_container_id BIGINT)");
db.execute("CREATE TABLE fl_customs_container(id BIGINT,loading_plan_id BIGINT,status VARCHAR(40))");
db.execute("ALTER TABLE fl_inbound_order ADD tax_mode VARCHAR(30)");
db.execute("ALTER TABLE fl_inbound_order ADD parent_inbound_id BIGINT");
db.execute("ALTER TABLE fl_inbound_order ADD inbound_role VARCHAR(30)");
db.execute("ALTER TABLE fl_inbound_order ADD split_purpose VARCHAR(30)");
db.execute(Files.readString(Path.of("src/main/resources/db/chargeable-weight-confirmation.sql")));
db.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,measure_review_status,chargeable_weight_kg,measure_completed_at,deleted,process_status,tax_mode) VALUES(1,11,101,'APPROVED',100,CURRENT_TIMESTAMP,0,'LOADED','TAX_REFUND'),(2,22,102,'APPROVED',200,CURRENT_TIMESTAMP,0,'LOADED','BUY_DECLARATION')");
db.update("INSERT INTO fl_loading_plan_item VALUES(3,1,11,4,0)");
db.update("INSERT INTO fl_loading_plan VALUES(4,'COMPLETED',5)");
db.update("INSERT INTO fl_container_booking VALUES(5,'ACTIVE')");
db.update("INSERT INTO fl_loading_pick_task VALUES(6,4,'COMPLETED')");
db.update("INSERT INTO fl_loading_pick_item VALUES(6,3,'PICKED',2)");
db.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(5,'DEPARTED_ORIGIN_PORT',CURRENT_TIMESTAMP)");
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(WorkflowFactMapper.class);
config.addMapper(com.baiyue.firstleg.mapper.ContainerBookingMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(WorkflowFactMapper.class);
var containers=new SqlSessionTemplate(factory.getObject()).getMapper(com.baiyue.firstleg.mapper.ContainerBookingMapper.class);
assertThat(containers.hasActualVesselDeparture(5)).isOne();
assertThat(containers.hasActualVesselDeparture(6)).isZero();
db.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(6,'DEPARTED_ORIGIN_PORT',NULL),(6,'ARRIVED_DESTINATION_PORT',CURRENT_TIMESTAMP)");
assertThat(containers.hasActualVesselDeparture(6)).isZero();
var repository=new WorkflowFactRepository(mapper);var service=new ChargeableWeightService(repository);
assertThat(service.read(22,101)).isEmpty();
assertThat(mapper.facts(List.of(1L))).singleElement().satisfies(f->{assertThat(f.departed()).isTrue();assertThat(f.clearanceUploaded()).isFalse();assertThat(f.missingCustoms()).isTrue();});
db.update("INSERT INTO fl_container_document VALUES(5,'DESTINATION_CLEARANCE',99)");
assertThat(mapper.facts(List.of(1L)).get(0).clearanceUploaded()).isTrue();
db.update("INSERT INTO fl_customs_container VALUES(8,4,'WAITING_CUSTOMER_DOCUMENTS')");
db.update("INSERT INTO fl_customs_customer_document VALUES(9,22,'ACTIVE',8)");
db.update("INSERT INTO fl_customs_customer_document_inbound VALUES(1,22,9)");
assertThat(mapper.facts(List.of(1L))).singleElement().satisfies(f->{
assertThat(f.missingCustoms()).isTrue();assertThat(f.exportCustomsReady()).isFalse();
});
db.update("INSERT INTO fl_customs_customer_document VALUES(10,11,'ACTIVE',8)");
db.update("INSERT INTO fl_customs_customer_document_inbound VALUES(1,11,10)");
assertThat(mapper.facts(List.of(1L))).singleElement().satisfies(f->{assertThat(f.missingCustoms()).isFalse();assertThat(f.exportCustomsReady()).isTrue();});
// 本票上传后即可汇总,不必等同柜其他票或点击开始报关;完成报关后仍等待实际开船。
for (String status : List.of("READY_TO_START", "DECLARING", "COMPLETED")) {
db.update("UPDATE fl_customs_container SET status=? WHERE id=8", status);
assertThat(mapper.facts(List.of(1L)).get(0).exportCustomsReady()).isTrue();
}
db.update("UPDATE fl_customs_customer_document SET status='DELETED' WHERE id=10");
assertThat(mapper.facts(List.of(1L))).singleElement().satisfies(f->{
assertThat(f.missingCustoms()).isTrue();assertThat(f.exportCustomsReady()).isFalse();
});
db.update("UPDATE fl_customs_customer_document SET status='ACTIVE' WHERE id=10");
db.update("INSERT INTO fl_customs_container VALUES(18,14,'READY_TO_START')");
db.update("UPDATE fl_customs_customer_document SET customs_container_id=18 WHERE id=10");
assertThat(mapper.facts(List.of(1L)).get(0).exportCustomsReady()).isFalse();
db.update("UPDATE fl_customs_customer_document SET customs_container_id=8 WHERE id=10");
db.update("UPDATE fl_loading_plan SET plan_status='LOADING' WHERE id=4");
assertThat(mapper.facts(List.of(1L)).get(0).exportCustomsReady()).isFalse();
db.update("UPDATE fl_loading_plan SET plan_status='COMPLETED' WHERE id=4");
db.update("UPDATE fl_loading_pick_item SET picked_package_count=0");
assertThat(mapper.facts(List.of(1L)).get(0).exportCustomsReady()).isFalse();
db.update("UPDATE fl_loading_pick_item SET picked_package_count=2");
// 不把预计开船当成实际开船;手动或拉取写入的实际节点都读同一事实表。
db.update("UPDATE fl_carrier_schedule_node_fact SET actual_at=NULL");
assertThat(mapper.facts(List.of(1L)).get(0).departed()).isFalse();
db.update("UPDATE fl_carrier_schedule_node_fact SET actual_at=CURRENT_TIMESTAMP");
assertThat(mapper.facts(List.of(1L)).get(0).departed()).isTrue();
// 买单票无需客户上传退税资料,但同样必须有实际装柜证据。
assertThat(mapper.facts(List.of(2L)).get(0).exportCustomsReady()).isFalse();
db.update("INSERT INTO fl_loading_plan_item VALUES(13,2,22,4,0)");
db.update("INSERT INTO fl_loading_pick_item VALUES(6,13,'PICKED',1)");
assertThat(mapper.facts(List.of(2L))).singleElement().satisfies(f->{
assertThat(f.missingCustoms()).isFalse();assertThat(f.exportCustomsReady()).isTrue();
});
var current=service.read(11,101).get(0);
assertThat(service.confirm(11,7,1,"cmd",new WorkflowFacts.Confirm(current.fingerprint())).confirmed()).isTrue();
assertThat(service.confirm(11,7,1,"cmd",new WorkflowFacts.Confirm(current.fingerprint())).confirmed()).isTrue();
assertThat(db.queryForObject("SELECT COUNT(*) FROM fl_chargeable_weight_confirmation",Integer.class)).isEqualTo(1);
assertThatThrownBy(()->service.confirm(11,7,1,"cmd",new WorkflowFacts.Confirm("0".repeat(64)))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
db.update("UPDATE fl_inbound_order SET chargeable_weight_kg=101 WHERE id=1");
assertThat(service.read(11,101).get(0).confirmed()).isFalse();
assertThatThrownBy(()->service.confirm(11,7,1,"old",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
assertThatThrownBy(()->service.confirm(22,7,1,"foreign",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
// 现行部分装柜由来源单承接实装,余量子单独立继续分柜,两票均应保留。
db.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,measure_review_status,chargeable_weight_kg,measure_completed_at,deleted,process_status,tax_mode,parent_inbound_id,inbound_role,split_purpose) VALUES(3,11,103,'APPROVED',50,CURRENT_TIMESTAMP,0,'WAIT_SPLIT','BUY_DECLARATION',1,'CHILD','REMAINDER')");
assertThat(mapper.facts(List.of(1L,3L))).hasSize(2);
assertThat(mapper.facts(List.of(1L)).get(0).departed()).isTrue();
assertThat(mapper.facts(List.of(3L)).get(0).departed()).isFalse();
assertThat(service.read(11,101)).hasSize(1);
assertThat(service.read(11,103)).hasSize(1);
// 旧模型已将实装移至独立子票时,父汇总不能重复进入页签或确认。
db.update("UPDATE fl_inbound_order SET process_status='LOADED',split_purpose='LOADED' WHERE id=3");
assertThat(service.read(11,101)).isEmpty();
assertThatThrownBy(()->service.confirm(11,7,1,"parent",new WorkflowFacts.Confirm(current.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
var child=service.read(11,103).get(0);
assertThat(child.confirmed()).isFalse();
assertThatThrownBy(()->service.confirm(11,7,3,"cmd",new WorkflowFacts.Confirm(child.fingerprint()))).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
assertThat(service.confirm(11,7,3,"child",new WorkflowFacts.Confirm(child.fingerprint())).confirmed()).isTrue();
}
}
@@ -11,20 +11,22 @@ import java.util.HashSet;
public class OutboundQuantityRepository { public class OutboundQuantityRepository {
@Resource private OutboundPalletItemMapper pallets; @Resource private OutboundPalletItemMapper pallets;
/** 废弃退货来源依据已冻结箱唛计数,读取已有单据也不会把件数当箱数。 */ /** 废弃来源依据已冻结箱唛去重计数,无箱唛的库存明细保留箱数快照。 */
public Integer boxes(OutboundOrder outbound) { public Integer boxes(OutboundOrder outbound) {
if (!"DISCARD_OUTBOUND".equals(outbound.getOutboundType())) return outbound.getPackageCount(); if (!"DISCARD_OUTBOUND".equals(outbound.getOutboundType())) return outbound.getPackageCount();
var lines = pallets.findByOutbound(outbound.getCustomerId(), outbound.getId()); var lines = pallets.findByOutbound(outbound.getCustomerId(), outbound.getId());
if (lines.isEmpty()) return outbound.getPackageCount(); if (lines.isEmpty()) return outbound.getPackageCount();
var returnBoxes = new HashSet<String>(); var markedBoxes = new HashSet<String>();
int count = 0; int count = 0;
for (var line : lines) { for (var line : lines) {
if (line.getReturnSkuQuantity() != null && line.getCartonMarks() != null && !line.getCartonMarks().isBlank()) { if (line.getCartonMarks() != null && !line.getCartonMarks().isBlank()) {
returnBoxes.add(line.getInboundCode() + ":" + line.getCartonMarks()); // 同一箱的多个 SKU 共用箱唛,只计一个实物箱;一行也可能冻结多个箱唛。
java.util.Arrays.stream(line.getCartonMarks().split(",")).map(String::trim)
.filter(mark -> !mark.isEmpty()).forEach(markedBoxes::add);
} else { } else {
count = Math.addExact(count, java.util.Objects.requireNonNullElse(line.getPackageCount(), 0)); count = Math.addExact(count, java.util.Objects.requireNonNullElse(line.getPackageCount(), 0));
} }
} }
return Math.addExact(count, returnBoxes.size()); return Math.addExact(count, markedBoxes.size());
} }
} }
@@ -130,6 +130,7 @@ public class DeliveryTransferService {
@Resource private DeliveryExecutionGuard deliveryExecution; @Resource private DeliveryExecutionGuard deliveryExecution;
@Resource private com.baiyue.lastleg.dispatch.port.DispatchExecutionPort execution; @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.DeliveryCancellationRepository cancellations;
@Resource private com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository outboundQuantities;
@Transactional @Transactional
public DeliveryResponses.PdaTask createOneClick(String customerId, String operatorId, DeliveryRequests.OneClickOutbound request) { 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); } } 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) { private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) {
return new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(), return new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(),
item.getInboundCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getInboundCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(),
@@ -46,6 +46,14 @@ public class CargoDiscardController {
return ApiResponse.success(service.page(scope(user, customerId), query)); return ApiResponse.success(service.page(scope(user, customerId), query));
} }
/** 读取全部筛选结果的数量统计,与分页记录分开返回。 */
@GetMapping("/statistics")
public ApiResponse<com.baiyue.lastleg.discard.service.CargoDiscardStatisticsService.Statistics> statistics(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
@Valid CargoDiscardRequests.Query query) {
return ApiResponse.success(service.statistics(scope(user, customerId), query));
}
@GetMapping("/{discardNo}") @GetMapping("/{discardNo}")
public ApiResponse<CargoDiscardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<CargoDiscardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String discardNo) { @RequestParam(required = false) String customerId, @PathVariable String discardNo) {
@@ -32,6 +32,14 @@ public class MerchantCargoDiscardController {
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')")
public ApiResponse<PageResult<CargoDiscardResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<PageResult<CargoDiscardResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid CargoDiscardRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); } @Valid CargoDiscardRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); }
/** 客户统计只读取当前认证客户,不接受客户端客户归属。 */
@GetMapping("/statistics")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')")
public ApiResponse<com.baiyue.lastleg.discard.service.CargoDiscardStatisticsService.Statistics> statistics(
@AuthenticationPrincipal AuthenticatedUser user, @Valid CargoDiscardRequests.Query query) {
return ApiResponse.success(service.statistics(customer(user), query));
}
@GetMapping("/{discardNo}") @GetMapping("/{discardNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')")
public ApiResponse<CargoDiscardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<CargoDiscardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@@ -33,5 +33,12 @@ public final class CargoDiscardRequests {
public record Query(@Min(1) long page, @Min(1) @Max(200) long size, public record Query(@Min(1) long page, @Min(1) @Max(200) long size,
String discardNo, String inboundCode, String overseasWarehouseId, 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);
}
}
} }
@@ -37,7 +37,12 @@ public final class CargoDiscardResponses {
String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, String outboundNo, LocalDateTime customerConfirmedAt) {} String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, String outboundNo, LocalDateTime customerConfirmedAt) {}
public record Item(Long id, Long orderId, String inboundCode, String skuCode, public record Item(Long id, Long orderId, String inboundCode, String skuCode,
String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks) { String productName, int availablePackagesSnapshot, int discardPackages, List<String> cartonMarks,
String quantityUnit, Integer actualDiscardPackages, String pdaStatus) {
/** 无来源事实的调用不猜测数量单位或实际完成数。 */
public Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages, List<String> 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) { 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()); this(id, orderId, inboundCode, skuCode, productName, availablePackagesSnapshot, discardPackages, List.of());
} }
@@ -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") @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); com.baiyue.lastleg.inventory.entity.InventoryPosition lockGenericPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId);
/** 按实收托盘及 SKU 关联已清点箱,避免漏箱或混入其他托盘的箱号。 */
@Select(""" @Select("""
SELECT c.id,c.carton_mark,c.sku_code,'INBOUND' source_type SELECT c.id,c.carton_mark,c.sku_code,'INBOUND' source_type
FROM ll_inbound_plan_carton c 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_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 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 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 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} 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 ORDER BY id
""") """)
List<com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow> genericCartons(@Param("customerId") String customerId, @Param("positionId") Long positionId); List<com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow> genericCartons(@Param("customerId") String customerId, @Param("positionId") Long positionId);
@@ -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();
}
}
@@ -201,7 +201,8 @@ public class PdaCargoDiscardService {
if ("INVENTORY".equals(item.getSourceType())) return stockService.cartonMarks(item); if ("INVENTORY".equals(item.getSourceType())) return stockService.cartonMarks(item);
if (isReturnItem(item)) { if (isReturnItem(item)) {
var carton = returnCartons.selectById(item.getReturnCartonId()); 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() return cartons.listApplication(application.getCustomerId(), application.getId()).stream()
.filter(c -> Objects.equals(c.getItemId(), item.getId())).map(CargoDiscardCarton::getCartonMark).filter(Objects::nonNull).toList(); .filter(c -> Objects.equals(c.getItemId(), item.getId())).map(CargoDiscardCarton::getCartonMark).filter(Objects::nonNull).toList();
@@ -73,7 +73,7 @@ public class CargoDiscardOutboundService {
|| !application.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId())) || !application.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId()))
throw conflict("废弃明细关联退货箱不存在或仓库不匹配"); throw conflict("废弃明细关联退货箱不存在或仓库不匹配");
palletNo = carton.getPalletCode() == null || carton.getPalletCode().isBlank() ? null : carton.getPalletCode(); palletNo = carton.getPalletCode() == null || carton.getPalletCode().isBlank() ? null : carton.getPalletCode();
cartonMarks = carton.getCartonMark(); cartonMarks = com.baiyue.lastleg.discard.model.CargoDiscardReturnMark.of(carton);
} else { } else {
var profile = profiles.selectById(line.getProfileId()); var profile = profiles.selectById(line.getProfileId());
if (profile == null || !application.getCustomerId().equals(profile.getCustomerId())) 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() var line = lines.stream().filter(i -> Objects.equals(i.getId(), snapshot.getId())).findFirst()
.orElseThrow(() -> conflict("废弃出库明细关联失效")); .orElseThrow(() -> conflict("废弃出库明细关联失效"));
boolean returned = line.getReturnCartonId() != null; 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(); int boxes = returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : line.getDiscardPackages();
snapshot.setPackageCount(boxes); snapshot.setPackageCount(boxes);
if ("COMPLETED".equals(line.getPdaStatus())) { if ("COMPLETED".equals(line.getPdaStatus())) {
@@ -50,6 +50,7 @@ import java.util.Set;
@Service @Service
public class CargoDiscardService { public class CargoDiscardService {
@Resource private CargoDiscardStatisticsService statisticsService;
@Resource private com.baiyue.lastleg.discard.repository.CargoDiscardQuantityRepository quantities; @Resource private com.baiyue.lastleg.discard.repository.CargoDiscardQuantityRepository quantities;
@Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator; @Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator;
public static final String APPROVAL_PERMISSION = "platform:overseas-warehouse:discard:approve"; 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; if (returnCartons == null || returnCartonItems == null) return result;
for (ReturnInventoryCarton carton : returnCartons.findAvailable(customerId, null)) { for (ReturnInventoryCarton carton : returnCartons.findAvailable(customerId, null)) {
if (StringUtils.hasText(keyword) && !containsIgnoreCase(carton.getSourceReturnNo(), keyword) 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<CargoDiscardResponses.CandidateSku> skus = returnCartonItems.listCarton(carton.getId()).stream() List<CargoDiscardResponses.CandidateSku> skus = returnCartonItems.listCarton(carton.getId()).stream()
.filter(item -> value(item.getQuantity()) > 0) .filter(item -> value(item.getQuantity()) > 0)
.map(item -> new CargoDiscardResponses.CandidateSku(item.getSkuCode(), item.getProductNameZh(), value(item.getQuantity()))) .map(item -> new CargoDiscardResponses.CandidateSku(item.getSkuCode(), item.getProductNameZh(), value(item.getQuantity())))
.toList(); .toList();
long available = skus.stream().mapToLong(CargoDiscardResponses.CandidateSku::availablePackages).sum(); long available = skus.stream().mapToLong(CargoDiscardResponses.CandidateSku::availablePackages).sum();
if (available <= 0) continue; 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, carton.getSourceReturnNo(), carton.getSourceReturnNo(), carton.getOverseasWarehouseId(), null,
null, available, skus)); null, available, skus));
} }
@@ -253,7 +254,8 @@ public class CargoDiscardService {
if ("INVENTORY".equals(line.getSourceType())) return stockService.cartonMarks(line); if ("INVENTORY".equals(line.getSourceType())) return stockService.cartonMarks(line);
if (isReturnItem(line)) { if (isReturnItem(line)) {
var carton = returnCartons.selectById(line.getReturnCartonId()); 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())) return discardCartons.listApplication(customer, application.getId()).stream().filter(link -> link.getItemId().equals(line.getId()))
.map(CargoDiscardCarton::getCartonMark).distinct().sorted().toList(); .map(CargoDiscardCarton::getCartonMark).distinct().sorted().toList();
@@ -261,16 +263,7 @@ public class CargoDiscardService {
public PageResult<CargoDiscardResponses.Summary> page(String customerId, CargoDiscardRequests.Query query) { public PageResult<CargoDiscardResponses.Summary> page(String customerId, CargoDiscardRequests.Query query) {
validateQuery(query); validateQuery(query);
LambdaQueryWrapper<CargoDiscardApplication> wrapper = new LambdaQueryWrapper<CargoDiscardApplication>() var wrapper = com.baiyue.lastleg.discard.repository.CargoDiscardQueryRepository.filter(customerId, query);
.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);
Page<CargoDiscardApplication> result = applications.selectPage(Page.of(query.page(), query.size()), wrapper); Page<CargoDiscardApplication> result = applications.selectPage(Page.of(query.page(), query.size()), wrapper);
Map<String, String> names = result.getRecords().stream().anyMatch(a -> !StringUtils.hasText(a.getOverseasWarehouseName())) Map<String, String> names = result.getRecords().stream().anyMatch(a -> !StringUtils.hasText(a.getOverseasWarehouseName()))
? warehouseNames() : Map.of(); ? warehouseNames() : Map.of();
@@ -278,12 +271,19 @@ public class CargoDiscardService {
return new PageResult<>(records, query.page(), query.size(), result.getTotal(), result.getPages()); 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) { public CargoDiscardResponses.Detail detail(String customerId, String discardNo) {
CargoDiscardApplication application = applications.findNo(customerId, discardNo); CargoDiscardApplication application = applications.findNo(customerId, discardNo);
if (application == null) throw notFound("Cargo discard application not found"); if (application == null) throw notFound("Cargo discard application not found");
List<CargoDiscardResponses.Item> itemViews = items.listApplication(customerId, application.getId()).stream() List<CargoDiscardResponses.Item> itemViews = items.listApplication(customerId, application.getId()).stream()
.map(value -> new CargoDiscardResponses.Item(value.getId(), value.getOrderId(), value.getInboundCode(), .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(); .toList();
List<CargoDiscardResponses.History> historyViews = histories.listApplication(customerId, application.getId()).stream() List<CargoDiscardResponses.History> historyViews = histories.listApplication(customerId, application.getId()).stream()
.map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(), .map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(),
@@ -528,6 +528,9 @@ public class CargoDiscardService {
} }
private void validateQuery(CargoDiscardRequests.Query query) { 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 (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())) { if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) {
throw badRequest("createdFrom must not be after createdTo"); throw badRequest("createdFrom must not be after createdTo");
@@ -66,6 +66,7 @@ public class CargoDiscardStockService {
return balance; return balance;
} }
/** 预留所选实收箱并保存箱号快照,转运箱从已清点状态进入待废弃。 */
public void reserve(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at) { public void reserve(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at) {
if (stock.lockGenericPosition(customer, request.inventoryPositionId()) == null) throw conflict("库存仓位不存在"); if (stock.lockGenericPosition(customer, request.inventoryPositionId()) == null) throw conflict("库存仓位不存在");
var balance = validate(customer, request); var balance = validate(customer, request);
@@ -80,7 +81,7 @@ public class CargoDiscardStockService {
: availableCartons.stream().filter(c -> request.cartonIds().contains(c.getId())).toList(); : availableCartons.stream().filter(c -> request.cartonIds().contains(c.getId())).toList();
if (!selected.isEmpty()) { if (!selected.isEmpty()) {
for (var carton : selected) { 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()); 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); 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)); } try { line.setCartonMarksJson(objectMapper.writeValueAsString(marks)); }
catch (Exception e) { throw new IllegalStateException("Cannot snapshot carton marks", e); } catch (Exception e) { throw new IllegalStateException("Cannot snapshot carton marks", e); }
} }
/** 按真实箱来源完成废弃或释放,转运箱释放后恢复已清点状态。 */
private void changeSelectedCartons(String customer, CargoDiscardApplication application, CargoDiscardItem line, String status) { private void changeSelectedCartons(String customer, CargoDiscardApplication application, CargoDiscardItem line, String status) {
for (var link : links.listApplication(customer, application.getId())) { 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("预留箱子的库存状态发生变化"); throw conflict("预留箱子的库存状态发生变化");
} }
} }
@@ -5,6 +5,11 @@ import java.util.List;
/** BOL按真实明细客户逐行读取整车,不依赖已移除的主单客户头。 */ /** BOL按真实明细客户逐行读取整车,不依赖已移除的主单客户头。 */
@Mapper @Mapper
public interface DispatchBolMapper { public interface DispatchBolMapper {
/** 旧客户详情入口沿用整车查询,但只返回该客户自己的BOL明细。 */
default List<com.baiyue.lastleg.dispatch.dto.DispatchResponse.BolLine> 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," + @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," + "COALESCE(MAX(NULLIF(o.container_no,'')),MAX(NULLIF(ip.container_no,''))) AS containerNo," +
"MAX(NULLIF(di.pallet_no,'')) AS palletNo," + "MAX(NULLIF(di.pallet_no,'')) AS palletNo," +
@@ -17,7 +17,7 @@ public final class OverseasProcurementRequests {
public record Create( public record Create(
@Size(max = 255) String customerName, @Size(max = 255) String customerName,
@NotBlank @Size(max = 128) String purchaseCategory, @Size(max = 128) String purchaseCategory,
@NotBlank @Size(max = 255) String productName, @NotBlank @Size(max = 255) String productName,
@Size(max = 500) String productSpecification, @Size(max = 500) String productSpecification,
@Size(max = 2000) String productUrl, @Size(max = 2000) String productUrl,
@@ -75,7 +75,7 @@ public class OverseasProcurementService {
order.setCustomerId(customerId); order.setCustomerId(customerId);
order.setCustomerName(trim(request.customerName())); order.setCustomerName(trim(request.customerName()));
order.setOrderNo(orderNoGenerator.next(customerId, 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.setProductName(request.productName().trim());
order.setProductSpecification(trim(request.productSpecification())); order.setProductSpecification(trim(request.productSpecification()));
order.setProductUrl(trim(request.productUrl())); order.setProductUrl(trim(request.productUrl()));
@@ -25,6 +25,15 @@ import java.util.List;
@RequestMapping("/api/merchant/v1/last-leg/relabel-applications") @RequestMapping("/api/merchant/v1/last-leg/relabel-applications")
public class MerchantRelabelApplicationController { public class MerchantRelabelApplicationController {
@Resource private RelabelApplicationService service; @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<com.baiyue.lastleg.relabel.service.RelabelQuantityService.Totals> quantities(
@AuthenticationPrincipal AuthenticatedUser user, @Valid RelabelRequests.Query query) {
return ApiResponse.success(quantities.totals(customer(user), query));
}
@GetMapping("/inventory-candidates") @GetMapping("/inventory-candidates")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')")
public ApiResponse<List<RelabelResponses.InventoryCandidate>> candidates( public ApiResponse<List<RelabelResponses.InventoryCandidate>> candidates(
@@ -25,6 +25,16 @@ import org.springframework.web.server.ResponseStatusException;
@RequestMapping("/api/platform/v1/last-leg/relabel-applications") @RequestMapping("/api/platform/v1/last-leg/relabel-applications")
public class RelabelApplicationController { public class RelabelApplicationController {
@Resource private RelabelApplicationService service; @Resource private RelabelApplicationService service;
@Resource private com.baiyue.lastleg.relabel.service.RelabelQuantityService quantities;
/** 按平台当前全部筛选结果返回独立数量维度。 */
@GetMapping("/quantity-summary")
@PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')")
public ApiResponse<com.baiyue.lastleg.relabel.service.RelabelQuantityService.Totals> 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; @Resource private com.baiyue.lastleg.relabel.pda.service.PdaRelabelService pdaService;
@PostMapping("/{relabelNo}/rework") @PostMapping("/{relabelNo}/rework")
@@ -570,30 +570,7 @@ public class RelabelApplicationService implements WalletResultHandler {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public PageResult<RelabelResponses.Summary> page(String customerId, RelabelRequests.Query query) { public PageResult<RelabelResponses.Summary> page(String customerId, RelabelRequests.Query query) {
if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { LambdaQueryWrapper<RelabelApplication> wrapper = com.baiyue.lastleg.relabel.model.RelabelQueryFilter.build(customerId, query);
throw badRequest("createdFrom must not be after createdTo");
}
String requestedStatus = trim(query.status());
LambdaQueryWrapper<RelabelApplication> wrapper = new LambdaQueryWrapper<RelabelApplication>()
.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);
}
Page<RelabelApplication> page = applications.selectPage(new Page<>(query.page(), query.size()), wrapper); Page<RelabelApplication> page = applications.selectPage(new Page<>(query.page(), query.size()), wrapper);
List<RelabelResponses.Summary> records = page.getRecords().stream().map(value -> summary(value, itemList(value.getId()))).toList(); List<RelabelResponses.Summary> records = page.getRecords().stream().map(value -> summary(value, itemList(value.getId()))).toList();
return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages());
@@ -529,6 +529,7 @@ public class ReturnInventoryService {
if (cartons.updateById(carton) != 1) throw conflict("退货箱状态已变化,请重新核对"); if (cartons.updateById(carton) != 1) throw conflict("退货箱状态已变化,请重新核对");
} }
/** 包裹直接沿用签收实物编码,避免同一实物生成两套标签。 */
@Transactional @Transactional
public ReturnInventoryCarton createReceiptCarton(ReturnOrder order, ReturnReceipt receipt, public ReturnInventoryCarton createReceiptCarton(ReturnOrder order, ReturnReceipt receipt,
String requestedMark, List<ReceiptLine> lines) { String requestedMark, List<ReceiptLine> lines) {
@@ -537,8 +538,8 @@ public class ReturnInventoryService {
required(lines.get(0).skuCode(), "SKU 不能为空"); required(lines.get(0).skuCode(), "SKU 不能为空");
String cartonMark = text(requestedMark); String cartonMark = text(requestedMark);
long cartonId = IdWorker.getId(); long cartonId = IdWorker.getId();
// One durable barcode identifies a physical parcel, irrespective of SKU count. // 包裹签收已生成 PK 标签,后续库存、废弃和扫码沿用同一编码。
if ("PARCEL".equals(receipt.getUnitType())) cartonMark = "RP-" + Long.toString(receipt.getId(), 36).toUpperCase(java.util.Locale.ROOT); if ("PARCEL".equals(receipt.getUnitType())) cartonMark = normalizedUnitCode;
else if (cartonMark == null) cartonMark = numbers.next("RETURN_CARTON", order.getOverseasWarehouseId(), cartonId); else if (cartonMark == null) cartonMark = numbers.next("RETURN_CARTON", order.getOverseasWarehouseId(), cartonId);
if (cartons.findMark(cartonMark) != null) throw conflict("箱号已存在: " + cartonMark); if (cartons.findMark(cartonMark) != null) throw conflict("箱号已存在: " + cartonMark);
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
@@ -458,7 +458,9 @@ public class ReturnReceiptService {
RelabelApplication app = new RelabelApplication(); app.setId(appId); app.setCustomerId(customerId); app.setRelabelNo(relabelNo); app.setLastlegOrderId(tail.getId()); 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.setCustomerOrderNo(order.getReturnNo()); app.setOverseasWarehouseId(order.getOverseasWarehouseId());
app.setLabelDeadline(now.plusDays(7)); 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); 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<ReturnInventoryCarton> availableCartons = cartons.findAvailable(customerId, order.getOverseasWarehouseId()); List<ReturnInventoryCarton> availableCartons = cartons.findAvailable(customerId, order.getOverseasWarehouseId());
Map<Long, ReturnInventoryCarton> selectedCartons = new LinkedHashMap<>(); Map<Long, ReturnInventoryCarton> selectedCartons = new LinkedHashMap<>();
@@ -577,6 +579,11 @@ public class ReturnReceiptService {
String unitType = norm(request.unitType()); String unitType = norm(request.unitType());
if (unitType == null) unitType = "PALLET"; if (unitType == null) unitType = "PALLET";
String unitCode = text(request.unitCode()); 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())); ReturnOrder order = orders.lockNo(text(request.returnNo()));
if (order == null || !String.valueOf(identity.overseasWarehouseId()).equals(order.getOverseasWarehouseId())) { if (order == null || !String.valueOf(identity.overseasWarehouseId()).equals(order.getOverseasWarehouseId())) {
throw notFound("当前绑定仓没有该退货单"); throw notFound("当前绑定仓没有该退货单");
@@ -869,3 +876,4 @@ public class ReturnReceiptService {
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
private record Allocation(ReturnOrderItem item, long quantity) { } private record Allocation(ReturnOrderItem item, long quantity) { }
} }
@@ -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 ORDER BY cartonMark
""") """)
java.util.List<SourceCarton> sourceCartons(@Param("customerId") String customerId, java.util.List<SourceCarton> sourceCartons(@Param("customerId") String customerId,
@@ -3,11 +3,30 @@ package com.baiyue.lastleg.transfer.mapper;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List; import java.util.List;
/** 使用提交单据的原箱快照表达业务占用,不提前扣减实物库存。 */ /** 使用提交单据的原箱快照表达业务占用,不提前扣减实物库存。 */
@Mapper @Mapper
public interface TransferSourceReservationMapper { 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") @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); Long lockInbound(@Param("customerId") String customerId, @Param("inboundOrderId") Long inboundOrderId);
@@ -21,6 +21,11 @@ public class TransferInventorySourceRepository {
return reservations.occupiedMarks(customerId, warehouseId, sourceCode); return reservations.occupiedMarks(customerId, warehouseId, sourceCode);
} }
/** 按当前转运实际选择的箱唛记录来源箱出库,不按箱数截断历史箱集合。 */
public int shipSelectedCartons(String customerId, Long transferOrderId) {
return reservations.shipSelectedCartons(customerId, transferOrderId);
}
/** 读取当前来源单未被有效换标申请使用的箱级快照。 */ /** 读取当前来源单未被有效换标申请使用的箱级快照。 */
public List<InboundSnapshotMapper.SourceCarton> sourceCartons(String customerId, String warehouseId, String inboundCode) { public List<InboundSnapshotMapper.SourceCarton> sourceCartons(String customerId, String warehouseId, String inboundCode) {
return snapshots.sourceCartons(customerId, warehouseId, inboundCode); return snapshots.sourceCartons(customerId, warehouseId, inboundCode);
@@ -1063,8 +1063,13 @@ public class WarehouseTransferService {
boolean hasSkuReceiptDifference(List<WarehouseTransferCarton> cartons, List<WarehouseTransferItem> items) { boolean hasSkuReceiptDifference(List<WarehouseTransferCarton> cartons, List<WarehouseTransferItem> items) {
Map<String, Long> expected = new java.util.HashMap<>(); Map<String, Long> expected = new java.util.HashMap<>();
Map<String, Long> received = new java.util.HashMap<>(); Map<String, Long> received = new java.util.HashMap<>();
Set<String> unknown = new java.util.HashSet<>();
Map<Long, WarehouseTransferItem> byId = items.stream().collect(Collectors.toMap(WarehouseTransferItem::getId, Function.identity())); Map<Long, WarehouseTransferItem> 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) { for (var carton : cartons) {
var contents = cartonContents(carton); var contents = cartonContents(carton);
if (contents.isEmpty()) { if (contents.isEmpty()) {
@@ -1075,8 +1080,10 @@ public class WarehouseTransferService {
for (var line : contents) received.merge(line.skuCode(), line.quantity(), Long::sum); 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))) return expected.entrySet().stream().anyMatch(entry -> !unknown.contains(entry.getKey())
|| received.entrySet().stream().anyMatch(entry -> !entry.getValue().equals(expected.getOrDefault(entry.getKey(), 0L))); && !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<WarehouseTransferCarton> requireCountedCartons(WarehouseTransferOrder transfer) { private List<WarehouseTransferCarton> requireCountedCartons(WarehouseTransferOrder transfer) {
@@ -1520,6 +1527,8 @@ public class WarehouseTransferService {
allocation.setStatus("SHIPPED"); allocation.setActivePositionKey(null); allocation.setStatus("SHIPPED"); allocation.setActivePositionKey(null);
allocation.setShippedAt(now); allocation.setUpdatedAt(now); inventoryAllocations.updateById(allocation); allocation.setShippedAt(now); allocation.setUpdatedAt(now); inventoryAllocations.updateById(allocation);
} }
// 同一事务记录所选原箱已出库,当前库存候选据此排除;签收快照仍完整保留。
inventorySources.shipSelectedCartons(customerId, transfer.getId());
transfer.setStatus(OUTBOUNDED); transfer.setOutboundIdempotencyKey(key); transfer.setStatus(OUTBOUNDED); transfer.setOutboundIdempotencyKey(key);
transfer.setOutboundRequestHash(requestHash); transfer.setOutboundAt(now); transfer.setUpdatedAt(now); transfer.setOutboundRequestHash(requestHash); transfer.setOutboundAt(now); transfer.setUpdatedAt(now);
transfers.updateById(transfer); transfers.updateById(transfer);
@@ -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<List<OrderWorkflowFacts.Fact>> 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));
}
}
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.workflow;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** 以不可变来源明细核对出库,取消不再遮挡待排车。 */
@Mapper
public interface OrderWorkflowFactMapper {
/** 精确核对客户及来源;字符串参数沿用列排序规则,避免连接默认排序规则冲突。 */
@Select("""
<script>
SELECT o.customer_id tenant_id,o.source_inbound_id inbound_id,
EXISTS(SELECT 1 FROM ll_outbound_pallet_item i JOIN ll_outbound_order b ON b.id=i.outbound_order_id AND b.customer_id=i.customer_id
WHERE i.customer_id=o.customer_id AND i.source_order_id=o.id AND b.status NOT IN ('CANCELLED','CANCELED')) outbound_exists,
EXISTS(SELECT 1 FROM ll_outbound_pallet_item i JOIN ll_outbound_order b ON b.id=i.outbound_order_id AND b.customer_id=i.customer_id
WHERE i.customer_id=o.customer_id AND i.source_order_id=o.id AND b.status NOT IN ('CANCELLED','CANCELED','OUTBOUNDED')) pending_outbound,
EXISTS(SELECT 1 FROM ll_outbound_pallet_item i JOIN ll_outbound_order b ON b.id=i.outbound_order_id AND b.customer_id=i.customer_id
JOIN ll_delivery_order d ON d.outbound_order_id=b.id AND d.customer_id=b.customer_id
WHERE i.customer_id=o.customer_id AND i.source_order_id=o.id AND b.status='OUTBOUNDED' AND d.status='DELIVERING' AND d.dispatched_at IS NOT NULL
AND (d.destination_warehouse_code=i.destination_warehouse_code OR d.destination_warehouse_id=i.destination_warehouse_id
OR (d.destination_type='PRIVATE' AND EXISTS(SELECT 1 FROM ll_dispatch_item di
WHERE di.id=i.dispatch_item_id AND di.customer_id=i.customer_id AND di.dispatch_order_id=b.dispatch_order_id
AND di.destination_type='PRIVATE' AND TRIM(di.delivery_address)=TRIM(d.delivery_address)
AND COALESCE(TRIM(di.postal_code),'')=COALESCE(TRIM(d.postal_code),''))))) delivering,
EXISTS(SELECT 1 FROM ll_inbound_plan p WHERE p.customer_id=o.customer_id AND p.source_order_id=o.id AND p.status IN ('COMPLETED','PENDING_RECONCILIATION')) unloaded
FROM ll_order o WHERE o.source_region='FIRSTLEG' AND o.order_type NOT IN ('RELABEL_APPLICATION','WAREHOUSE_TRANSFER')
AND <foreach collection='sources' item='s' open='(' separator=' OR ' close=')'>
(o.customer_id=#{s.tenantKey} AND o.source_inbound_id=#{s.inboundKey})
</foreach>
</script>
""")
List<OrderWorkflowFacts.Fact> read(@Param("sources") List<OrderWorkflowFacts.Source> sources);
}
@@ -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<OrderWorkflowFacts.Fact> read(List<OrderWorkflowFacts.Source> sources) { return sources.isEmpty()?List.of():mapper.read(sources); }
}
@@ -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<OrderWorkflowFacts.Fact> read(OrderWorkflowFacts.Request request) { return repository.read(request.sources()); }
}
@@ -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) {}
}
@@ -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}
@@ -4979,7 +4979,7 @@ components:
purchaseWarehouseId: { type: integer, format: int64, minimum: 1, nullable: true } purchaseWarehouseId: { type: integer, format: int64, minimum: 1, nullable: true }
ProcurementCreate: ProcurementCreate:
type: object type: object
required: [purchaseCategory, productName, purchaseQuantity, budgetUnitPrice, currency, receivingAddress, contactName, contactPhone, idempotencyKey] required: [productName, purchaseQuantity, budgetUnitPrice, currency, receivingAddress, contactName, contactPhone, idempotencyKey]
properties: properties:
customerName: { type: string, nullable: true, maxLength: 255 } customerName: { type: string, nullable: true, maxLength: 255 }
purchaseCategory: { type: string, maxLength: 128 } purchaseCategory: { type: string, maxLength: 128 }
@@ -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);
}
}
@@ -133,6 +133,9 @@ class DeliveryTransferServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
service = new DeliveryTransferService(); 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, "deliveryExecution", new DeliveryExecutionGuard(new com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository(outboundOrders, deliveryOrders)));
ReflectionTestUtils.setField(service, "execution", execution); ReflectionTestUtils.setField(service, "execution", execution);
ReflectionTestUtils.setField(service,"cancellations",new com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository( 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()); 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<OutboundOrder>(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 @Test
void dispatchOutboundStillRequiresItsDeliveryDocument() { void dispatchOutboundStillRequiresItsDeliveryDocument() {
OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "dispatch-301"); OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "dispatch-301");
@@ -57,6 +57,11 @@ class ReturnOutboundClosureTest {
ReturnInventoryCarton carton; ReturnInventoryCarton carton;
@BeforeEach void setup() { @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, "outboundOrders", outbounds);
ReflectionTestUtils.setField(service, "outboundPallets", items); ReflectionTestUtils.setField(service, "outboundPallets", items);
ReflectionTestUtils.setField(service, "pdaTasks", tasks); ReflectionTestUtils.setField(service, "pdaTasks", tasks);
@@ -54,6 +54,8 @@ class ReturnOutboundPdaPersistenceTest {
+ (field.getName().equals("id") ? " PRIMARY KEY" : "")).collect(Collectors.joining(",")); + (field.getName().equals("id") ? " PRIMARY KEY" : "")).collect(Collectors.joining(","));
jdbc.execute("CREATE TABLE " + entity.getAnnotation(TableName.class).value() + " (" + columns + ")"); 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); var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true);
for (Class<?> mapper : List.of(OutboundOrderMapper.class, OutboundPalletItemMapper.class, for (Class<?> mapper : List.of(OutboundOrderMapper.class, OutboundPalletItemMapper.class,
OutboundPdaTaskMapper.class, OutboundPdaPickMapper.class, DeliveryOrderMapper.class, ReturnInventoryCartonMapper.class, OutboundPdaTaskMapper.class, OutboundPdaPickMapper.class, DeliveryOrderMapper.class, ReturnInventoryCartonMapper.class,
@@ -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));
}
}
@@ -20,8 +20,7 @@ class ReturnDiscardMovementMapperTest {
var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
var db = new JdbcTemplate(source); 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_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("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("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_item(carton_id BIGINT,sku_code VARCHAR(30),product_name_zh VARCHAR(30),quantity BIGINT)"); 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_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))"); db.execute("CREATE TABLE ll_return_order(id BIGINT,customer_id VARCHAR(20),overseas_warehouse_id VARCHAR(20))");
@@ -193,18 +193,19 @@ class PdaCargoDiscardServiceTest {
@Test void returnCartonUsesPieceUnitAndRequiresSelectedCartonEvenWhenBoundToPallet() { @Test void returnCartonUsesPieceUnitAndRequiresSelectedCartonEvenWhenBoundToPallet() {
item.setSourceType("RETURN_CARTON"); item.setReturnCartonId(31L); item.setDiscardPackages(5); item.setSourceType("RETURN_CARTON"); item.setReturnCartonId(31L); item.setDiscardPackages(5);
var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton(); 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(returnCartons.selectById(31L)).thenReturn(carton);
when(applications.selectList(any())).thenReturn(List.of(application)); when(applications.selectList(any())).thenReturn(List.of(application));
var task = service.resolve(identity, "PALLET-1", "CD-1"); var task = service.resolve(identity, "PALLET-1", "CD-1");
assertEquals("CARTON", task.scanMode()); 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(5, task.skus().get(0).plannedPackages());
assertEquals("件", task.skus().get(0).quantityUnit()); assertEquals("件", task.skus().get(0).quantityUnit());
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(5))); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(5)));
verifyNoInteractions(adjustments); verifyNoInteractions(adjustments);
var confirm = new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 5)), 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); service.confirm(identity, "CD-1", "PALLET-1", confirm);
assertEquals(1, application.getTotalPackages()); assertEquals(1, application.getTotalPackages());
assertEquals(new BigDecimal("5.00"), application.getTotalFee()); assertEquals(new BigDecimal("5.00"), application.getTotalFee());
@@ -94,9 +94,10 @@ class CargoDiscardOutboundServiceTest {
@Test void returnSourceKeepsRealCartonMarkWithoutInventingPalletNumber() { @Test void returnSourceKeepsRealCartonMarkWithoutInventingPalletNumber() {
first.setReturnCartonId(31L); first.setSourceType("RETURN_CARTON"); first.setReturnCartonId(31L); first.setSourceType("RETURN_CARTON");
var carton = new ReturnInventoryCarton(); carton.setId(31L); carton.setCustomerId("C1"); 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); 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()); assertEquals(3L, snapshots.get(0).getReturnSkuQuantity());
} }
@Test void invalidSourcePreventsAnyOutboundInsert() { @Test void invalidSourcePreventsAnyOutboundInsert() {
@@ -47,6 +47,26 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class CargoDiscardServiceTest { 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 @Test
void customerConfirmationClosesPdaCompletedApplicationOnceWithoutAnotherDeduction() { void customerConfirmationClosesPdaCompletedApplicationOnceWithoutAnotherDeduction() {
CargoDiscardApplication application = new CargoDiscardApplication(); CargoDiscardApplication application = new CargoDiscardApplication();
@@ -112,20 +112,24 @@ class CargoDiscardStockServiceTest {
verifyNoInteractions(commands, items, links); verifyNoInteractions(commands, items, links);
} }
@Test void singlePositionTransferBoxesReleaseBackToTheirOriginalExpectedStatus() { @Test void receivedTransferBoxesAreSelectableAndReleaseBackToCountedStatus() {
position.setAvailablePackages(2L); balance.setAvailablePackages(2L); position.setAvailablePackages(2L); balance.setAvailablePackages(2L);
var first = carton(1L, "WT-1#2-1"); first.setSourceType("TRANSFER"); var first = carton(1L, "WT-1#2-1"); first.setSourceType("TRANSFER");
var second = carton(2L, "WT-1#2-2"); second.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.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()); 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()); var capture = ArgumentCaptor.forClass(CargoDiscardCarton.class); verify(links).insert(capture.capture());
assertEquals("TRANSFER", capture.getValue().getSourceType()); assertEquals("TRANSFER", capture.getValue().getSourceType());
when(links.listApplication("C1", 11L)).thenReturn(List.of(capture.getValue())); 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()); var line = ArgumentCaptor.forClass(CargoDiscardItem.class); verify(items).insert(line.capture());
service.release("C1", application, line.getValue()); 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()); verify(stock, never()).changeGenericCartonStatus(anyString(), anyLong(), anyString(), anyString());
} }
@@ -10,6 +10,19 @@ import java.util.List;
import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.Assertions.*;
class ProcurementRequestBoundaryTest { 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() { @Test void createRejectsMultiProductAndBatchExtensionsWithApplicationJacksonDefaults() {
new org.springframework.boot.test.context.runner.ApplicationContextRunner() new org.springframework.boot.test.context.runner.ApplicationContextRunner()
.withConfiguration(org.springframework.boot.autoconfigure.AutoConfigurations.of( .withConfiguration(org.springframework.boot.autoconfigure.AutoConfigurations.of(
@@ -185,7 +185,7 @@ class ReturnInventoryServiceTest {
var receipt = new ReturnReceipt(); receipt.setId(21L); receipt.setUnitType("PARCEL"); receipt.setUnitCode("PK-1"); var receipt = new ReturnReceipt(); receipt.setId(21L); receipt.setUnitType("PARCEL"); receipt.setUnitCode("PK-1");
var source = service.createReceiptCarton(completedOrder(), receipt, null, var source = service.createReceiptCarton(completedOrder(), receipt, null,
List.of(new ReturnInventoryService.ReceiptLine("A", "商品A", 3))); 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"); assertThat(source.getSourceUnitType()).isEqualTo("PARCEL");
verifyNoInteractions(numbers); verifyNoInteractions(numbers);
assertThat(source.getLabelStatus()).isEqualTo("CONFIRMED"); assertThat(source.getLabelStatus()).isEqualTo("CONFIRMED");
@@ -56,6 +56,16 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class ReturnReceiptServiceTest { 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() { @Test void countedCargoReplacesForecastTotalsAndRepeatedSyncUpdatesTheSameDetail() {
var details = org.mockito.Mockito.mock(com.baiyue.lastleg.mapper.LastlegOrderDetailMapper.class); var details = org.mockito.Mockito.mock(com.baiyue.lastleg.mapper.LastlegOrderDetailMapper.class);
ReflectionTestUtils.setField(service, "lastlegOrderDetails", details); ReflectionTestUtils.setField(service, "lastlegOrderDetails", details);
@@ -268,6 +278,8 @@ class ReturnReceiptServiceTest {
assertEquals("RL-MULTI", order.getRelabelNo()); assertEquals("RL-MULTI", order.getRelabelNo());
var application = ArgumentCaptor.forClass(RelabelApplication.class); var application = ArgumentCaptor.forClass(RelabelApplication.class);
verify(relabelApplications).insert(application.capture()); verify(relabelApplications).insert(application.capture());
assertEquals("", application.getValue().getContactName());
assertEquals("", application.getValue().getContactPhone());
assertEquals(firstQuantity + secondQuantity, application.getValue().getNotifiedPackageCount().longValue()); assertEquals(firstQuantity + secondQuantity, application.getValue().getNotifiedPackageCount().longValue());
assertEquals(2, application.getValue().getItemCount()); assertEquals(2, application.getValue().getItemCount());
var lines = ArgumentCaptor.forClass(RelabelItem.class); 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); "BOX", palletCode, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, 1L, true);
} }
} }
@@ -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_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_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_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_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_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())"); 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); var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds)); config.addMapper(InboundSnapshotMapper.class);
try(var session=new SqlSessionFactoryBuilder().build(config).openSession()) { try(var session=new SqlSessionFactoryBuilder().build(config).openSession()) {
var result=session.getMapper(InboundSnapshotMapper.class).sourceCartons("A","WH","IN"); var result=session.getMapper(InboundSnapshotMapper.class).sourceCartons("A","WH","IN");
assertEquals(List.of("FIRST", "MEASURED"),result.stream().map(InboundSnapshotMapper.SourceCarton::cartonMark).toList()); assertEquals(List.of("DROPSHIP", "FIRST", "MEASURED"),result.stream().map(InboundSnapshotMapper.SourceCarton::cartonMark).toList());
assertEquals("ONT8",result.get(0).destinationWarehouseCode()); assertEquals(5L,result.get(0).quantity()); assertTrue(result.get(0).skuItemsJson().contains("SKU"));
assertNull(result.get(0).quantity()); assertEquals(20L,result.get(1).quantity()); 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());
} }
} }
} }
@@ -175,6 +175,18 @@ class WarehouseTransferCartonCountTest {
ia.setDeclaredQuantity(4L); ia.setDeclaredQuantity(4L);
assertTrue(service.hasSkuReceiptDifference(List.of(a),List.of(ia,ib))); 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不能绕过预报生成另一套箱号。 */ /** 新箱必须先补客户预报,PDA不能绕过预报生成另一套箱号。 */
@Test void extraReceiptRequiresForecastCartonFirst() { @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))); 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)));
@@ -521,6 +521,8 @@ class WarehouseTransferServiceTest {
transfer.setInboundCode("IN-1"); transfer.setInboundCode("IN-1");
transfer.setNeedRelabel(true); transfer.setNeedRelabel(true);
transfer.setPackageCount(1); transfer.setPackageCount(1);
var selected = new WarehouseTransferCarton();
selected.setId(51L); selected.setCustomerCartonMark("CURRENT-BOX");
when(inboundSnapshots.sourceCartons("TENANT-A", "WH-1", "IN-1")) when(inboundSnapshots.sourceCartons("TENANT-A", "WH-1", "IN-1"))
.thenReturn(List.of(new InboundSnapshotMapper.SourceCarton("CURRENT-BOX", "SKU-1", null, null, null, null, null))); .thenReturn(List.of(new InboundSnapshotMapper.SourceCarton("CURRENT-BOX", "SKU-1", null, null, null, null, null)));
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
@@ -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);});
}
}
@@ -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);
}
}
@@ -24,6 +24,18 @@ public interface FirstlegUnifiedOrderClient {
record WorkbenchFilters(List<Long> matchingStatusOrderIds, List<Long> feeOrderIds, record WorkbenchFilters(List<Long> matchingStatusOrderIds, List<Long> feeOrderIds,
List<Long> customsOrderIds, List<com.baiyue.order.dto.MerchantOrderProgress> progress) {} List<Long> customsOrderIds, List<com.baiyue.order.dto.MerchantOrderProgress> progress) {}
/** 当前页候选订单按原筛选批量读取摘要。 */
@PostMapping("/status-summaries")
ApiResponse<List<Summary>> statusSummaries(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody Query request);
/** 批量读取符合筛选的去重订单状态,不返回履约明细。 */
@PostMapping("/status-rows")
ApiResponse<List<StatusRow>> statusRows(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody Query request);
record StatusRow(Long tenantId, Long orderId, String mainStatus) {}
@PostMapping("/query") @PostMapping("/query")
ApiResponse<PageResult<Summary>> page(@RequestHeader("X-Internal-Service-Token") String token, ApiResponse<PageResult<Summary>> page(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody Query request); @RequestBody Query request);
@@ -5,6 +5,7 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.order.dto.UnifiedOrderQuery; import com.baiyue.order.dto.UnifiedOrderQuery;
import com.baiyue.order.dto.UnifiedOrderStatusCountQuery;
import com.baiyue.order.dto.UnifiedOrderResponse; import com.baiyue.order.dto.UnifiedOrderResponse;
import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.dto.OrderDeclarationDtos; import com.baiyue.order.dto.OrderDeclarationDtos;
@@ -52,6 +53,19 @@ public class PlatformUnifiedOrderController {
return ApiResponse.success(service.platformPage(tenantId, query)); return ApiResponse.success(service.platformPage(tenantId, query));
} }
/** 与订单列表共用平台权限及客户范围,不提供全部汇总数量。 */
@GetMapping("/status-counts")
@PreAuthorize("hasAuthority('platform:order:list')")
@Operation(summary = "统一订单页签数量", description = "忽略mainStatus和分页,按其余列表筛选全量去重统计;不返回全部数量。")
public ApiResponse<UnifiedOrderQueryService.StatusCounts> 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}") @GetMapping("/{orderId}")
@PreAuthorize("hasAuthority('platform:order:list')") @PreAuthorize("hasAuthority('platform:order:list')")
@Operation(summary = "统一订单详情", description = "合并订单和履约时间线;平台响应包含内部备注。") @Operation(summary = "统一订单详情", description = "合并订单和履约时间线;平台响应包含内部备注。")
@@ -13,7 +13,7 @@ public record UnifiedOrderQuery(
@Size(max = 64) String keyword, @Size(max = 64) String keyword,
@Size(max = 64) String orderNo, @Size(max = 64) String orderNo,
@Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", @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, @Size(max = 32) String mainStatus,
Long customerId, Long customerId,
Long receivingWarehouseId, Long receivingWarehouseId,
@@ -297,9 +297,28 @@ public class OrderRepository {
return PageResult.from(orderMapper.selectPage(new Page<>(query.page(), query.size()), wrapper)); return PageResult.from(orderMapper.selectPage(new Page<>(query.page(), query.size()), wrapper));
} }
public PageResult<OrderEntity> unifiedPage(Long tenantId, UnifiedOrderQuery query, /** 统一订单列表按共享筛选分页。 */
List<OrderStatus> statuses) { public PageResult<OrderEntity> unifiedPage(Long tenantId, UnifiedOrderQuery query, List<OrderStatus> statuses) {
LambdaQueryWrapper<OrderEntity> wrapper = new LambdaQueryWrapper<OrderEntity>() return PageResult.from(orderMapper.selectPage(new Page<>(query.page(), query.size()), unifiedFilter(tenantId, query, statuses)));
}
/** 全量轻量投影只读取订单身份和权威交易状态。 */
public List<OrderEntity> unifiedStatusOrders(Long tenantId, UnifiedOrderQuery query, List<OrderStatus> statuses) {
return orderMapper.selectList(unifiedFilter(tenantId, query, statuses)
.select(OrderEntity::getId, OrderEntity::getTenantId, OrderEntity::getStatus));
}
/** 状态覆盖只接受本客户范围内未删除订单的权威状态。 */
public List<OrderEntity> statusAuthorities(Long tenantId, List<Long> orderIds) {
if (orderIds.isEmpty()) return List.of();
return orderMapper.selectList(new LambdaQueryWrapper<OrderEntity>()
.select(OrderEntity::getId, OrderEntity::getTenantId, OrderEntity::getStatus)
.eq(tenantId != null, OrderEntity::getTenantId, tenantId).in(OrderEntity::getId, orderIds));
}
/** 列表和统计共用非分页筛选及客户隔离。 */
private LambdaQueryWrapper<OrderEntity> unifiedFilter(Long tenantId, UnifiedOrderQuery query, List<OrderStatus> statuses) {
return new LambdaQueryWrapper<OrderEntity>()
.eq(tenantId != null, OrderEntity::getTenantId, tenantId) .eq(tenantId != null, OrderEntity::getTenantId, tenantId)
.eq(query.taxMode() != null, OrderEntity::getTaxMode, query.taxMode()) .eq(query.taxMode() != null, OrderEntity::getTaxMode, query.taxMode())
.in(OrderEntity::getStatus, statuses) .in(OrderEntity::getStatus, statuses)
@@ -325,7 +344,6 @@ public class OrderRepository {
.like(OrderEntity::getOrderNo, query.keyword()) .like(OrderEntity::getOrderNo, query.keyword())
.or().like(OrderEntity::getCustomerName, query.keyword())) .or().like(OrderEntity::getCustomerName, query.keyword()))
.orderByDesc(OrderEntity::getCreatedAt).orderByDesc(OrderEntity::getId); .orderByDesc(OrderEntity::getCreatedAt).orderByDesc(OrderEntity::getId);
return PageResult.from(orderMapper.selectPage(new Page<>(query.page(), query.size()), wrapper));
} }
public List<Long> findOrderIdsBySkuNo(Long tenantId, String fbaCode) { public List<Long> findOrderIdsBySkuNo(Long tenantId, String fbaCode) {
@@ -42,6 +42,7 @@ public class UnifiedOrderQueryService {
private static final Set<String> MAIN_STATUSES = Set.of( private static final Set<String> MAIN_STATUSES = Set.of(
"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT", "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT",
"LOADING", "LOADED", "DELIVERED", "COMPLETED"); "LOADING", "LOADED", "DELIVERED", "COMPLETED");
private static final List<String> 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<String> REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED"); private static final Set<String> REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED");
private static final List<OrderStatus> ACTIVE_ORDER_STATUSES = List.copyOf(EnumSet.of( private static final List<OrderStatus> ACTIVE_ORDER_STATUSES = List.copyOf(EnumSet.of(
OrderStatus.SUBMITTED, OrderStatus.APPROVED, OrderStatus.ACCEPTED, OrderStatus.SUBMITTED, OrderStatus.APPROVED, OrderStatus.ACCEPTED,
@@ -60,6 +61,102 @@ public class UnifiedOrderQueryService {
return page(tenantId, query, true); return page(tenantId, query, true);
} }
/** 页签统计忽略主状态和分页,返回筛选全量订单的准确去重数量。 */
public StatusCounts statusCounts(Long tenantId, UnifiedOrderQuery query) {
query = withoutMainStatus(query);
validate(query);
Map<String, Long> 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<String, Long> counts) {}
/** 数量保持 JSON number;不能被全局雪花 ID 的 Long 字符串序列化规则改写。 */
public static final class CountNumberSerializer extends com.fasterxml.jackson.databind.JsonSerializer<Long> {
/** 按数值输出数量,不影响系统其他 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<FirstlegUnifiedOrderClient.StatusRow> matchingStatusRows(Long tenantId, UnifiedOrderQuery query) {
List<Long> 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<FirstlegUnifiedOrderClient.StatusRow> source = require(
call(() -> firstleg.statusRows(internalToken, request)), "统一订单状态查询响应无效");
Map<String, FirstlegUnifiedOrderClient.StatusRow> 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<Long, OrderEntity> authority = new HashMap<>();
List<Long> 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<FirstlegUnifiedOrderClient.StatusRow> 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<String, FirstlegUnifiedOrderClient.StatusRow> 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<Long> 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 作为选择契约。 */ /** 平台导出选择项按业务订单号读取;不暴露内部 ID 作为选择契约。 */
public List<UnifiedOrderResponse.Row> platformRowsByOrderNos(List<String> orderNos) { public List<UnifiedOrderResponse.Row> platformRowsByOrderNos(List<String> orderNos) {
if (orderNos == null || orderNos.isEmpty()) return List.of(); 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(); return selected.stream().map(order->row(order,summaries.get(order.getId()),null,children,cargo.get(order.getId()))).toList();
} }
/** 依条件选择订单查询或履约筛选,保留订单审核优先规则。 */
private PageResult<UnifiedOrderResponse.Row> page(Long tenantId, UnifiedOrderQuery query, private PageResult<UnifiedOrderResponse.Row> page(Long tenantId, UnifiedOrderQuery query,
boolean includeCoLoadGroups) { boolean includeCoLoadGroups) {
validate(query); validate(query);
if (requiresFirstlegQuery(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); return pageFromFirstleg(tenantId, query, includeCoLoadGroups);
} }
List<OrderStatus> statuses = "PENDING_REVIEW".equals(query.mainStatus()) List<OrderStatus> statuses = "PENDING_REVIEW".equals(query.mainStatus())
@@ -162,25 +257,33 @@ public class UnifiedOrderQueryService {
return new UnifiedOrderResponse.Detail(summary, orderDetail, timeline, fragments); return new UnifiedOrderResponse.Detail(summary, orderDetail, timeline, fragments);
} }
/** 先校正权威状态并去重,再对匹配订单分页和批量补齐当前页摘要。 */
private PageResult<UnifiedOrderResponse.Row> pageFromFirstleg(Long tenantId, UnifiedOrderQuery query, private PageResult<UnifiedOrderResponse.Row> pageFromFirstleg(Long tenantId, UnifiedOrderQuery query,
boolean includeCoLoadGroups) { boolean includeCoLoadGroups) {
List<Long> candidateOrderIds = StringUtils.hasText(query.fbaCode()) List<FirstlegUnifiedOrderClient.StatusRow> matching = matchingStatusRows(tenantId, query).stream()
? orders.findOrderIdsBySkuNo(tenantId, query.fbaCode()) : null; .filter(row -> !StringUtils.hasText(query.mainStatus()) || query.mainStatus().equals(row.mainStatus()))
if (candidateOrderIds != null && candidateOrderIds.isEmpty()) { .collect(Collectors.collectingAndThen(Collectors.toMap(FirstlegUnifiedOrderClient.StatusRow::orderId,
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); Function.identity(), (left, right) -> left, java.util.LinkedHashMap::new),
} values -> List.copyOf(values.values())));
FirstlegUnifiedOrderClient.Query request = new FirstlegUnifiedOrderClient.Query( long total = matching.size();
tenantId, query.keyword(), query.orderNo(), query.mainStatus(), query.customerId(), long start = query.page() - 1 > Long.MAX_VALUE / query.size()
query.receivingWarehouseId(), query.destinationWarehouseId(), query.inboundCode(), ? total : Math.min((query.page() - 1) * query.size(), total);
query.internalOrderStatus(), query.expectedArrivalDate(), query.feeReviewStatus(), query.channelName(), List<Long> orderIds = matching.subList((int) start, (int) Math.min(start + query.size(), total))
query.containerNo(), query.cabinetNo(), query.sailingDate(), query.loaded(), query.fullContainer(), .stream().map(FirstlegUnifiedOrderClient.StatusRow::orderId).toList();
query.measurementReviewStatus(), candidateOrderIds, query.createdFrom(), query.createdTo(), Map<Long, FirstlegUnifiedOrderClient.Summary> byOrder = orderIds.isEmpty() ? Map.of()
query.page(), query.size(), query.taxMode()); : validateSummaries(require(call(() -> firstleg.statusSummaries(internalToken,
PageResult<FirstlegUnifiedOrderClient.Summary> source = require( statusRequest(tenantId, query, orderIds))), "统一订单履约摘要响应无效"),
call(() -> firstleg.page(internalToken, request)), "统一订单履约查询响应无效"); "统一订单履约摘要响应无效").stream().collect(Collectors.toMap(
List<FirstlegUnifiedOrderClient.Summary> summaries = validateSummaries( item -> item.inbound().orderId(), Function.identity(), (left, right) -> left));
source.records(), "统一订单履约查询响应无效"); Map<Long, Long> expectedTenants = matching.stream().collect(Collectors.toMap(
List<Long> orderIds = summaries.stream().map(item -> item.inbound().orderId()).toList(); FirstlegUnifiedOrderClient.StatusRow::orderId, FirstlegUnifiedOrderClient.StatusRow::tenantId));
List<FirstlegUnifiedOrderClient.Summary> 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<Long, OrderEntity> orderById = orders.findByIds(tenantId, orderIds).stream() Map<Long, OrderEntity> orderById = orders.findByIds(tenantId, orderIds).stream()
.collect(Collectors.toMap(OrderEntity::getId, Function.identity(), (left, right) -> left)); .collect(Collectors.toMap(OrderEntity::getId, Function.identity(), (left, right) -> left));
Map<Long, CargoSummary> cargoSummaries = cargoSummaries(tenantId, List.copyOf(orderById.values())); Map<Long, CargoSummary> cargoSummaries = cargoSummaries(tenantId, List.copyOf(orderById.values()));
@@ -193,7 +296,8 @@ public class UnifiedOrderQueryService {
cargoSummaries.get(summary.inbound().orderId()), cargoSummaries.get(summary.inbound().orderId()),
coLoadByOrder.get(summary.inbound().orderId()))) coLoadByOrder.get(summary.inbound().orderId())))
.toList(); .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<Long, FirstlegUnifiedOrderClient.Summary> summaries(Long tenantId, List<Long> orderIds) { private Map<Long, FirstlegUnifiedOrderClient.Summary> summaries(Long tenantId, List<Long> orderIds) {
@@ -391,14 +495,17 @@ public class UnifiedOrderQueryService {
order.getCreatedAt(), order.getUpdatedAt(), inbound.requestedPickupAt()); order.getCreatedAt(), order.getUpdatedAt(), inbound.requestedPickupAt());
} }
/** 将当前页匹配履约记录映射为订单行,审核与完成优先使用 Order 权威状态。 */
private UnifiedOrderResponse.Row row(FirstlegUnifiedOrderClient.Summary summary, OrderEntity order, private UnifiedOrderResponse.Row row(FirstlegUnifiedOrderClient.Summary summary, OrderEntity order,
String latestInternalNote, Map<Long, Integer> childCounts, String latestInternalNote, Map<Long, Integer> childCounts,
CargoSummary cargo, CargoSummary cargo,
UnifiedOrderResponse.CoLoadGroupSummary coLoadGroup) { UnifiedOrderResponse.CoLoadGroupSummary coLoadGroup) {
var inbound = summary.inbound(); var inbound = summary.inbound();
boolean completed = order != null && order.getStatus() == OrderStatus.COMPLETED; 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(), 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()), parseOrderStatus(inbound.orderStatusSnapshot()),
inbound.customerId(), inbound.customerName(), inbound.channelCode(), inbound.channelName(), inbound.customerId(), inbound.customerName(), inbound.channelCode(), inbound.channelName(),
order != null && Boolean.TRUE.equals(order.getChannelChanged()), inbound.warehouseId(), order != null && Boolean.TRUE.equals(order.getChannelChanged()), inbound.warehouseId(),
@@ -577,6 +684,7 @@ public class UnifiedOrderQueryService {
} }
if (StringUtils.hasText(query.mainStatus()) if (StringUtils.hasText(query.mainStatus())
&& !MAIN_STATUSES.contains(query.mainStatus()) && !MAIN_STATUSES.contains(query.mainStatus())
&& !WORKFLOW_STATUSES.contains(query.mainStatus())
) { ) {
throw new BizException(ResultCode.BAD_REQUEST, "统一订单主状态不合法"); throw new BizException(ResultCode.BAD_REQUEST, "统一订单主状态不合法");
} }
+53 -4
View File
@@ -378,7 +378,7 @@ paths:
inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion
才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。 才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。
tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询, tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询,
指定其他主状态时由 Firstleg 精确分页。旧 /orders/first-leg 接口保持兼容。 指定履约主状态时先按 Order 权威状态校正并去重,再分页读取 Firstleg 摘要。旧 /orders/first-leg 接口保持兼容。
列表行同时返回 customerRemark(客户下单备注)、latestInternalNote(最新一条平台内部备注)和 列表行同时返回 customerRemark(客户下单备注)、latestInternalNote(最新一条平台内部备注)和
relation(仅有关联时返回;PARENT 含 childCount,CHILD 含 rootOrderId、parentOrderId 与 splitSequence)。 relation(仅有关联时返回;PARENT 含 childCount,CHILD 含 rootOrderId、parentOrderId 与 splitSequence)。
coLoadGroup 仅平台响应返回,由 Order 经 Firstleg 内部批量只读契约聚合;成员业务标识由 Order 权威订单片段补齐。 coLoadGroup 仅平台响应返回,由 Order 经 Firstleg 内部批量只读契约聚合;成员业务标识由 Order 权威订单片段补齐。
@@ -414,6 +414,55 @@ paths:
application/json: application/json:
schema: {$ref: '#/components/schemas/UnifiedOrderPageEnvelope'} schema: {$ref: '#/components/schemas/UnifiedOrderPageEnvelope'}
'403': {description: 需要平台身份和 platform:order:list 权限} '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}: /api/platform/v1/orders/{orderId}:
get: get:
summary: 平台统一订单详情 summary: 平台统一订单详情
@@ -1038,7 +1087,7 @@ components:
tenantId: {type: string, pattern: '^[1-9][0-9]*$'} tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
keyword: {type: string, maxLength: 64} keyword: {type: string, maxLength: 64}
orderNo: {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]*$'} customerId: {type: string, pattern: '^[1-9][0-9]*$'}
receivingWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'} receivingWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'} destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
@@ -1694,10 +1743,10 @@ components:
name: mainStatus name: mainStatus
in: query in: query
required: false required: false
description: 统一订单八态主状态筛选;WAIT_PALLETIZE、WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询 description: 统一订单主状态及业务页签筛选;WAIT_PALLETIZE、WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询。WAIT_CUSTOMS_DOCS 为实际装柜后待上传客户报关资料;EXPORT_CUSTOMS 为本票报关资料已上传且实际装柜完成、尚未实际开船的汇总,买单票无需客户退税资料,不以点击开始报关为前提;SAILING_CLEARANCE 为实际开船后待上传清关资料;清关资料上传后进入 WAIT_UNLOAD,持续到实际卸柜完成。实际开船统一读取手动或拉取的实际离港节点。
schema: schema:
type: string 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: UnifiedInboundCode:
{name: inboundCode, in: query, required: false, description: 订单创建时生成的入仓码,所有订单状态均可查询, schema: {type: string, maxLength: 40}} {name: inboundCode, in: query, required: false, description: 订单创建时生成的入仓码,所有订单状态均可查询, schema: {type: string, maxLength: 40}}
UnifiedInternalOrderStatus: UnifiedInternalOrderStatus:
@@ -19,6 +19,38 @@ import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
class UnifiedOrderApiContractTest { 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<String, Object> 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 @Test
void keepsPlatformAndMerchantBoundaries() { void keepsPlatformAndMerchantBoundaries() {
assertThat(PlatformUnifiedOrderController.class.getAnnotation(RequestMapping.class).value()) assertThat(PlatformUnifiedOrderController.class.getAnnotation(RequestMapping.class).value())
@@ -43,7 +75,9 @@ class UnifiedOrderApiContractTest {
Map<String, Object> schema = stringMap(mainStatus.get("schema")); Map<String, Object> schema = stringMap(mainStatus.get("schema"));
assertThat(stringList(schema.get("enum"))).containsExactly( assertThat(stringList(schema.get("enum"))).containsExactly(
"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT", "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()) assertThat(mainStatus.get("description").toString())
.contains("WAIT_PALLETIZE", "WAIT_SPLIT", "INBOUNDED"); .contains("WAIT_PALLETIZE", "WAIT_SPLIT", "INBOUNDED");
@@ -59,7 +93,9 @@ class UnifiedOrderApiContractTest {
assertThat(queryAnnotation).isNotNull(); assertThat(queryAnnotation).isNotNull();
assertThat(queryAnnotation.allowableValues()).containsExactly( assertThat(queryAnnotation.allowableValues()).containsExactly(
"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT", "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<String, Object> row = stringMap(schemas.get("UnifiedOrderRow")); Map<String, Object> row = stringMap(schemas.get("UnifiedOrderRow"));
assertThat(stringMap(row.get("properties"))).containsKeys( assertThat(stringMap(row.get("properties"))).containsKeys(
@@ -75,6 +75,17 @@ class UnifiedOrderTaxModePaginationTest {
assertPage(null, 2, 4, 4); 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.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"TAX_REFUND", "BUY_DECLARATION"}) @org.junit.jupiter.params.provider.ValueSource(strings = {"TAX_REFUND", "BUY_DECLARATION"})
void realSplitChildIsPersistedAndIncludedInTaxFilteredParentChildPages(String taxMode) { void realSplitChildIsPersistedAndIncludedInTaxFilteredParentChildPages(String taxMode) {
@@ -586,8 +586,7 @@ class UnifiedOrderQueryServiceTest {
void fulfillmentStatusFilterUsesFirstlegPagingWithoutPostPageFiltering() { void fulfillmentStatusFilterUsesFirstlegPagingWithoutPostPageFiltering() {
UnifiedOrderQuery query = query("INBOUNDED"); UnifiedOrderQuery query = query("INBOUNDED");
var summary = summary("INBOUNDED", "CONFIRM_PRELOAD"); var summary = summary("INBOUNDED", "CONFIRM_PRELOAD");
when(firstleg.page(eq("internal-token"), any())) stubStatusProjection(List.of(summary), 1);
.thenReturn(ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1)));
var result = service.page(null, query); var result = service.page(null, query);
@@ -617,8 +616,7 @@ class UnifiedOrderQueryServiceTest {
UnifiedOrderQuery query = query("INBOUNDED"); UnifiedOrderQuery query = query("INBOUNDED");
var summary = summary("INBOUNDED", null); var summary = summary("INBOUNDED", null);
when(summary.inbound().actualNetWeightKg()).thenReturn(null); when(summary.inbound().actualNetWeightKg()).thenReturn(null);
when(firstleg.page(eq("internal-token"), any())).thenReturn( stubStatusProjection(List.of(summary), 1);
ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1)));
var row = service.page(null, query).records().get(0); var row = service.page(null, query).records().get(0);
@@ -631,8 +629,7 @@ class UnifiedOrderQueryServiceTest {
UnifiedOrderQuery query = query("INBOUNDED"); UnifiedOrderQuery query = query("INBOUNDED");
var summary = summary("INBOUNDED", null); var summary = summary("INBOUNDED", null);
when(summary.inbound().locationCodes()).thenReturn("A0、 A0, A0,A0"); when(summary.inbound().locationCodes()).thenReturn("A0、 A0, A0,A0");
when(firstleg.page(eq("internal-token"), any())).thenReturn( stubStatusProjection(List.of(summary), 1);
ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1)));
var row = service.page(null, query).records().get(0); var row = service.page(null, query).records().get(0);
@@ -643,8 +640,7 @@ class UnifiedOrderQueryServiceTest {
void waitSplitFilterForwardsAndReturnsWaitSplitIndependently() { void waitSplitFilterForwardsAndReturnsWaitSplitIndependently() {
UnifiedOrderQuery query = query("WAIT_SPLIT"); UnifiedOrderQuery query = query("WAIT_SPLIT");
var summary = summary("WAIT_SPLIT", "CONFIRM_PRELOAD"); var summary = summary("WAIT_SPLIT", "CONFIRM_PRELOAD");
when(firstleg.page(eq("internal-token"), any())) stubStatusProjection(List.of(summary), 1);
.thenReturn(ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1)));
when(orders.findByIds(eq(10001L), eq(List.of(77L)))).thenReturn(List.of(order(OrderStatus.PROCESSING))); when(orders.findByIds(eq(10001L), eq(List.of(77L)))).thenReturn(List.of(order(OrderStatus.PROCESSING)));
var result = service.page(10001L, query); var result = service.page(10001L, query);
@@ -653,17 +649,16 @@ class UnifiedOrderQueryServiceTest {
.extracting(row -> row.mainStatus()).isEqualTo("WAIT_SPLIT"); .extracting(row -> row.mainStatus()).isEqualTo("WAIT_SPLIT");
ArgumentCaptor<FirstlegUnifiedOrderClient.Query> captor = ArgumentCaptor<FirstlegUnifiedOrderClient.Query> captor =
ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class); 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().tenantId()).isEqualTo(10001L);
assertThat(captor.getValue().mainStatus()).isEqualTo("WAIT_SPLIT"); assertThat(captor.getValue().mainStatus()).isNull();
} }
@Test @Test
void waitPalletizeFilterReturnsDropshipProjectionAndForwardsToFirstleg() { void waitPalletizeFilterReturnsDropshipProjectionAndForwardsToFirstleg() {
UnifiedOrderQuery query = query("WAIT_PALLETIZE"); UnifiedOrderQuery query = query("WAIT_PALLETIZE");
var summary = summary("WAIT_PALLETIZE", "PALLETIZE_DROPSHIP"); var summary = summary("WAIT_PALLETIZE", "PALLETIZE_DROPSHIP");
when(firstleg.page(eq("internal-token"), any())) stubStatusProjection(List.of(summary), 1);
.thenReturn(ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1)));
when(orders.findByIds(eq(10001L), eq(List.of(77L)))).thenReturn(List.of(order(OrderStatus.PROCESSING))); when(orders.findByIds(eq(10001L), eq(List.of(77L)))).thenReturn(List.of(order(OrderStatus.PROCESSING)));
var result = service.page(10001L, query); var result = service.page(10001L, query);
@@ -676,8 +671,8 @@ class UnifiedOrderQueryServiceTest {
}); });
ArgumentCaptor<FirstlegUnifiedOrderClient.Query> captor = ArgumentCaptor<FirstlegUnifiedOrderClient.Query> captor =
ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class); ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class);
verify(firstleg).page(eq("internal-token"), captor.capture()); verify(firstleg).statusRows(eq("internal-token"), captor.capture());
assertThat(captor.getValue().mainStatus()).isEqualTo("WAIT_PALLETIZE"); assertThat(captor.getValue().mainStatus()).isNull();
} }
@Test @Test
@@ -800,8 +795,7 @@ class UnifiedOrderQueryServiceTest {
var summary = summary("INBOUNDED", null); var summary = summary("INBOUNDED", null);
when(summary.inbound().holdFlag()).thenReturn(false); when(summary.inbound().holdFlag()).thenReturn(false);
when(summary.inbound().currentHoldReason()).thenReturn(null); when(summary.inbound().currentHoldReason()).thenReturn(null);
when(firstleg.page(eq("internal-token"), any())).thenReturn( stubStatusProjection(List.of(summary), 31);
ApiResponse.success(new PageResult<>(List.of(summary), 4, 10, 31, 4)));
var result = service.page(10001L, query); var result = service.page(10001L, query);
@@ -815,10 +809,10 @@ class UnifiedOrderQueryServiceTest {
}); });
ArgumentCaptor<FirstlegUnifiedOrderClient.Query> captor = ArgumentCaptor<FirstlegUnifiedOrderClient.Query> captor =
ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class); ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class);
verify(firstleg).page(eq("internal-token"), captor.capture()); verify(firstleg).statusRows(eq("internal-token"), captor.capture());
assertThat(captor.getValue().mainStatus()).isEqualTo("INBOUNDED"); assertThat(captor.getValue().mainStatus()).isNull();
assertThat(captor.getValue().page()).isEqualTo(4); assertThat(captor.getValue().page()).isEqualTo(1);
assertThat(captor.getValue().size()).isEqualTo(10); assertThat(captor.getValue().size()).isEqualTo(200);
} }
@Test @Test
@@ -830,8 +824,7 @@ class UnifiedOrderQueryServiceTest {
when(summary.inbound().orderId()).thenReturn(orderId); when(summary.inbound().orderId()).thenReturn(orderId);
when(summary.inbound().id()).thenReturn(inboundId); when(summary.inbound().id()).thenReturn(inboundId);
when(summary.inbound().tenantId()).thenReturn(tenantId); when(summary.inbound().tenantId()).thenReturn(tenantId);
when(firstleg.page(eq("internal-token"), any())).thenReturn( stubStatusProjection(List.of(summary), 1);
ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1)));
assertThat(service.page(null, query("INBOUNDED")).records()).singleElement().satisfies(row -> { assertThat(service.page(null, query("INBOUNDED")).records()).singleElement().satisfies(row -> {
assertThat(row.orderId()).isEqualTo(orderId); assertThat(row.orderId()).isEqualTo(orderId);
@@ -862,8 +855,7 @@ class UnifiedOrderQueryServiceTest {
void missingHoldFlagFailsClosed() { void missingHoldFlagFailsClosed() {
var summary = summary("INBOUNDED", null); var summary = summary("INBOUNDED", null);
when(summary.inbound().holdFlag()).thenReturn(null); when(summary.inbound().holdFlag()).thenReturn(null);
when(firstleg.page(eq("internal-token"), any())).thenReturn( stubStatusProjection(List.of(summary), 1);
ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1)));
assertServiceUnavailable(() -> service.page(null, query("INBOUNDED"))); assertServiceUnavailable(() -> service.page(null, query("INBOUNDED")));
} }
@@ -872,8 +864,7 @@ class UnifiedOrderQueryServiceTest {
void heldInboundWithoutReasonFailsClosed() { void heldInboundWithoutReasonFailsClosed() {
var summary = summary("INBOUNDED", null); var summary = summary("INBOUNDED", null);
when(summary.inbound().currentHoldReason()).thenReturn(" "); when(summary.inbound().currentHoldReason()).thenReturn(" ");
when(firstleg.page(eq("internal-token"), any())).thenReturn( stubStatusProjection(List.of(summary), 1);
ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1)));
assertServiceUnavailable(() -> service.page(null, query("INBOUNDED"))); assertServiceUnavailable(() -> service.page(null, query("INBOUNDED")));
} }
@@ -883,8 +874,7 @@ class UnifiedOrderQueryServiceTest {
var summary = summary("INBOUNDED", null); var summary = summary("INBOUNDED", null);
when(summary.inbound().holdFlag()).thenReturn(false); when(summary.inbound().holdFlag()).thenReturn(false);
when(summary.inbound().currentHoldReason()).thenReturn("旧扣货原因"); when(summary.inbound().currentHoldReason()).thenReturn("旧扣货原因");
when(firstleg.page(eq("internal-token"), any())).thenReturn( stubStatusProjection(List.of(summary), 1);
ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1)));
assertServiceUnavailable(() -> service.page(null, query("INBOUNDED"))); assertServiceUnavailable(() -> service.page(null, query("INBOUNDED")));
} }
@@ -905,7 +895,7 @@ class UnifiedOrderQueryServiceTest {
@Test @Test
void firstlegCallExceptionFailsClosed() { 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"))); assertServiceUnavailable(() -> service.page(null, query("INBOUNDED")));
} }
@@ -914,8 +904,7 @@ class UnifiedOrderQueryServiceTest {
void fulfillmentTabsForwardStatusSearchAndPaginationToFirstleg() { void fulfillmentTabsForwardStatusSearchAndPaginationToFirstleg() {
List<String> statuses = List.of( List<String> statuses = List.of(
"WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"); "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED");
when(firstleg.page(eq("internal-token"), any())) stubStatusProjection(List.of(), 0);
.thenReturn(ApiResponse.success(new PageResult<>(List.of(), 3, 20, 0, 0)));
statuses.forEach(status -> service.page(null, new UnifiedOrderQuery( statuses.forEach(status -> service.page(null, new UnifiedOrderQuery(
"CC-2026", "FL-77", status, null, null, null, null, null, "CC-2026", "FL-77", status, null, null, null, null, null,
@@ -925,14 +914,14 @@ class UnifiedOrderQueryServiceTest {
ArgumentCaptor<FirstlegUnifiedOrderClient.Query> captor = ArgumentCaptor<FirstlegUnifiedOrderClient.Query> captor =
ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class); ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class);
verify(firstleg, org.mockito.Mockito.times(statuses.size())) 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 -> IntStream.range(0, statuses.size()).forEach(index ->
assertThat(captor.getAllValues().get(index)).satisfies(request -> { 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.keyword()).isEqualTo("CC-2026");
assertThat(request.orderNo()).isEqualTo("FL-77"); assertThat(request.orderNo()).isEqualTo("FL-77");
assertThat(request.page()).isEqualTo(3); assertThat(request.page()).isEqualTo(1);
assertThat(request.size()).isEqualTo(20); assertThat(request.size()).isEqualTo(200);
})); }));
} }
@@ -944,14 +933,13 @@ class UnifiedOrderQueryServiceTest {
LocalDate.of(2026, 8, 8), true, true, "UNRECORDED", "FBA-1", LocalDate.of(2026, 8, 8), true, true, "UNRECORDED", "FBA-1",
null, null, 1, 20, null); null, null, 1, 20, null);
when(orders.findOrderIdsBySkuNo(10001L, "FBA-1")).thenReturn(List.of(77L)); when(orders.findOrderIdsBySkuNo(10001L, "FBA-1")).thenReturn(List.of(77L));
when(firstleg.page(eq("internal-token"), any())) stubStatusProjection(List.of(), 0);
.thenReturn(ApiResponse.success(new PageResult<>(List.of(), 1, 20, 0, 0)));
service.page(10001L, query); service.page(10001L, query);
ArgumentCaptor<FirstlegUnifiedOrderClient.Query> captor = ArgumentCaptor<FirstlegUnifiedOrderClient.Query> captor =
ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class); 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(captor.getValue()).satisfies(request -> {
assertThat(request.inboundCode()).isEqualTo("RK-77"); assertThat(request.inboundCode()).isEqualTo("RK-77");
assertThat(request.internalOrderStatus()).isEqualTo("APPROVED"); assertThat(request.internalOrderStatus()).isEqualTo("APPROVED");
@@ -978,6 +966,101 @@ class UnifiedOrderQueryServiceTest {
verify(firstleg, never()).page(any(), any()); 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<FirstlegUnifiedOrderClient.StatusRow>();
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<Long> 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<FirstlegUnifiedOrderClient.Summary> summaries, long total) {
var rows = new java.util.ArrayList<FirstlegUnifiedOrderClient.StatusRow>();
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<Long> 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) { private UnifiedOrderQuery query(String status) {
return new UnifiedOrderQuery(null, null, status, null, null, null, return new UnifiedOrderQuery(null, null, status, null, null, null,
null, null, null, null, null, null, null, null, null, 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); service.page(10001L, direct);
verify(orders).unifiedPage(eq(10001L), eq(direct), any()); verify(orders).unifiedPage(eq(10001L), eq(direct), any());
org.mockito.Mockito.verify(firstleg, org.mockito.Mockito.never()).page(any(), any()); org.mockito.Mockito.verify(firstleg, org.mockito.Mockito.never()).page(any(), any());
when(firstleg.page(eq("internal-token"), any())) stubStatusProjection(List.of(), 0);
.thenReturn(ApiResponse.success(new PageResult<>(List.of(), 2, 1, 0, 0)));
service.page(10001L, taxQuery("LOADED", taxMode)); service.page(10001L, taxQuery("LOADED", taxMode));
ArgumentCaptor<FirstlegUnifiedOrderClient.Query> captured = ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class); ArgumentCaptor<FirstlegUnifiedOrderClient.Query> 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().taxMode()).isEqualTo(taxMode);
assertThat(captured.getValue().tenantId()).isEqualTo(10001L); assertThat(captured.getValue().tenantId()).isEqualTo(10001L);
assertThat(captured.getValue().page()).isEqualTo(2); assertThat(captured.getValue().page()).isEqualTo(1);
assertThat(captured.getValue().size()).isEqualTo(1); assertThat(captured.getValue().size()).isEqualTo(200);
} }
@org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.ParameterizedTest
@@ -1,5 +1,4 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test' import test from 'node:test'
import { readInitialization, tableDefinitions, table, seedRows, roleAuthorities } from '../sql-baseline-contract-helpers.mjs' import { readInitialization, tableDefinitions, table, seedRows, roleAuthorities } from '../sql-baseline-contract-helpers.mjs'
@@ -174,7 +173,7 @@ test('平台管理员由外部bcrypt引导,强制首登改密且不创建客
}) })
test('必要系统种子完整保留,但不写入示例资源与业务事实', () => { 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, 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, 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) 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) 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') const merchant = roleAuthorities(cn, 'MERCHANT_OWNER')
for (const code of ['merchant:enterprise', 'merchant:order:quick-create', 'merchant:finance:statement', for (const code of ['merchant:enterprise', 'merchant:order:quick-create', 'merchant:finance:statement',
'merchant:warehouse:delivery:list']) assert.ok(merchant.has(code), code) 'merchant:warehouse:delivery:list', 'merchant:warehouse:delivery:create']) 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'))
const admin = roleAuthorities(cn, 'PLATFORM_ADMIN') 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') 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']) { for (const action of ['list', 'create', 'edit', 'status', 'review', 'upload']) {
assert.ok(admin.has(`platform:overseas-master-data:driver:${action}`)) assert.ok(admin.has(`platform:overseas-master-data:driver:${action}`))