This commit is contained in:
2026-09-16 11:06:33 +08:00
parent a4c659f4c6
commit a098c4377c
46 changed files with 356 additions and 109 deletions
@@ -23,7 +23,7 @@ public interface WarehouseSupplierClient {
@RequestParam String carrierCode);
record Snapshot(long supplierId, String name, String category, String status,
List<String> carrierCodes) {}
List<String> carrierCodes, String truckingScope) {}
@GetMapping("/{supplierId}/trucking-contact")
ApiResponse<TruckingContact> truckingContact(@RequestHeader("X-Internal-Service-Token") String token,
@@ -92,12 +92,17 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
Integer inboundFragmentCount,Integer fullPackageCount,List<Long> memberInboundIds) {
public PlanCoLoadGroup { memberInboundIds=memberInboundIds==null?List.of():List.copyOf(memberInboundIds); }
}
public record PlanPalletMembers(Long palletId,List<GroupMemberCandidate> members) {
public PlanPalletMembers { members=members==null?List.of():List.copyOf(members); }
}
public record Plan(Long id,String planNo,String status,Long bookingId,String containerNo,String voyageNo,
Integer version,Boolean canAssign,Boolean canEdit,String blockedReason,String finalCompletionStatus,
Long pdaTaskId,String pdaTaskStatus,Integer pdaTaskVersion,List<PlanCoLoadGroup> coLoadGroups,
List<PlanPalletMembers> palletMembers,
List<LoadingPlanItemEntity> items) {
public Plan {
coLoadGroups=coLoadGroups==null?List.of():List.copyOf(coLoadGroups);
palletMembers=palletMembers==null?List.of():List.copyOf(palletMembers);
items=items==null?List.of():List.copyOf(items);
}
}
@@ -9,6 +9,7 @@ import org.apache.ibatis.annotations.*;
import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.util.Collection;
import java.util.List;
/** 一件代发逐箱清点、待绑托与封托事实。 */
@@ -282,6 +283,21 @@ public interface DropshipPalletizationMapper {
ORDER BY o.inbound_code,o.id
""") List<GroupMemberCandidate> palletInboundMembers(@Param("palletId") long palletId);
@Select("""
<script>
SELECT c.pallet_id palletId,o.tenant_id accountId,o.id inboundId,o.order_id orderId,o.order_no orderNo,
o.inbound_code inboundCode,o.customer_name customerName,COUNT(DISTINCT c.id) availablePackageCount,
o.process_status orderStatusSnapshot,o.expected_arrival_at expectedArrivalAt
FROM fl_dropship_pallet_carton c
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id
WHERE c.pallet_id IN
<foreach collection="palletIds" item="palletId" open="(" separator="," close=")">#{palletId}</foreach>
GROUP BY c.pallet_id,o.tenant_id,o.id,o.order_id,o.order_no,o.inbound_code,o.customer_name,
o.process_status,o.expected_arrival_at
ORDER BY c.pallet_id,o.inbound_code,o.id
</script>
""") List<PlanPalletMemberRow> palletInboundMembersByPalletIds(@Param("palletIds") Collection<Long> palletIds);
@Select("""
SELECT p.id pallet_id,p.pallet_no,'SEALED_PALLET' unit_type,TRUE indivisible,COUNT(DISTINCT c.id) carton_count,
(SELECT COUNT(DISTINCT sku.sku_code) FROM fl_dropship_pallet_carton pc
@@ -318,6 +334,7 @@ public interface DropshipPalletizationMapper {
@Getter @Setter class TaskRow { private Long id,tenantId,inboundId,warehouseId,assigneeId; private String inboundCode,customerName,warehouseName,taskStatus,assigneeName,processStatus,fulfillmentType; private Integer totalCartons,countedCartons,allocatedCartons,discrepancyCartons,expectedPalletCount,version; }
@Getter @Setter class PalletRow { private Long id,taskId,tenantId,inboundId,warehouseId; private String palletNo,palletStatus,sealOperationId; private Integer version; private LocalDateTime completedAt; }
@Getter @Setter class PlanPalletMemberRow { private Long palletId,accountId,inboundId,orderId; private String orderNo,inboundCode,customerName,orderStatusSnapshot; private Integer availablePackageCount; private LocalDateTime expectedArrivalAt; }
@Getter @Setter class LabelRow { private Long cartonLabelId,inboundDetailId; private String skuCode,cargoName,inboundCode,boxCode; private Integer unitsPerCarton; }
@Getter @Setter class LabelSkuRow { private Long inboundDetailId; private String skuCode,cargoName; private Integer expectedQuantity,lineNo; }
@Getter @Setter class CartonRow { private Long id,taskId,palletId,inboundId,inboundDetailId; private String palletNo,inboundCode,skuCode,cargoName,boxCode,discrepancyStatus,discrepancyRemark; private Integer expectedSkuQuantity,actualSkuQuantity; }
@@ -149,7 +149,7 @@ public class ContainerService {
validateSchedule(request.data());
ContainerSpecificationCatalog.requireMatch(request.data().containerType(), request.data().capacityCbm(), request.data().maxWeightKg());
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.data().warehouseId());
var trucking = suppliers.trucking(request.data().terminalTruckingCompanyId(), request.data().terminalTruckingCompany());
var trucking = suppliers.overseasTrucking(request.data().terminalTruckingCompanyId(), request.data().terminalTruckingCompany());
copy(current, request.data());
current.setWarehouseId(warehouse.id()); current.setWarehouseName(warehouse.warehouseName());
current.setTerminalTruckingCompanyId(trucking == null ? null : trucking.supplierId());
@@ -1663,7 +1663,22 @@ public class SmartLoadingService {
: "COMPLETED".equals(task.taskStatus()) ? "COMPLETED" : "IN_PROGRESS";
return new Plan(p.getId(),p.getPlanNo(),p.getPlanStatus(),p.getContainerBookingId(),
b.getContainerNo(),b.getVoyageNo(),p.getVersion(),mutable,mutable,blockedReason,finalStatus,
task.taskId(),task.taskStatus(),task.taskVersion(),planCoLoadGroups(p.getId()),collapsePalletUnits(items));
task.taskId(),task.taskStatus(),task.taskVersion(),planCoLoadGroups(p.getId()),planPalletMembers(items),
collapsePalletUnits(items));
}
private List<PlanPalletMembers> planPalletMembers(List<LoadingPlanItemEntity> items) {
List<Long> palletIds=items.stream().map(LoadingPlanItemEntity::getPalletId).filter(Objects::nonNull)
.distinct().toList();
if(palletIds.isEmpty()) return List.of();
return dropshipPallets.palletInboundMembersByPalletIds(palletIds).stream()
.collect(java.util.stream.Collectors.groupingBy(
com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PlanPalletMemberRow::getPalletId,
LinkedHashMap::new,java.util.stream.Collectors.toList()))
.entrySet().stream().map(entry->new PlanPalletMembers(entry.getKey(),entry.getValue().stream()
.map(row->new GroupMemberCandidate(row.getAccountId(),row.getInboundId(),row.getOrderId(),
row.getOrderNo(),row.getInboundCode(),row.getCustomerName(),row.getAvailablePackageCount(),
row.getOrderStatusSnapshot(),row.getExpectedArrivalAt()))
.toList())).toList();
}
private List<LoadingPlanItemEntity> collapsePalletUnits(List<LoadingPlanItemEntity> items) {
List<LoadingPlanItemEntity> result = new ArrayList<>();
@@ -34,7 +34,7 @@ public class TruckAppointmentFeeService {
if (item == null || !"ACTIVE".equals(item.getStatus()) || !Boolean.TRUE.equals(item.getPickupPayable())) {
throw conflict("国内拖车费项目不存在、已停用或未启用应付");
}
suppliers.trucking(appointment.getCarrierId(), null);
suppliers.domesticTrucking(appointment.getCarrierId(), null);
List<Share> shares = allocate(total, rows);
Map<Long, List<Share>> plans = new LinkedHashMap<>();
shares.forEach(share -> plans.computeIfAbsent(share.owner().planId(), ignored -> new ArrayList<>()).add(share));
@@ -47,8 +47,8 @@ public class TruckAppointmentService {
@Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){String attachmentType=attachmentType(type);required(id);try{var response=files.upload(internalToken,uid,0L,"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),file);if(response==null||!response.successful()||response.data()==null)throw bad("附件上传失败");var m=response.data();TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(m.fileId());a.setAttachmentType(attachmentType);a.setFileName(m.fileName());a.setFileHash(m.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}}
@Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){String attachmentType=attachmentType(r.attachmentType());required(id);TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,0L,"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),r.fileId());if(response==null||!response.successful()||response.data()==null)throw bad("附件不存在或归属不匹配");metadata=response.data();}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(metadata.fileId());a.setAttachmentType(attachmentType);a.setFileName(metadata.fileName());a.setFileHash(metadata.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}
@Transactional public void deleteAttachment(long id,long attachmentId){required(id);if(repo.deleteAttachment(id,attachmentId)!=1)throw new BizException(ResultCode.NOT_FOUND,"附件引用不存在");}
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r);completion.lockBooking(r.oceanBookingId());TruckAppointmentEntity active=repo.firstActiveForUpdate(r.oceanBookingId());if(active!=null)throw existingAppointmentConflict(active);AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());TruckAppointmentEntity e=new TruckAppointmentEntity();e.setId(IdWorker.getId());e.setAppointmentNo(documentNumbers.next("TA"));e.setStatus(TruckAppointmentStatus.DRAFT);e.setRevision(0);e.setVersion(0);e.setCreatedAt(LocalDateTime.now());e.setUpdatedAt(e.getCreatedAt());copy(e,r,links,carrier.name());try{repo.insert(e);}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_ocean_booking"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.oceanBookingId()));throw exception;}record(e,null,e.getStatus().name(),"CREATE",null,uid,name);completion.completeForBooking(e.getOceanBookingId());return enrich(required(e.getId()));}
@Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){validateRequired(r);TruckAppointmentEntity e=required(id);completion.lockBooking(Math.min(e.getOceanBookingId(),r.oceanBookingId()));if(!Objects.equals(e.getOceanBookingId(),r.oceanBookingId()))completion.lockBooking(Math.max(e.getOceanBookingId(),r.oceanBookingId()));checkVersion(e,r.expectedVersion());if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑");TruckAppointmentEntity active=repo.firstActiveForUpdate(r.oceanBookingId());if(active!=null&&!Objects.equals(active.getId(),e.getId()))throw existingAppointmentConflict(active);AssociationContext links=validateAssociations(r);var carrier=suppliers.trucking(r.carrierId(),r.carrierName());copy(e,r,links,carrier.name());try{if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试");}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_ocean_booking"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.oceanBookingId()));throw exception;}record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name);completion.completeForBooking(e.getOceanBookingId());return enrich(required(id));}
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r);completion.lockBooking(r.oceanBookingId());TruckAppointmentEntity active=repo.firstActiveForUpdate(r.oceanBookingId());if(active!=null)throw existingAppointmentConflict(active);AssociationContext links=validateAssociations(r);var carrier=suppliers.domesticTrucking(r.carrierId(),r.carrierName());TruckAppointmentEntity e=new TruckAppointmentEntity();e.setId(IdWorker.getId());e.setAppointmentNo(documentNumbers.next("TA"));e.setStatus(TruckAppointmentStatus.DRAFT);e.setRevision(0);e.setVersion(0);e.setCreatedAt(LocalDateTime.now());e.setUpdatedAt(e.getCreatedAt());copy(e,r,links,carrier.name());try{repo.insert(e);}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_ocean_booking"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.oceanBookingId()));throw exception;}record(e,null,e.getStatus().name(),"CREATE",null,uid,name);completion.completeForBooking(e.getOceanBookingId());return enrich(required(e.getId()));}
@Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){validateRequired(r);TruckAppointmentEntity e=required(id);completion.lockBooking(Math.min(e.getOceanBookingId(),r.oceanBookingId()));if(!Objects.equals(e.getOceanBookingId(),r.oceanBookingId()))completion.lockBooking(Math.max(e.getOceanBookingId(),r.oceanBookingId()));checkVersion(e,r.expectedVersion());if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑");TruckAppointmentEntity active=repo.firstActiveForUpdate(r.oceanBookingId());if(active!=null&&!Objects.equals(active.getId(),e.getId()))throw existingAppointmentConflict(active);AssociationContext links=validateAssociations(r);var carrier=suppliers.domesticTrucking(r.carrierId(),r.carrierName());copy(e,r,links,carrier.name());try{if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试");}catch(DuplicateKeyException exception){if(exception.getMessage()!=null&&exception.getMessage().contains("uk_truck_active_ocean_booking"))throw existingAppointmentConflict(repo.firstActiveForUpdate(r.oceanBookingId()));throw exception;}record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name);completion.completeForBooking(e.getOceanBookingId());return enrich(required(id));}
@Transactional
public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){
String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);
@@ -31,8 +31,21 @@ public class WarehouseSupplierSnapshotService {
}
}
public WarehouseSupplierClient.Snapshot trucking(Long supplierId, String suppliedName) {
return resolve(supplierId, suppliedName, "TRUCKING");
public WarehouseSupplierClient.Snapshot domesticTrucking(Long supplierId, String suppliedName) {
return trucking(supplierId, suppliedName, "DOMESTIC");
}
public WarehouseSupplierClient.Snapshot overseasTrucking(Long supplierId, String suppliedName) {
return trucking(supplierId, suppliedName, "OVERSEAS");
}
private WarehouseSupplierClient.Snapshot trucking(Long supplierId, String suppliedName, String scope) {
var snapshot = resolve(supplierId, suppliedName, "TRUCKING");
if (snapshot != null && !scope.equals(snapshot.truckingScope())) {
throw new BizException(ResultCode.CONFLICT, "DOMESTIC".equals(scope)
? "国内拖车只能使用国内拖车供应商" : "清关提柜只能使用海外车队");
}
return snapshot;
}
public WarehouseSupplierClient.Snapshot customs(Long supplierId, String suppliedName) {
@@ -96,7 +96,7 @@ public class TerminalDrayageService {
TerminalDrayageEntity value = mapper.lock(containerBookingId);
requireVersion(value, request.expectedVersion());
if (actualPickupAt(context, value) != null) throw conflict("实际提柜后不可更换码头车队");
var fleet = suppliers.trucking(request.fleetSupplierId(), request.fleetSupplierName());
var fleet = suppliers.overseasTrucking(request.fleetSupplierId(), request.fleetSupplierName());
if (value != null && Objects.equals(value.getFleetSupplierId(), fleet.supplierId())) return view(context, value);
if (value != null && value.getFleetSupplierId() != null) {
mails.replaceFleet(containerBookingId, context.base().containerNo(), value.getFleetSupplierId(), fleet.supplierId(), operatorId);
@@ -1622,7 +1622,6 @@ components:
lxCode: {type: string, nullable: true, description: 一件代发线路或履约产品编码快照}
channelCode: {type: string, nullable: true}
channelName: {type: string, nullable: true}
fulfillmentType: {type: string, nullable: true, enum: [STANDARD, ONE_CLICK_DROPSHIP]}
expectedPalletCount: {type: integer, minimum: 1, maximum: 1000, nullable: true, description: 一件代发预计托数;测量审核通过后按此数量预生成 DP 托盘码}
taxMode: {type: string, nullable: true}
pricingTaxMode: {type: string, nullable: true}
@@ -2445,19 +2444,20 @@ components:
groupMembers:
type: array
description: 同柜组或封托托盘的完整入仓成员;SEALED_PALLET 行按托盘唯一分页,成员 availablePackageCount 为该入仓码在本托的箱数,行 availablePackageCount 为整托总箱数。搜索命中任一成员仍返回整托完整组成;普通箱件候选为空数组。
items:
type: object
required: [accountId, inboundId, orderId, orderNo, inboundCode, customerName, availablePackageCount]
properties:
accountId: {type: string, pattern: '^[1-9][0-9]*$'}
inboundId: {type: string, pattern: '^[1-9][0-9]*$'}
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
orderNo: {type: string}
inboundCode: {type: string}
customerName: {type: string}
availablePackageCount: {type: integer, minimum: 0}
orderStatusSnapshot: {type: string, nullable: true}
expectedArrivalAt: {type: string, format: date-time, nullable: true}
items: {$ref: '#/components/schemas/SmartLoadingCandidateGroupMember'}
SmartLoadingCandidateGroupMember:
type: object
required: [accountId, inboundId, orderId, orderNo, inboundCode, customerName, availablePackageCount]
properties:
accountId: {type: string, pattern: '^[1-9][0-9]*$'}
inboundId: {type: string, pattern: '^[1-9][0-9]*$'}
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
orderNo: {type: string}
inboundCode: {type: string}
customerName: {type: string}
availablePackageCount: {type: integer, minimum: 0}
orderStatusSnapshot: {type: string, nullable: true}
expectedArrivalAt: {type: string, format: date-time, nullable: true}
SmartLoadingWorkbenchContainerPageEnvelope:
type: object
required: [code, message, data]
@@ -2496,7 +2496,7 @@ components:
version: {type: integer, minimum: 0, description: 柜次版本}
SmartLoadingPlan:
type: object
required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, coLoadGroups, items]
required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, coLoadGroups, palletMembers, items]
properties:
id: {type: string}
planNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号}
@@ -2516,6 +2516,10 @@ components:
type: array
description: 当前计划内同柜不可拆组的完整冻结快照;随计划一次返回,调用方不得按 inboundId 扇出重建
items: {$ref: '#/components/schemas/SmartLoadingPlanCoLoadGroup'}
palletMembers:
type: array
description: 当前计划内每个整托按入仓码汇总的实际装柜箱数;用于一托一行时展示完整组成
items: {$ref: '#/components/schemas/SmartLoadingPlanPalletMembers'}
items:
type: array
items: {$ref: '#/components/schemas/SmartLoadingPlanItem'}
@@ -2534,6 +2538,15 @@ components:
minItems: 1
uniqueItems: true
items: {type: string, pattern: '^[1-9][0-9]*$'}
SmartLoadingPlanPalletMembers:
type: object
required: [palletId, members]
properties:
palletId: {type: string, pattern: '^[1-9][0-9]*$'}
members:
type: array
minItems: 1
items: {$ref: '#/components/schemas/SmartLoadingCandidateGroupMember'}
SmartLoadingPlanItem:
type: object
required: [id, packageCount, totalPackageCount, remainingPackageCount, version, pickedPackageCount, minPackageCount, canAdjust, canRemove]
@@ -109,7 +109,7 @@ components:
required: [expectedVersion, fleetSupplierId]
properties:
expectedVersion: {type: integer, minimum: 0}
fleetSupplierId: {type: string, pattern: '^[1-9][0-9]*$', description: Warehouse ACTIVE/TRUCKING供应商ID}
fleetSupplierId: {type: string, pattern: '^[1-9][0-9]*$', description: Warehouse ACTIVE/TRUCKING且truckingScope=OVERSEAS的海外车队供应商ID}
fleetSupplierName: {type: string, maxLength: 128, nullable: true, description: 可选一致性提示,持久化名称以服务端快照为准}
reason: {type: string, maxLength: 500, nullable: true}
PickupAppointmentRequest:
@@ -223,7 +223,7 @@ components:
outboundOrderId: {type: integer, format: int64, minimum: 1, nullable: true}
oceanBookingId: {type: integer, format: int64, minimum: 1, description: Required RELEASED booking; must have a bill of lading and own an active BOOKING_CONFIRMATION or BILL_OF_LADING file.}
warehouseId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional consistency hint. The persisted warehouse is frozen from the selected booking.}
carrierId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE Warehouse supplier ID validated as TRUCKING.}
carrierId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE Warehouse supplier ID validated as TRUCKING with truckingScope DOMESTIC (国内拖车).}
carrierName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; the persisted snapshot comes from Warehouse supplier master data.}
billOfLadingNo: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint; the persisted snapshot is always frozen from the selected RELEASED booking.}
plannedLoadingAt: {type: string, format: date-time}
@@ -92,7 +92,7 @@ class OceanBookingServiceTest {
private void saveAndReleaseWithoutCustomer(boolean initialCustomer) throws Exception {
stubMasterData();
when(suppliers.booking(81L, "订舱代理")).thenReturn(
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("MATSON")));
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("MATSON"), null));
var tree = objectMapper.valueToTree(completeDraft());
((com.fasterxml.jackson.databind.node.ObjectNode) tree).putNull("customerId").putNull("customerName");
DraftData emptyCustomer = objectMapper.treeToValue(tree, DraftData.class);
@@ -243,7 +243,7 @@ class OceanBookingServiceTest {
stubMasterData();
when(suppliers.booking(81L, null)).thenReturn(
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("SML")));
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("SML"), null));
assertThatThrownBy(() -> service.create(9L, "admin", "agent-mismatch",
new CreateRequest(0, completeDraft())))
.isInstanceOf(BizException.class).hasMessage("所选订舱代理未关联当前船公司");
@@ -253,8 +253,8 @@ class OceanBookingServiceTest {
@Test
void bookingAgentCandidatesAreFilteredByCarrierCode() {
when(suppliers.bookingCandidates("MATSON")).thenReturn(List.of(
new WarehouseSupplierClient.Snapshot(81L, "匹配代理", "BOOKING", "ACTIVE", List.of("MATSON")),
new WarehouseSupplierClient.Snapshot(82L, "不匹配代理", "BOOKING", "ACTIVE", List.of("SML"))));
new WarehouseSupplierClient.Snapshot(81L, "匹配代理", "BOOKING", "ACTIVE", List.of("MATSON"), null),
new WarehouseSupplierClient.Snapshot(82L, "不匹配代理", "BOOKING", "ACTIVE", List.of("SML"), null)));
assertThat(service.bookingAgents("matson")).extracting(BookingAgentOption::name)
.containsExactly("匹配代理");
@@ -268,7 +268,7 @@ class OceanBookingServiceTest {
when(repository.parties(1L)).thenReturn(parties());
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
when(suppliers.booking(81L, null)).thenReturn(
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("SML")));
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("SML"), null));
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-agent-mismatch",
new ReleaseRequest(3, "确认")))
@@ -392,7 +392,7 @@ class OceanBookingServiceTest {
new WarehouseMasterDataClient.Detail(new WarehouseMasterDataClient.Warehouse(
701L, 0L, "SZX", null, "深圳中转仓", "DOMESTIC", "ACTIVE"))));
when(suppliers.booking(81L, null)).thenReturn(
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("MATSON")));
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("MATSON"), null));
when(partners.active(any(), eq(0L), eq(71L))).thenReturn(ApiResponse.success(
new com.baiyue.firstleg.client.WarehousePartnerClient.Snapshot(71L, 0L, "船公司", "CARRIER",
"MATSON", List.of("CARRIER"), "ACTIVE")));
@@ -9,6 +9,7 @@ import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.util.Set;
import java.util.Collection;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery;
import com.baiyue.firstleg.dto.SmartLoadingDtos.InboundCandidateQuery;
@@ -34,6 +35,12 @@ class SmartLoadingCandidateMapperContractTest {
.contains("WHERE c.pallet_id=#{palletId}")
.contains("GROUP BY o.tenant_id,o.id")
.doesNotContain("#{inboundId}");
String planMembers = String.join(" ", com.baiyue.firstleg.mapper.DropshipPalletizationMapper.class
.getDeclaredMethod("palletInboundMembersByPalletIds", Collection.class)
.getAnnotation(Select.class).value());
assertThat(planMembers).contains("COUNT(DISTINCT c.id) availablePackageCount")
.contains("<foreach collection=\"palletIds\"")
.contains("GROUP BY c.pallet_id,o.tenant_id,o.id");
}
@Test
void candidateHasOnlyTheCanonicalMybatisConstructor() {
@@ -220,7 +220,7 @@ class ContainerFeeDetailServiceTest {
when(feeItems.findItem(11L)).thenReturn(item());
when(suppliers.fee(71L, null)).thenReturn(
new com.baiyue.firstleg.client.WarehouseSupplierClient.Snapshot(
71L, "现有供应商", "TRUCKING", "ACTIVE", List.of()));
71L, "现有供应商", "TRUCKING", "ACTIVE", List.of(), "DOMESTIC"));
LocalDateTime paidAt = LocalDateTime.of(2026, 8, 20, 10, 0);
var request = new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
LocalDate.of(2026, 8, 20), new BigDecimal("300.00"), "CNY", 71L,
@@ -57,11 +57,11 @@ class ContainerServiceTest {
when(smartLoadingService.createForContainer(anyLong(), anyLong(), anyString())).thenAnswer(invocation ->
new com.baiyue.firstleg.dto.SmartLoadingDtos.Plan(501L, "LP-260818-0001", "EDITING",
invocation.getArgument(0, Long.class), null, null, 0, true, true, null,
"NOT_STARTED", null, null, null, List.of(), List.of()));
"NOT_STARTED", null, null, null, List.of(), List.of(), List.of()));
when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation ->
activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓"));
when(suppliers.trucking(202L, "拖车公司A"))
.thenReturn(new WarehouseSupplierClient.Snapshot(202L, "拖车公司A", "TRUCKING", "ACTIVE", List.of()));
when(suppliers.overseasTrucking(202L, "拖车公司A"))
.thenReturn(new WarehouseSupplierClient.Snapshot(202L, "拖车公司A", "TRUCKING", "ACTIVE", List.of(), "OVERSEAS"));
}
@Test
@@ -159,6 +159,40 @@ class SmartLoadingServiceTest {
verify(coloadGroups,never()).findByInbound(anyLong(),anyLong());
}
@Test
void returnsEveryInboundCompositionWhenAPlanCollapsesASealedPalletToOneRow() {
LoadingPlanEntity plan = plan("EDITING", 2);
when(repository.plan(1L)).thenReturn(plan);
when(repository.booking(99L)).thenReturn(booking());
LoadingPlanItemEntity first=item(101L,1L,201L,6);
first.setInboundId(180L); first.setOrderId(280L); first.setOrderNo("O180"); first.setInboundCode("I180");
first.setCustomerName("客户"); first.setPalletId(801L); first.setPalletNo("DP-0025"); first.setUnitType("SEALED_PALLET");
LoadingPlanItemEntity second=item(102L,1L,202L,5);
second.setInboundId(181L); second.setOrderId(281L); second.setOrderNo("O181"); second.setInboundCode("I181");
second.setCustomerName("客户"); second.setPalletId(801L); second.setPalletNo("DP-0025"); second.setUnitType("SEALED_PALLET");
when(repository.items(1L)).thenReturn(List.of(first,second));
var firstMember=new com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PlanPalletMemberRow();
firstMember.setPalletId(801L); firstMember.setAccountId(1L); firstMember.setInboundId(180L);
firstMember.setOrderId(280L); firstMember.setOrderNo("O180"); firstMember.setInboundCode("I180");
firstMember.setCustomerName("客户"); firstMember.setAvailablePackageCount(6);
var secondMember=new com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PlanPalletMemberRow();
secondMember.setPalletId(801L); secondMember.setAccountId(1L); secondMember.setInboundId(181L);
secondMember.setOrderId(281L); secondMember.setOrderNo("O181"); secondMember.setInboundCode("I181");
secondMember.setCustomerName("客户"); secondMember.setAvailablePackageCount(5);
when(dropshipPallets.palletInboundMembersByPalletIds(List.of(801L)))
.thenReturn(List.of(firstMember,secondMember));
Plan result=service.detail(1L);
assertThat(result.items()).singleElement().satisfies(row->assertThat(row.getPackageCount()).isEqualTo(11));
assertThat(result.palletMembers()).singleElement().satisfies(pallet->{
assertThat(pallet.palletId()).isEqualTo(801L);
assertThat(pallet.members()).extracting(GroupMemberCandidate::inboundCode).containsExactly("I180","I181");
assertThat(pallet.members()).extracting(GroupMemberCandidate::availablePackageCount).containsExactly(6,5);
});
verify(dropshipPallets).palletInboundMembersByPalletIds(List.of(801L));
}
@Test
void restoresLoadingPlanForContinuedAssignment() {
LoadingPlanEntity plan = plan("LOADING", 3);
@@ -59,7 +59,7 @@ class TruckAppointmentFeeServiceTest {
assertThat(request.getValue().fee().remark()).contains("TA-260914-0111");
assertThat(request.getValue().fee().businessDate()).isEqualTo(DEPARTED.toLocalDate());
assertThat(request.getValue().allocations().get(0).amount()).isEqualByComparingTo("100");
verify(suppliers).trucking(8L, null);
verify(suppliers).domesticTrucking(8L, null);
verify(containers).approveAllocationGroup(10L, 500L, 0L, "系统(拖车离仓完成)");
}
@@ -43,8 +43,8 @@ class TruckAppointmentPayableFlowTest {
item.setFeeTypeName("拖车费"); item.setStatus("ACTIVE"); item.setPickupPayable(true); item.setSupplierCategory("TRUCKING");
when(items.findItemByCode("FEE_TYPE_108")).thenReturn(item);
when(items.findItem(108L)).thenReturn(item);
var supplier = new WarehouseSupplierClient.Snapshot(8L, "测试车队", "TRUCKING", "ACTIVE", List.of());
when(suppliers.trucking(8L, null)).thenReturn(supplier);
var supplier = new WarehouseSupplierClient.Snapshot(8L, "测试车队", "TRUCKING", "ACTIVE", List.of(), "DOMESTIC");
when(suppliers.domesticTrucking(8L, null)).thenReturn(supplier);
when(suppliers.fee(8L, "TRUCKING")).thenReturn(supplier);
var plan = new LoadingPlanEntity(); plan.setId(10L); plan.setContainerBookingId(1L);
var container = new ContainerBookingEntity(); container.setId(1L); container.setContainerNo("TEST1234567");
@@ -58,8 +58,8 @@ class TruckAppointmentServiceTest {
return null;
}).when(repository).insert(any());
lenient().when(documentNumbers.next("TA")).thenReturn("TA-260818-0001");
when(suppliers.trucking(any(), any())).thenReturn(
new WarehouseSupplierClient.Snapshot(8L, "Canonical Carrier", "TRUCKING", "ACTIVE", java.util.List.of()));
when(suppliers.domesticTrucking(any(), any())).thenReturn(
new WarehouseSupplierClient.Snapshot(8L, "Canonical Carrier", "TRUCKING", "ACTIVE", java.util.List.of(), "DOMESTIC"));
lenient().when(repository.attachments(anyLong())).thenReturn(List.of());
OceanBookingEntity booking = releasedBooking();
lenient().when(oceanBookings.find(99L)).thenReturn(booking);
@@ -536,7 +536,7 @@ class TruckAppointmentServiceTest {
@Test void createRejectsInvalidTruckingSupplierWithoutWriting() {
SaveRequest request = validRequest();
when(suppliers.trucking(8L, null)).thenThrow(
when(suppliers.domesticTrucking(8L, null)).thenThrow(
new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "拖车服务商未启用"));
assertThatThrownBy(() -> service.create(7L, "admin", request))
@@ -28,7 +28,7 @@ class WarehouseSupplierSnapshotServiceTest {
@Test
void rejectsFreeTextWithoutSupplierId() {
assertThatThrownBy(() -> service.trucking(null, "任意承运商"))
assertThatThrownBy(() -> service.domesticTrucking(null, "任意承运商"))
.isInstanceOf(BizException.class).hasMessageContaining("自由文本");
verifyNoInteractions(client);
}
@@ -36,7 +36,7 @@ class WarehouseSupplierSnapshotServiceTest {
@Test
void resolvesActiveBookingSupplierByCategoryWithoutScopeAndUsesServerName() {
when(client.get("internal-token", 7L, true, "BOOKING"))
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(7L, "海运代理", "BOOKING", "ACTIVE", java.util.List.of("MATSON"))));
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(7L, "海运代理", "BOOKING", "ACTIVE", java.util.List.of("MATSON"), null)));
var snapshot = service.booking(7L, "海运代理");
assertThat(snapshot.name()).isEqualTo("海运代理");
verify(client).get("internal-token", 7L, true, "BOOKING");
@@ -46,7 +46,7 @@ class WarehouseSupplierSnapshotServiceTest {
void feeWithoutCategoryAcceptsAnyActiveWarehouseSupplier() {
when(client.get("internal-token", 8L, true, null))
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(
8L, "任意启用供应商", "CERTIFICATE", "ACTIVE", java.util.List.of())));
8L, "任意启用供应商", "CERTIFICATE", "ACTIVE", java.util.List.of(), null)));
assertThat(service.fee(8L, null).name()).isEqualTo("任意启用供应商");
verify(client).get("internal-token", 8L, true, null);
@@ -55,7 +55,7 @@ class WarehouseSupplierSnapshotServiceTest {
@Test
void rejectsClientNameThatDiffersFromWarehouseSnapshot() {
when(client.get(anyString(), eq(9L), eq(true), eq("CUSTOMS")))
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(9L, "真实报关行", "CUSTOMS", "ACTIVE", java.util.List.of())));
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(9L, "真实报关行", "CUSTOMS", "ACTIVE", java.util.List.of(), null)));
assertThatThrownBy(() -> service.customs(9L, "伪造名称"))
.isInstanceOf(BizException.class).hasMessageContaining("不一致");
}
@@ -64,9 +64,29 @@ class WarehouseSupplierSnapshotServiceTest {
void rejectsSupplierSnapshotWithWrongCategoryOrInactiveStatus() {
when(client.get("internal-token", 7L, true, "BOOKING"))
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(
7L, "错误代理", "TRUCKING", "ACTIVE", java.util.List.of())));
7L, "错误代理", "TRUCKING", "ACTIVE", java.util.List.of(), "DOMESTIC")));
assertThatThrownBy(() -> service.booking(7L, null))
.isInstanceOf(BizException.class).hasMessageContaining("类别不匹配");
}
@Test void truckingScopesAreMutuallyExclusiveAndMissingScopeIsRejected() {
for (String scope : new String[] {"DOMESTIC", "OVERSEAS", null}) {
when(client.get("internal-token", 7L, true, "TRUCKING"))
.thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(
7L, "车队", "TRUCKING", "ACTIVE", java.util.List.of(), scope)));
if ("DOMESTIC".equals(scope)) {
assertThat(service.domesticTrucking(7L, null).name()).isEqualTo("车队");
} else {
assertThatThrownBy(() -> service.domesticTrucking(7L, null))
.isInstanceOf(BizException.class).hasMessageContaining("国内拖车");
}
if ("OVERSEAS".equals(scope)) {
assertThat(service.overseasTrucking(7L, null).name()).isEqualTo("车队");
} else {
assertThatThrownBy(() -> service.overseasTrucking(7L, null))
.isInstanceOf(BizException.class).hasMessageContaining("海外车队");
}
}
}
}
@@ -72,7 +72,7 @@ class TerminalDrayageServiceTest {
new FleetSelectionRequest(0, 91L, null, null)))
.isInstanceOf(BizException.class)
.hasMessage("货柜实际到达目的港后才能安排码头车队");
verify(suppliers, never()).trucking(anyLong(), any());
verify(suppliers, never()).overseasTrucking(anyLong(), any());
}
@Test
@@ -82,8 +82,8 @@ class TerminalDrayageServiceTest {
when(mapper.context(11L)).thenReturn(context);
when(mapper.lock(11L)).thenReturn(null);
when(mapper.operation(11L, "fleet-1")).thenReturn(null);
when(suppliers.trucking(91L, "用户提示名")).thenReturn(
new WarehouseSupplierClient.Snapshot(91L, "服务端车队", "TRUCKING", "ACTIVE", List.of()));
when(suppliers.overseasTrucking(91L, "用户提示名")).thenReturn(
new WarehouseSupplierClient.Snapshot(91L, "服务端车队", "TRUCKING", "ACTIVE", List.of(), "OVERSEAS"));
AtomicReference<TerminalDrayageEntity> stored = new AtomicReference<>();
when(mapper.insert(any(TerminalDrayageEntity.class))).thenAnswer(invocation -> {
TerminalDrayageEntity value = invocation.getArgument(0);
@@ -256,8 +256,8 @@ class TerminalDrayageServiceTest {
when(mapper.lockContext(11L)).thenReturn(context);
when(mapper.context(11L)).thenReturn(context);
when(mapper.lock(11L)).thenReturn(null);
when(suppliers.trucking(91L, null)).thenReturn(
new WarehouseSupplierClient.Snapshot(91L, "服务端车队", "TRUCKING", "ACTIVE", List.of()));
when(suppliers.overseasTrucking(91L, null)).thenReturn(
new WarehouseSupplierClient.Snapshot(91L, "服务端车队", "TRUCKING", "ACTIVE", List.of(), "OVERSEAS"));
AtomicReference<TerminalDrayageEntity> stored = new AtomicReference<>();
AtomicReference<Operation> operation = new AtomicReference<>();
when(mapper.insert(any(TerminalDrayageEntity.class))).thenAnswer(invocation -> {
@@ -279,7 +279,7 @@ class TerminalDrayageServiceTest {
assertThat(replay).isEqualTo(first);
verify(mapper, times(1)).insert(any(TerminalDrayageEntity.class));
verify(mapper, times(1)).insertOperation(anyLong(), anyLong(), anyString(), anyString(), anyString(), anyInt(), anyLong());
verify(suppliers, times(1)).trucking(91L, null);
verify(suppliers, times(1)).overseasTrucking(91L, null);
}
@Test
@@ -317,7 +317,7 @@ class TerminalDrayageServiceTest {
void selectingSameFleetDoesNotQueueMailOrChangeVersion() {
when(mapper.lockContext(11L)).thenReturn(context("ARRIVED_DESTINATION_PORT", null));
when(mapper.lock(11L)).thenReturn(row(3));
when(suppliers.trucking(91L, null)).thenReturn(new WarehouseSupplierClient.Snapshot(91L,"车队","TRUCKING","ACTIVE",List.of()));
when(suppliers.overseasTrucking(91L, null)).thenReturn(new WarehouseSupplierClient.Snapshot(91L,"车队","TRUCKING","ACTIVE",List.of(), "OVERSEAS"));
service.selectFleet(11L,7L,"admin","same-fleet",new FleetSelectionRequest(3,91L,null,null));
verifyNoInteractions(mails);
verify(mapper,never()).updateAggregate(any(),anyInt(),anyLong(),anyString());
@@ -332,7 +332,7 @@ class TerminalDrayageServiceTest {
when(mapper.lock(11L)).thenReturn(row);
when(mapper.find(11L)).thenReturn(row);
when(mapper.updateAggregate(any(),anyInt(),anyLong(),anyString())).thenReturn(1);
when(suppliers.trucking(92L,null)).thenReturn(new WarehouseSupplierClient.Snapshot(92L,"新车队","TRUCKING","ACTIVE",List.of()));
when(suppliers.overseasTrucking(92L,null)).thenReturn(new WarehouseSupplierClient.Snapshot(92L,"新车队","TRUCKING","ACTIVE",List.of(), "OVERSEAS"));
service.selectFleet(11L,7L,"admin","change-fleet",new FleetSelectionRequest(3,92L,null,null));
var ordered = org.mockito.Mockito.inOrder(mails,mapper);
ordered.verify(mails).replaceFleet(11L,"MSCU1234567",91L,92L,7L);