From 555acca779c40664e5731d278d02ae939d683f3a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 15 Aug 2026 10:32:39 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client/WarehouseSupplierClient.java | 21 ++++++++ .../firstleg/customs/dto/CustomsDtos.java | 3 +- .../customs/service/CustomsService.java | 6 ++- .../service/OceanBookingService.java | 9 ++-- .../service/TruckAppointmentService.java | 7 +-- .../WarehouseSupplierSnapshotService.java | 51 +++++++++++++++++++ .../admin-test-env-modules/phase3-loading.mjs | 7 +-- .../phase3-producer-chain-contract.test.mjs | 12 +++-- 8 files changed, 102 insertions(+), 14 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotService.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java new file mode 100644 index 000000000..7242156e6 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java @@ -0,0 +1,21 @@ +package com.baiyue.firstleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +@FeignClient(name = "warehouse-service", contextId = "firstlegWarehouseSupplierClient", + url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse/v1/suppliers") +public interface WarehouseSupplierClient { + @GetMapping("/{supplierId}") + ApiResponse get(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long supplierId, + @RequestParam(defaultValue = "true") boolean requireActive, + @RequestParam(required = false) String category, + @RequestParam(required = false) String scope); + + record Snapshot(long supplierId, String name, String category, String scope, String status) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/dto/CustomsDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/dto/CustomsDtos.java index 494a9c8b7..7efb53113 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/dto/CustomsDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/dto/CustomsDtos.java @@ -51,7 +51,8 @@ public final class CustomsDtos { LocalDateTime supplementDueAt, @Min(0) int expectedVersion) {} - public record SubmitBatchRequest(@NotBlank @Size(max = 128) String customsAgentName, + public record SubmitBatchRequest(@NotNull @jakarta.validation.constraints.Positive Long customsAgentId, + @Size(max = 128) String customsAgentName, @Size(max = 128) String externalReference, @Size(max = 500) String note, @Min(0) int expectedVersion) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsService.java index bbdcfa067..faafbf7c4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/service/CustomsService.java @@ -8,6 +8,7 @@ import com.baiyue.firstleg.customs.client.CustomsFileClient; import com.baiyue.firstleg.customs.dto.CustomsDtos.*; import com.baiyue.firstleg.customs.entity.*; import com.baiyue.firstleg.customs.repository.CustomsRepository; +import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -36,6 +37,7 @@ public class CustomsService { @Resource private CustomsRepository repository; @Resource private CustomsFileClient files; @Resource private ObjectMapper objectMapper; + @Resource private WarehouseSupplierSnapshotService suppliers; @Value("${baiyue.security.internal-service-token}") private String internalToken; public PageResult page(Query query) { return repository.page(query); } @@ -177,7 +179,9 @@ public class CustomsService { } CustomsSubmissionBatchEntity batch = new CustomsSubmissionBatchEntity(); batch.setId(IdWorker.getId()); batch.setCustomsCaseId(caseId); batch.setBatchVersion(repository.batches(caseId).size() + 1); - batch.setBatchNo("CB" + batch.getId()); batch.setCustomsAgentName(request.customsAgentName().trim()); + batch.setBatchNo("CB" + batch.getId()); + var customsAgent = suppliers.customs(request.customsAgentId(), request.customsAgentName()); + batch.setCustomsAgentName(customsAgent == null ? null : customsAgent.name()); batch.setExternalReference(trim(request.externalReference())); batch.setFileSnapshotJson(json(current.stream() .map(v -> Map.of("documentId", v.getId(), "fileId", v.getFileId(), "itemCode", v.getChecklistItemCode(), "documentVersion", v.getDocumentVersion(), "fileHash", v.getFileHash())).toList())); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index c01a5ffda..3e8b1c887 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -12,6 +12,7 @@ import com.baiyue.firstleg.repository.PriceBookQueryRepository; import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow; import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow; import com.baiyue.firstleg.service.LoadingPickService; +import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -38,6 +39,7 @@ public class OceanBookingService { @Resource private PriceBookQueryRepository priceBooks; @Resource private ObjectMapper objectMapper; @Resource private LoadingPickService loadingPickService; + @Resource private WarehouseSupplierSnapshotService suppliers; public PageResult page(Query query) { String status = trim(query.status()); @@ -73,13 +75,14 @@ public class OceanBookingService { Detail replay = claimOrReplay(null, operatorId, action, key, hash); if (replay != null) return replay; ChannelOption channel = requireActiveChannel(request.data().warehouseId(), request.data().channelCode()); + var bookingAgent = suppliers.booking(request.data().bookingAgentId(), request.data().bookingAgentName()); OceanBookingEntity value = new OceanBookingEntity(); value.setId(IdWorker.getId()); value.setBookingNo("OB" + value.getId()); // Child-row triggers still require DRAFT while the aggregate is assembled. // The status transition is in the same transaction, so callers only observe CONFIRMED. value.setStatus(OceanBookingStatus.DRAFT.name()); - copy(value, request.data(), channel.channelName()); + copy(value, request.data(), channel.channelName(), bookingAgent == null ? null : bookingAgent.name()); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); repository.insert(value); @@ -248,13 +251,13 @@ public class OceanBookingService { return value.trim(); } - private static void copy(OceanBookingEntity v, DraftData d, String resolvedChannelName) { + private static void copy(OceanBookingEntity v, DraftData d, String resolvedChannelName, String resolvedBookingAgentName) { v.setCustomerId(d.customerId()); v.setCustomerName(trim(d.customerName())); v.setChannelCode(d.channelCode().trim()); v.setChannelName(resolvedChannelName); v.setWarehouseId(d.warehouseId()); v.setWarehouseName(d.warehouseName().trim()); v.setBillOfLadingNo(d.billOfLadingNo().trim()); v.setShippingCompanyId(d.shippingCompanyId()); v.setShippingCompanyName(trim(d.shippingCompanyName())); - v.setBookingAgentId(d.bookingAgentId()); v.setBookingAgentName(trim(d.bookingAgentName())); + v.setBookingAgentId(d.bookingAgentId()); v.setBookingAgentName(trim(resolvedBookingAgentName)); v.setVesselName(d.vesselName().trim()); v.setVoyageNo(d.voyageNo().trim()); v.setDeparturePortCode(trim(d.departurePortCode())); v.setDeparturePortName(trim(d.departurePortName())); v.setDestinationPortCode(trim(d.destinationPortCode())); v.setDestinationPortName(trim(d.destinationPortName())); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java index 798ce846d..6e8cf8ab6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java @@ -35,6 +35,7 @@ public class TruckAppointmentService { @Resource SmartLoadingRepository loadingPlans; @Resource ContainerRepository containers; @Resource TruckAppointmentFileClient files; + @Resource WarehouseSupplierSnapshotService suppliers; @Resource ObjectMapper objectMapper; @Value("${baiyue.security.internal-service-token}") String internalToken; public PageResult page(Query q){return repo.page(q);} public TruckAppointmentEntity detail(long id){return enrich(required(id));} @@ -42,8 +43,8 @@ public class TruckAppointmentService { @Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){TruckAppointmentEntity appointment=required(id);if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法上传附件");try{var response=files.upload(internalToken,uid,appointment.getCustomerId(),"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(type);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){TruckAppointmentEntity appointment=required(id); if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法引用附件"); TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,appointment.getCustomerId(),"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(r.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()!=0)throw bad("新增 expectedVersion 必须为0"); OceanBookingEntity booking=validateBookingLink(r); TruckAppointmentEntity e=new TruckAppointmentEntity(); e.setId(IdWorker.getId()); e.setAppointmentNo("TA"+e.getId()); e.setStatus(TruckAppointmentStatus.DRAFT); e.setRevision(0); e.setVersion(0); e.setCreatedAt(LocalDateTime.now()); e.setUpdatedAt(e.getCreatedAt()); copy(e,r,booking); repo.insert(e); record(e,null,e.getStatus().name(),"CREATE",null,uid,name); return e;} - @Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑"); OceanBookingEntity booking=validateBookingLink(r); copy(e,r,booking); if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试"); record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name); return required(id);} + @Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0"); OceanBookingEntity booking=validateBookingLink(r); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); TruckAppointmentEntity e=new TruckAppointmentEntity(); e.setId(IdWorker.getId()); e.setAppointmentNo("TA"+e.getId()); e.setStatus(TruckAppointmentStatus.DRAFT); e.setRevision(0); e.setVersion(0); e.setCreatedAt(LocalDateTime.now()); e.setUpdatedAt(e.getCreatedAt()); copy(e,r,booking,carrier==null?null:carrier.name()); repo.insert(e); record(e,null,e.getStatus().name(),"CREATE",null,uid,name); return e;} + @Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑"); OceanBookingEntity booking=validateBookingLink(r); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); copy(e,r,booking,carrier==null?null:carrier.name()); if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试"); record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name); return 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);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口"); TruckAppointmentStatus from=e.getStatus(),to=next(from,action); if(repo.transition(id,from.name(),to.name(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试"); TruckAppointmentEntity after=required(id);record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);} @Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);} private OceanBookingEntity validateBookingLink(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空"); OceanBookingEntity b=oceanBookings.find(r.oceanBookingId()); if(b==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在"); if(!"RELEASED".equals(b.getStatus()))throw conflict("仅已放舱订舱可创建拖车预约"); if(b.getCustomerId()==null||!StringUtils.hasText(b.getCustomerName()))throw conflict("关联订舱缺少客户快照"); if(r.customerId()!=null&&r.customerId()>0&&!Objects.equals(r.customerId(),b.getCustomerId()))throw bad("customerId与订舱客户不一致"); if(StringUtils.hasText(r.customerName())&&!r.customerName().trim().equals(b.getCustomerName()))throw bad("customerName与订舱客户快照不一致"); if(r.containerBookingId()!=null&&r.containerBookingId()>0){ContainerBookingEntity c=containers.find(r.containerBookingId()); if(c==null)throw new BizException(ResultCode.NOT_FOUND,"关联柜体不存在"); boolean allocated=oceanBookings.allocations(r.oceanBookingId()).stream().anyMatch(a->Objects.equals(a.getContainerBookingId(),r.containerBookingId())); if(!allocated)throw bad("containerBookingId未分配至该订舱"); if(r.warehouseId()!=null&&r.warehouseId()>0&&c.getWarehouseId()!=null&&!Objects.equals(r.warehouseId(),c.getWarehouseId()))throw bad("warehouseId与柜体不一致");} validateLoadingPlanLink(r);return b;} @@ -51,7 +52,7 @@ public class TruckAppointmentService { private TruckAppointmentStatus next(TruckAppointmentStatus s,String a){return switch(a){case"SUBMIT"->s==TruckAppointmentStatus.DRAFT?TruckAppointmentStatus.PENDING_DISPATCH:badStatus();case"DISPATCH"->s==TruckAppointmentStatus.PENDING_DISPATCH||s==TruckAppointmentStatus.RESCHEDULED?TruckAppointmentStatus.DISPATCHED:badStatus();case"PICKUP"->s==TruckAppointmentStatus.DISPATCHED?TruckAppointmentStatus.PICKUP_IN_PROGRESS:badStatus();case"ARRIVE"->s==TruckAppointmentStatus.PICKUP_IN_PROGRESS?TruckAppointmentStatus.ARRIVED_WAREHOUSE:badStatus();case"LOAD"->s==TruckAppointmentStatus.ARRIVED_WAREHOUSE?TruckAppointmentStatus.LOADING:badStatus();case"DEPART"->s==TruckAppointmentStatus.LOADING?TruckAppointmentStatus.DEPARTED:badStatus();case"COMPLETE"->s==TruckAppointmentStatus.DEPARTED?TruckAppointmentStatus.COMPLETED:badStatus();case"EXCEPTION"->s!=TruckAppointmentStatus.COMPLETED&&s!=TruckAppointmentStatus.CANCELLED?TruckAppointmentStatus.EXCEPTION:badStatus();case"CANCEL"->s!=TruckAppointmentStatus.COMPLETED?TruckAppointmentStatus.CANCELLED:badStatus();default->badStatus();};} private TruckAppointmentStatus badStatus(){throw conflict("不允许的状态动作");} private TruckAppointmentEntity required(long id){TruckAppointmentEntity e=repo.find(id);if(e==null)throw new BizException(ResultCode.NOT_FOUND,"拖车预约不存在");return e;} private void checkVersion(TruckAppointmentEntity e,int v){if(e.getVersion()!=v)throw conflict("版本已变化,请刷新后重试");} private static BizException conflict(String m){return new BizException(ResultCode.CONFLICT,m);} private static BizException bad(String m){return new BizException(ResultCode.BAD_REQUEST,m);} private TruckAppointmentEntity enrich(TruckAppointmentEntity value){OceanBookingEntity booking=oceanBookings.find(value.getOceanBookingId());if(booking!=null){value.setOceanBookingNo(booking.getBookingNo());value.setWarehouseName(booking.getWarehouseName());value.setShippingCompanyId(booking.getShippingCompanyId());value.setShippingCompanyName(booking.getShippingCompanyName());value.setBookingAgentId(booking.getBookingAgentId());value.setBookingAgentName(booking.getBookingAgentName());}if(value.getContainerBookingId()!=null){ContainerBookingEntity container=containers.find(value.getContainerBookingId());if(container!=null){value.setCabinetNo(container.getCabinetNo());value.setContainerNo(container.getContainerNo());if(!StringUtils.hasText(value.getWarehouseName()))value.setWarehouseName(container.getWarehouseName());}}return value;} - private void copy(TruckAppointmentEntity e,SaveRequest r,OceanBookingEntity booking){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());e.setWarehouseId(r.warehouseId());e.setCustomerId(booking.getCustomerId());e.setCustomerName(booking.getCustomerName());e.setCarrierId(r.carrierId());e.setCarrierName(r.carrierName());e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setReceivableAmount(r.receivableAmount());e.setCurrency(r.currency()==null?"RMB":r.currency());e.setRemark(r.remark());} + private void copy(TruckAppointmentEntity e,SaveRequest r,OceanBookingEntity booking,String carrierName){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());e.setWarehouseId(r.warehouseId());e.setCustomerId(booking.getCustomerId());e.setCustomerName(booking.getCustomerName());e.setCarrierId(r.carrierId());e.setCarrierName(carrierName);e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setReceivableAmount(r.receivableAmount());e.setCurrency(r.currency()==null?"RMB":r.currency());e.setRemark(r.remark());} private TruckAppointmentEntity claimOrReplay(long id,String action,String key,String hash){validateKey(key);String normalized=key.trim();if(repo.claimOperation(IdWorker.getId(),id,action,normalized,hash))return null;TruckAppointmentOperationEntity existing=repo.operation(normalized);if(existing==null||!Objects.equals(existing.getAppointmentId(),id)||!action.equals(existing.getAction())||!hash.equals(existing.getRequestHash()))throw conflict("Idempotency-Key已用于不同请求");if(!StringUtils.hasText(existing.getResponseSnapshot()))throw conflict("相同请求正在处理中,请稍后重试");try{return objectMapper.readValue(existing.getResponseSnapshot(),TruckAppointmentEntity.class);}catch(JsonProcessingException ex){throw new IllegalStateException("无法读取拖车预约幂等结果",ex);}} private TruckAppointmentEntity completeOperation(long id,String action,String key,TruckAppointmentEntity result){try{String normalized=key.trim();if(repo.completeOperation(id,action,normalized,objectMapper.writeValueAsString(result))!=1)throw conflict("拖车预约幂等请求完成状态冲突");return result;}catch(JsonProcessingException ex){throw new IllegalStateException("无法保存拖车预约幂等结果",ex);}} private String hash(String action,long id,Object request){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((action+'|'+id+'|'+objectMapper.writeValueAsString(request)).getBytes(StandardCharsets.UTF_8)));}catch(Exception ex){throw new IllegalStateException("无法计算拖车预约请求摘要",ex);}} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotService.java new file mode 100644 index 000000000..a543cee5d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotService.java @@ -0,0 +1,51 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.WarehouseSupplierClient; +import feign.FeignException; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +@Service +public class WarehouseSupplierSnapshotService { + @Resource private WarehouseSupplierClient client; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + + public WarehouseSupplierClient.Snapshot booking(Long supplierId, String suppliedName) { + return resolve(supplierId, suppliedName, "BOOKING", "INTERNATIONAL"); + } + + public WarehouseSupplierClient.Snapshot trucking(Long supplierId, String suppliedName) { + return resolve(supplierId, suppliedName, "TRUCKING", null); + } + + public WarehouseSupplierClient.Snapshot customs(Long supplierId, String suppliedName) { + return resolve(supplierId, suppliedName, "CUSTOMS", null); + } + + private WarehouseSupplierClient.Snapshot resolve(Long supplierId, String suppliedName, + String category, String scope) { + if (supplierId == null || supplierId <= 0) { + if (StringUtils.hasText(suppliedName)) throw new BizException(ResultCode.BAD_REQUEST, + "供应商名称必须来自有效的供应商ID,禁止自由文本"); + return null; + } + try { + var response = client.get(internalToken, supplierId, true, category, scope); + if (response == null || !response.successful() || response.data() == null + || !StringUtils.hasText(response.data().name())) { + throw new BizException(ResultCode.CONFLICT, "所选供应商不存在、未启用或类别/范围不匹配"); + } + if (StringUtils.hasText(suppliedName) + && !suppliedName.trim().equals(response.data().name())) { + throw new BizException(ResultCode.BAD_REQUEST, "供应商名称与服务端主数据不一致"); + } + return response.data(); + } catch (FeignException ex) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "供应商主数据服务暂不可用"); + } + } +} diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 2c04a9594..2451863d8 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -155,11 +155,12 @@ export async function run(context) { const transportFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt'] const frozenTransport = Object.fromEntries(transportFields.map((field) => [field, bookingConfirmed?.oceanBooking?.[field]])) const frozenTransportValid = transportFields.every((field) => frozenTransport[field] != null && String(frozenTransport[field]).trim() !== '') + const frozenTransportMatches = frozenTransportValid + && transportFields.every((field) => String(frozenTransport[field]) === String(bookingDraft[field])) context.assert(MODULE, 'CONFIRMED 订舱回显完整且冻结五项运输快照', bookingCreateResponse, - frozenTransportValid - && transportFields.every((field) => String(frozenTransport[field]) === String(bookingDraft[field])), + frozenTransportMatches, `bookingId=${bookingId}, ${transportFields.map((field) => `${field}=${frozenTransport[field] || 'missing'}`).join(', ')}`, OPTIONS) - if (!frozenTransportValid) return + if (!frozenTransportMatches) return const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ') context.assert(MODULE, '一次登记直接返回 CONFIRMED 且不自动生成三期内部柜', bookingCreateResponse, bookingConfirmed?.oceanBooking?.status === 'CONFIRMED' && Boolean(requirement?.id) diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index a4901bbae..a78a4e5b9 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -111,6 +111,15 @@ test('bridge loading starts through a real PDA batch before preload confirmation assert.match(value, /version: transportVersion/) assert.match(value, /Number\.isInteger\(transportVersion\)/) assert.match(value, /billOfLadingNo:[\s\S]*vesselName:[\s\S]*voyageNo:[\s\S]*estimatedSailingAt:[\s\S]*estimatedArrivalAt:/) + assert.match(value, /const frozenTransport = Object\.fromEntries\(transportFields\.map/) + assert.match(value, /const frozenTransportMatches = frozenTransportValid[\s\S]*transportFields\.every/) + assertOrder(value, [ + 'CONFIRMED 订舱回显完整且冻结五项运输快照', + 'if (!frozenTransportMatches) return', + '三期订舱携带已冻结运输快照并放舱', + ]) + assert.match(value, /\.\.\.frozenTransport, reason: context\.marker/) + assert.doesNotMatch(value, /new Date\(Date\.now\(\)/) assert.match(value, /bookingReleased\?\.oceanBooking\?\.status === 'RELEASED'/) }) @@ -185,9 +194,6 @@ test('container loading arranges a simulation before linking a confirmed real bo assert.ok(value.includes("'PATCH', `${CONTAINERS}/${containerId}/transport")) assert.match(value, /在放舱前绑定本轮真实箱号/) assert.match(value, /订舱携带已冻结运输快照放舱并满足 PDA 门禁/) - assert.match(value, /const frozenTransport = Object\.fromEntries\(transportFields\.map/) - assert.match(value, /\.\.\.frozenTransport, reason: context\.marker/) - assert.doesNotMatch(value, /new Date\(Date\.now\(\)/) assert.match(value, /无真实柜号拒绝推送预装计划/) assert.match(value, /柜号与 RELEASED 订舱就绪后推送预装计划/) assert.match(value, /const confirmedTaskAbsent =[\s\S]*if \(!confirmedTaskAbsent\) return/)