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