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