From ea26eecc50a9754b02b8ed8b683cc29fef3a52db Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 18 Aug 2026 09:09:28 +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/IamCustomerSnapshotClient.java | 24 ++++ .../firstleg/dto/TruckAppointmentDtos.java | 2 +- .../mapper/TruckAppointmentMapper.java | 2 +- .../service/IamCustomerSnapshotService.java | 38 ++++++ .../firstleg/service/LoadingPickService.java | 29 +++- .../service/TruckAppointmentService.java | 18 ++- .../openapi/truck-appointments-v1.yaml | 51 +++---- ...IamCustomerSnapshotClientContractTest.java | 26 ++++ ...ruckAppointmentControllerContractTest.java | 1 + .../TruckAppointmentMapperContractTest.java | 5 +- .../IamCustomerSnapshotServiceTest.java | 59 ++++++++ .../service/LoadingPickServiceTest.java | 26 ++-- .../service/TruckAppointmentServiceTest.java | 122 ++++++++++++++--- scripts/admin-test-env-modules/README.md | 6 +- .../fulfillment-list-field-contract.mjs | 16 ++- ...t-prototype-field-matrix-contract.test.mjs | 4 +- .../phase3-loading-finalize.mjs | 2 +- .../admin-test-env-modules/phase3-loading.mjs | 2 +- .../phase3-producer-chain-contract.test.mjs | 2 +- .../truck-appointments-contract.test.mjs | 103 +++++++++++--- .../truck-appointments.mjs | 128 ++++++++++++------ 21 files changed, 523 insertions(+), 143 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/client/IamCustomerSnapshotClient.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/IamCustomerSnapshotService.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/client/IamCustomerSnapshotClientContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/IamCustomerSnapshotServiceTest.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/IamCustomerSnapshotClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/IamCustomerSnapshotClient.java new file mode 100644 index 000000000..775caebbf --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/IamCustomerSnapshotClient.java @@ -0,0 +1,24 @@ +package com.baiyue.firstleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +@FeignClient(name = "iam-service", contextId = "firstlegIamCustomerSnapshotClient", + url = "${baiyue.clients.iam.url:}", path = "/internal/iam") +public interface IamCustomerSnapshotClient { + @GetMapping("/finance-customers") + ApiResponse> page( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestParam("page") int page, + @RequestParam("size") int size, + @RequestParam(value = "keyword", required = false) String keyword, + @RequestParam(value = "status", required = false) String status, + @RequestParam(value = "customerId", required = false) Long customerId); + + record Customer(long id, String tenantCode, String tenantName, String status, + String contactName, String contactPhone) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java index dec5c790a..6c61dabc5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java @@ -2,7 +2,7 @@ package com.baiyue.firstleg.dto; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime; public final class TruckAppointmentDtos { private TruckAppointmentDtos(){} public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long customerId, @Positive Long containerBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} } - public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@NotNull @Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@Positive Long customerId,@Size(max=128) String customerName,@Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@DecimalMin("0") BigDecimal receivableAmount,@Size(max=8) String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){} + public record SaveRequest(@Positive Long outboundOrderId,@Positive Long oceanBookingId,@Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@NotNull @Positive Long customerId,@Size(max=128) String customerName,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@DecimalMin("0") BigDecimal receivableAmount,@Size(max=8) String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){} public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason){} public record AttachmentRequest(@NotNull @Positive Long fileId,@Size(max=64) String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java index 885a770b5..4e4f2415d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java @@ -6,5 +6,5 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.myba @Update("UPDATE fl_truck_appointment SET status=#{toStatus}, actual_arrived_warehouse_at=CASE WHEN #{toStatus}='ARRIVED_WAREHOUSE' THEN CURRENT_TIMESTAMP(3) ELSE actual_arrived_warehouse_at END, actual_loading_at=CASE WHEN #{toStatus}='LOADING' THEN CURRENT_TIMESTAMP(3) ELSE actual_loading_at END, actual_departed_at=CASE WHEN #{toStatus}='DEPARTED' THEN CURRENT_TIMESTAMP(3) ELSE actual_departed_at END, revision=revision+1, version=version+1, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int transition(@Param("id") long id,@Param("fromStatus") String from,@Param("toStatus") String to,@Param("version") int version); @Update("UPDATE fl_truck_appointment SET status='COMPLETED',actual_loading_at=COALESCE(actual_loading_at,CURRENT_TIMESTAMP(3)),revision=revision+1,version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int completeLoadingFromPda(@Param("id") long id,@Param("fromStatus") String fromStatus,@Param("version") int version); @Update("UPDATE fl_truck_appointment SET planned_loading_at=#{at}, revision=revision+1, version=version+1, status='RESCHEDULED', remark=#{reason}, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND version=#{version}") int reschedule(@Param("id") long id,@Param("at") java.time.LocalDateTime at,@Param("reason") String reason,@Param("version") int version); - @Update("UPDATE fl_truck_appointment SET ocean_booking_id=#{e.oceanBookingId},container_booking_id=#{e.containerBookingId},loading_plan_id=#{e.loadingPlanId},warehouse_id=#{e.warehouseId},customer_id=#{e.customerId},customer_name=#{e.customerName},carrier_id=#{e.carrierId},carrier_name=#{e.carrierName},vehicle_no=#{e.vehicleNo},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},payable_amount=#{e.payableAmount},receivable_amount=#{e.receivableAmount},planned_loading_at=#{e.plannedLoadingAt},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version); + @Update("UPDATE fl_truck_appointment SET outbound_order_id=#{e.outboundOrderId},ocean_booking_id=#{e.oceanBookingId},container_booking_id=#{e.containerBookingId},loading_plan_id=#{e.loadingPlanId},warehouse_id=#{e.warehouseId},customer_id=#{e.customerId},customer_name=#{e.customerName},carrier_id=#{e.carrierId},carrier_name=#{e.carrierName},fleet_contact=#{e.fleetContact},fleet_phone=#{e.fleetPhone},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},driver_id_card=#{e.driverIdCard},vehicle_no=#{e.vehicleNo},store_name=#{e.storeName},store_contact=#{e.storeContact},store_phone=#{e.storePhone},store_address=#{e.storeAddress},store_city=#{e.storeCity},store_zipcode=#{e.storeZipcode},package_count=#{e.packageCount},gross_weight_kg=#{e.grossWeightKg},volume_cbm=#{e.volumeCbm},pallet_count=#{e.palletCount},po_no=#{e.poNo},planned_loading_at=#{e.plannedLoadingAt},transit_minutes=#{e.transitMinutes},payable_amount=#{e.payableAmount},receivable_amount=#{e.receivableAmount},currency=#{e.currency},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/IamCustomerSnapshotService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/IamCustomerSnapshotService.java new file mode 100644 index 000000000..a94b2e9bd --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/IamCustomerSnapshotService.java @@ -0,0 +1,38 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.IamCustomerSnapshotClient; +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 IamCustomerSnapshotService { + @Resource private IamCustomerSnapshotClient client; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + + public IamCustomerSnapshotClient.Customer active(long customerId, String suppliedName) { + try { + var response = client.page(internalToken, 1, 2, null, "ACTIVE", customerId); + if (response == null || !response.successful() || response.data() == null + || response.data().records() == null || response.data().records().size() != 1) { + throw new BizException(ResultCode.CONFLICT, "所选客户不存在或未启用"); + } + var customer = response.data().records().get(0); + if (customer.id() != customerId || !"ACTIVE".equals(customer.status()) + || !StringUtils.hasText(customer.tenantName())) { + throw new BizException(ResultCode.CONFLICT, "所选客户不存在或未启用"); + } + if (StringUtils.hasText(suppliedName) + && !suppliedName.trim().equals(customer.tenantName())) { + throw new BizException(ResultCode.BAD_REQUEST, "客户名称与服务端主数据不一致"); + } + return customer; + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "客户主数据服务暂不可用"); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index 6922b9c2e..6fda57d36 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -46,8 +46,12 @@ import feign.FeignException; import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -66,6 +70,7 @@ import java.time.LocalDateTime; /** 装柜中 PDA 拣货任务及明细同步。 */ @Service public class LoadingPickService { + private static final Logger log = LoggerFactory.getLogger(LoadingPickService.class); private static final long PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID = 0L; private static final String SOURCE_SERVICE = "firstleg-service"; @Resource @@ -456,8 +461,6 @@ public class LoadingPickService { if (snapshot == null) throw conflict("PDA任务关联入库单不存在"); loadingDeliveryService.requirePdaCompletionReady(owner.tenantId(), owner.inboundId()); } - truckAppointmentService.completeLoadingFromPda(task.getContainerBookingId(), taskId, - operatorId, operatorName); settlementService.settle(taskId, operatorId, operatorName); if (!repository.allPlanItemsPicked(task.getLoadingPlanId())) { throw conflict("柜次仍有未完成PDA拣货的箱数"); @@ -473,9 +476,31 @@ public class LoadingPickService { operatorName, "PDA-" + operationId + '-' + owner.inboundId(), remark); } loadingListService.publishCompletedEvent(task.getLoadingPlanId()); + completeTruckAfterCommit(task.getContainerBookingId(), taskId, operatorId, operatorName); return view(requiredTask(taskId), null); } + private void completeTruckAfterCommit(long containerBookingId, long taskId, + long operatorId, String operatorName) { + Runnable completion = () -> { + try { + truckAppointmentService.completeLoadingFromPda(containerBookingId, taskId, + operatorId, operatorName); + } catch (RuntimeException exception) { + log.warn("PDA loading completed but linked truck appointment was not auto-completed: " + + "containerBookingId={}, taskId={}, reason={}", containerBookingId, taskId, + exception.getMessage()); + } + }; + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override public void afterCommit() { completion.run(); } + }); + } else { + completion.run(); + } + } + public List operations(long taskId) { requiredTask(taskId); return repository.operations(taskId).stream().map(item -> new Operation(item.getId(), item.getPickTaskId(), 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 7301eb6f8..97d54b0bd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java @@ -18,6 +18,7 @@ import feign.FeignException; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Propagation; import org.springframework.util.StringUtils; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -36,6 +37,7 @@ public class TruckAppointmentService { @Resource ContainerRepository containers; @Resource TruckAppointmentFileClient files; @Resource WarehouseSupplierSnapshotService suppliers; + @Resource IamCustomerSnapshotService customers; @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));} @@ -43,22 +45,24 @@ 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"); BookingLink link=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,link,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("当前状态不可编辑"); BookingLink link=validateBookingLink(r); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); copy(e,r,link,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 create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r); var customer=customers.active(r.customerId(),r.customerName()); AssociationContext links=validateAssociations(r,customer.id()); 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,links,customer.tenantName(),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){validateRequired(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("当前状态不可编辑"); var customer=customers.active(r.customerId(),r.customerName()); AssociationContext links=validateAssociations(r,customer.id()); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); copy(e,r,links,customer.tenantName(),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);} - @Transactional public void completeLoadingFromPda(long containerBookingId,long taskId,long uid,String name){java.util.List active=repo.activeForUpdate(containerBookingId);if(active.isEmpty())return;if(active.size()!=1)throw conflict("同一柜次存在多条有效拖车预约,不能确认装柜完成");TruckAppointmentEntity appointment=active.get(0);String from=appointment.getStatus().name();if(repo.completeLoadingFromPda(appointment.getId(),from,appointment.getVersion())!=1)throw conflict("拖车预约已被并发修改,请刷新后重试");TruckAppointmentEntity after=required(appointment.getId());record(after,from,TruckAppointmentStatus.COMPLETED.name(),"PDA_COMPLETE_LOADING","PDA task "+taskId+" 确认装柜完成",uid,name);} - private BookingLink validateBookingLink(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空"); if(r.containerBookingId()==null||r.containerBookingId()<=0)throw bad("containerBookingId不能为空"); 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与订舱客户快照不一致"); 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与柜体不一致"); LoadingPlanEntity plan=loadingPlans.planByBooking(r.containerBookingId()); if(plan==null)throw conflict("关联柜次尚未建立装柜计划"); if(r.loadingPlanId()!=null&&!Objects.equals(r.loadingPlanId(),plan.getId()))throw bad("loadingPlanId与柜体当前装柜计划不一致"); return new BookingLink(b,plan);} + @Transactional(propagation=Propagation.REQUIRES_NEW) public void completeLoadingFromPda(long containerBookingId,long taskId,long uid,String name){java.util.List active=repo.activeForUpdate(containerBookingId);if(active.isEmpty())return;if(active.size()!=1)throw conflict("同一柜次存在多条有效拖车预约,跳过自动完成");TruckAppointmentEntity appointment=active.get(0);String from=appointment.getStatus().name();if(repo.completeLoadingFromPda(appointment.getId(),from,appointment.getVersion())!=1)throw conflict("拖车预约已被并发修改,跳过自动完成");TruckAppointmentEntity after=required(appointment.getId());record(after,from,TruckAppointmentStatus.COMPLETED.name(),"PDA_COMPLETE_LOADING","PDA task "+taskId+" 确认装柜完成",uid,name);} + private AssociationContext validateAssociations(SaveRequest r,long customerId){OceanBookingEntity booking=null;ContainerBookingEntity container=null;Long warehouseId=r.warehouseId();if(r.oceanBookingId()!=null){booking=oceanBookings.find(r.oceanBookingId());if(booking==null)throw new BizException(ResultCode.NOT_FOUND,"关联订舱不存在");if(booking.getCustomerId()!=null&&!Objects.equals(customerId,booking.getCustomerId()))throw bad("customerId与订舱客户不一致");warehouseId=mergeWarehouse(warehouseId,booking.getWarehouseId(),"订舱");}if(r.containerBookingId()!=null){container=containers.find(r.containerBookingId());if(container==null)throw new BizException(ResultCode.NOT_FOUND,"关联柜次不存在");if(container.getCustomerId()!=null&&!Objects.equals(customerId,container.getCustomerId()))throw bad("customerId与柜次客户不一致");warehouseId=mergeWarehouse(warehouseId,container.getWarehouseId(),"柜次");}if(booking!=null&&container!=null){long containerId=container.getId();boolean allocated=oceanBookings.allocations(booking.getId()).stream().anyMatch(a->Objects.equals(a.getContainerBookingId(),containerId));if(!allocated)throw bad("containerBookingId未分配至该订舱");}if(r.loadingPlanId()!=null){if(container==null)throw bad("选择loadingPlanId时必须同时选择containerBookingId");LoadingPlanEntity plan=loadingPlans.planByBooking(container.getId());if(plan==null||!Objects.equals(r.loadingPlanId(),plan.getId()))throw bad("loadingPlanId与柜次当前装柜计划不一致");}return new AssociationContext(booking,container,warehouseId);} + private static void validateRequired(SaveRequest r){if(r.customerId()==null||r.customerId()<=0)throw bad("customerId不能为空");if(r.carrierId()==null||r.carrierId()<=0)throw bad("carrierId不能为空");if(r.plannedLoadingAt()==null)throw bad("plannedLoadingAt不能为空");if(r.expectedVersion()==null||r.expectedVersion()<0)throw bad("expectedVersion不合法");} + private static Long mergeWarehouse(Long requested,Long linked,String source){if(requested!=null&&linked!=null&&!Objects.equals(requested,linked))throw bad("warehouseId与"+source+"不一致");return requested!=null?requested:linked;} 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());}}value.setAttachmentCount((long)repo.attachments(value.getId()).size());return value;} - private void copy(TruckAppointmentEntity e,SaveRequest r,BookingLink link,String carrierName){OceanBookingEntity booking=link.booking();e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(link.plan().getId());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 enrich(TruckAppointmentEntity value){if(value.getOceanBookingId()!=null){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());}}value.setAttachmentCount((long)repo.attachments(value.getId()).size());return value;} + private void copy(TruckAppointmentEntity e,SaveRequest r,AssociationContext links,String customerName,String carrierName){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());e.setWarehouseId(links.warehouseId());e.setCustomerId(r.customerId());e.setCustomerName(customerName);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);}} private static void verifyClientHash(String clientHash,String serverHash){if(StringUtils.hasText(clientHash)&&!serverHash.equalsIgnoreCase(clientHash.trim()))throw bad("X-Request-Hash与请求内容不一致");} private static void validateKey(String key){if(!StringUtils.hasText(key)||key.trim().length()>128)throw bad("Idempotency-Key不能为空且不能超过128个字符");} private record ReschedulePayload(LocalDateTime plannedLoadingAt,ActionRequest request){} - private record BookingLink(OceanBookingEntity booking,LoadingPlanEntity plan){} + private record AssociationContext(OceanBookingEntity booking,ContainerBookingEntity container,Long warehouseId){} private void record(TruckAppointmentEntity e,String from,String to,String action,String reason,long uid,String name){TruckAppointmentHistoryEntity h=new TruckAppointmentHistoryEntity();h.setId(IdWorker.getId());h.setAppointmentId(e.getId());h.setRevision(e.getRevision());h.setAction(action);h.setFromStatus(from);h.setToStatus(to);h.setReason(reason);h.setOperatorId(uid);h.setOperatorName(name);h.setCreatedAt(LocalDateTime.now());repo.history(h);} } diff --git a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml index 1f716f79f..44275a5a9 100644 --- a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml +++ b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml @@ -1,8 +1,8 @@ openapi: 3.0.3 info: title: Truck Appointment API - version: 1.1.0 - description: PDA final complete uses the stable containerBookingId to lock the unique active appointment, transition it to COMPLETED and append operator history. No appointment does not block loading completion; multiple active appointments or an optimistic-lock conflict return A0409. + version: 1.2.0 + description: Truck appointments are independent operational records. Ocean booking, container booking and loading plan are optional links. After a PDA loading transaction commits, the server best-effort completes the unique active appointment explicitly linked to that container in an isolated transaction; absence, duplicates or concurrent truck edits never roll back loading completion. paths: /api/platform/v1/truck-appointments: get: @@ -22,7 +22,7 @@ paths: properties: records: {type: array, items: {$ref: '#/components/schemas/DetailStableReferences'}} post: - summary: Create a truck appointment linked to a released ocean booking + summary: Create an independent truck appointment with optional fulfillment links requestBody: required: true content: @@ -166,33 +166,38 @@ components: reason: {type: string, maxLength: 500} SaveRequest: type: object - required: [oceanBookingId, containerBookingId, plannedLoadingAt, expectedVersion] + required: [customerId, carrierId, plannedLoadingAt, expectedVersion] properties: - oceanBookingId: {type: integer, format: int64, minimum: 1} - containerBookingId: {type: integer, format: int64, minimum: 1} - loadingPlanId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional consistency hint; the server resolves and freezes the current loadingPlanId from containerBookingId and rejects a mismatch.} - carrierId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional ACTIVE Warehouse supplier ID validated as TRUCKING.} - carrierName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; the persisted snapshot comes from Warehouse and cannot be supplied without carrierId.} + outboundOrderId: {type: integer, format: int64, minimum: 1, nullable: true} + oceanBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional booking association; no RELEASED-state prerequisite. When supplied, customer and warehouse must match.} + containerBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional ACTIVE container association. When supplied, customer and warehouse must match.} + loadingPlanId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional loading-plan association. It may be supplied only with containerBookingId and must be that container's current plan.} + warehouseId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional warehouse scope. Linked booking/container warehouse facts must agree; when omitted the linked fact is frozen if available.} + customerId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE IAM customer used for isolation and attachment ownership.} + customerName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; persisted value is resolved from IAM customer master data.} + carrierId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE Warehouse supplier ID validated as TRUCKING.} + carrierName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; the persisted snapshot comes from Warehouse supplier master data.} plannedLoadingAt: {type: string, format: date-time} - expectedVersion: {type: integer, minimum: 0} + expectedVersion: {type: integer, minimum: 0, description: Must be 0 on create; current version on edit.} DetailStableReferences: type: object - description: Stable IDs plus server-resolved saved resource names returned by appointment detail. + required: [customerId, customerName, carrierId, carrierName, attachmentCount] + description: Stable IDs plus server-resolved saved resource names returned by appointment detail. Fulfillment associations remain null for an independently created appointment. properties: - oceanBookingId: {type: integer, format: int64} - oceanBookingNo: {type: string} - containerBookingId: {type: integer, format: int64} - cabinetNo: {type: string} + oceanBookingId: {type: integer, format: int64, nullable: true} + oceanBookingNo: {type: string, nullable: true} + containerBookingId: {type: integer, format: int64, nullable: true} + cabinetNo: {type: string, nullable: true} containerNo: {type: string, nullable: true} - loadingPlanId: {type: integer, format: int64} - warehouseId: {type: integer, format: int64} - warehouseName: {type: string} + loadingPlanId: {type: integer, format: int64, nullable: true} + warehouseId: {type: integer, format: int64, nullable: true} + warehouseName: {type: string, nullable: true} customerId: {type: integer, format: int64} - customerName: {type: string, description: Frozen from the associated ocean booking; request input cannot override it} - shippingCompanyId: {type: integer, format: int64} - shippingCompanyName: {type: string} - bookingAgentId: {type: integer, format: int64} - bookingAgentName: {type: string} + customerName: {type: string, description: Frozen from ACTIVE IAM customer master data; request input cannot override it} + shippingCompanyId: {type: integer, format: int64, nullable: true} + shippingCompanyName: {type: string, nullable: true} + bookingAgentId: {type: integer, format: int64, nullable: true} + bookingAgentName: {type: string, nullable: true} carrierId: {type: integer, format: int64} carrierName: {type: string} attachmentCount: {type: integer, format: int64, minimum: 0, description: Number of attachment references owned by this appointment} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/client/IamCustomerSnapshotClientContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/client/IamCustomerSnapshotClientContractTest.java new file mode 100644 index 000000000..a8e719c65 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/client/IamCustomerSnapshotClientContractTest.java @@ -0,0 +1,26 @@ +package com.baiyue.firstleg.client; + +import org.junit.jupiter.api.Test; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class IamCustomerSnapshotClientContractTest { + @Test + void usesInternalIamCustomerQueryWithTokenAndExactCustomerFilter() throws Exception { + FeignClient annotation = IamCustomerSnapshotClient.class.getAnnotation(FeignClient.class); + assertThat(annotation.path()).isEqualTo("/internal/iam"); + Method method = IamCustomerSnapshotClient.class.getDeclaredMethod("page", String.class, + int.class, int.class, String.class, String.class, Long.class); + assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly("/finance-customers"); + assertThat(((RequestHeader) method.getParameterAnnotations()[0][0]).value()) + .isEqualTo("X-Internal-Service-Token"); + assertThat(((RequestParam) method.getParameterAnnotations()[4][0]).value()).isEqualTo("status"); + assertThat(((RequestParam) method.getParameterAnnotations()[5][0]).value()).isEqualTo("customerId"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java index bddfd4463..577dc6326 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java @@ -19,6 +19,7 @@ class TruckAppointmentControllerContractTest { @Test void exposesDedicatedEndpointAndPermissions() {String p=PlatformTruckAppointmentController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0];assertThat(p).isEqualTo("/api/platform/v1/truck-appointments"); for(String n:new String[]{"page","detail","history","attachments","attach","upload","deleteAttachment","create","edit","action","reschedule"}) assertThat(java.util.Arrays.stream(PlatformTruckAppointmentController.class.getDeclaredMethods()).anyMatch(m->m.getName().equals(n))).isTrue();} @Test void schemaHasAggregateTablesStatesAndActualEventTimes() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column");} @Test void openApiDocumentsLifecycleIdempotencyAndActualTimes() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/{id}/{action}:","/api/platform/v1/truck-appointments/{id}/reschedule:","name: Idempotency-Key","required: true","actual event timestamps");} + @Test void openApiFreezesIndependentCreateAndOptionalAssociations() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [customerId, carrierId, plannedLoadingAt, expectedVersion]","independent operational records","no RELEASED-state prerequisite","It may be supplied only with containerBookingId","ACTIVE IAM customer","ACTIVE Warehouse supplier ID validated as TRUCKING","Fulfillment associations remain null");} @Test void openApiFreezesAttachmentRequestAndOwnershipContract() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId]","maxLength: 64","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","cabinetNo:");} @Test void methodsHavePlatformPermissionsAndAuthenticatedScope() { Map expected=Map.ofEntries( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java index 493c00b5c..69f8ac80b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java @@ -26,6 +26,9 @@ class TruckAppointmentMapperContractTest { String reschedule = TruckAppointmentMapper.class.getMethod("reschedule", long.class, java.time.LocalDateTime.class, String.class, int.class).getAnnotation(Update.class).value()[0]; assertThat(reschedule).contains("status='RESCHEDULED'", "version=#{version}", "revision=revision+1"); String edit = TruckAppointmentMapper.class.getMethod("updateEdit", TruckAppointmentEntity.class, int.class).getAnnotation(Update.class).value()[0]; - assertThat(edit).contains("WHERE id=#{e.id} AND version=#{version}", "version=version+1", "revision=revision+1"); + assertThat(edit).contains("ocean_booking_id=#{e.oceanBookingId}", "container_booking_id=#{e.containerBookingId}", + "loading_plan_id=#{e.loadingPlanId}", "customer_id=#{e.customerId}", "carrier_id=#{e.carrierId}", + "planned_loading_at=#{e.plannedLoadingAt}", "WHERE id=#{e.id} AND version=#{version}", + "version=version+1", "revision=revision+1"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/IamCustomerSnapshotServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/IamCustomerSnapshotServiceTest.java new file mode 100644 index 000000000..821a4164d --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/IamCustomerSnapshotServiceTest.java @@ -0,0 +1,59 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.client.IamCustomerSnapshotClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class IamCustomerSnapshotServiceTest { + @Mock IamCustomerSnapshotClient client; + private IamCustomerSnapshotService service; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + service = new IamCustomerSnapshotService(); + ReflectionTestUtils.setField(service, "client", client); + ReflectionTestUtils.setField(service, "internalToken", "internal-token"); + } + + @Test + void resolvesExactlyOneActiveCustomerAndUsesServerName() { + var customer = new IamCustomerSnapshotClient.Customer( + 7L, "C-7", "Canonical Customer", "ACTIVE", null, null); + when(client.page("internal-token", 1, 2, null, "ACTIVE", 7L)) + .thenReturn(ApiResponse.success(new PageResult<>(List.of(customer), 1, 2, 1, 1))); + + var result = service.active(7L, "Canonical Customer"); + + assertThat(result.tenantName()).isEqualTo("Canonical Customer"); + verify(client).page("internal-token", 1, 2, null, "ACTIVE", 7L); + } + + @Test + void rejectsMissingInactiveOrClientSuppliedDifferentName() { + when(client.page("internal-token", 1, 2, null, "ACTIVE", 7L)) + .thenReturn(ApiResponse.success(new PageResult<>(List.of(), 1, 2, 0, 0))); + assertThatThrownBy(() -> service.active(7L, null)) + .isInstanceOf(BizException.class).hasMessageContaining("不存在或未启用"); + + var customer = new IamCustomerSnapshotClient.Customer( + 7L, "C-7", "Canonical Customer", "ACTIVE", null, null); + when(client.page("internal-token", 1, 2, null, "ACTIVE", 7L)) + .thenReturn(ApiResponse.success(new PageResult<>(List.of(customer), 1, 2, 1, 1))); + assertThatThrownBy(() -> service.active(7L, "Forged Customer")) + .isInstanceOf(BizException.class).hasMessageContaining("不一致"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 433ab5d6b..dbaefc6b3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -532,29 +532,33 @@ class LoadingPickServiceTest { } @Test - void conflictingTruckAppointmentsLeavePdaTaskPlanAndSettlementUntouched() { + void conflictingTruckAppointmentsDoNotBlockPdaCompletion() { LoadingPickTaskEntity task = task(); + LoadingPickItemEntity item = item(); when(repository.taskForUpdate(20L)).thenReturn(task); + when(repository.task(20L)).thenReturn(task); + when(repository.items(20L)).thenReturn(List.of(item)); when(repository.allTaskItemsResolved(20L)).thenReturn(true); + when(repository.allPlanItemsPicked(10L)).thenReturn(true); + when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1); when(repository.review(20L)).thenReturn(null); var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L); when(repository.inboundOwners(20L)).thenReturn(List.of(owner)); when(repository.inboundForUpdate(101L, 50L)).thenReturn( new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7)); doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, - "同一柜次存在多条有效拖车预约,不能确认装柜完成")) + "同一柜次存在多条有效拖车预约,跳过自动完成")) .when(truckAppointmentService).completeLoadingFromPda(99L, 20L, 9L, "operator"); - assertThatThrownBy(() -> service.complete(20L, - new CompleteLoadingRequest(0, "complete-blocked", "checked"), 9L, "operator")) - .isInstanceOf(BizException.class).hasMessageContaining("多条有效拖车预约"); + service.complete(20L, + new CompleteLoadingRequest(0, "complete-not-blocked", "checked"), 9L, "operator"); - verify(settlementService, never()).settle(anyLong(), anyLong(), anyString()); - verify(repository, never()).approve(anyLong(), anyLong(), anyString(), any()); - verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString()); - verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(), - anyString(), anyString(), any()); - verify(loadingListService, never()).publishCompletedEvent(anyLong()); + verify(settlementService).settle(20L, 9L, "operator"); + verify(repository).approve(20L, 9L, "operator", "checked"); + verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); + verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator", + "PDA-complete-not-blocked-50", "checked"); + verify(loadingListService).publishCompletedEvent(10L); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java index 6ddee035f..75d905624 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java @@ -15,6 +15,8 @@ import com.baiyue.firstleg.repository.ContainerRepository; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity; +import com.baiyue.firstleg.client.IamCustomerSnapshotClient; +import com.baiyue.firstleg.client.WarehouseSupplierClient; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -40,11 +42,16 @@ class TruckAppointmentServiceTest { @Mock SmartLoadingRepository loadingPlans; @Mock ContainerRepository containers; @Mock WarehouseSupplierSnapshotService suppliers; + @Mock IamCustomerSnapshotService customers; @Spy ObjectMapper objectMapper = JsonMapper.builder().findAndAddModules().build(); @InjectMocks TruckAppointmentService service; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); - when(suppliers.trucking(any(), any())).thenReturn(null); + when(customers.active(anyLong(), any())).thenAnswer(invocation -> + new IamCustomerSnapshotClient.Customer(invocation.getArgument(0), "C-7", + "Canonical Customer", "ACTIVE", null, null)); + when(suppliers.trucking(any(), any())).thenReturn( + new WarehouseSupplierClient.Snapshot(8L, "Canonical Carrier", "TRUCKING", "ACTIVE")); } @Test void detailResolvesStableNamesAndIdentifiersFromLinkedResources() { @@ -75,49 +82,103 @@ class TruckAppointmentServiceTest { verifyNoInteractions(repository); } - @Test void createRejectsBookingThatIsNotReleased() { + @Test void createWithoutBookingContainerOrPlanWritesIndependentDraft() { SaveRequest request = mock(SaveRequest.class); when(request.expectedVersion()).thenReturn(0); - when(request.oceanBookingId()).thenReturn(99L); - when(request.containerBookingId()).thenReturn(88L); - OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED"); - when(oceanBookings.find(99L)).thenReturn(booking); - assertThatThrownBy(() -> service.create(7L, "admin", request)).isInstanceOf(BizException.class).hasMessageContaining("放舱"); - verify(repository, never()).insert(any()); + when(request.customerId()).thenReturn(7L); + when(request.carrierId()).thenReturn(8L); + when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0)); + when(request.oceanBookingId()).thenReturn(null); + when(request.containerBookingId()).thenReturn(null); + when(request.loadingPlanId()).thenReturn(null); + when(request.warehouseId()).thenReturn(null); + + service.create(7L, "admin", request); + + ArgumentCaptor entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class); + verify(repository).insert(entity.capture()); + assertThat(entity.getValue().getOceanBookingId()).isNull(); + assertThat(entity.getValue().getContainerBookingId()).isNull(); + assertThat(entity.getValue().getLoadingPlanId()).isNull(); + assertThat(entity.getValue().getCustomerName()).isEqualTo("Canonical Customer"); + assertThat(entity.getValue().getCarrierName()).isEqualTo("Canonical Carrier"); + verifyNoInteractions(oceanBookings, containers, loadingPlans); } - @Test void createAcceptsReleasedBookingAndWritesDraft() { + @Test void createAcceptsConfirmedBookingWithoutContainerOrPlan() { SaveRequest request = mock(SaveRequest.class); when(request.expectedVersion()).thenReturn(0); when(request.oceanBookingId()).thenReturn(99L); - when(request.containerBookingId()).thenReturn(88L); when(request.customerId()).thenReturn(7L); + when(request.carrierId()).thenReturn(8L); + when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0)); + when(request.containerBookingId()).thenReturn(null); when(request.loadingPlanId()).thenReturn(null); - OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(7L); booking.setCustomerName("Canonical Customer"); + when(request.warehouseId()).thenReturn(null); + OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED"); booking.setCustomerId(7L); booking.setWarehouseId(18L); when(oceanBookings.find(99L)).thenReturn(booking); - ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(88L); - when(containers.find(88L)).thenReturn(container); - var allocation = new OceanBookingContainerAllocationEntity(); allocation.setContainerBookingId(88L); - when(oceanBookings.allocations(99L)).thenReturn(java.util.List.of(allocation)); - LoadingPlanEntity plan = new LoadingPlanEntity(); plan.setId(77L); plan.setContainerBookingId(88L); - when(loadingPlans.planByBooking(88L)).thenReturn(plan); service.create(7L, "admin", request); ArgumentCaptor entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class); verify(repository).insert(entity.capture()); assertThat(entity.getValue().getCustomerId()).isEqualTo(7L); assertThat(entity.getValue().getCustomerName()).isEqualTo("Canonical Customer"); - assertThat(entity.getValue().getContainerBookingId()).isEqualTo(88L); - assertThat(entity.getValue().getLoadingPlanId()).isEqualTo(77L); + assertThat(entity.getValue().getOceanBookingId()).isEqualTo(99L); + assertThat(entity.getValue().getContainerBookingId()).isNull(); + assertThat(entity.getValue().getLoadingPlanId()).isNull(); + assertThat(entity.getValue().getWarehouseId()).isEqualTo(18L); verify(repository).history(any(TruckAppointmentHistoryEntity.class)); } + @Test void createRejectsBookingFromAnotherCustomer() { + SaveRequest request = validRequest(); + when(request.oceanBookingId()).thenReturn(99L); + OceanBookingEntity booking = new OceanBookingEntity(); + booking.setId(99L); booking.setCustomerId(17L); + when(oceanBookings.find(99L)).thenReturn(booking); + + assertThatThrownBy(() -> service.create(7L, "admin", request)) + .isInstanceOf(BizException.class).hasMessageContaining("customerId").hasMessageContaining("订舱"); + verify(repository, never()).insert(any()); + } + + @Test void createWithBookingAndContainerRequiresTheirAllocation() { + SaveRequest request = validRequest(); + when(request.oceanBookingId()).thenReturn(99L); + when(request.containerBookingId()).thenReturn(88L); + OceanBookingEntity booking = new OceanBookingEntity(); + booking.setId(99L); booking.setCustomerId(7L); booking.setWarehouseId(18L); + ContainerBookingEntity container = new ContainerBookingEntity(); + container.setId(88L); container.setCustomerId(7L); container.setWarehouseId(18L); + when(oceanBookings.find(99L)).thenReturn(booking); + when(containers.find(88L)).thenReturn(container); + when(oceanBookings.allocations(99L)).thenReturn(List.of()); + + assertThatThrownBy(() -> service.create(7L, "admin", request)) + .isInstanceOf(BizException.class).hasMessageContaining("未分配至该订舱"); + verify(repository, never()).insert(any()); + } + + @Test void createRejectsLoadingPlanWithoutContainerAssociation() { + SaveRequest request = validRequest(); + when(request.loadingPlanId()).thenReturn(77L); + + assertThatThrownBy(() -> service.create(7L, "admin", request)) + .isInstanceOf(BizException.class).hasMessageContaining("loadingPlanId") + .hasMessageContaining("containerBookingId"); + verify(repository, never()).insert(any()); + } + @Test void createRejectsLoadingPlanFromAnotherContainer() { SaveRequest request = mock(SaveRequest.class); when(request.expectedVersion()).thenReturn(0); when(request.oceanBookingId()).thenReturn(99L); when(request.containerBookingId()).thenReturn(88L); when(request.loadingPlanId()).thenReturn(77L); - OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(7L); booking.setCustomerName("Canonical Customer"); + when(request.customerId()).thenReturn(7L); + when(request.carrierId()).thenReturn(8L); + when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0)); + when(request.warehouseId()).thenReturn(null); + OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED"); booking.setCustomerId(7L); when(oceanBookings.find(99L)).thenReturn(booking); ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(88L); when(containers.find(88L)).thenReturn(container); @@ -138,7 +199,11 @@ class TruckAppointmentServiceTest { when(request.oceanBookingId()).thenReturn(99L); when(request.containerBookingId()).thenReturn(88L); when(request.loadingPlanId()).thenReturn(77L); - OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(7L); booking.setCustomerName("Canonical Customer"); + when(request.customerId()).thenReturn(7L); + when(request.carrierId()).thenReturn(8L); + when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0)); + when(request.warehouseId()).thenReturn(null); + OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED"); booking.setCustomerId(7L); when(oceanBookings.find(99L)).thenReturn(booking); ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(88L); when(containers.find(88L)).thenReturn(container); @@ -148,7 +213,7 @@ class TruckAppointmentServiceTest { when(loadingPlans.planByBooking(88L)).thenReturn(null); assertThatThrownBy(() -> service.create(7L, "admin", request)) - .isInstanceOf(BizException.class).hasMessageContaining("尚未建立装柜计划"); + .isInstanceOf(BizException.class).hasMessageContaining("loadingPlanId").hasMessageContaining("当前装柜计划"); verify(repository, never()).insert(any()); } @@ -292,4 +357,17 @@ class TruckAppointmentServiceTest { return (SaveRequest) c.newInstance(args); } catch (ReflectiveOperationException ex) { throw new AssertionError(ex); } } + + private static SaveRequest validRequest() { + SaveRequest request = mock(SaveRequest.class); + when(request.expectedVersion()).thenReturn(0); + when(request.customerId()).thenReturn(7L); + when(request.carrierId()).thenReturn(8L); + when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0)); + when(request.oceanBookingId()).thenReturn(null); + when(request.containerBookingId()).thenReturn(null); + when(request.loadingPlanId()).thenReturn(null); + when(request.warehouseId()).thenReturn(null); + return request; + } } diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 59bb1e4f0..7e12fdec3 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -50,7 +50,7 @@ export async function run(context) { | 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-customs.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | | 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` | | 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs` | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` | -| 拖车预约 | `truck-appointments.mjs` | `truck-appointments-contract.test.mjs` | +| 独立拖车预约与可选履约关联 | `truck-appointments.mjs`(同 marker 客户 + ACTIVE TRUCKING 承运商;订舱/柜次/计划可空) | `truck-appointments-contract.test.mjs` | | 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` | | Finance 真实前置、对账、结算与下载中心 | `finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs`、`finance-downloads.mjs` | `finance-full-fixtures-contract.test.mjs`、`finance-reconciliation-full-contract.test.mjs`、`finance-settlement-full-contract.test.mjs`、`finance-downloads-contract.test.mjs` | | 初始化基线(仅离线) | — | `initialization-baseline-contract.test.mjs` | @@ -63,11 +63,11 @@ export async function run(context) { 默认 Admin 全量入口已注册并按同一 `context.marker` 串行执行货柜主链。冻结时序为: -`WAIT_SPLIT -> SIMULATION/ACTIVE 内部模拟柜 -> 智能排货 -> 无集装箱号 push-preload -> exact replay/冲突零漂移 -> 订舱逐柜登记 OCEAN_BOOKING/ACTIVE 真实柜 -> 按 realContainerBookingId 原地合并(来源 MERGED,模拟柜身份存活)-> RELEASED -> 计划级原子确认 -> 唯一 PDA task -> claim 后 LOADING -> 原子批量拣货 -> AWAITING_REVIEW -> 五类证据 -> LOADING 阶段增量入库单/旧 inventory token A0409/刷新后续拣 -> PDA task complete 联动同柜有效拖车(如有)并同事务完成 task/plan/inbound -> LOADED`。 +`WAIT_SPLIT -> SIMULATION/ACTIVE 内部模拟柜 -> 智能排货 -> 无集装箱号 push-preload -> exact replay/冲突零漂移 -> 订舱逐柜登记 OCEAN_BOOKING/ACTIVE 真实柜 -> 按 realContainerBookingId 原地合并(来源 MERGED,模拟柜身份存活)-> RELEASED -> 计划级原子确认 -> 唯一 PDA task -> claim 后 LOADING -> 原子批量拣货 -> AWAITING_REVIEW -> 五类证据 -> LOADING 阶段增量入库单/旧 inventory token A0409/刷新后续拣 -> PDA task complete 先完成 task/plan/inbound -> LOADED -> 提交后独立尝试同步唯一同柜有效拖车`。 其中,无号 push 只冻结装柜计划、建立 bridge 并使预装可见,不得创建 PDA task;真实柜号由合并动作从未消费的订舱真实柜带入,合并前、订舱未 `RELEASED` 或整份计划未确认时,PDA 查询必须返回业务码 `A0404`。合并后正常列表只保留模拟柜原地转成的一个 ACTIVE 操作身份,PDA 禁止使用 MERGED 来源 ID。唯一确认入口为 `POST /api/platform/v1/first-leg/loading-plans/{loadingPlanId}/confirm`,请求体只含当前计划 `expectedVersion` 并携带 `Idempotency-Key`;成功必须返回 `confirmationStatus=CONFIRMED`、`readiness.readyForPda=true` 和唯一 `readiness.pdaTaskId`。验收脚本禁止调用旧入库单级 `/preload/confirming`、`/preload/confirm`。 -计划在 `EDITING/LOADING` 可通过正式 assignments/PATCH/DELETE 端点增量;计划、任务和明细版本必须递增,PDA 四元身份不变,旧 `inventorySnapshotToken` 必须返回 `A0409 库存已变化,请刷新后重试`。已拣项不可删或减量,未拣项可删;增量会把全拣的 `AWAITING_REVIEW` 撤回为 `IN_PROGRESS`,刷新 task detail 与 inventory 后续拣才可再次等待最终确认。最终唯一完成入口仍为 `POST /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete`:保留同柜 `RELEASED` 订舱、bridge 与五类 ACTIVE 证据门禁;拖车状态不是前置门禁,无有效拖车时继续,恰有一条有效拖车时同一事务推进为 `COMPLETED`,多条或并发冲突返回 `A0409` 且本地状态回滚。PDA 结算可能先递增来源入库单版本或创建实装分片,完成动作必须在结算后重新锁定实际完成行并使用当前版本;HTTP finalizer 会回查 full/partial 履约分片,失败时保存 `requestId/traceId` 并断言 task/plan/inbound/operation 零漂移。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。 +计划在 `EDITING/LOADING` 可通过正式 assignments/PATCH/DELETE 端点增量;计划、任务和明细版本必须递增,PDA 四元身份不变,旧 `inventorySnapshotToken` 必须返回 `A0409 库存已变化,请刷新后重试`。已拣项不可删或减量,未拣项可删;增量会把全拣的 `AWAITING_REVIEW` 撤回为 `IN_PROGRESS`,刷新 task detail 与 inventory 后续拣才可再次等待最终确认。最终唯一完成入口仍为 `POST /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/complete`:保留同柜 `RELEASED` 订舱、bridge 与五类 ACTIVE 证据门禁;拖车状态和数量不是前置门禁。主事务提交后,零条有效拖车直接跳过,恰有一条时在独立事务尝试推进 `COMPLETED`,多条或并发冲突只记录拖车同步失败,不回滚已完成的 task/plan/inbound。PDA 结算可能先递增来源入库单版本或创建实装分片,完成动作必须在结算后重新锁定实际完成行并使用当前版本;HTTP finalizer 会回查 full/partial 履约分片,失败时保存 `requestId/traceId` 并断言 task/plan/inbound/operation 零漂移。后续模块必须复用 `phase3Loading` 发布的本轮 `taskId + inboundId + loadingPlanId + containerBookingId`,不得回退历史资源。 `phase3-pda-convergence` 同时回查 `/tasks/pending` 与稳定 task detail:两者的 `workflowStatus`、`itemCount`、`pendingItemCount` 必须来自同一任务明细事实。全拣任务必须在两处均为 `AWAITING_REVIEW`,可执行证据 GET,但派生 `actions=[]`,不得重复发送 batch;操作历史必须恰有一次有效 `BATCH`。若 pending 仍按 assignment 返回 `IN_PROGRESS`,该轮以真实 HTTP 失败阻断,不由脚本或前端改写状态。 diff --git a/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs b/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs index dc04c7c5c..24ada16cb 100644 --- a/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs +++ b/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs @@ -114,15 +114,17 @@ export function truckAppointmentFieldGaps(record) { return gaps(record, { id: isPositiveId, appointmentNo: isNonBlank, - oceanBookingId: isPositiveId, - containerBookingId: isPositiveId, - loadingPlanId: isPositiveId, - warehouseId: isPositiveId, - warehouseName: isNonBlank, + oceanBookingId: (value) => value == null || isPositiveId(value), + containerBookingId: (value) => value == null || isPositiveId(value), + loadingPlanId: (value) => value == null || isPositiveId(value), + warehouseId: (value) => value == null || isPositiveId(value), plannedLoadingAt: isIsoDateTime, status: isNonBlank, - customerName: (value, item) => item.customerId == null ? value == null : isNonBlank(value), - carrierName: (value, item) => item.carrierId == null ? value == null : isNonBlank(value), + customerId: isPositiveId, + customerName: isNonBlank, + carrierId: isPositiveId, + carrierName: isNonBlank, + attachmentCount: isNonNegativeNumber, }) } diff --git a/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs b/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs index 0114a2974..33330e2b0 100644 --- a/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs @@ -56,7 +56,7 @@ test('Admin 当前列表逐字段列与验收矩阵一致', () => { 'loading-lists/components/LoadingListsView.vue': ['preloadNo', 'customers', 'skuCount', 'loadingListNo', 'totalPackageCount', 'totalGrossWeightKg', 'totalNetWeightKg', 'totalVolumeCbm', 'totalQuantity', 'createdByName'], 'ocean-bookings/pages/OceanBookingsView.vue': ['bookingNo', 'customerName', 'billOfLadingNo', 'carrierName', 'bookingAgent', 'vesselName', 'pol', 'pod', 'etd', 'eta', 'updatedAt'], 'port-schedules/pages/PortSchedulesView.vue': ['schedule.scheduleNo', 'schedule.sourceName', 'schedule.vesselName', 'schedule.departurePortName', 'schedule.destinationPortName', 'schedule.etdAt', 'schedule.etaAt', 'schedule.observedAt', 'expired'], - 'trucking/pages/TruckAppointmentsView.vue': ['appointmentNo', 'containerNo', 'customerName', 'company', 'driver', 'vehicleNo', 'store', 'payableAmount', 'receivableAmount', 'plannedLoadingAt', 'attachments'], + 'trucking/pages/TruckAppointmentsView.vue': ['appointmentNo', 'containerNo', 'customerName', 'company', 'driver', 'vehicleNo', 'store', 'payableAmount', 'receivableAmount', 'plannedLoadingAt', 'attachmentCount'], 'customs/pages/CustomsCasesView.vue': ['customsNo', 'orderLink', 'inboundLink', 'checklistCode', 'checklistVersionNo', 'status', 'updatedAt'], 'outbound-dispatch/pages/OutboundDispatchesView.vue': ['inboundCode', 'customerName', 'containerNo', 'oceanBookingStatus', 'truckAppointmentStatus', 'pickTaskStatus', 'dispatchStatus', 'gate'], } @@ -89,7 +89,7 @@ test('字段校验器接受正式完整投影并精确暴露缺字段', () => { [loadingListFieldGaps, { planId: ids.plan, containerBookingId: ids.container, loadingListNo: 'LL-1', containerNo: 'MSCU1234567', cabinetNo: 'CAB-1', warehouseName: '宁波仓', status: 'COMPLETED', totalPackageCount: 1, totalGrossWeightKg: 10, totalNetWeightKg: 9, totalVolumeCbm: 1, totalQuantity: 2, createdAt: at }, undefined, 'containerNo'], [oceanBookingFieldGaps, { id: ids.id, bookingNo: 'BK-1', status: 'RELEASED', vesselName: 'VESSEL', voyageNo: 'V1', estimatedSailingAt: at, estimatedArrivalAt: at, customerId: ids.id, customerName: '测试客户', shippingCompanyId: null, shippingCompanyName: null, bookingAgentId: ids.id, bookingAgentName: '代理' }, undefined, 'voyageNo'], [portScheduleFieldGaps, { portSchedule: { schedule: { id: ids.id, scheduleNo: 'PS-1', sourceName: '船公司', vesselName: 'VESSEL', voyageNo: 'V1', departurePortCode: 'CNNGB', destinationPortCode: 'USLAX', etdAt: at, etaAt: at, observedAt: at, updatedAt: at, expiresAt: at }, expired: false } }, undefined, 'sourceName'], - [truckAppointmentFieldGaps, { id: ids.id, appointmentNo: 'TA-1', oceanBookingId: ids.id, containerBookingId: ids.container, loadingPlanId: ids.plan, warehouseId: ids.id, warehouseName: '宁波仓', customerId: ids.id, customerName: '测试客户', carrierId: ids.id, carrierName: '拖车商', plannedLoadingAt: at, status: 'DRAFT' }, undefined, 'appointmentNo'], + [truckAppointmentFieldGaps, { id: ids.id, appointmentNo: 'TA-1', oceanBookingId: ids.id, containerBookingId: ids.container, loadingPlanId: ids.plan, warehouseId: ids.id, warehouseName: '宁波仓', customerId: ids.id, customerName: '测试客户', carrierId: ids.id, carrierName: '拖车商', plannedLoadingAt: at, status: 'DRAFT', attachmentCount: 0 }, undefined, 'appointmentNo'], [customsCaseFieldGaps, { customsCase: { id: ids.id, customsNo: 'CUS-1', orderId: ids.order, inboundId: ids.inbound, containerBookingId: ids.container, checklistVersionId: ids.id, checklistCode: 'CHECK', checklistVersionNo: 'V1', status: 'DOCUMENTS_PENDING', updatedAt: at } }, undefined, 'checklistCode'], [outboundAggregateFieldGaps, { orderId: ids.order, inboundId: ids.inbound, inboundCode: 'IN-1', customerId: ids.id, customerName: '测试客户', containerBookingId: ids.container, containerNo: 'MSCU1234567', loadingPlanId: ids.plan, loadingPlanStatus: 'COMPLETED', pickTaskId: ids.task, pickTaskStatus: 'COMPLETED', oceanBookingId: ids.id, oceanBookingStatus: 'RELEASED', dispatchStatus: 'READY_FOR_DISPATCH', version: 1, gate: { items: [] } }, undefined, 'inboundCode'], ] diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index 6c58c6cac..ade1a84c0 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -132,7 +132,7 @@ export async function run(context) { `taskUnchanged=${taskAfterSignature === taskBeforeSignature}, planUnchanged=${planAfterSignature === planBeforeSignature}, inboundStatus=${inboundAfterFailure?.summary?.processStatus || 'missing'}, inboundVersion=${inboundAfterFailure?.summary?.version ?? 'missing'}, operationsUnchanged=${JSON.stringify(operationsAfterFailure || []) === operationsBeforeSignature}`, OPTIONS) return } - const completed = context.apiSuccess(MODULE, '订舱拖车报关齐备后完成装柜', completeResponse, '', OPTIONS) + const completed = context.apiSuccess(MODULE, '订舱与五类证据等保留门禁齐备后完成装柜', completeResponse, '', OPTIONS) context.assert(MODULE, '统一硬门禁通过后 task 与 workflow 同事务完成且身份不漂移', completeResponse, completed?.taskStatus === 'COMPLETED' && completed?.workflowStatus === 'COMPLETED' && id(completed?.id) === taskId && id(completed?.loadingPlanId) === loadingPlanId diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index f33861a12..3cef58256 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1083,7 +1083,7 @@ export async function run(context) { { expectedTaskVersion: Number(beforeBlockedTask?.version), operationId: `${context.marker}-complete-before-downstream`.slice(0, 128), remark: `${context.marker}-before-downstream` }, { 'Idempotency-Key': `${context.marker}-p3-complete-before-downstream`.slice(0, 128) }) - context.addStep(MODULE, '订舱拖车报关未齐备时 LOADED 硬门禁拒绝', blockedBeforeDownstream, + context.addStep(MODULE, '订舱或五类证据等保留门禁未齐备时 LOADED 拒绝', blockedBeforeDownstream, blockedBeforeDownstream.http === 200 && codeOf(blockedBeforeDownstream) === 'A0409', responseEvidence(blockedBeforeDownstream), OPTIONS) const blockedDetailResponse = await context.request('GET', detailPath) 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 7b4d543e7..c93cd459d 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 @@ -450,7 +450,7 @@ test('phase3 loading proves stale inventory zero-write and defers final completi 'PDA 原子批次同参同键重放保持待人工确认', '回查下游门禁前原子 Batch 审计', '最终确认前仅保留 CLAIM/BATCH 且无 COMPLETE_LOADING', - '订舱拖车报关未齐备时 LOADED 硬门禁拒绝', + '订舱或五类证据等保留门禁未齐备时 LOADED 拒绝', 'LOADED 硬门禁拒绝后 task/plan/inbound 零写入', ]) }) diff --git a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs index 13e94de57..3c3bfb4b4 100644 --- a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs +++ b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' -import { inspectOceanBookingIdentity, truckAppointmentPayload } from './truck-appointments.mjs' +import { inspectOceanBookingIdentity, selectIndependentTruckCustomer, truckAppointmentPayload } from './truck-appointments.mjs' test('truck appointment accepts only the current marker ocean booking identity', () => { const current = inspectOceanBookingIdentity('RUN-2', { @@ -45,9 +45,11 @@ test('truck appointment rejects mismatched booking detail instead of mixing iden assert.match(conflictingCustomer.reason, /customerId/) }) -test('create edit and stale replay payloads keep the resolved booking identity stable', () => { - const context = { marker: 'RUN-2' } - const identity = { oceanBookingId: '91', customerId: '21', warehouseId: '41', customerName: '本轮客户' } +test('create edit and stale replay payloads keep the independent customer and empty links stable', () => { + const context = { marker: 'RUN-2', createdResources: { + truckSupplier: { supplierId: '501', supplierName: '正式拖车商' }, + } } + const identity = { customerId: '21', customerName: '本轮客户' } const payloads = [ truckAppointmentPayload(context, '2', identity), truckAppointmentPayload(context, '2', identity, 3), @@ -55,18 +57,20 @@ test('create edit and stale replay payloads keep the resolved booking identity s ] for (const payload of payloads) { assert.deepEqual({ - oceanBookingId: payload.oceanBookingId, customerId: payload.customerId, - warehouseId: payload.warehouseId, customerName: payload.customerName, - }, identity) + oceanBookingId: payload.oceanBookingId, containerBookingId: payload.containerBookingId, + loadingPlanId: payload.loadingPlanId, customerId: payload.customerId, + customerName: payload.customerName, carrierId: payload.carrierId, + }, { oceanBookingId: null, containerBookingId: null, loadingPlanId: null, + customerId: '21', customerName: '本轮客户', carrierId: '501' }) } assert.deepEqual(payloads.map((payload) => payload.expectedVersion), [0, 3, 0]) }) -test('truck payload sends carrier fields only as a verified ID/name pair', () => { - const identity = { oceanBookingId: '91', customerId: '21', warehouseId: '41', customerName: '本轮客户' } +test('truck payload requires a verified carrier ID/name pair', () => { + const identity = { customerId: '21', customerName: '本轮客户' } const missing = truckAppointmentPayload({ marker: 'RUN-2', createdResources: {} }, '2', identity) - assert.equal(Object.hasOwn(missing, 'carrierId'), false) - assert.equal(Object.hasOwn(missing, 'carrierName'), false) + assert.equal(missing.carrierId, null) + assert.equal(missing.carrierName, null) const valid = truckAppointmentPayload({ marker: 'RUN-2', createdResources: { truckSupplier: { supplierId: '501', supplierName: '正式拖车商' }, } }, '2', identity) @@ -74,13 +78,80 @@ test('truck payload sends carrier fields only as a verified ID/name pair', () => const incomplete = truckAppointmentPayload({ marker: 'RUN-2', createdResources: { truckSupplier: { supplierId: '501', supplierName: null }, } }, '2', identity) - assert.equal(Object.hasOwn(incomplete, 'carrierId'), false) - assert.equal(Object.hasOwn(incomplete, 'carrierName'), false) + assert.equal(incomplete.carrierId, '501') + assert.equal(incomplete.carrierName, null) }) -test('truck appointment source has no historical list or hardcoded identity fallback', () => { +test('independent customer comes only from the current marker fixture', () => { + const current = selectIndependentTruckCustomer({ marker: 'RUN-2', createdResources: { + orders: { marker: 'RUN-2', tenantId: '21', customerName: '本轮客户' }, + } }) + assert.deepEqual(current, { customerId: '21', customerName: '本轮客户' }) + const stale = selectIndependentTruckCustomer({ marker: 'RUN-2', createdResources: { + orders: { marker: 'RUN-1', tenantId: '20', customerName: '历史客户' }, + } }) + assert.equal(stale, null) +}) + +test('truck appointment source has no booking prerequisite historical list or hardcoded identity fallback', () => { const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8') assert.doesNotMatch(source, /customerId:\s*['"]10001['"]|warehouseId:\s*['"]193000000000000001['"]/) - assert.match(source, /\/api\/platform\/v1\/ocean-bookings/) - assert.match(source, /未使用历史订舱或硬编码客户\/仓库/) + assert.doesNotMatch(source, /import\(new URL\('\.\/ocean-bookings\.mjs'/) + assert.match(source, /创建无订舱柜次计划关联的独立拖车预约/) + assert.match(source, /缺少同 marker 的 customerId;未借用历史客户或订舱/) +}) + +test('backend freezes independent creation association validation and PDA failure isolation', () => { + const dto = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java', import.meta.url), 'utf8') + assert.match(dto, /@Positive Long oceanBookingId,@Positive Long containerBookingId,@Positive Long loadingPlanId/) + assert.match(dto, /@NotNull @Positive Long customerId/) + assert.match(dto, /@NotNull @Positive Long carrierId/) + assert.match(dto, /@NotNull LocalDateTime plannedLoadingAt/) + assert.doesNotMatch(dto, /@NotNull @Positive Long oceanBookingId|@NotNull @Positive Long containerBookingId/) + + const openapi = readFileSync(new URL('../../firstleg/src/main/resources/openapi/truck-appointments-v1.yaml', import.meta.url), 'utf8') + assert.match(openapi, /required: \[customerId, carrierId, plannedLoadingAt, expectedVersion\]/) + assert.match(openapi, /Ocean booking, container booking and loading plan are optional links/) + assert.match(openapi, /absence, duplicates or concurrent truck edits never roll back loading completion/) + + const service = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java', import.meta.url), 'utf8') + for (const token of ['customers.active(r.customerId(),r.customerName())', 'suppliers.trucking(r.carrierId(),r.carrierName())', + 'customerId与订舱客户不一致', 'customerId与柜次客户不一致', 'containerBookingId未分配至该订舱', + 'loadingPlanId与柜次当前装柜计划不一致', 'Propagation.REQUIRES_NEW', 'active.isEmpty()', + 'active.size()!=1', 'repo.completeLoadingFromPda']) assert.match(service, new RegExp(token.replace(/[().]/g, '\\$&'))) + + const loading = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java', import.meta.url), 'utf8') + assert.match(loading, /completeTruckAfterCommit/) + assert.match(loading, /afterCommit\(\) \{ completion\.run\(\); \}/) + assert.match(loading, /catch \(RuntimeException exception\)/) + assert.ok(loading.indexOf('loadingListService.publishCompletedEvent') < loading.indexOf('completeTruckAfterCommit'), + 'PDA task/plan/inbound completion must precede best-effort truck synchronization') + + const truckTests = readFileSync(new URL('../../firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java', import.meta.url), 'utf8') + for (const scenario of ['pdaCompletionDoesNotRequireTruckAppointment', + 'pdaCompletionLocksAndCompletesTheOnlyActiveTruckWithHistory', + 'pdaCompletionRejectsMultipleActiveTruckAppointmentsWithoutMutation', + 'pdaCompletionRejectsConcurrentTruckModificationWithoutHistory']) assert.match(truckTests, new RegExp(scenario)) + const loadingTests = readFileSync(new URL('../../firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java', import.meta.url), 'utf8') + assert.match(loadingTests, /conflictingTruckAppointmentsDoNotBlockPdaCompletion/) +}) + +test('Admin requires customer carrier and planned time but keeps booking and container optional', () => { + const source = readFileSync(new URL('../../../apps/admin-web/src/modules/trucking/components/TruckAppointmentForm.vue', import.meta.url), 'utf8') + assert.match(source, /customerId: \[\{ required: true/) + assert.match(source, /carrierId: \[\{ required: true/) + assert.match(source, /plannedLoadingAt: \[\{ required: true/) + assert.match(source, /label="关联柜次(可选)"/) + assert.match(source, /label="关联订舱(可选)"/) + assert.doesNotMatch(source, /containerBookingId: \[\{ required: true|oceanBookingId: \[\{ required: true/) +}) + +test('default HTTP entry carries independent truck positives and association negatives after current-marker producers', () => { + const source = readFileSync(new URL('truck-appointments.mjs', import.meta.url), 'utf8') + for (const label of ['缺少 customerId 拒绝创建独立拖车', '缺少 ACTIVE TRUCKING carrier 拒绝创建独立拖车', + '缺少 plannedLoadingAt 拒绝创建独立拖车', 'loadingPlanId 与关联柜次当前计划不一致时零写入拒绝', + 'warehouseId 与可选关联柜次不一致时零写入拒绝', '订舱与柜次无 allocation 关系时零写入拒绝', + '创建无订舱柜次计划关联的独立拖车预约']) assert.match(source, new RegExp(label)) + const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') + assert.match(entry, /'orders'[\s\S]*'phase3-loading'[\s\S]*'ocean-bookings'[\s\S]*'truck-appointments'/) }) diff --git a/scripts/admin-test-env-modules/truck-appointments.mjs b/scripts/admin-test-env-modules/truck-appointments.mjs index 857879f98..c3c390e0a 100644 --- a/scripts/admin-test-env-modules/truck-appointments.mjs +++ b/scripts/admin-test-env-modules/truck-appointments.mjs @@ -3,7 +3,6 @@ import { truckAppointmentFieldGaps } from './fulfillment-list-field-contract.mjs const MODULE = 'truck-appointments' const OPTIONS = { scenario: 'truck-appointment-lifecycle', deploymentBlocker: true } const BASE = '/api/platform/v1/truck-appointments' -const OCEAN_BOOKING_BASE = '/api/platform/v1/ocean-bookings' const SUPPLIER_BASE = '/api/platform/v1/suppliers?tenantId=0' function id(value) { return value == null ? '' : String(value) } @@ -69,17 +68,28 @@ export function inspectOceanBookingIdentity(marker, resource, detail = null) { : { identity, reason: null, missingFields: [] } } -export function truckAppointmentPayload(context, suffix, identity, version = 0) { +export function selectIndependentTruckCustomer(context) { + const candidates = [ + context.createdResources?.orders, + context.createdResources?.merchantQualificationFixture, + context.createdResources?.phase3Loading, + context.createdResources?.phase3ContainerLoading, + ].filter(Boolean) + const source = candidates.find((item) => item.marker === context.marker && id(item.tenantId)) + return source ? { customerId: id(source.tenantId), customerName: source.customerName || null } : null +} + +export function truckAppointmentPayload(context, suffix, identity, version = 0, associations = {}) { const supplier = context.createdResources?.truckSupplier || {} const supplierId = id(supplier.supplierId) const supplierName = id(supplier.supplierName) - const carrierSnapshot = supplierId && supplierName - ? { carrierId: supplierId, carrierName: supplierName } - : {} return { - outboundOrderId: null, oceanBookingId: identity.oceanBookingId, containerBookingId: null, loadingPlanId: null, - warehouseId: identity.warehouseId, customerId: identity.customerId, customerName: identity.customerName, - ...carrierSnapshot, fleetContact: '车队联系人', fleetPhone: '13800000004', + outboundOrderId: null, oceanBookingId: associations.oceanBookingId || null, + containerBookingId: associations.containerBookingId || null, + loadingPlanId: associations.loadingPlanId || null, warehouseId: associations.warehouseId || null, + customerId: identity.customerId, customerName: identity.customerName || null, + carrierId: supplierId || null, carrierName: supplierName || null, + fleetContact: '车队联系人', fleetPhone: '13800000004', driverName: '司机验收', driverPhone: '13800000005', driverIdCard: null, vehicleNo: `粤B-${suffix.slice(-6)}`, storeName: `门店-${suffix}`, storeContact: '门店联系人', storePhone: '13800000006', storeAddress: '深圳市南山区验收地址', storeCity: '深圳市', storeZipcode: '518000', packageCount: 10, grossWeightKg: 100, volumeCbm: 2.5, @@ -103,14 +113,15 @@ async function ensureTruckSupplier(context, suffix) { const detailResponse = await request(context, 'GET', `/api/platform/v1/suppliers/${encodeURIComponent(supplierId)}?tenantId=0`) supplier = data(detailResponse) } - context.addStep(MODULE, '创建本轮有效拖车供应商(可选)', response, - !supplierId || (supplier?.category === 'TRUCKING' && supplier?.status === 'ACTIVE' && Boolean(supplier?.name)), + context.addStep(MODULE, '创建本轮有效 ACTIVE TRUCKING 拖车供应商', response, + Boolean(supplierId) && supplier?.category === 'TRUCKING' && supplier?.status === 'ACTIVE' && Boolean(supplier?.name), `supplierId=${supplierId || 'missing'}, category=${supplier?.category || 'missing'}, status=${supplier?.status || 'missing'}`, OPTIONS) if (supplierId) context.createdResources.truckSupplier = { marker: context.marker, supplierId, supplierName: supplier.name || supplier.supplierName || null, category: supplier.category, status: supplier.status, finalStatus: 'ACTIVE', retained: true, } - return context.createdResources.truckSupplier || null + return supplierId && supplier?.category === 'TRUCKING' && supplier?.status === 'ACTIVE' + ? context.createdResources.truckSupplier : null } export async function run(context) { @@ -124,43 +135,69 @@ export async function run(context) { context.addStep(MODULE, `拖车预约 ${status} 筛选`, response, response.http === 200 && codeOf(response) === '200' && Array.isArray(result?.records) && result.records.every((item) => item.status === status), `HTTP=${response.http}, code=${codeOf(response)}, records=${result?.records?.length ?? 'n/a'}`, OPTIONS) } - if (!context.createdResources?.oceanBooking?.id) { - const oceanBookings = await import(new URL('./ocean-bookings.mjs', import.meta.url)) - await oceanBookings.run(context) - } - const oceanBooking = context.createdResources?.oceanBooking - if (!oceanBooking?.id) { - context.addStep(MODULE, '创建拖车预约所需隔离订舱 fixture', null, false, - '本轮 ocean-bookings 未返回可关联的隔离订舱,未使用历史订舱', OPTIONS) + const customerIdentity = selectIndependentTruckCustomer(context) + if (!customerIdentity) { + context.addStep(MODULE, '取得本轮独立拖车客户身份', null, false, + '缺少同 marker 的 customerId;未借用历史客户或订舱', OPTIONS) return } - let identityResult = inspectOceanBookingIdentity(context.marker, oceanBooking) - if (!identityResult.identity && identityResult.missingFields?.length) { - const bookingPath = `${OCEAN_BOOKING_BASE}/${encodeURIComponent(id(oceanBooking.id))}` - const bookingResponse = await request(context, 'GET', bookingPath) - if (bookingResponse.http !== 200 || codeOf(bookingResponse) !== '200') { - context.addStep(MODULE, '读取本轮订舱身份快照', bookingResponse, false, - `path=${bookingPath}, HTTP=${bookingResponse.http}, code=${codeOf(bookingResponse)}, requestId=${bookingResponse.requestId || 'missing'}, traceId=${bookingResponse.traceId || 'missing'}`, OPTIONS) - return + const supplier = await ensureTruckSupplier(context, suffix) + if (!supplier?.supplierId || !supplier?.supplierName) return + + const basePayload = truckAppointmentPayload(context, suffix, customerIdentity) + for (const [name, payload] of [ + ['缺少 customerId 拒绝创建独立拖车', { ...basePayload, customerId: null, customerName: null }], + ['缺少 ACTIVE TRUCKING carrier 拒绝创建独立拖车', { ...basePayload, carrierId: null, carrierName: null }], + ['缺少 plannedLoadingAt 拒绝创建独立拖车', { ...basePayload, plannedLoadingAt: null }], + ]) { + const rejected = await request(context, 'POST', BASE, payload) + context.addStep(MODULE, name, rejected, failure(rejected), + `HTTP=${rejected.http}, code=${codeOf(rejected)}, requestId=${rejected.requestId || 'missing'}, traceId=${rejected.traceId || 'missing'}`, OPTIONS) + } + + const linkedFlow = [context.createdResources?.phase3Loading, context.createdResources?.phase3ContainerLoading] + .find((item) => item?.marker === context.marker && id(item.containerBookingId) && id(item.loadingPlanId)) + if (linkedFlow) { + const invalidPlanId = (BigInt(id(linkedFlow.loadingPlanId)) + 1n).toString() + const mismatchedPlan = await request(context, 'POST', BASE, + truckAppointmentPayload(context, `${suffix}P`, customerIdentity, 0, { + containerBookingId: id(linkedFlow.containerBookingId), loadingPlanId: invalidPlanId, + warehouseId: id(linkedFlow.warehouseId), oceanBookingId: id(linkedFlow.oceanBookingId) || null, + })) + context.addStep(MODULE, 'loadingPlanId 与关联柜次当前计划不一致时零写入拒绝', mismatchedPlan, + failure(mismatchedPlan), + `containerBookingId=${id(linkedFlow.containerBookingId)}, loadingPlanId=${invalidPlanId}, HTTP=${mismatchedPlan.http}, code=${codeOf(mismatchedPlan)}`, OPTIONS) + if (id(linkedFlow.warehouseId)) { + const mismatchedWarehouseId = (BigInt(id(linkedFlow.warehouseId)) + 1n).toString() + const mismatchedWarehouse = await request(context, 'POST', BASE, + truckAppointmentPayload(context, `${suffix}W`, customerIdentity, 0, { + containerBookingId: id(linkedFlow.containerBookingId), warehouseId: mismatchedWarehouseId, + })) + context.addStep(MODULE, 'warehouseId 与可选关联柜次不一致时零写入拒绝', mismatchedWarehouse, + failure(mismatchedWarehouse), + `containerBookingId=${id(linkedFlow.containerBookingId)}, warehouseId=${mismatchedWarehouseId}, HTTP=${mismatchedWarehouse.http}, code=${codeOf(mismatchedWarehouse)}`, OPTIONS) + } + const unrelatedBooking = context.createdResources?.oceanBookingUnassignedCustomer + if (unrelatedBooking?.marker === context.marker && id(unrelatedBooking.id)) { + const mismatchedAllocation = await request(context, 'POST', BASE, + truckAppointmentPayload(context, `${suffix}A`, customerIdentity, 0, { + oceanBookingId: id(unrelatedBooking.id), containerBookingId: id(linkedFlow.containerBookingId), + })) + context.addStep(MODULE, '订舱与柜次无 allocation 关系时零写入拒绝', mismatchedAllocation, + failure(mismatchedAllocation), + `oceanBookingId=${id(unrelatedBooking.id)}, containerBookingId=${id(linkedFlow.containerBookingId)}, HTTP=${mismatchedAllocation.http}, code=${codeOf(mismatchedAllocation)}`, OPTIONS) } - identityResult = inspectOceanBookingIdentity(context.marker, oceanBooking, data(bookingResponse)) - context.addStep(MODULE, '读取本轮订舱身份快照', bookingResponse, Boolean(identityResult.identity), - identityResult.identity - ? `oceanBookingId=${identityResult.identity.oceanBookingId}, customerId=${identityResult.identity.customerId}, warehouseId=${identityResult.identity.warehouseId}` - : `${identityResult.reason}, path=${bookingPath}, requestId=${bookingResponse.requestId || 'missing'}, traceId=${bookingResponse.traceId || 'missing'}`, OPTIONS) } - if (!identityResult.identity) { - context.addStep(MODULE, '创建拖车预约所需本轮订舱身份', null, false, - `${identityResult.reason};未使用历史订舱或硬编码客户/仓库`, OPTIONS) - return - } - const bookingIdentity = Object.freeze({ ...identityResult.identity }) - await ensureTruckSupplier(context, suffix) - - const createResponse = await request(context, 'POST', BASE, truckAppointmentPayload(context, suffix, bookingIdentity)); const appointment = context.apiSuccess(MODULE, '创建隔离拖车预约', createResponse, '', OPTIONS) + const createResponse = await request(context, 'POST', BASE, basePayload); const appointment = context.apiSuccess(MODULE, '创建无订舱柜次计划关联的独立拖车预约', createResponse, '', OPTIONS) const appointmentId = id(appointment?.id) - context.assert(MODULE, '拖车预约费用、司机车辆和地址字段完整', createResponse, Boolean(appointmentId) && appointment?.status === 'DRAFT' && appointment?.payableAmount != null && appointment?.receivableAmount != null, `id=${appointmentId || 'missing'}, status=${appointment?.status || 'missing'}`, OPTIONS) + context.assert(MODULE, '独立拖车仅冻结客户承运商与自身业务字段', createResponse, + Boolean(appointmentId) && appointment?.status === 'DRAFT' + && appointment?.oceanBookingId == null && appointment?.containerBookingId == null + && appointment?.loadingPlanId == null && id(appointment?.customerId) === customerIdentity.customerId + && id(appointment?.carrierId) === id(supplier.supplierId) + && appointment?.payableAmount != null && appointment?.receivableAmount != null, + `id=${appointmentId || 'missing'}, status=${appointment?.status || 'missing'}, oceanBookingId=${id(appointment?.oceanBookingId) || 'null'}, containerBookingId=${id(appointment?.containerBookingId) || 'null'}`, OPTIONS) if (!appointmentId) return context.createdResources.truckAppointment = { id: appointmentId, marker: context.marker, finalStatus: 'COMPLETED' } const path = `${BASE}/${appointmentId}` @@ -183,8 +220,8 @@ export async function run(context) { } let version = Number(appointment.version) - const edit = await request(context, 'PUT', path, { ...truckAppointmentPayload(context, suffix, bookingIdentity, version), vehicleNo: `粤C-${suffix.slice(-6)}` }); const edited = context.apiSuccess(MODULE, '编辑拖车预约', edit, `version=${version}`, OPTIONS); version = Number(edited?.version) - const stale = await request(context, 'PUT', path, { ...truckAppointmentPayload(context, suffix, bookingIdentity, 0), vehicleNo: `STALE-${suffix}` }) + const edit = await request(context, 'PUT', path, { ...truckAppointmentPayload(context, suffix, customerIdentity, version), vehicleNo: `粤C-${suffix.slice(-6)}` }); const edited = context.apiSuccess(MODULE, '编辑独立拖车预约且关联身份保持为空', edit, `version=${version}`, OPTIONS); version = Number(edited?.version) + const stale = await request(context, 'PUT', path, { ...truckAppointmentPayload(context, suffix, customerIdentity, 0), vehicleNo: `STALE-${suffix}` }) context.addStep(MODULE, '拖车预约旧版本编辑返回冲突', stale, stale.http === 200 && ['A0409','409'].includes(codeOf(stale)), `HTTP=${stale.http}, code=${codeOf(stale)}`, OPTIONS) const after = await request(context, 'GET', path); const afterData = data(after); context.apiSuccess(MODULE, '冲突后回查拖车预约', after, '', OPTIONS) context.assert(MODULE, '拖车预约冲突未部分更新', after, afterData?.vehicleNo === edited?.vehicleNo, `vehicleNo=${afterData?.vehicleNo || 'missing'}`, OPTIONS); version = Number(afterData?.version) @@ -195,6 +232,9 @@ export async function run(context) { context.assert(MODULE, '拖车列表客户、仓库、订舱柜次、供应商与关键时间字段完整', final, appointmentFieldGaps.length === 0, `missing=${appointmentFieldGaps.join(',') || 'none'}`, OPTIONS) context.assert(MODULE, '拖车预约状态终态和费用快照正确', final, finalData?.status === 'COMPLETED' && finalData?.payableAmount != null && finalData?.receivableAmount != null, `status=${finalData?.status || 'missing'}`, OPTIONS) + context.assert(MODULE, '独立拖车完成自身状态机后仍不产生履约关联', final, + finalData?.oceanBookingId == null && finalData?.containerBookingId == null && finalData?.loadingPlanId == null, + `oceanBookingId=${id(finalData?.oceanBookingId) || 'null'}, containerBookingId=${id(finalData?.containerBookingId) || 'null'}, loadingPlanId=${id(finalData?.loadingPlanId) || 'null'}`, OPTIONS) const history = await request(context, 'GET', `${path}/history`); const histories = context.apiSuccess(MODULE, '查询拖车预约状态历史', history, '', OPTIONS) const actions = Array.isArray(histories) ? histories.map((item) => item.action) : [] context.assert(MODULE, '拖车状态历史动作、版本和审计操作者完整', history, ['SUBMIT','DISPATCH','PICKUP','ARRIVE','LOAD','DEPART','COMPLETE'].every((item) => actions.includes(item)) && histories.every((item) => Number.isFinite(Number(item.revision)) && Boolean(item.operatorName) && Boolean(item.createdAt)), `actions=${actions.join(',')}`, OPTIONS)