优化
This commit is contained in:
@@ -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<PageResult<Customer>> 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) {}
|
||||
}
|
||||
@@ -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){ }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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, "客户主数据服务暂不可用");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Operation> operations(long taskId) {
|
||||
requiredTask(taskId);
|
||||
return repository.operations(taskId).stream().map(item -> new Operation(item.getId(), item.getPickTaskId(),
|
||||
|
||||
@@ -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<TruckAppointmentEntity> 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<TruckAppointmentEntity> 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<TruckAppointmentEntity> 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);}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
+26
@@ -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");
|
||||
}
|
||||
}
|
||||
+1
@@ -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<String,String> expected=Map.ofEntries(
|
||||
|
||||
+4
-1
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+59
@@ -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("不一致");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+100
-22
@@ -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<TruckAppointmentEntity> 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<TruckAppointmentEntity> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 失败阻断,不由脚本或前端改写状态。
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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'],
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 零写入',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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'/)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user