fix(dropship): generate pallet numbers atomically and extend full regressions
This commit is contained in:
+9
@@ -92,6 +92,15 @@ public class PlatformDropshipPalletizationController {
|
||||
return ApiResponse.success(service.bindPending(taskId, request, user.userId(), user.username()));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/pallets/create-and-bind")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
|
||||
public ApiResponse<Task> createAndBind(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long taskId,
|
||||
@Valid @RequestBody DropshipPalletizationDtos.CreateAndBindRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.createAndBind(taskId, request, user.userId(), user.username()));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/cartons/{cartonId}/move")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
|
||||
public ApiResponse<Task> moveCarton(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
|
||||
@@ -15,6 +15,9 @@ public final class DropshipPalletizationDtos {
|
||||
|
||||
public record ClaimRequest(@Positive long warehouseId) {}
|
||||
|
||||
public record CreateAndBindRequest(@Positive long warehouseId,
|
||||
@NotBlank @Size(max = 128) String operationId) {}
|
||||
|
||||
public enum DiscrepancyAction { MATCH, ACCEPT_VARIANCE }
|
||||
|
||||
public record CountCartonRequest(@Positive long warehouseId,
|
||||
|
||||
@@ -13,6 +13,11 @@ import java.util.List;
|
||||
/** 一件代发逐箱清点、待绑托与封托事实。 */
|
||||
@Mapper
|
||||
public interface DropshipPalletizationMapper {
|
||||
@Select("SELECT id FROM fl_dropship_pallet_task WHERE id=#{taskId} AND warehouse_id=#{warehouseId} FOR UPDATE")
|
||||
Long lockTask(@Param("taskId") long taskId,@Param("warehouseId") long warehouseId);
|
||||
|
||||
@Select("SELECT * FROM fl_dropship_pallet WHERE open_operation_id=#{operationId}")
|
||||
PalletRow palletByOpenOperation(@Param("operationId") String operationId);
|
||||
@Insert("""
|
||||
INSERT IGNORE INTO fl_dropship_pallet_task
|
||||
(id,tenant_id,inbound_id,warehouse_id,task_status,total_cartons,counted_cartons,allocated_cartons,discrepancy_cartons,version,created_at,updated_at)
|
||||
|
||||
+5
-10
@@ -34,6 +34,7 @@ import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity;
|
||||
@Service
|
||||
public class OceanBookingDocumentService {
|
||||
public static final String BUSINESS_TYPE = "OCEAN_BOOKING_DOCUMENT";
|
||||
private static final long FILE_TENANT_ID = 0L;
|
||||
private static final Set<String> DOCUMENT_TYPES = Set.of("BOOKING_CONFIRMATION", "BILL_OF_LADING");
|
||||
|
||||
@Resource private OceanBookingRepository repository;
|
||||
@@ -54,7 +55,7 @@ public class OceanBookingDocumentService {
|
||||
OceanBookingEntity booking = lockDraft(oceanBookingId, expectedVersion);
|
||||
OceanBookingFileClient.Metadata metadata;
|
||||
try {
|
||||
var response = files.upload(internalToken, operatorId, booking.getCustomerId(), BUSINESS_TYPE,
|
||||
var response = files.upload(internalToken, operatorId, FILE_TENANT_ID, BUSINESS_TYPE,
|
||||
Long.toString(oceanBookingId), file);
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
throw bad("订舱文件上传失败");
|
||||
@@ -115,7 +116,7 @@ public class OceanBookingDocumentService {
|
||||
}
|
||||
metadata(booking, document.getFileId());
|
||||
try {
|
||||
ResponseEntity<byte[]> response = files.download(internalToken, booking.getCustomerId(), BUSINESS_TYPE,
|
||||
ResponseEntity<byte[]> response = files.download(internalToken, FILE_TENANT_ID, BUSINESS_TYPE,
|
||||
Long.toString(oceanBookingId), document.getFileId());
|
||||
byte[] content = response.getBody();
|
||||
if (!response.getStatusCode().is2xxSuccessful() || content == null || content.length == 0) {
|
||||
@@ -132,9 +133,6 @@ public class OceanBookingDocumentService {
|
||||
}
|
||||
|
||||
public void requireActiveValidDocument(OceanBookingEntity booking) {
|
||||
if (booking.getCustomerId() == null || booking.getCustomerId() <= 0) {
|
||||
throw conflict("订舱缺少客户归属,无法校验文件");
|
||||
}
|
||||
boolean valid = false;
|
||||
for (OceanBookingDocumentEntity document : repository.activeDocuments(booking.getId())) {
|
||||
if (!DOCUMENT_TYPES.contains(document.getDocumentType())) continue;
|
||||
@@ -178,9 +176,6 @@ public class OceanBookingDocumentService {
|
||||
if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "订舱单不存在");
|
||||
if (!Integer.valueOf(expectedVersion).equals(booking.getVersion())) throw concurrent();
|
||||
if (!"DRAFT".equals(booking.getStatus())) throw conflict("已放舱订舱文件永久只读");
|
||||
if (booking.getCustomerId() == null || booking.getCustomerId() <= 0) {
|
||||
throw bad("请先保存订舱客户后再上传文件");
|
||||
}
|
||||
return booking;
|
||||
}
|
||||
|
||||
@@ -192,7 +187,7 @@ public class OceanBookingDocumentService {
|
||||
|
||||
private OceanBookingFileClient.Metadata metadata(OceanBookingEntity booking, long fileId) {
|
||||
try {
|
||||
var response = files.metadata(internalToken, booking.getCustomerId(), BUSINESS_TYPE,
|
||||
var response = files.metadata(internalToken, FILE_TENANT_ID, BUSINESS_TYPE,
|
||||
Long.toString(booking.getId()), fileId);
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "订舱文件不存在或归属不匹配");
|
||||
@@ -207,7 +202,7 @@ public class OceanBookingDocumentService {
|
||||
}
|
||||
|
||||
private static void validateMetadata(OceanBookingEntity booking, OceanBookingFileClient.Metadata metadata) {
|
||||
if (metadata.fileId() == null || !Objects.equals(metadata.tenantId(), booking.getCustomerId())
|
||||
if (metadata.fileId() == null || !Objects.equals(metadata.tenantId(), FILE_TENANT_ID)
|
||||
|| !BUSINESS_TYPE.equals(metadata.businessType())
|
||||
|| !Long.toString(booking.getId()).equals(metadata.businessId())) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "订舱文件归属不匹配");
|
||||
|
||||
+17
-6
@@ -50,6 +50,7 @@ public class OceanBookingService {
|
||||
@Resource private WarehousePartnerClient partners;
|
||||
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
|
||||
@Resource private OceanBookingDocumentService documents;
|
||||
@Resource private com.baiyue.firstleg.service.IamCustomerSnapshotService customers;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
|
||||
|
||||
@@ -200,7 +201,7 @@ public class OceanBookingService {
|
||||
private void validateDraftStructure(DraftData data) {
|
||||
boolean hasCustomerId = data.customerId() != null;
|
||||
boolean hasCustomerName = StringUtils.hasText(data.customerName());
|
||||
if (hasCustomerId != hasCustomerName) throw badRequest("客户ID和客户名称必须同时填写或同时为空");
|
||||
if (!hasCustomerId && hasCustomerName) throw badRequest("客户名称不能脱离客户ID提交");
|
||||
if (data.parties() != null) {
|
||||
Set<String> types = new HashSet<>();
|
||||
for (PartyRequest party : data.parties()) {
|
||||
@@ -221,8 +222,7 @@ public class OceanBookingService {
|
||||
private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); }
|
||||
private static void validateRelease(OceanBookingEntity booking, List<OceanBookingPartyEntity> parties,
|
||||
List<OceanBookingContainerRequirementEntity> requirements) {
|
||||
if (booking.getCustomerId() == null || !StringUtils.hasText(booking.getCustomerName())
|
||||
|| !StringUtils.hasText(booking.getChannelCode()) || booking.getWarehouseId() == null
|
||||
if (!StringUtils.hasText(booking.getChannelCode()) || booking.getWarehouseId() == null
|
||||
|| !StringUtils.hasText(booking.getBillOfLadingNo()) || !StringUtils.hasText(booking.getVesselName())
|
||||
|| !StringUtils.hasText(booking.getVoyageNo()) || !StringUtils.hasText(booking.getServiceTerm())
|
||||
|| !StringUtils.hasText(booking.getFreightTerm()) || booking.getEstimatedSailingAt() == null
|
||||
@@ -258,8 +258,9 @@ public class OceanBookingService {
|
||||
}
|
||||
|
||||
private record ResolvedDraft(String warehouseName, String channelName, String shippingCompanyName,
|
||||
String carrierCode, String bookingAgentName) {}
|
||||
String carrierCode, String bookingAgentName, String customerName) {}
|
||||
private ResolvedDraft resolveDraft(DraftData d) {
|
||||
String customerName = resolveCustomer(d.customerId(), d.customerName());
|
||||
String warehouseName = null; String channelName = null;
|
||||
if (d.warehouseId() != null && StringUtils.hasText(d.channelCode())) {
|
||||
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(d.warehouseId());
|
||||
@@ -269,10 +270,10 @@ public class OceanBookingService {
|
||||
var carrier = resolveCarrier(d.shippingCompanyId(), d.shippingCompanyName(), d.carrierCode());
|
||||
var agent = resolveBookingAgent(d.bookingAgentId(), d.bookingAgentName(), carrier == null ? null : carrier.carrierCode());
|
||||
return new ResolvedDraft(warehouseName, channelName, carrier == null ? null : carrier.partnerName(),
|
||||
carrier == null ? null : carrier.carrierCode(), agent == null ? null : agent.name());
|
||||
carrier == null ? null : carrier.carrierCode(), agent == null ? null : agent.name(), customerName);
|
||||
}
|
||||
private static void copy(OceanBookingEntity v, DraftData d, ResolvedDraft resolved) {
|
||||
v.setCustomerId(d.customerId()); v.setCustomerName(trim(d.customerName()));
|
||||
v.setCustomerId(d.customerId()); v.setCustomerName(resolved.customerName());
|
||||
v.setChannelCode(trim(d.channelCode())); v.setChannelName(resolved.channelName());
|
||||
v.setWarehouseId(d.warehouseId()); v.setWarehouseName(resolved.warehouseName());
|
||||
v.setBillOfLadingNo(trim(d.billOfLadingNo()));
|
||||
@@ -288,10 +289,20 @@ public class OceanBookingService {
|
||||
}
|
||||
|
||||
private void validateMasterData(OceanBookingEntity booking) {
|
||||
resolveCustomer(booking.getCustomerId(), booking.getCustomerName());
|
||||
resolveCarrier(booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getCarrierCode());
|
||||
resolveBookingAgent(booking.getBookingAgentId(), booking.getBookingAgentName(), booking.getCarrierCode());
|
||||
}
|
||||
|
||||
private String resolveCustomer(Long id, String name) {
|
||||
if (id == null) {
|
||||
if (StringUtils.hasText(name)) throw badRequest("客户名称不能脱离客户ID提交");
|
||||
return null;
|
||||
}
|
||||
if (id <= 0) throw badRequest("请选择有效客户");
|
||||
return customers.active(id, name).tenantName();
|
||||
}
|
||||
|
||||
private WarehousePartnerClient.Snapshot resolveCarrier(Long id, String suppliedName, String suppliedCode) {
|
||||
if (id == null) {
|
||||
if (StringUtils.hasText(suppliedName) || StringUtils.hasText(suppliedCode)) throw badRequest("船公司名称和编码不能脱离shippingCompanyId提交");
|
||||
|
||||
@@ -84,7 +84,7 @@ public class ContainerFeeDetailService {
|
||||
SaveRequest common = request.fee();
|
||||
FeeItemEntity item = requireItem(common.feeItemId(), common.direction());
|
||||
BigDecimal total = common.amount().setScale(2, RoundingMode.HALF_UP);
|
||||
BigDecimal allocated = request.allocations().stream().map(Allocation::amount)
|
||||
BigDecimal allocated = request.allocations().stream().map(value -> allocationAmount(value.amount()))
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(2, RoundingMode.HALF_UP);
|
||||
if (allocated.compareTo(total) != 0) throw conflict("柜级费用分摊合计必须等于费用总额");
|
||||
Map<InboundAllocationKey, Long> ownership = new HashMap<>();
|
||||
@@ -158,7 +158,7 @@ public class ContainerFeeDetailService {
|
||||
throw conflict("只有完整的当前已审核分摊组可以调整");
|
||||
Map<Long, BigDecimal> amounts = new HashMap<>();
|
||||
for (AllocationAmount allocation : request.allocations()) {
|
||||
if (amounts.put(allocation.feeId(), allocation.amount().setScale(2, RoundingMode.HALF_UP)) != null)
|
||||
if (amounts.put(allocation.feeId(), allocationAmount(allocation.amount())) != null)
|
||||
throw conflict("分摊费用行不得重复");
|
||||
}
|
||||
if (!amounts.keySet().equals(rows.stream().map(ContainerFeeDetailEntity::getId).collect(java.util.stream.Collectors.toSet())))
|
||||
@@ -372,6 +372,12 @@ public class ContainerFeeDetailService {
|
||||
return new BizException(ResultCode.CONFLICT, message);
|
||||
}
|
||||
|
||||
private static BigDecimal allocationAmount(BigDecimal amount) {
|
||||
BigDecimal rounded = amount.setScale(2, RoundingMode.HALF_UP);
|
||||
if (rounded.signum() <= 0) throw conflict("分摊金额按两位小数取整后必须大于0");
|
||||
return rounded;
|
||||
}
|
||||
|
||||
private static long containerBookingId(ContainerFeeDetailEntity value) {
|
||||
if (value.getContainerBookingId() == null || value.getContainerBookingId() <= 0) {
|
||||
throw conflict("柜次费用缺少真实货柜关联");
|
||||
|
||||
@@ -21,6 +21,30 @@ import java.util.*;
|
||||
@Service
|
||||
public class DropshipPalletizationService {
|
||||
@Resource private DropshipPalletizationMapper mapper;
|
||||
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
|
||||
|
||||
@Transactional
|
||||
public Task createAndBind(long taskId,CreateAndBindRequest request,long operatorId,String operatorName){
|
||||
if(mapper.lockTask(taskId,request.warehouseId())==null) throw new BizException(ResultCode.NOT_FOUND,"未找到当前仓库的一件代发清点组托任务");
|
||||
TaskRow task=requireTask(taskId,request.warehouseId());
|
||||
if(!Objects.equals(task.getAssigneeId(),operatorId)) throw conflict("任务不属于当前操作员");
|
||||
String operation=operation(request.operationId());
|
||||
PalletRow replay=mapper.palletByOpenOperation(operation);
|
||||
if(replay!=null){
|
||||
if(!Objects.equals(replay.getTaskId(),taskId)||!Objects.equals(replay.getWarehouseId(),request.warehouseId())
|
||||
||!Objects.equals(replay.getTenantId(),task.getTenantId())) throw conflict("创建托盘幂等号已用于不同任务");
|
||||
return view(task);
|
||||
}
|
||||
if(!"CLAIMED".equals(task.getTaskStatus())||!"WAIT_PALLETIZE".equals(task.getProcessStatus())) throw conflict("当前任务不允许创建托盘");
|
||||
int expected=mapper.unboundCount(taskId);
|
||||
if(expected==0) throw conflict("当前没有待绑托箱,请先清点箱码");
|
||||
long palletId=IdWorker.getId();
|
||||
String palletNo=documentNumbers.next("DP");
|
||||
mapper.insertPallet(palletId,taskId,task.getTenantId(),task.getInboundId(),task.getWarehouseId(),palletNo,operation,operatorId,operatorName);
|
||||
int bound=mapper.bindPending(taskId,palletId,operation,operatorId,operatorName);
|
||||
if(bound!=expected||mapper.adjustBound(taskId,operatorId,bound)!=1) throw conflict("待绑托箱已变化,请刷新后重试");
|
||||
return view(requireTask(taskId,request.warehouseId()));
|
||||
}
|
||||
|
||||
public void createTask(InboundOrderEntity inbound) {
|
||||
if (!"ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) return;
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ public final class TerminalDrayageDtos {
|
||||
|
||||
public record ActualTimeRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotNull LocalDateTime actualAt,
|
||||
@Size(max = 500) String reason) {}
|
||||
@Size(max = 500) String remark) {}
|
||||
|
||||
public record Warning(String status, LocalDateTime deadlineAt,
|
||||
long overdueHours, long overdueDays) {}
|
||||
|
||||
+4
-4
@@ -147,14 +147,14 @@ public class TerminalDrayageService {
|
||||
throw badRequest("实际提柜时间不能早于当前实际船节点时间");
|
||||
}
|
||||
advanceNode(context, CarrierScheduleNode.PICKED_UP, request.actualAt(), action, key,
|
||||
request.reason(), operatorId, operatorName);
|
||||
request.remark(), operatorId, operatorName);
|
||||
int fromVersion = version(value);
|
||||
String fromStatus = status(context, value);
|
||||
value.setActualPickupAt(request.actualAt());
|
||||
applyAuthoritativeDeadline(context, value);
|
||||
persist(value, fromVersion, operatorId, operatorName);
|
||||
return finish(requiredContext(containerBookingId), value, action, key, request, fromStatus,
|
||||
fromVersion, request.reason(), operatorId, operatorName);
|
||||
fromVersion, request.remark(), operatorId, operatorName);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -174,14 +174,14 @@ public class TerminalDrayageService {
|
||||
if (request.actualAt().isBefore(earliest)) throw badRequest("实际还柜时间不能早于提柜或卸柜完成时间");
|
||||
if (actualEmptyReturnAt(context, value) != null) throw conflict("实际还柜时间已确认");
|
||||
advanceNode(context, CarrierScheduleNode.EMPTY_RETURNED, request.actualAt(), action, key,
|
||||
request.reason(), operatorId, operatorName);
|
||||
request.remark(), operatorId, operatorName);
|
||||
int fromVersion = version(value);
|
||||
String fromStatus = status(context, value);
|
||||
applyAuthoritativeDeadline(context, value);
|
||||
value.setActualEmptyReturnAt(request.actualAt());
|
||||
persist(value, fromVersion, operatorId, operatorName);
|
||||
return finish(requiredContext(containerBookingId), value, action, key, request, fromStatus,
|
||||
fromVersion, request.reason(), operatorId, operatorName);
|
||||
fromVersion, request.remark(), operatorId, operatorName);
|
||||
}
|
||||
|
||||
private void advanceNode(WorkContext context, CarrierScheduleNode target, LocalDateTime actualAt,
|
||||
|
||||
@@ -1,45 +1,371 @@
|
||||
openapi: 3.0.3
|
||||
info: { title: Firstleg Container Fee Allocation API, version: 1.0.0 }
|
||||
info:
|
||||
title: Firstleg Container Fee API
|
||||
version: 1.0.0
|
||||
description: |
|
||||
本契约只生产国内段费用来源(订舱、国内拖车、出口报关、海运等)。
|
||||
loadingPlanId 由服务端解析到同一真实 containerBookingId;海外段费用由 Lastleg 的 OVERSEAS_CONTAINER_FEE 独立维护。
|
||||
审核通过后发布不可变来源版本到 Finance,不复制海外费用或财务状态。
|
||||
应付必须选择有效供应商;DIRECT_PAID 是已付快照,不再进入待结算。
|
||||
当前币种仅支持 CNY,不进行跨币种加总。公共费用按柜内真实客户与订单分摊,
|
||||
各行按 HALF_UP 保留两位后必须大于零,落库金额合计必须等于两位小数的总额。
|
||||
成功和业务错误均使用 HTTP 200 包装,业务冲突 code 为 A0409。
|
||||
security: [{bearerAuth: []}]
|
||||
paths:
|
||||
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details:
|
||||
get:
|
||||
operationId: listContainerFees
|
||||
summary: 查询当前客户的国内段柜级费用
|
||||
x-permission: firstleg:inbound:fee-detail:view
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||
- {$ref: '#/components/parameters/AccountId'}
|
||||
responses:
|
||||
'200':
|
||||
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/FeeListResponse'}
|
||||
post:
|
||||
operationId: createContainerFee
|
||||
summary: 创建单一业务归属的国内段柜级费用
|
||||
x-permission: firstleg:inbound:fee-detail:edit
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||
- {$ref: '#/components/parameters/AccountId'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/SaveRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/FeeResponse'}
|
||||
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups:
|
||||
post:
|
||||
summary: 原子创建柜级公共费用分摊组
|
||||
description: 客户、订单必填;入仓ID与入仓码同时填写或同时留空。各分摊金额必须大于0且合计等于费用总额。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ $ref: '#/components/parameters/LoadingPlanId' }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/AllocationGroupRequest' } } } }
|
||||
responses: { '200': { description: 按客户和业务归属生成的待审核费用行 }, '409': { description: 分摊合计或柜内真实归属不一致 } }
|
||||
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups/{groupId}/{action}:
|
||||
post:
|
||||
summary: 整组审核、驳回、调整或作废柜级分摊费用
|
||||
description: action 仅为 approve、reject、adjust、void;单行接口拒绝处理分摊组,避免部分成功。
|
||||
security: [{ bearerAuth: [] }]
|
||||
operationId: createContainerFeeAllocationGroup
|
||||
summary: 原子创建国内段柜级公共费用分摊组
|
||||
x-permission: firstleg:inbound:fee-detail:edit
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/LoadingPlanId' }
|
||||
- { in: path, name: groupId, required: true, schema: { type: string, pattern: '^[1-9][0-9]{0,19}$' } }
|
||||
- { in: path, name: action, required: true, schema: { type: string, enum: [approve, reject, adjust, void] } }
|
||||
responses: { '200': { description: 整组处理成功 }, '409': { description: 版本、状态或完整性冲突 } }
|
||||
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/AllocationGroupRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/FeeListResponse'}
|
||||
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups/{groupId}/approve:
|
||||
post:
|
||||
operationId: approveContainerFeeAllocationGroup
|
||||
summary: 整组审核国内段柜级费用
|
||||
x-permission: firstleg:inbound:fee-detail:review
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||
- {$ref: '#/components/parameters/GroupId'}
|
||||
responses:
|
||||
'200':
|
||||
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/FeeListResponse'}
|
||||
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups/{groupId}/reject:
|
||||
post:
|
||||
operationId: rejectContainerFeeAllocationGroup
|
||||
summary: 整组驳回国内段柜级费用
|
||||
x-permission: firstleg:inbound:fee-detail:review
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||
- {$ref: '#/components/parameters/GroupId'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/RejectRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/FeeListResponse'}
|
||||
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups/{groupId}/adjust:
|
||||
post:
|
||||
operationId: adjustContainerFeeAllocationGroup
|
||||
summary: 整组调整国内段柜级费用
|
||||
x-permission: firstleg:inbound:fee-detail:edit
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||
- {$ref: '#/components/parameters/GroupId'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/AllocationGroupAdjustRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/FeeListResponse'}
|
||||
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/allocation-groups/{groupId}/void:
|
||||
post:
|
||||
operationId: voidContainerFeeAllocationGroup
|
||||
summary: 整组作废国内段柜级费用
|
||||
x-permission: firstleg:inbound:fee-detail:review
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||
- {$ref: '#/components/parameters/GroupId'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/VersionRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/EmptyResponse'}
|
||||
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/{feeId}/approve:
|
||||
post:
|
||||
operationId: approveContainerFee
|
||||
summary: 审核单行国内段柜级费用
|
||||
x-permission: firstleg:inbound:fee-detail:review
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||
- {$ref: '#/components/parameters/AccountId'}
|
||||
- {$ref: '#/components/parameters/FeeId'}
|
||||
responses:
|
||||
'200':
|
||||
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/EmptyResponse'}
|
||||
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/{feeId}/reject:
|
||||
post:
|
||||
operationId: rejectContainerFee
|
||||
summary: 驳回单行国内段柜级费用
|
||||
x-permission: firstleg:inbound:fee-detail:review
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||
- {$ref: '#/components/parameters/AccountId'}
|
||||
- {$ref: '#/components/parameters/FeeId'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/RejectRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/EmptyResponse'}
|
||||
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/{feeId}/adjust:
|
||||
post:
|
||||
operationId: adjustContainerFee
|
||||
summary: 调整单行国内段柜级费用
|
||||
x-permission: firstleg:inbound:fee-detail:edit
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||
- {$ref: '#/components/parameters/AccountId'}
|
||||
- {$ref: '#/components/parameters/FeeId'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/AdjustRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/FeeResponse'}
|
||||
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/{feeId}/void:
|
||||
post:
|
||||
operationId: voidContainerFee
|
||||
summary: 作废单行国内段柜级费用
|
||||
x-permission: firstleg:inbound:fee-detail:review
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LoadingPlanId'}
|
||||
- {$ref: '#/components/parameters/AccountId'}
|
||||
- {$ref: '#/components/parameters/FeeId'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/VersionRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: 通过 code 区分成功与业务失败;分摊组不可经单行入口修改。
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/EmptyResponse'}
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
|
||||
parameters:
|
||||
LoadingPlanId: { in: path, name: loadingPlanId, required: true, schema: { type: string, pattern: '^[1-9][0-9]{0,19}$' } }
|
||||
LoadingPlanId: {in: path, name: loadingPlanId, required: true, schema: {$ref: '#/components/schemas/Id'}}
|
||||
GroupId: {in: path, name: groupId, required: true, schema: {$ref: '#/components/schemas/Id'}}
|
||||
FeeId: {in: path, name: feeId, required: true, schema: {$ref: '#/components/schemas/Id'}}
|
||||
AccountId: {in: query, name: accountId, required: true, schema: {type: integer, format: int64, minimum: 1}, description: 平台选择的客户,服务端校验柜内归属}
|
||||
schemas:
|
||||
Id: {type: string, pattern: '^[1-9][0-9]{0,19}$'}
|
||||
Amount: {type: number, format: decimal, minimum: 0, description: HALF_UP 保留两位小数}
|
||||
SaveRequest:
|
||||
type: object
|
||||
required: [feeItemId, direction, businessDate, amount, currency]
|
||||
properties:
|
||||
feeItemId: {$ref: '#/components/schemas/Id'}
|
||||
direction: {type: string, enum: [RECEIVABLE, PAYABLE]}
|
||||
businessDate: {type: string, format: date}
|
||||
amount: {$ref: '#/components/schemas/Amount'}
|
||||
currency: {type: string, enum: [CNY]}
|
||||
supplierId: {type: string, nullable: true, description: PAYABLE 必填,必须为启用供应商}
|
||||
settlementMode: {type: string, enum: [ON_ACCOUNT, DIRECT_PAID], nullable: true, description: 仅 PAYABLE 必填}
|
||||
paidAt: {type: string, format: date-time, nullable: true}
|
||||
paymentMethod: {type: string, enum: [ONLINE, BANK_TRANSFER, CASH, OTHER], nullable: true}
|
||||
paymentReferenceNo: {type: string, maxLength: 128, nullable: true}
|
||||
paymentVoucherFileIds:
|
||||
type: array
|
||||
maxItems: 20
|
||||
nullable: true
|
||||
items: {$ref: '#/components/schemas/Id'}
|
||||
remark: {type: string, maxLength: 500, nullable: true}
|
||||
description: DIRECT_PAID 必须填写付款时间、方式及流水号或当前客户凭证;RECEIVABLE 和 ON_ACCOUNT 不允许残留付款字段。
|
||||
Allocation:
|
||||
type: object
|
||||
required: [customerId, orderId, orderNo, amount]
|
||||
properties:
|
||||
customerId: {$ref: '#/components/schemas/Id'}
|
||||
orderId: {$ref: '#/components/schemas/Id'}
|
||||
orderNo: {type: string, minLength: 1, maxLength: 64}
|
||||
inboundId: {type: string, nullable: true}
|
||||
inboundCode: {type: string, maxLength: 64, nullable: true}
|
||||
amount: {type: number, format: decimal, minimum: 0, exclusiveMinimum: true}
|
||||
description: 客户和订单必须属于当前柜;入仓ID与入仓码同时填写或同时留空。
|
||||
AllocationGroupRequest:
|
||||
type: object
|
||||
required: [fee, allocations]
|
||||
properties:
|
||||
fee: { type: object, description: 公共费用字段;amount为原始柜级总额 }
|
||||
fee: {$ref: '#/components/schemas/SaveRequest'}
|
||||
allocations:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 200
|
||||
items:
|
||||
type: object
|
||||
required: [customerId, orderId, orderNo, amount]
|
||||
properties:
|
||||
customerId: { type: string }
|
||||
orderId: { type: string }
|
||||
orderNo: { type: string, maxLength: 64 }
|
||||
inboundId: { type: string, nullable: true }
|
||||
inboundCode: { type: string, maxLength: 64, nullable: true }
|
||||
amount: { type: number, format: decimal, exclusiveMinimum: 0 }
|
||||
securitySchemes: { bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } }
|
||||
items: {$ref: '#/components/schemas/Allocation'}
|
||||
AdjustRequest:
|
||||
type: object
|
||||
required: [expectedSourceVersion, businessDate, amount]
|
||||
properties:
|
||||
expectedSourceVersion: {type: integer, minimum: 1}
|
||||
businessDate: {type: string, format: date}
|
||||
amount: {$ref: '#/components/schemas/Amount'}
|
||||
description: 只允许调整当前已审核有效版本,成功递增来源版本并重新待审核。
|
||||
AllocationAmount:
|
||||
type: object
|
||||
required: [feeId, amount]
|
||||
properties:
|
||||
feeId: {$ref: '#/components/schemas/Id'}
|
||||
amount: {type: number, format: decimal, minimum: 0, exclusiveMinimum: true}
|
||||
AllocationGroupAdjustRequest:
|
||||
type: object
|
||||
required: [expectedSourceVersion, businessDate, totalAmount, allocations]
|
||||
properties:
|
||||
expectedSourceVersion: {type: integer, minimum: 1}
|
||||
businessDate: {type: string, format: date}
|
||||
totalAmount: {type: number, format: decimal, minimum: 0, exclusiveMinimum: true}
|
||||
allocations:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 200
|
||||
items: {$ref: '#/components/schemas/AllocationAmount'}
|
||||
description: 必须包含整组全部费用行,逐行取两位后的合计与总额一致;成功后整组重新待审核。
|
||||
VersionRequest:
|
||||
type: object
|
||||
required: [expectedSourceVersion]
|
||||
properties:
|
||||
expectedSourceVersion: {type: integer, minimum: 1}
|
||||
RejectRequest:
|
||||
type: object
|
||||
required: [reason]
|
||||
properties:
|
||||
reason: {type: string, minLength: 1, maxLength: 500}
|
||||
FeeDetail:
|
||||
type: object
|
||||
properties:
|
||||
id: {type: string, nullable: true}
|
||||
tenantId: {type: string, nullable: true}
|
||||
allocationGroupId: {type: string, nullable: true}
|
||||
allocationOrderId: {type: string, nullable: true}
|
||||
allocationInboundId: {type: string, nullable: true}
|
||||
loadingPlanId: {type: string, nullable: true}
|
||||
containerBookingId: {type: string, nullable: true}
|
||||
feeItemId: {type: string, nullable: true}
|
||||
supplierId: {type: string, nullable: true}
|
||||
reviewerId: {type: string, nullable: true}
|
||||
createdBy: {type: string, nullable: true}
|
||||
updatedBy: {type: string, nullable: true}
|
||||
allocationOrderNo: {type: string, nullable: true}
|
||||
allocationInboundCode: {type: string, nullable: true}
|
||||
containerNo: {type: string, nullable: true}
|
||||
feeItemCodeSnapshot: {type: string, nullable: true}
|
||||
feeItemNameSnapshot: {type: string, nullable: true}
|
||||
supplierName: {type: string, nullable: true}
|
||||
supplierCategory: {type: string, nullable: true}
|
||||
remark: {type: string, nullable: true}
|
||||
reviewerName: {type: string, nullable: true}
|
||||
reviewReason: {type: string, nullable: true}
|
||||
paymentReferenceNo: {type: string, nullable: true}
|
||||
paidAt: {type: string, format: date-time, nullable: true}
|
||||
reviewedAt: {type: string, format: date-time, nullable: true}
|
||||
createdAt: {type: string, format: date-time, nullable: true}
|
||||
updatedAt: {type: string, format: date-time, nullable: true}
|
||||
amount: {$ref: '#/components/schemas/Amount'}
|
||||
allocationGroupAmount: {$ref: '#/components/schemas/Amount'}
|
||||
businessDate: {type: string, format: date}
|
||||
currency: {type: string, enum: [CNY]}
|
||||
direction: {type: string, enum: [RECEIVABLE, PAYABLE]}
|
||||
reviewStatus: {type: string, enum: [PENDING, APPROVED, REJECTED]}
|
||||
sourceStatus: {type: string, enum: [ACTIVE, VOIDED]}
|
||||
sourceVersion: {type: integer, minimum: 1}
|
||||
settlementMode: {type: string, enum: [ON_ACCOUNT, DIRECT_PAID], nullable: true}
|
||||
paymentMethod: {type: string, enum: [ONLINE, BANK_TRANSFER, CASH, OTHER], nullable: true}
|
||||
paymentVoucherFileIds: {type: array, nullable: true, items: {$ref: '#/components/schemas/Id'}}
|
||||
FeeResponse:
|
||||
type: object
|
||||
required: [code, message]
|
||||
properties:
|
||||
code: {type: string, description: 成功为200,业务冲突为A0409}
|
||||
message: {type: string}
|
||||
requestId: {type: string, nullable: true}
|
||||
traceId: {type: string, nullable: true}
|
||||
data: {$ref: '#/components/schemas/FeeDetail'}
|
||||
timestamp: {type: string, format: date-time}
|
||||
FeeListResponse:
|
||||
type: object
|
||||
required: [code, message]
|
||||
properties:
|
||||
code: {type: string, description: 成功为200,业务冲突为A0409}
|
||||
message: {type: string}
|
||||
requestId: {type: string, nullable: true}
|
||||
traceId: {type: string, nullable: true}
|
||||
data: {type: array, items: {$ref: '#/components/schemas/FeeDetail'}}
|
||||
timestamp: {type: string, format: date-time}
|
||||
EmptyResponse:
|
||||
type: object
|
||||
required: [code, message]
|
||||
properties:
|
||||
code: {type: string, description: 成功为200,业务冲突为A0409}
|
||||
message: {type: string}
|
||||
requestId: {type: string, nullable: true}
|
||||
traceId: {type: string, nullable: true}
|
||||
data: {type: object, nullable: true}
|
||||
timestamp: {type: string, format: date-time}
|
||||
|
||||
@@ -46,6 +46,14 @@ paths:
|
||||
parameters: [{$ref: '#/components/parameters/TaskId'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BindPendingRequest'}}}}
|
||||
responses: {'200': {description: OK}}
|
||||
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/create-and-bind:
|
||||
post:
|
||||
summary: 系统生成 DP 托盘码并原子绑定全部待绑箱
|
||||
description: 仅当前仓库本人已领取的待组托任务可执行。没有待绑箱时不发码、不建空托。operationId 在同任务和仓库重放不重复创建;不同任务使用同幂等号拒绝。返回当前 Task。
|
||||
x-required-authority: platform:firstleg:pda:work
|
||||
parameters: [{$ref: '#/components/parameters/TaskId'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateAndBindRequest'}}}}
|
||||
responses: {'200': {description: Task}}
|
||||
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/cartons/{cartonId}/move:
|
||||
post:
|
||||
summary: 换托,targetPalletNo 为空时退回待绑区
|
||||
@@ -150,4 +158,10 @@ components:
|
||||
expectedVersion: {type: integer, minimum: 1}
|
||||
operationId: {type: string, maxLength: 128}
|
||||
PalletNo: {type: string, pattern: '^DP-[0-9]{6}-[0-9]{4}$'}
|
||||
CreateAndBindRequest:
|
||||
type: object
|
||||
required: [warehouseId, operationId]
|
||||
properties:
|
||||
warehouseId: {type: integer, format: int64, minimum: 1}
|
||||
operationId: {type: string, minLength: 1, maxLength: 128}
|
||||
NullablePalletNo: {type: string, pattern: '^DP-[0-9]{6}-[0-9]{4}$', nullable: true}
|
||||
|
||||
@@ -32,7 +32,7 @@ paths:
|
||||
/api/platform/v1/ocean-bookings/{oceanBookingId}/release:
|
||||
post:
|
||||
summary: Validate and permanently release a DRAFT
|
||||
description: Release validates the persisted aggregate plus at least one active booking-owned BOOKING_CONFIRMATION or BILL_OF_LADING file. It never requires or creates a real container number.
|
||||
description: Release validates the persisted aggregate plus at least one active booking-owned BOOKING_CONFIRMATION or BILL_OF_LADING file. The customer is optional; a supplied customer must remain ACTIVE. Null denotes no single customer snapshot, never public access. It never requires or creates a real container number.
|
||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReleaseRequest'}}}}
|
||||
responses: {'200': {description: RELEASED detail}}
|
||||
@@ -45,7 +45,7 @@ paths:
|
||||
'200': {description: Booking document history, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentListResponse'}}}}
|
||||
post:
|
||||
summary: Reference an existing File object in a DRAFT booking
|
||||
description: File scope must be customerId + OCEAN_BOOKING_DOCUMENT + oceanBookingId. The booking version is incremented atomically.
|
||||
description: File scope must be platform tenantId=0 + OCEAN_BOOKING_DOCUMENT + oceanBookingId, independent of the optional customer snapshot. The booking version is incremented atomically.
|
||||
x-permission: platform:firstleg:ocean-booking:edit
|
||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentRequest'}}}}
|
||||
@@ -162,8 +162,8 @@ components:
|
||||
type: object
|
||||
description: Fields may be incomplete in DRAFT. RELEASE validates the complete persisted aggregate.
|
||||
properties:
|
||||
customerId: {type: integer, format: int64, nullable: true}
|
||||
customerName: {type: string, nullable: true}
|
||||
customerId: {type: integer, format: int64, minimum: 1, nullable: true, description: 可选启用客户ID;多客户订舱留空,空值不是公开访问授权}
|
||||
customerName: {type: string, nullable: true, description: 可选名称提示;有客户ID时由服务端校验并保存客户主数据名称,清空客户时同时置null}
|
||||
channelCode: {type: string, nullable: true}
|
||||
warehouseId: {type: integer, format: int64, nullable: true}
|
||||
billOfLadingNo: {type: string, nullable: true}
|
||||
@@ -279,7 +279,7 @@ components:
|
||||
requiredQuantity: {type: integer, minimum: 1}
|
||||
allocatedQuantity: {type: integer, format: int64, minimum: 0}
|
||||
remainingQuantity: {type: integer, format: int64, minimum: 1}
|
||||
customerName: {type: string}
|
||||
customerName: {type: string, nullable: true}
|
||||
warehouseName: {type: string}
|
||||
channelName: {type: string}
|
||||
LinkCandidatePage:
|
||||
|
||||
@@ -77,7 +77,7 @@ components:
|
||||
properties:
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
actualAt: {type: string, format: date-time}
|
||||
reason: {type: string, maxLength: 500, nullable: true}
|
||||
remark: {type: string, maxLength: 500, nullable: true}
|
||||
Warning:
|
||||
type: object
|
||||
required: [status, overdueHours, overdueDays]
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos;
|
||||
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ContainerFeeOpenApiContractTest {
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void coversEveryControllerOperationPermissionAndResponse() throws Exception {
|
||||
Map<String, Object> api = read();
|
||||
Map<String, Map<String, Map<String, Object>>> paths = (Map<String, Map<String, Map<String, Object>>>) api.get("paths");
|
||||
String base = PlatformContainerFeeDetailController.class.getAnnotation(RequestMapping.class).value()[0];
|
||||
int operationCount = 0;
|
||||
for (var method : PlatformContainerFeeDetailController.class.getDeclaredMethods()) {
|
||||
var get = method.getAnnotation(GetMapping.class);
|
||||
var post = method.getAnnotation(PostMapping.class);
|
||||
if (get == null && post == null) continue;
|
||||
String[] suffix = get != null ? get.value() : post.value();
|
||||
String path = base + (suffix.length == 0 ? "" : suffix[0]);
|
||||
assertThat(paths).containsKey(path);
|
||||
var operation = paths.get(path).get(get != null ? "get" : "post");
|
||||
assertThat(operation).isNotNull();
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('" + operation.get("x-permission") + "')");
|
||||
var responses = (Map<String, Object>) operation.get("responses");
|
||||
assertThat((Map<String, Object>) responses.get("200")).containsKey("content");
|
||||
operationCount++;
|
||||
}
|
||||
assertThat(paths.values().stream().mapToInt(Map::size).sum()).isEqualTo(operationCount);
|
||||
validateReferences(api, api);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void requestAndResponseSchemasMatchJavaFields() throws Exception {
|
||||
var components = (Map<String, Object>) read().get("components");
|
||||
var schemas = (Map<String, Map<String, Object>>) components.get("schemas");
|
||||
for (Class<?> dto : List.of(ContainerFeeDetailDtos.SaveRequest.class,
|
||||
ContainerFeeDetailDtos.AdjustRequest.class, ContainerFeeDetailDtos.Allocation.class,
|
||||
ContainerFeeDetailDtos.AllocationGroupRequest.class, ContainerFeeDetailDtos.AllocationAmount.class,
|
||||
ContainerFeeDetailDtos.AllocationGroupAdjustRequest.class, ContainerFeeDetailDtos.VersionRequest.class,
|
||||
ContainerFeeDetailDtos.RejectRequest.class)) {
|
||||
Set<String> fields = Arrays.stream(dto.getRecordComponents()).map(value -> value.getName()).collect(Collectors.toSet());
|
||||
var properties = (Map<String, Object>) schemas.get(dto.getSimpleName()).get("properties");
|
||||
assertThat(properties.keySet()).containsExactlyInAnyOrderElementsOf(fields);
|
||||
}
|
||||
Set<String> responseFields = Arrays.stream(ContainerFeeDetailEntity.class.getDeclaredFields())
|
||||
.filter(field -> !Modifier.isStatic(field.getModifiers())).map(field -> field.getName()).collect(Collectors.toSet());
|
||||
var properties = (Map<String, Object>) schemas.get("FeeDetail").get("properties");
|
||||
assertThat(properties.keySet()).containsExactlyInAnyOrderElementsOf(responseFields);
|
||||
Set<String> envelopeFields = Arrays.stream(com.baiyue.common.core.api.ApiResponse.class.getRecordComponents())
|
||||
.map(component -> component.getName()).collect(Collectors.toSet());
|
||||
for (String name : List.of("FeeResponse", "FeeListResponse", "EmptyResponse")) {
|
||||
var envelope = (Map<String, Object>) schemas.get(name).get("properties");
|
||||
assertThat(envelope.keySet()).containsExactlyInAnyOrderElementsOf(envelopeFields);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> read() throws Exception {
|
||||
return new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/container-fees-v1.yaml")));
|
||||
}
|
||||
|
||||
private static void validateReferences(Object value, Map<String, Object> root) {
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
if (map.get("$ref") instanceof String ref) {
|
||||
assertThat(ref).startsWith("#/");
|
||||
Object target = root;
|
||||
for (String segment : ref.substring(2).split("/")) target = ((Map<?, ?>) target).get(segment);
|
||||
assertThat(target).as(ref).isNotNull();
|
||||
}
|
||||
map.values().forEach(child -> validateReferences(child, root));
|
||||
} else if (value instanceof List<?> list) {
|
||||
list.forEach(child -> validateReferences(child, root));
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -39,6 +39,8 @@ class OceanBookingMapperContractTest {
|
||||
.flatMap(annotation -> Arrays.stream(annotation.value()))
|
||||
.reduce("", (left, right) -> left + '\n' + right);
|
||||
assertThat(sql)
|
||||
.contains("customer_id=#{value.customerId},customer_name=#{value.customerName}")
|
||||
.doesNotContain("customer_id=COALESCE", "customer_name=COALESCE")
|
||||
.contains("status=#{fromStatus} AND version=#{expectedVersion}")
|
||||
.contains("status='DRAFT' AND version=#{version}")
|
||||
.doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED", "SUBMITTED", "FAILED");
|
||||
|
||||
+45
-9
@@ -40,11 +40,12 @@ class OceanBookingDocumentServiceTest {
|
||||
|
||||
@Test void uploadIsIdempotentAndDoesNotUploadASecondFileOnReplay() throws Exception {
|
||||
OceanBookingEntity booking = draft(2);
|
||||
booking.setCustomerId(null);
|
||||
MockMultipartFile file = new MockMultipartFile("file", "booking.pdf", "application/pdf", "pdf".getBytes());
|
||||
when(repository.claimOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), eq("upload-1"), anyString()))
|
||||
.thenReturn(true, false);
|
||||
when(repository.findForUpdate(10L)).thenReturn(booking);
|
||||
when(files.upload("token", 7L, 9L, "OCEAN_BOOKING_DOCUMENT", "10", file))
|
||||
when(files.upload("token", 7L, 0L, "OCEAN_BOOKING_DOCUMENT", "10", file))
|
||||
.thenReturn(ApiResponse.success(metadata(91L)));
|
||||
when(repository.touchDraft(10L, 2, 7L, "admin")).thenReturn(1);
|
||||
when(repository.completeOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), eq("upload-1"), anyString()))
|
||||
@@ -75,9 +76,9 @@ class OceanBookingDocumentServiceTest {
|
||||
when(repository.claimOperation(anyLong(), eq(7L), eq("UPLOAD_DOCUMENT"), anyString(), anyString()))
|
||||
.thenReturn(true);
|
||||
when(repository.findForUpdate(10L)).thenReturn(draft(0), draft(1));
|
||||
when(files.upload("token", 7L, 9L, "OCEAN_BOOKING_DOCUMENT", "10", confirmation))
|
||||
when(files.upload("token", 7L, 0L, "OCEAN_BOOKING_DOCUMENT", "10", confirmation))
|
||||
.thenReturn(ApiResponse.success(metadata(91L)));
|
||||
when(files.upload("token", 7L, 9L, "OCEAN_BOOKING_DOCUMENT", "10", bill))
|
||||
when(files.upload("token", 7L, 0L, "OCEAN_BOOKING_DOCUMENT", "10", bill))
|
||||
.thenReturn(ApiResponse.success(metadata(92L)));
|
||||
when(repository.touchDraft(eq(10L), anyInt(), eq(7L), eq("admin"))).thenReturn(1);
|
||||
when(repository.completeOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), anyString(), anyString()))
|
||||
@@ -101,7 +102,7 @@ class OceanBookingDocumentServiceTest {
|
||||
@Test void attachRejectsCrossScopeFileBeforePersisting() {
|
||||
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
||||
when(repository.findForUpdate(10L)).thenReturn(draft(1));
|
||||
when(files.metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 92L))
|
||||
when(files.metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 92L))
|
||||
.thenReturn(ApiResponse.success(new OceanBookingFileClient.Metadata(92L, 19L,
|
||||
"OCEAN_BOOKING_DOCUMENT", "10", "x.pdf", "hash", "application/pdf", 3L)));
|
||||
|
||||
@@ -144,12 +145,12 @@ class OceanBookingDocumentServiceTest {
|
||||
document.setId(20L); document.setOceanBookingId(10L); document.setFileId(91L);
|
||||
document.setDocumentType("BILL_OF_LADING"); document.setValid(true);
|
||||
when(repository.activeDocuments(10L)).thenReturn(List.of(document));
|
||||
when(files.metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L))
|
||||
when(files.metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L))
|
||||
.thenReturn(ApiResponse.success(metadata(91L)));
|
||||
|
||||
service.requireActiveValidDocument(booking);
|
||||
|
||||
verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
|
||||
verify(files).metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
|
||||
}
|
||||
|
||||
@Test void releaseGateRejectsWhenBothActiveFileReferencesNoLongerExist() {
|
||||
@@ -161,8 +162,8 @@ class OceanBookingDocumentServiceTest {
|
||||
assertThatThrownBy(() -> service.requireActiveValidDocument(booking))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件");
|
||||
|
||||
verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
|
||||
verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 92L);
|
||||
verify(files).metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
|
||||
verify(files).metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 92L);
|
||||
verify(repository, never()).touchDraft(anyLong(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@@ -173,13 +174,48 @@ class OceanBookingDocumentServiceTest {
|
||||
return document;
|
||||
}
|
||||
|
||||
@Test void changingOrClearingCustomerKeepsPlatformFileDownloadAndReleaseScope() {
|
||||
OceanBookingEntity booking = draft(3);
|
||||
OceanBookingDocumentEntity document = activeDocument(20L, 91L, "BOOKING_CONFIRMATION");
|
||||
document.setFileName("booking.pdf");
|
||||
when(repository.find(10L)).thenReturn(booking);
|
||||
when(repository.document(10L, 20L)).thenReturn(document);
|
||||
when(repository.activeDocuments(10L)).thenReturn(List.of(document));
|
||||
when(files.metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L))
|
||||
.thenReturn(ApiResponse.success(metadata(91L)));
|
||||
when(files.download("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L))
|
||||
.thenReturn(org.springframework.http.ResponseEntity.ok(new byte[]{1, 2, 3}));
|
||||
for (Long customer : java.util.Arrays.asList(9L, 19L, null)) {
|
||||
booking.setCustomerId(customer);
|
||||
service.requireActiveValidDocument(booking);
|
||||
assertThat(service.download(10L, 20L).content()).containsExactly(1, 2, 3);
|
||||
}
|
||||
verify(files, times(6)).metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
|
||||
verify(files, times(3)).download("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
|
||||
}
|
||||
|
||||
@Test void platformFileFromAnotherBookingIsRejected() {
|
||||
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
||||
OceanBookingEntity booking = draft(1);
|
||||
booking.setCustomerId(null);
|
||||
when(repository.findForUpdate(10L)).thenReturn(booking);
|
||||
when(files.metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 92L))
|
||||
.thenReturn(ApiResponse.success(new OceanBookingFileClient.Metadata(92L, 0L,
|
||||
"OCEAN_BOOKING_DOCUMENT", "99", "x.pdf", "hash", "application/pdf", 3L)));
|
||||
assertThatThrownBy(() -> service.attach(10L,
|
||||
new com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DocumentRequest(1, 92L, "BILL_OF_LADING"),
|
||||
"other-booking", 7L, "admin"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
|
||||
verify(repository, never()).insert(any(OceanBookingDocumentEntity.class));
|
||||
}
|
||||
|
||||
private static OceanBookingEntity draft(int version) {
|
||||
OceanBookingEntity booking = new OceanBookingEntity();
|
||||
booking.setId(10L); booking.setCustomerId(9L); booking.setStatus("DRAFT"); booking.setVersion(version);
|
||||
return booking;
|
||||
}
|
||||
private static OceanBookingFileClient.Metadata metadata(long fileId) {
|
||||
return new OceanBookingFileClient.Metadata(fileId, 9L, "OCEAN_BOOKING_DOCUMENT", "10",
|
||||
return new OceanBookingFileClient.Metadata(fileId, 0L, "OCEAN_BOOKING_DOCUMENT", "10",
|
||||
"booking.pdf", "hash", "application/pdf", 3L);
|
||||
}
|
||||
}
|
||||
|
||||
+70
@@ -40,6 +40,7 @@ class OceanBookingServiceTest {
|
||||
@Mock private com.baiyue.firstleg.client.WarehousePartnerClient partners;
|
||||
@Mock private FirstlegDocumentNumberGenerator documentNumbers;
|
||||
@Mock private OceanBookingDocumentService documents;
|
||||
@Mock private com.baiyue.firstleg.service.IamCustomerSnapshotService customers;
|
||||
@InjectMocks private OceanBookingService service;
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@@ -47,6 +48,8 @@ class OceanBookingServiceTest {
|
||||
void setUp() throws Exception {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
objectMapper = new ObjectMapper().findAndRegisterModules();
|
||||
lenient().when(customers.active(eq(100L), any())).thenReturn(
|
||||
new com.baiyue.firstleg.client.IamCustomerSnapshotClient.Customer(100L, "C100", "客户", "ACTIVE", null, null));
|
||||
lenient().when(documentNumbers.next("OB")).thenReturn("OB-260818-0001");
|
||||
var field = OceanBookingService.class.getDeclaredField("objectMapper");
|
||||
field.setAccessible(true);
|
||||
@@ -76,6 +79,70 @@ class OceanBookingServiceTest {
|
||||
verifyNoInteractions(priceBooks, suppliers, warehouses);
|
||||
}
|
||||
|
||||
@Test
|
||||
void customerlessCreateSaveAndReleaseRetainsOtherRequirements() throws Exception {
|
||||
saveAndReleaseWithoutCustomer(false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearingExistingCustomerPersistsNullAndAllowsRelease() throws Exception {
|
||||
saveAndReleaseWithoutCustomer(true);
|
||||
}
|
||||
|
||||
private void saveAndReleaseWithoutCustomer(boolean initialCustomer) throws Exception {
|
||||
stubMasterData();
|
||||
when(suppliers.booking(81L, "订舱代理")).thenReturn(
|
||||
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("MATSON")));
|
||||
var tree = objectMapper.valueToTree(completeDraft());
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode) tree).putNull("customerId").putNull("customerName");
|
||||
DraftData emptyCustomer = objectMapper.treeToValue(tree, DraftData.class);
|
||||
AtomicReference<OceanBookingEntity> stored = stubCreatedDraft();
|
||||
var created = service.create(9L, "admin", "optional-create",
|
||||
new CreateRequest(0, initialCustomer ? completeDraft() : emptyCustomer));
|
||||
long id = created.oceanBooking().getId();
|
||||
when(repository.updateDraft(any(), eq(0), eq(9L), eq("admin"))).thenAnswer(invocation -> {
|
||||
OceanBookingEntity value = invocation.getArgument(0);
|
||||
assertThat(value.getCustomerId()).isNull();
|
||||
assertThat(value.getCustomerName()).isNull();
|
||||
value.setVersion(1);
|
||||
return 1;
|
||||
});
|
||||
service.updateDraft(id, 9L, "admin", "optional-save", new UpdateRequest(0, emptyCustomer));
|
||||
when(repository.parties(id)).thenReturn(parties());
|
||||
when(repository.requirements(id)).thenReturn(List.of(requirement()));
|
||||
when(repository.transition(id, "DRAFT", "RELEASED", 1, 9L, "admin")).thenAnswer(invocation -> {
|
||||
stored.get().setStatus("RELEASED"); stored.get().setVersion(2); return 1;
|
||||
});
|
||||
|
||||
var result = service.release(id, 9L, "admin", "optional-release", new ReleaseRequest(1, "确认"));
|
||||
assertThat(result.oceanBooking().getStatus()).isEqualTo("RELEASED");
|
||||
assertThat(result.oceanBooking().getCustomerId()).isNull();
|
||||
assertThat(result.oceanBooking().getCustomerName()).isNull();
|
||||
verify(documents).requireActiveValidDocument(stored.get());
|
||||
verify(customers, times(initialCustomer ? 1 : 0)).active(anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidSpecifiedCustomerIsRejectedBeforeCreatingDraft() {
|
||||
when(customers.active(100L, "客户")).thenThrow(new BizException(
|
||||
com.baiyue.common.core.api.ResultCode.CONFLICT, "所选客户不存在或未启用"));
|
||||
assertThatThrownBy(() -> service.create(9L, "admin", "bad-customer", new CreateRequest(0, completeDraft())))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("客户不存在或未启用");
|
||||
verify(repository, never()).insert(any(OceanBookingEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void releaseRevalidatesSpecifiedCustomer() {
|
||||
when(repository.find(1L)).thenReturn(completeBooking("DRAFT", 3));
|
||||
when(repository.parties(1L)).thenReturn(parties());
|
||||
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
|
||||
when(customers.active(100L, "客户")).thenThrow(new BizException(
|
||||
com.baiyue.common.core.api.ResultCode.CONFLICT, "所选客户不存在或未启用"));
|
||||
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "inactive-customer", new ReleaseRequest(3, null)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("客户不存在或未启用");
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void freezesServerSnapshotsAndPersistsQuantityRequirementsOnly() {
|
||||
stubMasterData();
|
||||
@@ -86,6 +153,9 @@ class OceanBookingServiceTest {
|
||||
assertThat(inserted.get().getChannelName()).isEqualTo("美西海运");
|
||||
assertThat(inserted.get().getWarehouseName()).isEqualTo("深圳中转仓");
|
||||
assertThat(inserted.get().getBookingAgentName()).isEqualTo("订舱代理");
|
||||
assertThat(inserted.get().getCustomerId()).isEqualTo(100L);
|
||||
assertThat(inserted.get().getCustomerName()).isEqualTo("客户");
|
||||
verify(customers).active(100L, "客户");
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<List<OceanBookingContainerRequirementEntity>> captor = ArgumentCaptor.forClass(List.class);
|
||||
verify(repository).replaceRequirements(eq(inserted.get().getId()), captor.capture());
|
||||
|
||||
@@ -265,6 +265,48 @@ class ContainerFeeDetailServiceTest {
|
||||
.doesNotContainNull().allMatch(captor.getAllValues().get(0).getAllocationGroupId()::equals);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAllocationWhenPersistedRoundedRowsWouldExceedTotal() {
|
||||
ownedPlan();
|
||||
when(feeItems.findItem(11L)).thenReturn(item());
|
||||
var fee = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE",
|
||||
LocalDate.of(2026, 8, 20), new BigDecimal("0.01"), "CNY", null, null);
|
||||
var request = new AllocationGroupRequest(fee, List.of(
|
||||
new Allocation(10001L, 501L, "FL-501", null, null, new BigDecimal("0.005")),
|
||||
new Allocation(10002L, 502L, "FL-502", null, null, new BigDecimal("0.005"))));
|
||||
|
||||
assertThatThrownBy(() -> service.createAllocationGroup(41L, 9L, request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("分摊合计必须等于费用总额");
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAllocationThatRoundsToZero() {
|
||||
ownedPlan();
|
||||
when(feeItems.findItem(11L)).thenReturn(item());
|
||||
var fee = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE",
|
||||
LocalDate.of(2026, 8, 20), new BigDecimal("0.00"), "CNY", null, null);
|
||||
var request = new AllocationGroupRequest(fee, List.of(
|
||||
new Allocation(10001L, 501L, "FL-501", null, null, new BigDecimal("0.001"))));
|
||||
|
||||
assertThatThrownBy(() -> service.createAllocationGroup(41L, 9L, request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("取整后必须大于0");
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsGroupAdjustmentThatRoundsAnAllocationToZero() {
|
||||
ownedPlan();
|
||||
when(repository.listGroup(41L, 81L)).thenReturn(List.of(approvedFee(1)));
|
||||
var request = new ContainerFeeDetailDtos.AllocationGroupAdjustRequest(1,
|
||||
LocalDate.of(2026, 8, 20), new BigDecimal("0.001"), List.of(
|
||||
new ContainerFeeDetailDtos.AllocationAmount(51L, new BigDecimal("0.001"))));
|
||||
|
||||
assertThatThrownBy(() -> service.adjustAllocationGroup(41L, 81L, 9L, request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("取整后必须大于0");
|
||||
verify(repository, never()).adjust(anyLong(), anyLong(), anyLong(), anyInt(), any(), any(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAllocationWhenInboundIdAndCodeAreOnlyPartiallyProvided() {
|
||||
LoadingPlanEntity plan = new LoadingPlanEntity();
|
||||
|
||||
+45
-1
@@ -16,10 +16,54 @@ import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class DropshipPalletizationServiceTest {
|
||||
private final FirstlegDocumentNumberGenerator numbers=mock(FirstlegDocumentNumberGenerator.class);
|
||||
private final DropshipPalletizationMapper mapper=mock(DropshipPalletizationMapper.class);
|
||||
private final DropshipPalletizationService service=new DropshipPalletizationService();
|
||||
|
||||
DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); }
|
||||
DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"documentNumbers",numbers); }
|
||||
|
||||
@Test void generatesAndBindsOnlyWhenPendingCartonsExist(){
|
||||
when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(task());
|
||||
when(mapper.unboundCount(10L)).thenReturn(2); when(numbers.next("DP")).thenReturn("DP-260909-0001");
|
||||
when(mapper.bindPending(eq(10L),anyLong(),eq("create-1"),eq(7L),eq("张三"))).thenReturn(2);
|
||||
when(mapper.adjustBound(10L,7L,2)).thenReturn(1);
|
||||
service.createAndBind(10L,new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"create-1"),7L,"张三");
|
||||
verify(mapper).insertPallet(anyLong(),eq(10L),eq(1L),eq(30L),eq(100L),eq("DP-260909-0001"),eq("create-1"),eq(7L),eq("张三"));
|
||||
verify(numbers,times(1)).next("DP");
|
||||
}
|
||||
|
||||
@Test void noPendingCartonsNeverAllocatesNumberOrEmptyPallet(){
|
||||
when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(task());
|
||||
assertThatThrownBy(()->service.createAndBind(10L,new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"empty"),7L,"张三")).hasMessageContaining("没有待绑托箱");
|
||||
verifyNoInteractions(numbers);
|
||||
verify(mapper,never()).insertPallet(anyLong(),anyLong(),anyLong(),anyLong(),anyLong(),anyString(),anyString(),anyLong(),anyString());
|
||||
}
|
||||
|
||||
@Test void replaysAfterCompletionWithoutGeneratingAnotherPallet(){
|
||||
TaskRow completed=task(); completed.setTaskStatus("COMPLETED"); completed.setProcessStatus("WAIT_SPLIT");
|
||||
when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(completed);
|
||||
when(mapper.palletByOpenOperation("replay")).thenReturn(pallet());
|
||||
service.createAndBind(10L,new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"replay"),7L,"张三");
|
||||
verifyNoInteractions(numbers); verify(mapper,never()).unboundCount(anyLong());
|
||||
}
|
||||
|
||||
@Test void rejectsOtherOperatorAndCrossTaskReplay(){
|
||||
when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(task());
|
||||
var request=new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"replay");
|
||||
assertThatThrownBy(()->service.createAndBind(10L,request,8L,"李四")).hasMessageContaining("当前操作员");
|
||||
PalletRow other=pallet(); other.setTaskId(11L); when(mapper.palletByOpenOperation("replay")).thenReturn(other);
|
||||
assertThatThrownBy(()->service.createAndBind(10L,request,7L,"张三")).hasMessageContaining("不同任务");
|
||||
verifyNoInteractions(numbers);
|
||||
}
|
||||
|
||||
@Test void rejectsUnknownWarehouseAndChangedBindCount(){
|
||||
var request=new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"new");
|
||||
assertThatThrownBy(()->service.createAndBind(10L,request,7L,"张三")).hasMessageContaining("当前仓库");
|
||||
when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(task());
|
||||
when(mapper.unboundCount(10L)).thenReturn(2); when(numbers.next("DP")).thenReturn("DP-260909-0002");
|
||||
assertThatThrownBy(()->service.createAndBind(10L,request,7L,"张三")).hasMessageContaining("待绑托箱已变化");
|
||||
verify(mapper,never()).adjustBound(anyLong(),anyLong(),anyInt());
|
||||
}
|
||||
|
||||
@Test void resolvesUncountedCartonBeforeQuantityConfirmation(){
|
||||
TaskRow task=task(); LabelRow label=label();
|
||||
|
||||
+13
@@ -9,6 +9,19 @@ import java.nio.file.Path;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class TerminalDrayageOpenApiContractTest {
|
||||
@Test
|
||||
void actualTimeRequestBindsRemarkFromPublicJson() throws Exception {
|
||||
var mapper = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();
|
||||
var request = mapper.readValue("""
|
||||
{"expectedVersion":3,"actualAt":"2026-09-04T09:00:00","remark":"已还柜"}
|
||||
""", com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest.class);
|
||||
assertThat(request.remark()).isEqualTo("已还柜");
|
||||
assertThat(request.expectedVersion()).isEqualTo(3);
|
||||
String yaml = Files.readString(Path.of("src/main/resources/openapi/terminal-drayage-v1.yaml"));
|
||||
String schema = yaml.substring(yaml.indexOf(" ActualTimeRequest:"), yaml.indexOf(" Warning:"));
|
||||
assertThat(schema).contains("remark:", "expectedVersion:", "actualAt:").doesNotContain("reason:");
|
||||
}
|
||||
|
||||
@Test
|
||||
void controllerUsesFrozenOverseasPermissionsWithoutGenericContainerFallback() throws Exception {
|
||||
assertPermission("views", "platform:firstleg:terminal-drayage:list");
|
||||
|
||||
+59
@@ -84,6 +84,65 @@ class ContainerWorkbenchServiceTest {
|
||||
verifyNoInteractions(carrierSchedules);
|
||||
}
|
||||
|
||||
@Test
|
||||
void realOverseasListAndDetailProjectBothEmptyReturnActions() throws Exception {
|
||||
verifyRealEmptyReturnProjection(true, true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void realOverseasListAndDetailRemoveBothActionsWithoutPermission() throws Exception {
|
||||
verifyRealEmptyReturnProjection(false, true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unloadedListWithoutDrayageAggregateDoesNotOfferUnexecutableReturn() throws Exception {
|
||||
verifyRealEmptyReturnProjection(true, false);
|
||||
}
|
||||
|
||||
private void verifyRealEmptyReturnProjection(boolean hasPermission, boolean hasAggregate) throws Exception {
|
||||
var mapper = mock(com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class);
|
||||
var json = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();
|
||||
var realDrayages = new TerminalDrayageService(mapper,
|
||||
mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules, json);
|
||||
ReflectionTestUtils.setField(service, "terminalDrayages", realDrayages);
|
||||
ContainerBookingEntity container = container();
|
||||
container.setUnloadingCompletedAt(LocalDateTime.of(2026, 9, 5, 8, 0));
|
||||
var context = new com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Context(
|
||||
11L, "MSCU1234567", 21L, "ACTIVE", container.getUnloadingCompletedAt());
|
||||
var row = new com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity();
|
||||
row.setId(51L);
|
||||
row.setContainerBookingId(11L);
|
||||
row.setVersion(3);
|
||||
row.setActualPickupAt(LocalDateTime.of(2026, 9, 4, 9, 0));
|
||||
when(repository.page(eq("OVERSEAS"), any())).thenReturn(new PageResult<>(List.of(container), 1, 20, 1, 1));
|
||||
when(repository.find(11L)).thenReturn(container);
|
||||
when(repository.departed(11L)).thenReturn(true);
|
||||
when(mapper.contexts(List.of(11L))).thenReturn(List.of(context));
|
||||
when(mapper.context(11L)).thenReturn(context);
|
||||
when(mapper.findAll(List.of(11L))).thenReturn(hasAggregate ? List.of(row) : List.of());
|
||||
when(mapper.find(11L)).thenReturn(hasAggregate ? row : null);
|
||||
when(carrierSchedules.containerActualFacts(List.of(11L))).thenReturn(List.of(facts("PICKED_UP")));
|
||||
Set<String> permissions = hasPermission
|
||||
? Set.of("platform:firstleg:terminal-drayage:list", "platform:firstleg:terminal-drayage:empty-return:confirm")
|
||||
: Set.of("platform:firstleg:terminal-drayage:list");
|
||||
|
||||
var page = service.overseasPage(query(WorkbenchScope.ACTIVE), permissions);
|
||||
var listed = page.records().get(0);
|
||||
var detail = service.overseasDetail(11L, permissions);
|
||||
List<String> expected = hasPermission && hasAggregate ? List.of("RECORD_EMPTY_RETURN") : List.of();
|
||||
assertThat(listed.workbenchStage()).isEqualTo("UNLOADED");
|
||||
assertThat(listed.terminalDrayage().status()).isEqualTo("UNLOADED");
|
||||
assertThat(listed.allowedActions()).isEqualTo(expected);
|
||||
assertThat(listed.terminalDrayage().allowedActions()).isEqualTo(expected);
|
||||
assertThat(detail.allowedActions()).isEqualTo(listed.allowedActions());
|
||||
assertThat(detail.terminalDrayage().allowedActions()).isEqualTo(listed.terminalDrayage().allowedActions());
|
||||
var serialized = json.readTree(json.writeValueAsString(com.baiyue.common.core.api.ApiResponse.success(page)));
|
||||
assertThat(serialized.at("/data/records/0/allowedActions")).isEqualTo(json.valueToTree(expected));
|
||||
assertThat(serialized.at("/data/records/0/terminalDrayage/allowedActions")).isEqualTo(json.valueToTree(expected));
|
||||
verify(mapper).findAll(List.of(11L));
|
||||
verify(mapper).contexts(List.of(11L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void domesticHistoryDetailKeepsTheDepartedContainerAndItsUniqueLoadingPlan() {
|
||||
ContainerBookingEntity container = container();
|
||||
|
||||
+20
@@ -177,6 +177,23 @@ class TerminalDrayageServiceTest {
|
||||
assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unloadedContainerOffersEmptyReturnOnlyWithDedicatedPermission() {
|
||||
Context context = context("PICKED_UP", LocalDateTime.of(2026, 9, 3, 0, 0));
|
||||
TerminalDrayageEntity row = row(3);
|
||||
row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0));
|
||||
when(mapper.context(11L)).thenReturn(context);
|
||||
when(mapper.find(11L)).thenReturn(row);
|
||||
|
||||
var raw = service.detail(11L);
|
||||
assertThat(raw.status()).isEqualTo("UNLOADED");
|
||||
assertThat(service.authorize(raw,
|
||||
Set.of("platform:firstleg:terminal-drayage:empty-return:confirm")).allowedActions())
|
||||
.containsExactly("RECORD_EMPTY_RETURN");
|
||||
assertThat(service.authorize(raw,
|
||||
Set.of("platform:firstleg:terminal-drayage:list")).allowedActions()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkViewOmitsContainerWhenActualFactPortHasNoTrackingRecord() {
|
||||
when(mapper.contexts(List.of(11L))).thenReturn(List.of(
|
||||
@@ -276,6 +293,9 @@ class TerminalDrayageServiceTest {
|
||||
ArgumentCaptor<ManualNodeRequest> node = ArgumentCaptor.forClass(ManualNodeRequest.class);
|
||||
verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
|
||||
assertThat(node.getValue().standardNode()).isEqualTo("EMPTY_RETURNED");
|
||||
assertThat(node.getValue().reason()).isEqualTo("已还柜");
|
||||
assertThat(node.getValue().actualAt()).isEqualTo(LocalDateTime.of(2026, 9, 4, 9, 0));
|
||||
assertThat(result.version()).isEqualTo(4);
|
||||
}
|
||||
|
||||
private Context context(String node, LocalDateTime unloadedAt) {
|
||||
|
||||
Reference in New Issue
Block a user