refactor(firstleg): keep trucking payable only
This commit is contained in:
@@ -9395,12 +9395,13 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment (
|
||||
store_name VARCHAR(128) NULL COMMENT '到仓装柜地点名称快照', store_contact VARCHAR(64) NULL COMMENT '到仓地点联系人快照', store_phone VARCHAR(32) NULL COMMENT '到仓地点联系电话快照', store_address VARCHAR(255) NULL COMMENT '到仓装柜地点地址快照',
|
||||
store_city VARCHAR(64) NULL COMMENT '到仓地点城市快照', store_zipcode VARCHAR(16) NULL COMMENT '到仓地点邮编快照', package_count INT UNSIGNED NULL COMMENT '预约货物包装件数快照', gross_weight_kg DECIMAL(18,3) NULL COMMENT '预约货物毛重,单位千克',
|
||||
volume_cbm DECIMAL(18,4) NULL COMMENT '预约货物体积,单位立方米', pallet_count INT UNSIGNED NULL COMMENT '预约托盘数量', po_no VARCHAR(255) NULL COMMENT '关联采购单号', planned_loading_at DATETIME(3) NULL COMMENT '预约到仓装柜时间',
|
||||
actual_arrived_warehouse_at DATETIME(3) NULL COMMENT '车辆实际到达收货仓时间', actual_loading_at DATETIME(3) NULL COMMENT '实际开始装柜时间', actual_departed_at DATETIME(3) NULL COMMENT '车辆实际离仓时间;DEPARTED后供LOADED门禁使用', transit_minutes INT UNSIGNED NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟', payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定', receivable_amount DECIMAL(18,2) NULL COMMENT '拖车业务应收金额;币种由currency指定',
|
||||
currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '金额币种;首期默认RMB', status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消', active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN container_booking_id ELSE NULL END) STORED COMMENT '非终态预约的柜次唯一键', revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增',
|
||||
actual_arrived_warehouse_at DATETIME(3) NULL COMMENT '车辆实际到达收货仓时间', actual_loading_at DATETIME(3) NULL COMMENT '实际开始装柜时间', actual_departed_at DATETIME(3) NULL COMMENT '车辆实际离仓时间;DEPARTED后供LOADED门禁使用', transit_minutes INT UNSIGNED NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟', payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定',
|
||||
currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '应付金额币种;仅RMB或USD', status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消', active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN container_booking_id ELSE NULL END) STORED COMMENT '非终态预约的柜次唯一键', revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', remark VARCHAR(1000) NULL COMMENT '平台内部备注或异常说明', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拖车预约创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '拖车预约最后更新时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no), UNIQUE KEY uk_truck_active_container(active_container_booking_id),
|
||||
KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_container(container_booking_id),
|
||||
CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED'))
|
||||
CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')),
|
||||
CONSTRAINT chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))
|
||||
) ENGINE=InnoDB COMMENT='三期拖车预约派车聚合';
|
||||
CREATE TABLE IF NOT EXISTS fl_truck_appointment_history (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '拖车预约历史ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', revision INT UNSIGNED NOT NULL COMMENT '该预约业务修订号', action VARCHAR(32) NOT NULL COMMENT '触发状态或改约的业务动作',
|
||||
@@ -13909,7 +13910,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'carrier_id
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'carrier_name', '拖车服务商名称快照');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'container_booking_id', '关联柜次逻辑ID;拖车完成供装柜门禁校验');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'created_at', '拖车预约创建时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'currency', '金额币种;首期默认RMB');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'currency', '应付金额币种;仅RMB或USD');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'driver_id_card', '司机身份证号;平台内部敏感信息');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'driver_name', '司机姓名');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'driver_phone', '司机联系电话');
|
||||
@@ -13925,7 +13926,6 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'pallet_cou
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'payable_amount', '应付拖车服务商金额;币种由currency指定');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'planned_loading_at', '预约到仓装柜时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'po_no', '关联采购单号');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'receivable_amount', '拖车业务应收金额;币种由currency指定');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'remark', '平台内部备注或异常说明');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'revision', '拖车预约业务修订号;每次状态或改约递增');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_truck_appointment', 'status', '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消');
|
||||
|
||||
@@ -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 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,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@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,@NotNull @Positive Long oceanBookingId,@NotNull @Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@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,@Pattern(regexp="RMB|USD") 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,@NotBlank @Pattern(regexp="DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ }
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ public class TruckAppointmentEntity {
|
||||
private LocalDateTime actualDepartedAt;
|
||||
private Integer transitMinutes;
|
||||
private BigDecimal payableAmount;
|
||||
private BigDecimal receivableAmount;
|
||||
private String currency;
|
||||
private TruckAppointmentStatus status;
|
||||
private Integer revision;
|
||||
|
||||
@@ -7,5 +7,5 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.myba
|
||||
@Update("UPDATE fl_truck_appointment SET status='DISPATCHED',bill_of_lading_no=#{billOfLadingNo},revision=revision+1,version=version+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int dispatch(@Param("id") long id,@Param("fromStatus") String fromStatus,@Param("billOfLadingNo") String billOfLadingNo,@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 outbound_order_id=#{e.outboundOrderId},ocean_booking_id=#{e.oceanBookingId},container_booking_id=#{e.containerBookingId},loading_plan_id=#{e.loadingPlanId},warehouse_id=#{e.warehouseId},carrier_id=#{e.carrierId},carrier_name=#{e.carrierName},bill_of_lading_no=#{e.billOfLadingNo},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);
|
||||
@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},carrier_id=#{e.carrierId},carrier_name=#{e.carrierName},bill_of_lading_no=#{e.billOfLadingNo},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},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);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
@Service
|
||||
public class TruckAppointmentService {
|
||||
private static final Set<String> ATTACHMENT_TYPES = Set.of("DISPATCH_ORDER", "DRIVER_DOCUMENT", "VEHICLE_DOCUMENT", "OTHER");
|
||||
private static final Set<String> CURRENCIES = Set.of("RMB", "USD");
|
||||
@Resource TruckAppointmentRepository repo;
|
||||
@Resource OceanBookingRepository oceanBookings;
|
||||
@Resource OceanBookingDocumentService oceanBookingDocuments;
|
||||
@@ -79,7 +80,7 @@ public class TruckAppointmentService {
|
||||
private TruckAppointmentStatus next(TruckAppointmentStatus s,String a){return switch(a){case"SUBMIT"->s==TruckAppointmentStatus.DRAFT?TruckAppointmentStatus.PENDING_DISPATCH:badStatus();case"DISPATCH"->s==TruckAppointmentStatus.PENDING_DISPATCH||s==TruckAppointmentStatus.RESCHEDULED?TruckAppointmentStatus.DISPATCHED:badStatus();case"PICKUP"->s==TruckAppointmentStatus.DISPATCHED?TruckAppointmentStatus.PICKUP_IN_PROGRESS:badStatus();case"ARRIVE"->s==TruckAppointmentStatus.PICKUP_IN_PROGRESS?TruckAppointmentStatus.ARRIVED_WAREHOUSE:badStatus();case"LOAD"->s==TruckAppointmentStatus.ARRIVED_WAREHOUSE?TruckAppointmentStatus.LOADING:badStatus();case"DEPART"->s==TruckAppointmentStatus.LOADING?TruckAppointmentStatus.DEPARTED:badStatus();case"COMPLETE"->s==TruckAppointmentStatus.DEPARTED?TruckAppointmentStatus.COMPLETED:badStatus();case"EXCEPTION"->s!=TruckAppointmentStatus.COMPLETED&&s!=TruckAppointmentStatus.CANCELLED?TruckAppointmentStatus.EXCEPTION:badStatus();case"CANCEL"->s!=TruckAppointmentStatus.COMPLETED?TruckAppointmentStatus.CANCELLED:badStatus();default->badStatus();};}
|
||||
private TruckAppointmentStatus badStatus(){throw conflict("不允许的状态动作");} private TruckAppointmentEntity required(long id){TruckAppointmentEntity e=repo.find(id);if(e==null)throw new BizException(ResultCode.NOT_FOUND,"拖车预约不存在");return e;} private void checkVersion(TruckAppointmentEntity e,int v){if(e.getVersion()!=v)throw conflict("版本已变化,请刷新后重试");} private static BizException conflict(String m){return new BizException(ResultCode.CONFLICT,m);} private static BizException bad(String m){return new BizException(ResultCode.BAD_REQUEST,m);}
|
||||
private TruckAppointmentEntity enrich(TruckAppointmentEntity value){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 carrierName){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());e.setWarehouseId(links.warehouseId());e.setCarrierId(r.carrierId());e.setCarrierName(carrierName);e.setBillOfLadingNo(links.billOfLadingNo());e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setReceivableAmount(r.receivableAmount());e.setCurrency(r.currency()==null?"RMB":r.currency());e.setRemark(r.remark());}
|
||||
private void copy(TruckAppointmentEntity e,SaveRequest r,AssociationContext links,String carrierName){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());e.setWarehouseId(links.warehouseId());e.setCarrierId(r.carrierId());e.setCarrierName(carrierName);e.setBillOfLadingNo(links.billOfLadingNo());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.setCurrency(currency(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);}}
|
||||
@@ -102,6 +103,7 @@ public class TruckAppointmentService {
|
||||
}
|
||||
private static String releasedBookingBill(OceanBookingEntity booking,String requested){String authoritative=trim(booking.getBillOfLadingNo());if(!StringUtils.hasText(authoritative))throw conflict("关联已放舱订舱缺少提单号");if(StringUtils.hasText(requested)&&!authoritative.equals(requested))throw bad("billOfLadingNo与关联订舱不一致");return authoritative;}
|
||||
private static String attachmentType(String value){String normalized=trim(value);if(!ATTACHMENT_TYPES.contains(normalized))throw bad("不支持的拖车附件类型");return normalized;}
|
||||
private static String currency(String value){if(!StringUtils.hasText(value))return "RMB";String normalized=value.trim();if(!CURRENCIES.contains(normalized))throw bad("currency仅支持RMB或USD");return normalized;}
|
||||
private static String trim(String value){return StringUtils.hasText(value)?value.trim():null;}
|
||||
private record AssociationContext(OceanBookingEntity booking,ContainerBookingEntity container,Long warehouseId,String billOfLadingNo){}
|
||||
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);}
|
||||
|
||||
@@ -33,8 +33,7 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment (
|
||||
actual_departed_at DATETIME(3) NULL COMMENT '车辆实际离仓时间;DEPARTED后供LOADED门禁使用',
|
||||
transit_minutes INT UNSIGNED NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟',
|
||||
payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定',
|
||||
receivable_amount DECIMAL(18,2) NULL COMMENT '拖车业务应收金额;币种由currency指定',
|
||||
currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '金额币种;首期默认RMB',
|
||||
currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '应付金额币种;仅RMB或USD',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消',
|
||||
active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') THEN container_booking_id ELSE NULL END) STORED COMMENT '非终态预约的柜次唯一键',
|
||||
revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增',
|
||||
@@ -44,7 +43,8 @@ CREATE TABLE IF NOT EXISTS fl_truck_appointment (
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '拖车预约最后更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no), UNIQUE KEY uk_truck_active_container(active_container_booking_id),
|
||||
KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_container(container_booking_id),
|
||||
CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED'))
|
||||
CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED')),
|
||||
CONSTRAINT chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))
|
||||
) ENGINE=InnoDB COMMENT='三期拖车预约派车聚合';
|
||||
CREATE TABLE IF NOT EXISTS fl_truck_appointment_history (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '拖车预约历史ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', revision INT UNSIGNED NOT NULL COMMENT '该预约业务修订号', action VARCHAR(32) NOT NULL COMMENT '触发状态或改约的业务动作',
|
||||
|
||||
@@ -187,6 +187,8 @@ components:
|
||||
carrierName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; the persisted snapshot comes from Warehouse supplier master data.}
|
||||
billOfLadingNo: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint; the persisted snapshot is always frozen from the selected RELEASED booking.}
|
||||
plannedLoadingAt: {type: string, format: date-time}
|
||||
payableAmount: {type: number, format: double, minimum: 0, nullable: true, description: Amount payable to the trucking supplier.}
|
||||
currency: {type: string, enum: [RMB, USD], default: RMB, description: Payable amount currency.}
|
||||
expectedVersion: {type: integer, minimum: 0, description: Must be 0 on create; current version on edit.}
|
||||
DetailStableReferences:
|
||||
type: object
|
||||
@@ -209,4 +211,6 @@ components:
|
||||
carrierId: {type: integer, format: int64}
|
||||
carrierName: {type: string}
|
||||
billOfLadingNo: {type: string, maxLength: 128, nullable: true, description: Frozen bill of lading snapshot used by dispatch readiness.}
|
||||
payableAmount: {type: number, format: double, minimum: 0, nullable: true}
|
||||
currency: {type: string, enum: [RMB, USD]}
|
||||
attachmentCount: {type: integer, format: int64, minimum: 0, description: Number of attachment references owned by this appointment}
|
||||
|
||||
+2
-1
@@ -19,7 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
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 schemaHasPlatformAggregateWithoutCustomerColumns() 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","bill_of_lading_no VARCHAR(128)","chk_truck_attachment_type","DISPATCH_ORDER","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column","visibility VARCHAR(24) NOT NULL DEFAULT 'PLATFORM'","active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS","UNIQUE KEY uk_truck_active_container(active_container_booking_id)").doesNotContain("customer_id", "customer_name", "idx_truck_appointment_customer");}
|
||||
@Test void schemaHasPlatformAggregateWithoutCustomerOrReceivableColumns() 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","bill_of_lading_no VARCHAR(128)","chk_truck_attachment_type","DISPATCH_ORDER","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column","visibility VARCHAR(24) NOT NULL DEFAULT 'PLATFORM'","active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS","UNIQUE KEY uk_truck_active_container(active_container_booking_id)","chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))").doesNotContain("customer_id", "customer_name", "idx_truck_appointment_customer", "receivable_amount");}
|
||||
@Test void existingAppointmentTableReceivesAllLateColumns() throws Exception {
|
||||
String moduleSql=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);
|
||||
Path direct=Path.of("deploy","sql","头程数据库统一增量-MySQL8.sql");
|
||||
@@ -32,6 +32,7 @@ class TruckAppointmentControllerContractTest {
|
||||
@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 openApiRequiresReleasedBookingAndAllocatedRealContainerWithoutCustomerScope() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, containerBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","ACTIVE real-numbered container","OCEAN_BOOKING_DOCUMENT","valid unique ISO 6346 container number","schema: {$ref: '#/components/schemas/DetailEnvelope'}","required: [code, message, data, timestamp]","allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]","nullable: true").doesNotContain("customerId", "customerName", "Filter by customer");}
|
||||
@Test void openApiKeepsOnlyNonBookingTruckAttachments() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","cabinetNo:","billOfLadingNo:").doesNotContain("enum: [BILL_OF_LADING, DISPATCH_ORDER");}
|
||||
@Test void openApiExposesOnlyPayableWithFixedCurrencies() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("payableAmount:","enum: [RMB, USD]","default: RMB").doesNotContain("receivableAmount");}
|
||||
@Test void methodsHavePlatformPermissionsAndAuthenticatedScope() {
|
||||
Map<String,String> expected=Map.ofEntries(
|
||||
Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"),
|
||||
|
||||
+1
-1
@@ -155,7 +155,7 @@ class Phase3FinalUnifiedSqlContractTest {
|
||||
.contains("存在缺失中文注释的业务字段")
|
||||
.doesNotContain("CREATE PROCEDURE upgrade_column_comment");
|
||||
assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(144);
|
||||
assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2379);
|
||||
assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2378);
|
||||
assertThat(sql)
|
||||
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'")
|
||||
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_name'")
|
||||
|
||||
+1
@@ -34,5 +34,6 @@ class TruckAppointmentMapperContractTest {
|
||||
"planned_loading_at=#{e.plannedLoadingAt}", "bill_of_lading_no=#{e.billOfLadingNo}", "WHERE id=#{e.id} AND version=#{version}",
|
||||
"version=version+1", "revision=revision+1");
|
||||
assertThat(edit).doesNotContain("customer_id", "customer_name", "customerId", "customerName");
|
||||
assertThat(edit).doesNotContain("receivable_amount", "receivableAmount");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +164,31 @@ class TruckAppointmentServiceTest {
|
||||
assertThat(inserted.getValue().getPalletCount()).isZero();
|
||||
assertThat(inserted.getValue().getGrossWeightKg()).isZero();
|
||||
assertThat(inserted.getValue().getVolumeCbm()).isZero();
|
||||
assertThat(inserted.getValue().getCurrency()).isEqualTo("RMB");
|
||||
}
|
||||
|
||||
@Test void createAcceptsUsd() {
|
||||
SaveRequest request = validRequest();
|
||||
when(request.currency()).thenReturn("USD");
|
||||
when(request.payableAmount()).thenReturn(new java.math.BigDecimal("123.45"));
|
||||
|
||||
service.create(7L, "admin", request);
|
||||
|
||||
ArgumentCaptor<TruckAppointmentEntity> inserted = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
|
||||
verify(repository).insert(inserted.capture());
|
||||
assertThat(inserted.getValue().getCurrency()).isEqualTo("USD");
|
||||
assertThat(inserted.getValue().getPayableAmount()).isEqualByComparingTo("123.45");
|
||||
}
|
||||
|
||||
@Test void createRejectsUnsupportedCurrencyWithoutWriting() {
|
||||
SaveRequest request = validRequest();
|
||||
when(request.currency()).thenReturn("CNY");
|
||||
|
||||
assertThatThrownBy(() -> service.create(7L, "admin", request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("RMB或USD");
|
||||
|
||||
verify(repository, never()).insert(any());
|
||||
verify(repository, never()).history(any());
|
||||
}
|
||||
|
||||
@Test void platformOwnedAttachmentUsesZeroTenantWithoutCustomerSnapshot() {
|
||||
|
||||
@@ -122,6 +122,8 @@ export function truckAppointmentFieldGaps(record) {
|
||||
status: isNonBlank,
|
||||
carrierId: isPositiveId,
|
||||
carrierName: isNonBlank,
|
||||
payableAmount: isNonNegativeNumber,
|
||||
currency: (value) => value === 'RMB' || value === 'USD',
|
||||
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', 'company', 'driver', 'vehicleNo', 'store', 'payableAmount', 'receivableAmount', 'plannedLoadingAt', 'attachmentCount'],
|
||||
'trucking/pages/TruckAppointmentsView.vue': ['appointmentNo', 'containerNo', 'company', 'driver', 'vehicleNo', 'store', 'payableAmount', 'currency', '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: '宁波仓', carrierId: ids.id, carrierName: '拖车商', plannedLoadingAt: at, status: 'DRAFT', attachmentCount: 0 }, undefined, 'appointmentNo'],
|
||||
[truckAppointmentFieldGaps, { id: ids.id, appointmentNo: 'TA-1', oceanBookingId: ids.id, containerBookingId: ids.container, loadingPlanId: ids.plan, warehouseId: ids.id, warehouseName: '宁波仓', carrierId: ids.id, carrierName: '拖车商', payableAmount: 0, currency: 'RMB', 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, { containerBookingId: ids.container, containerNo: 'MSCU1234567', loadingPlanId: ids.plan, loadingPlanStatus: 'COMPLETED', oceanBookingId: ids.id, oceanBookingStatus: 'RELEASED', dispatchStatus: 'READY_FOR_DISPATCH', version: 1, inbounds: [{ orderId: ids.order, inboundId: ids.inbound, inboundCode: 'IN-1', tenantId: ids.id, inboundStatus: 'LOADED', pickTaskId: ids.task, pickTaskStatus: 'COMPLETED' }], gate: { inbounds: [{ inboundId: ids.inbound, inboundCode: 'IN-1', gate: { items: [] } }] } }, undefined, 'containerNo'],
|
||||
]
|
||||
|
||||
@@ -95,7 +95,7 @@ function truckPayload(context, oceanBooking, carrier, index) {
|
||||
storeAddress: '深圳市南山区人工验收地址', storeCity: '深圳市', storeZipcode: '518000',
|
||||
packageCount: 10, grossWeightKg: 100, volumeCbm: 2.5, palletCount: 1, poNo: `PO-${value}`,
|
||||
plannedLoadingAt: '2026-09-16T08:00:00', transitMinutes: 120, payableAmount: 100,
|
||||
receivableAmount: 150, currency: 'RMB', remark: `${context.marker} 人工列表状态 ${index}`, expectedVersion: 0 }
|
||||
currency: 'RMB', remark: `${context.marker} 人工列表状态 ${index}`, expectedVersion: 0 }
|
||||
}
|
||||
|
||||
async function createTruck(context, oceanBooking, index) {
|
||||
|
||||
@@ -98,7 +98,7 @@ export async function run(context) {
|
||||
driverIdCard: null, vehicleNo: `粤D-${suffix.slice(-6)}`, storeName: `链路门店-${suffix}`, storeContact: '链路联系人',
|
||||
storePhone: '13800000009', storeAddress: '深圳市南山区链路地址', storeCity: '深圳市', storeZipcode: '518000',
|
||||
packageCount: 1, grossWeightKg: 1, volumeCbm: 0.1, palletCount: 0, poNo: `FLOW-PO-${suffix}`,
|
||||
plannedLoadingAt: '2026-09-03T08:00:00', transitMinutes: 60, payableAmount: 1, receivableAmount: 2,
|
||||
plannedLoadingAt: '2026-09-03T08:00:00', transitMinutes: 60, payableAmount: 1,
|
||||
currency: 'RMB', remark: context.marker, expectedVersion: 0,
|
||||
}
|
||||
if (linkContainer) {
|
||||
|
||||
@@ -58,6 +58,7 @@ test('payload requires booking container and carrier but never emits customer fi
|
||||
{ oceanBookingId: '91', containerBookingId: '81', carrierId: '501', carrierName: '正式拖车商' })
|
||||
assert.equal(Object.hasOwn(payload, 'customerId'), false)
|
||||
assert.equal(Object.hasOwn(payload, 'customerName'), false)
|
||||
assert.equal(Object.hasOwn(payload, 'receivableAmount'), false)
|
||||
})
|
||||
|
||||
test('backend DTO entity mapper service and OpenAPI have no truck customer protocol', () => {
|
||||
@@ -91,13 +92,15 @@ test('HTTP regression is self-contained and covers no-customer and idempotency m
|
||||
for (const label of ['自包含创建 DRAFT 订舱', '上传当前有效 BILL_OF_LADING 文件', '放舱自包含订舱',
|
||||
'绑定本轮真实 ISO 柜号', '拖车请求不含客户字段', '同柜第二条非终态预约拒绝且零漂移',
|
||||
'同键同载荷重放', '同键重放版本零漂移', '同键异载荷拒绝', '错误订舱拒绝创建',
|
||||
'错误柜号归属拒绝创建', '错误提单拒绝创建', '错误承运商拒绝创建',
|
||||
'错误柜号归属拒绝创建', '错误提单拒绝创建', '错误承运商拒绝创建', '非法拖车币种拒绝创建',
|
||||
'RMB应付费用响应无客户及应收字段', '编辑为USD应付费用', 'USD应付费用保存且无应收字段',
|
||||
'派车命令返回状态身份与版本正确', '派车后回查拖车完整详情',
|
||||
'详情字段完整且订舱柜号身份无客户字段漂移']) assert.match(source, new RegExp(label))
|
||||
assert.match(source, /const detailResponse = await request\(context, 'GET', path\); const detail = data\(detailResponse\)/)
|
||||
assert.match(source, /const detailGaps = truckAppointmentFieldGaps\(detail\)/)
|
||||
assert.doesNotMatch(source, /truckAppointmentFieldGaps\(dispatched\)/)
|
||||
assert.doesNotMatch(source, /phase3ContainerLoading|phase3Loading/)
|
||||
assert.doesNotMatch(source, /receivableAmount\s*:/)
|
||||
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
|
||||
assert.match(entry, /\['truck-appointments', \['merchant-qualification-fixture'\]\]/)
|
||||
assert.doesNotMatch(entry, /\['truck-appointments', \[[^\]]*phase3-loading/)
|
||||
|
||||
@@ -55,7 +55,7 @@ export function truckAppointmentPayload(context, suffix, identity, version = 0,
|
||||
storeName: `装柜点-${suffix}`, storeContact: '仓库联系人', storePhone: '13800000006',
|
||||
storeAddress: '深圳市南山区验收地址', storeCity: '深圳', storeZipcode: '518000',
|
||||
packageCount: 0, grossWeightKg: 0, volumeCbm: 0, palletCount: 0, poNo: `PO-${suffix}`,
|
||||
plannedLoadingAt: '2026-12-31T10:00:00', transitMinutes: 0, payableAmount: 0, receivableAmount: 0,
|
||||
plannedLoadingAt: '2026-12-31T10:00:00', transitMinutes: 0, payableAmount: 0,
|
||||
currency: 'RMB', remark: `拖车平台业务-${suffix}`, expectedVersion: version, ...overrides }
|
||||
}
|
||||
|
||||
@@ -172,6 +172,7 @@ export async function run(context) {
|
||||
['错误柜号归属拒绝创建', { ...base, containerBookingId: (BigInt(identity.containerBookingId) + 1n).toString() }],
|
||||
['错误提单拒绝创建', { ...base, billOfLadingNo: `WRONG-${identity.suffix}` }],
|
||||
['错误承运商拒绝创建', { ...base, carrierId: (BigInt(identity.truckSupplier.supplierId) + 1n).toString() }],
|
||||
['非法拖车币种拒绝创建', { ...base, currency: 'CNY' }],
|
||||
]) {
|
||||
const response = await request(context, 'POST', TRUCKS, payload)
|
||||
context.addStep(MODULE, label, response, failure(response), `code=${codeOf(response)}`, OPTIONS)
|
||||
@@ -179,12 +180,21 @@ export async function run(context) {
|
||||
const createdResponse = await request(context, 'POST', TRUCKS, base)
|
||||
const appointment = context.apiSuccess(MODULE, '创建合法拖车预约', createdResponse, '', OPTIONS)
|
||||
const appointmentId = id(appointment?.id); if (!appointmentId) return
|
||||
context.assert(MODULE, '拖车响应不含客户字段', createdResponse,
|
||||
!Object.hasOwn(appointment, 'customerId') && !Object.hasOwn(appointment, 'customerName'), '', OPTIONS)
|
||||
context.assert(MODULE, 'RMB应付费用响应无客户及应收字段', createdResponse,
|
||||
appointment?.currency === 'RMB' && Number(appointment?.payableAmount) === 0
|
||||
&& !Object.hasOwn(appointment, 'receivableAmount')
|
||||
&& !Object.hasOwn(appointment, 'customerId') && !Object.hasOwn(appointment, 'customerName'), '', OPTIONS)
|
||||
const path = `${TRUCKS}/${appointmentId}`
|
||||
const usdResponse = await request(context, 'PUT', path,
|
||||
{ ...base, currency: 'USD', payableAmount: 12.34, expectedVersion: Number(appointment.version) })
|
||||
const usdAppointment = context.apiSuccess(MODULE, '编辑为USD应付费用', usdResponse, '', OPTIONS)
|
||||
context.assert(MODULE, 'USD应付费用保存且无应收字段', usdResponse,
|
||||
usdAppointment?.currency === 'USD' && Number(usdAppointment?.payableAmount) === 12.34
|
||||
&& !Object.hasOwn(usdAppointment || {}, 'receivableAmount'), '', OPTIONS)
|
||||
const duplicate = await request(context, 'POST', TRUCKS, base)
|
||||
context.addStep(MODULE, '同柜第二条非终态预约拒绝且零漂移', duplicate, failure(duplicate), `code=${codeOf(duplicate)}`, OPTIONS)
|
||||
const path = `${TRUCKS}/${appointmentId}`; const submitKey = `${context.marker}-truck-submit`.slice(0, 128)
|
||||
const submitBody = { expectedVersion: Number(appointment.version), reason: '幂等提交' }
|
||||
const submitKey = `${context.marker}-truck-submit`.slice(0, 128)
|
||||
const submitBody = { expectedVersion: Number(usdAppointment?.version), reason: '幂等提交' }
|
||||
const first = await request(context, 'POST', `${path}/submit`, submitBody, { 'Idempotency-Key': submitKey })
|
||||
const firstData = context.apiSuccess(MODULE, '提交拖车预约', first, '', OPTIONS)
|
||||
const replay = await request(context, 'POST', `${path}/submit`, submitBody, { 'Idempotency-Key': submitKey })
|
||||
@@ -206,9 +216,11 @@ export async function run(context) {
|
||||
context.assert(MODULE, '详情字段完整且订舱柜号身份无客户字段漂移', detailResponse,
|
||||
detail?.status === 'DISPATCHED' && detailGaps.length === 0
|
||||
&& !Object.hasOwn(detail || {}, 'customerId') && !Object.hasOwn(detail || {}, 'customerName')
|
||||
&& !Object.hasOwn(detail || {}, 'receivableAmount')
|
||||
&& detail?.currency === 'USD' && Number(detail?.payableAmount) === 12.34
|
||||
&& id(detail?.oceanBookingId) === identity.oceanBookingId
|
||||
&& id(detail?.containerBookingId) === identity.containerBookingId,
|
||||
`missing=${detailGaps.join(',') || 'none'}, oceanBookingId=${id(detail?.oceanBookingId) || 'missing'}, containerBookingId=${id(detail?.containerBookingId) || 'missing'}`, OPTIONS)
|
||||
`missing=${detailGaps.join(',') || 'none'}, currency=${detail?.currency || 'missing'}, payableAmount=${detail?.payableAmount ?? 'missing'}, oceanBookingId=${id(detail?.oceanBookingId) || 'missing'}, containerBookingId=${id(detail?.containerBookingId) || 'missing'}`, OPTIONS)
|
||||
context.createdResources.truckAppointment = { id: appointmentId, marker: context.marker, finalStatus: 'DISPATCHED',
|
||||
oceanBookingId: identity.oceanBookingId, containerBookingId: identity.containerBookingId }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user