From 87c1bfbb715a0c95626c78a44c3fbed782d3b794 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 8 Aug 2026 14:17:06 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/TruckAppointmentService.java | 2 +- .../TruckAppointmentMapperContractTest.java | 24 ++++ .../service/TruckAppointmentServiceTest.java | 90 +++++++++++++ gateway/src/main/resources/application.yml | 6 +- .../FirstlegContainerRouteContractTest.java | 10 ++ ...service-center-contract-gate-20260808.json | 123 ++++++++++++++++++ scripts/admin-test-env-acceptance.mjs | 2 +- .../admin-test-env-modules/ocean-bookings.mjs | 16 ++- .../truck-appointments.mjs | 5 +- 9 files changed, 267 insertions(+), 11 deletions(-) create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java create mode 100644 reports/admin-service-center-contract-gate-20260808.json diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java index 78996c132..8f02c4ade 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java @@ -5,7 +5,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.common. @Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0"); 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); 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("当前状态不可编辑"); copy(e,r); 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 hash,long uid,String name){TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); TruckAppointmentStatus from=e.getStatus(),to=next(from,action); if("RESCHEDULE".equals(action)){throw conflict("改约请使用专用接口");} if(repo.transition(id,from.name(),to.name(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试"); e.setStatus(to);e.setVersion(e.getVersion()+1);e.setRevision(e.getRevision()+1); record(e,from.name(),to.name(),action,r.reason(),uid,name); operation(e,action,idem,hash); return e;} - @Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,long uid,String name){TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");e.setStatus(TruckAppointmentStatus.RESCHEDULED);e.setPlannedLoadingAt(at);record(e,e.getStatus().name(),"RESCHEDULED","RESCHEDULE",r.reason(),uid,name);return e;} + @Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,long uid,String name){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("状态或版本已变化,请刷新后重试");e.setStatus(TruckAppointmentStatus.RESCHEDULED);e.setPlannedLoadingAt(at);e.setVersion(e.getVersion()+1);e.setRevision(e.getRevision()+1);record(e,from,"RESCHEDULED","RESCHEDULE",r.reason(),uid,name);return e;} 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 void copy(TruckAppointmentEntity e,SaveRequest r){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());e.setWarehouseId(r.warehouseId());e.setCustomerId(r.customerId());e.setCustomerName(r.customerName());e.setCarrierId(r.carrierId());e.setCarrierName(r.carrierName());e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setReceivableAmount(r.receivableAmount());e.setCurrency(r.currency()==null?"RMB":r.currency());e.setRemark(r.remark());} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java new file mode 100644 index 000000000..b5d8b1c0c --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java @@ -0,0 +1,24 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.entity.TruckAppointmentEntity; +import com.baiyue.firstleg.mapper.TruckAppointmentMapper; +import com.baomidou.mybatisplus.annotation.TableName; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class TruckAppointmentMapperContractTest { + @Test void mapsToPhase3TableAndUsesOptimisticGuards() throws Exception { + assertThat(TruckAppointmentEntity.class.getAnnotation(TableName.class).value()).isEqualTo("fl_truck_appointment"); + String page = TruckAppointmentMapper.class.getMethod("page", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, com.baiyue.firstleg.dto.TruckAppointmentDtos.Query.class).getAnnotation(Select.class).value()[0]; + assertThat(page).contains("fl_truck_appointment", "appointment_no", "vehicle_no", "status=#{q.status}", "container_booking_id=#{q.containerBookingId}"); + String transition = TruckAppointmentMapper.class.getMethod("transition", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value(); + assertThat(transition).contains("status=#{toStatus}", "status=#{fromStatus}", "version=#{version}", "revision=revision+1", "version=version+1"); + String reschedule = TruckAppointmentMapper.class.getMethod("reschedule", long.class, java.time.LocalDateTime.class, String.class, int.class).getAnnotation(Update.class).value(); + assertThat(reschedule).contains("status='RESCHEDULED'", "version=#{version}", "revision=revision+1"); + String edit = TruckAppointmentMapper.class.getMethod("updateEdit", TruckAppointmentEntity.class, int.class).getAnnotation(Update.class).value(); + assertThat(edit).contains("WHERE id=#{e.id} AND version=#{version}", "version=version+1", "revision=revision+1"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java new file mode 100644 index 000000000..a1f82e1ca --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java @@ -0,0 +1,90 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.domain.TruckAppointmentStatus; +import com.baiyue.firstleg.dto.TruckAppointmentDtos.ActionRequest; +import com.baiyue.firstleg.dto.TruckAppointmentDtos.SaveRequest; +import com.baiyue.firstleg.entity.TruckAppointmentEntity; +import com.baiyue.firstleg.entity.TruckAppointmentHistoryEntity; +import com.baiyue.firstleg.entity.TruckAppointmentOperationEntity; +import com.baiyue.firstleg.repository.TruckAppointmentRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.lang.reflect.Constructor; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class TruckAppointmentServiceTest { + @Mock TruckAppointmentRepository repository; + @InjectMocks TruckAppointmentService service; + + @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } + + @Test void createRequiresZeroVersionAndInitialisesDraft() { + SaveRequest invalid = request(1); + assertThatThrownBy(() -> service.create(7L, "admin", invalid)).isInstanceOf(BizException.class); + verifyNoInteractions(repository); + } + + @Test void actionAdvancesMainLifecycleAndWritesIdempotentOperation() { + TruckAppointmentEntity e = entity(TruckAppointmentStatus.DRAFT, 0); + when(repository.find(1L)).thenReturn(e); + when(repository.transition(1L, "DRAFT", "PENDING_DISPATCH", 0)).thenReturn(1); + + var result = service.action(1L, "submit", new ActionRequest(0, "提交"), "idem-1", "hash-1", 7L, "admin"); + + assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.PENDING_DISPATCH); + assertThat(result.getVersion()).isEqualTo(1); + assertThat(result.getRevision()).isEqualTo(1); + ArgumentCaptor operation = ArgumentCaptor.forClass(TruckAppointmentOperationEntity.class); + verify(repository).operation(operation.capture()); + assertThat(operation.getValue().getIdempotencyKey()).isEqualTo("idem-1"); + verify(repository).history(any(TruckAppointmentHistoryEntity.class)); + } + + @Test void rejectsRescheduleThroughGenericActionAndCompletedReschedule() { + TruckAppointmentEntity e = entity(TruckAppointmentStatus.DRAFT, 0); + when(repository.find(1L)).thenReturn(e); + assertThatThrownBy(() -> service.action(1L, "RESCHEDULE", new ActionRequest(0, null), null, null, 7L, "admin")) + .isInstanceOf(BizException.class).hasMessageContaining("专用接口"); + e.setStatus(TruckAppointmentStatus.COMPLETED); + assertThatThrownBy(() -> service.reschedule(1L, LocalDateTime.now(), new ActionRequest(0, "改约"), 7L, "admin")) + .isInstanceOf(BizException.class).hasMessageContaining("不可改约"); + verify(repository, never()).reschedule(anyLong(), any(), anyString(), anyInt()); + } + + @Test void rescheduleKeepsFromStatusAndIncrementsOptimisticFields() { + TruckAppointmentEntity e = entity(TruckAppointmentStatus.DISPATCHED, 2); + when(repository.find(1L)).thenReturn(e); + when(repository.reschedule(eq(1L), any(), eq("改约"), eq(2))).thenReturn(1); + var at = LocalDateTime.of(2026, 8, 12, 10, 0); + var result = service.reschedule(1L, at, new ActionRequest(2, "改约"), 7L, "admin"); + assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.RESCHEDULED); + assertThat(result.getVersion()).isEqualTo(3); + ArgumentCaptor history = ArgumentCaptor.forClass(TruckAppointmentHistoryEntity.class); + verify(repository).history(history.capture()); + assertThat(history.getValue().getFromStatus()).isEqualTo("DISPATCHED"); + assertThat(history.getValue().getToStatus()).isEqualTo("RESCHEDULED"); + } + + private static TruckAppointmentEntity entity(TruckAppointmentStatus status, int version) { + TruckAppointmentEntity e = new TruckAppointmentEntity(); e.setId(1L); e.setStatus(status); e.setVersion(version); e.setRevision(version); return e; + } + + private static SaveRequest request(int expectedVersion) { + try { + Constructor c = SaveRequest.class.getDeclaredConstructors()[0]; + Object[] args = new Object[c.getParameterCount()]; args[args.length - 1] = expectedVersion; + return (SaveRequest) c.newInstance(args); + } catch (ReflectiveOperationException ex) { throw new AssertionError(ex); } + } +} diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml index eafc6c63f..41463d204 100644 --- a/gateway/src/main/resources/application.yml +++ b/gateway/src/main/resources/application.yml @@ -46,6 +46,10 @@ spring: uri: lb://auth-service predicates: - Path=/api/auth/** + - id: warehouse-customer-overseas-fees + uri: lb://warehouse-service + predicates: + - Path=/api/platform/v1/iam/tenants/{tenantId}/overseas-storage-fees,/api/platform/v1/iam/tenants/{tenantId}/overseas-storage-fees/** - id: iam-service uri: lb://iam-service predicates: @@ -73,7 +77,7 @@ spring: - id: firstleg-service uri: lb://firstleg-service predicates: - - Path=/api/merchant/v1/first-leg/**,/api/platform/v1/first-leg/**,/api/platform/v1/containers/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/v1/fee-items/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/** + - Path=/api/merchant/v1/first-leg/**,/api/platform/v1/first-leg/**,/api/platform/v1/containers/**,/api/platform/v1/ocean-bookings/**,/api/platform/v1/truck-appointments/**,/api/platform/v1/fba-pricing/**,/api/platform/v1/inbounds/**,/api/platform/v1/fee-items/**,/api/platform/inbounds/**,/openapi/v1/inbound-measurements/** # Swagger 文档聚合路由。 - id: auth-openapi uri: lb://auth-service diff --git a/gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java b/gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java index defdfd285..9945111e5 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/FirstlegContainerRouteContractTest.java @@ -15,4 +15,14 @@ class FirstlegContainerRouteContractTest { assertThat(yaml).contains("/api/platform/v1/containers/**"); } + + @Test + void routesOceanBookingsAndTruckAppointments() throws Exception { + String yaml = new ClassPathResource("application.yml") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(yaml) + .contains("/api/platform/v1/ocean-bookings/**") + .contains("/api/platform/v1/truck-appointments/**"); + } } diff --git a/reports/admin-service-center-contract-gate-20260808.json b/reports/admin-service-center-contract-gate-20260808.json new file mode 100644 index 000000000..636b49d57 --- /dev/null +++ b/reports/admin-service-center-contract-gate-20260808.json @@ -0,0 +1,123 @@ +{ + "reportName": "后台管理测试环境 HTTP API 验收", + "marker": "ADMIN-API-20260808061502679", + "gateway": "http://8.163.30.128:9000", + "generatedAt": "2026-08-08T06:15:03.114Z", + "startedAt": "2026-08-08T06:15:02.679Z", + "success": false, + "fatal": null, + "summary": { + "total": 3, + "passed": 1, + "failed": 2 + }, + "modules": [ + { + "name": "ocean-bookings", + "startedAt": "2026-08-08T06:15:03.056Z", + "finishedAt": "2026-08-08T06:15:03.091Z", + "success": false, + "passed": 0, + "failed": 1, + "fatal": null + }, + { + "name": "truck-appointments", + "startedAt": "2026-08-08T06:15:03.092Z", + "finishedAt": "2026-08-08T06:15:03.114Z", + "success": false, + "passed": 0, + "failed": 1, + "fatal": null + } + ], + "scenarios": [], + "deploymentBlockers": [ + { + "module": "ocean-bookings", + "scenario": "ocean-booking-lifecycle", + "name": "海运订舱服务契约已部署", + "evidence": "入口 /api/platform/v1/ocean-bookings 不可用:HTTP=404, code=missing", + "code": null, + "requestId": "a965e219-80", + "traceId": null + }, + { + "module": "truck-appointments", + "scenario": "truck-appointment-lifecycle", + "name": "拖车预约服务契约已部署", + "evidence": "入口 /api/platform/v1/truck-appointments 不可用:HTTP=404, code=missing", + "code": null, + "requestId": "81f11067-81", + "traceId": null + } + ], + "createdResources": {}, + "excluded": [ + { + "scope": "dashboard", + "reason": "首页为静态演示数据,不调用后端 API" + }, + { + "scope": "hidden-prototypes", + "reason": "隐藏原型页面尚无已确认后端契约" + } + ], + "securityNote": "报告不保存登录密码、Access Token、Refresh Token、图片二进制或 OCR 原文全文。", + "steps": [ + { + "module": "authentication", + "scenario": null, + "name": "平台管理员登录", + "passed": true, + "deploymentBlocker": false, + "request": { + "method": "POST", + "path": "/api/auth/login" + }, + "http": 200, + "code": "200", + "message": "success", + "requestId": "fac3872e-0160-4c28-8d1c-863d6cfdb1b0, f932ac3f-37a8-45eb-9853-8cf2d40ee1f6", + "traceId": "e94538f8e9e14b76bad6115ed8fb22c2", + "evidence": "", + "checkedAt": "2026-08-08T06:15:03.055Z" + }, + { + "module": "ocean-bookings", + "scenario": "ocean-booking-lifecycle", + "name": "海运订舱服务契约已部署", + "passed": false, + "deploymentBlocker": true, + "request": { + "method": "GET", + "path": "/api/platform/v1/ocean-bookings?page=1&size=5&keyword=ADMIN-API-20260808061502679" + }, + "http": 404, + "code": null, + "message": null, + "requestId": "a965e219-80", + "traceId": null, + "evidence": "入口 /api/platform/v1/ocean-bookings 不可用:HTTP=404, code=missing", + "checkedAt": "2026-08-08T06:15:03.091Z" + }, + { + "module": "truck-appointments", + "scenario": "truck-appointment-lifecycle", + "name": "拖车预约服务契约已部署", + "passed": false, + "deploymentBlocker": true, + "request": { + "method": "GET", + "path": "/api/platform/v1/truck-appointments?page=1&size=5&keyword=ADMIN-API-20260808061502679" + }, + "http": 404, + "code": null, + "message": null, + "requestId": "81f11067-81", + "traceId": null, + "evidence": "入口 /api/platform/v1/truck-appointments 不可用:HTTP=404, code=missing", + "checkedAt": "2026-08-08T06:15:03.114Z" + } + ] +} diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 62327402a..31e4293cd 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -11,7 +11,7 @@ import process from 'node:process' const DEFAULT_GATEWAY = 'http://8.163.30.128:9000' const DEFAULT_USERNAME = 'platform_admin' const DEFAULT_PASSWORD = 'ChangeMe123!' -const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'orders', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'supplemental', 'supplemental-boundaries'] +const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'orders', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments'] const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG' const CUSTOMER_FIELDS = [ 'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative', diff --git a/scripts/admin-test-env-modules/ocean-bookings.mjs b/scripts/admin-test-env-modules/ocean-bookings.mjs index d14cfae4c..c451e3df2 100644 --- a/scripts/admin-test-env-modules/ocean-bookings.mjs +++ b/scripts/admin-test-env-modules/ocean-bookings.mjs @@ -16,25 +16,29 @@ async function request(context, method, path, body, headers = {}) { } function data(r) { return String(r?.body?.code) === '200' ? r.body.data : null } function draft(context, suffix) { return { customerId: '10001', customerName: `订舱验收客户-${suffix}`, shippingCompanyId: null, shippingCompanyName: `船公司-${suffix}`, bookingAgentId: null, bookingAgentName: `订舱代理-${suffix}`, vesselName: `验收船-${suffix}`, voyageNo: `V${suffix}`, departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID', estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00', billOfLadingNo: `BL-${suffix}`, remark: context.marker, parties: [{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` }, { partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` }, { partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` }], containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: 1 }] } } -async function create(context, suffix, key) { return request(context, 'POST', BASE, { expectedVersion: 0, data: draft(context, suffix) }, { 'Idempotency-Key': key }) } +async function createBooking(context, suffix, key) { return request(context, 'POST', BASE, { expectedVersion: 0, data: draft(context, suffix) }, { 'Idempotency-Key': key }) } function step(context, name, r, pass, evidence, options = OPTIONS) { context.addStep(MODULE, name, r, pass, evidence, { ...options, deploymentBlocker: options.deploymentBlocker && !pass }) } export async function run(context) { const suffix = context.marker.replace(/\D/g, '').slice(-10) const list = await request(context, 'GET', `${BASE}?page=1&size=5&keyword=${encodeURIComponent(context.marker)}`) + if (missing(list) || list.http === 0) { + step(context, '海运订舱服务契约已部署', list, false, `入口 ${BASE} 不可用:HTTP=${list.http}, code=${codeOf(list)}`) + return + } const page = context.apiSuccess(MODULE, '订舱列表分页与 keyword 查询', list, '', OPTIONS) context.assert(MODULE, '订舱列表分页结构正确', list, Array.isArray(page?.records) && Number.isFinite(Number(page?.total)), `records=${page?.records?.length ?? 'n/a'}`, OPTIONS) const statuses = ['DRAFT', 'SUBMITTED', 'CONFIRMED', 'RELEASED', 'FAILED', 'CANCELLED'] for (const status of statuses) { const r = await request(context, 'GET', `${BASE}?page=1&size=5&status=${status}`); const d = data(r); step(context, `订舱 ${status} 状态筛选`, r, r.http === 200 && codeOf(r) === '200' && Array.isArray(d?.records) && d.records.every((x) => x.status === status), `HTTP=${r.http}, code=${codeOf(r)}, records=${d?.records?.length ?? 'n/a'}`) } const key = `${context.marker}-ocean-create` - const create = await create(context, suffix, key) - const detail = data(create); const bookingId = id(detail?.oceanBooking?.id) - context.apiSuccess(MODULE, '创建隔离海运订舱', create, `key=${key}`, OPTIONS) - context.assert(MODULE, '订舱 DTO 字段、DRAFT 和9类关联字段完整', create, Boolean(bookingId) && detail?.oceanBooking?.status === 'DRAFT' && detail?.parties?.length === 3 && detail?.containerRequirements?.length === 1, `bookingId=${bookingId || 'missing'}, parties=${detail?.parties?.length ?? 'n/a'}, requirements=${detail?.containerRequirements?.length ?? 'n/a'}`, OPTIONS) + const createResponse = await createBooking(context, suffix, key) + const detail = data(createResponse); const bookingId = id(detail?.oceanBooking?.id) + context.apiSuccess(MODULE, '创建隔离海运订舱', createResponse, `key=${key}`, OPTIONS) + context.assert(MODULE, '订舱 DTO 字段、DRAFT 和9类关联字段完整', createResponse, Boolean(bookingId) && detail?.oceanBooking?.status === 'DRAFT' && detail?.parties?.length === 3 && detail?.containerRequirements?.length === 1, `bookingId=${bookingId || 'missing'}, parties=${detail?.parties?.length ?? 'n/a'}, requirements=${detail?.containerRequirements?.length ?? 'n/a'}`, OPTIONS) if (!bookingId) return context.createdResources.oceanBooking = { id: bookingId, marker: context.marker, finalStatus: 'RELEASED' } - const replay = await create(context, suffix, key) + const replay = await createBooking(context, suffix, key) context.assert(MODULE, '创建 Idempotency-Key 重放返回同一订舱', replay, String(data(replay)?.oceanBooking?.id) === bookingId, `first=${bookingId}, replay=${id(data(replay)?.oceanBooking?.id)}`, OPTIONS) const path = `${BASE}/${bookingId}` const initialVersion = Number(detail.oceanBooking.version) diff --git a/scripts/admin-test-env-modules/truck-appointments.mjs b/scripts/admin-test-env-modules/truck-appointments.mjs index 54fff4d20..1a7ebed97 100644 --- a/scripts/admin-test-env-modules/truck-appointments.mjs +++ b/scripts/admin-test-env-modules/truck-appointments.mjs @@ -3,9 +3,10 @@ const OPTIONS = { scenario: 'truck-appointment-lifecycle', deploymentBlocker: tr const BASE = '/api/platform/v1/truck-appointments' function id(v) { return v == null ? '' : String(v) } function codeOf(r) { return r?.body?.code == null ? 'missing' : String(r.body.code) } +function missing(r) { return r?.http === 404 || r?.http === 405 || r?.http === 503 } function failure(r) { return [400, 401, 403, 404, 409, 503].includes(r?.http) || (r?.http === 200 && codeOf(r) !== '200') } async function req(context, method, path, body, headers = {}) { try { const response = await fetch(`${context.options.gateway}${path}`, { method, headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000) }); const raw = await response.text(); let payload = null; try { payload = raw ? JSON.parse(raw) : null } catch {} return { method, path, http: response.status, body: payload, raw: payload === null ? raw.slice(0, 500) : null, requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id') } } catch (e) { return { method, path, http: 0, body: null, raw: String(e), requestId: null, traceId: null } } } function data(r) { return String(r?.body?.code) === '200' ? r.body.data : null } function payload(context, suffix, version = 0) { return { outboundOrderId: null, oceanBookingId: null, containerBookingId: null, loadingPlanId: null, warehouseId: 193000000000000001, customerId: 10001, customerName: `拖车验收客户-${suffix}`, carrierId: null, carrierName: `承运商-${suffix}`, 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, palletCount: 1, poNo: `PO-${suffix}`, plannedLoadingAt: '2026-09-02T08:00:00', transitMinutes: 120, payableAmount: 100, receivableAmount: 150, currency: 'RMB', remark: context.marker, expectedVersion: version } } -export async function run(context) { const suffix = context.marker.replace(/\D/g, '').slice(-10); const list = await req(context, 'GET', `${BASE}?page=1&size=5&keyword=${encodeURIComponent(context.marker)}`); const page = context.apiSuccess(MODULE, '拖车预约分页 keyword 查询', list, '', OPTIONS); context.assert(MODULE, '拖车预约分页结构正确', list, Array.isArray(page?.records), `records=${page?.records?.length ?? 'n/a'}`, OPTIONS); for (const status of ['DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED']) { const r = await req(context, 'GET', `${BASE}?page=1&size=5&status=${status}`); const d = data(r); context.addStep(MODULE, `拖车预约 ${status} 筛选`, r, r.http === 200 && codeOf(r) === '200' && Array.isArray(d?.records) && d.records.every((x) => x.status === status), `HTTP=${r.http}, code=${codeOf(r)}, records=${d?.records?.length ?? 'n/a'}`, OPTIONS) } - const create = await req(context, 'POST', BASE, payload(context, suffix)); const appointment = context.apiSuccess(MODULE, '创建隔离拖车预约', create, '', OPTIONS); const appointmentId = id(appointment?.id); context.assert(MODULE, '拖车预约费用、司机车辆和地址字段完整', create, Boolean(appointmentId) && appointment?.status === 'DRAFT' && appointment?.payableAmount != null && appointment?.receivableAmount != null, `id=${appointmentId || 'missing'}, status=${appointment?.status || 'missing'}`, OPTIONS); if (!appointmentId) return; context.createdResources.truckAppointment = { id: appointmentId, marker: context.marker, finalStatus: 'CANCELLED' }; const path = `${BASE}/${appointmentId}`; let version = Number(appointment.version); const edit = await req(context, 'PUT', path, { ...payload(context, suffix, version), vehicleNo: `粤C-${suffix.slice(-6)}` }); const edited = context.apiSuccess(MODULE, '编辑拖车预约', edit, `version=${version}`, OPTIONS); version = Number(edited?.version); const stale = await req(context, 'PUT', path, { ...payload(context, suffix, 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 req(context, 'GET', path); const ad = data(after); context.apiSuccess(MODULE, '冲突后回查拖车预约', after, '', OPTIONS); context.assert(MODULE, '拖车预约冲突未部分更新', after, ad?.vehicleNo === edited?.vehicleNo, `vehicleNo=${ad?.vehicleNo || 'missing'}`, OPTIONS); version = Number(ad?.version); const action = async (name, endpoint, expected) => { const r = await req(context, 'POST', `${path}/${endpoint}`, { expectedVersion: expected, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-${endpoint}` }); const d = context.apiSuccess(MODULE, name, r, `status=${d?.status || 'missing'}`, OPTIONS); version = Number(d?.version); return d }; for (const [name, endpoint] of [['提交预约','submit'],['派车','dispatch'],['开始提货','pickup'],['到仓','arrive'],['开始装货','load'],['离场','depart'],['完成','complete']]) await action(name, endpoint, version); const final = await req(context, 'GET', path); const fd = data(final); context.apiSuccess(MODULE, '回查 COMPLETED 拖车预约', final, '', OPTIONS); context.assert(MODULE, '拖车预约状态终态和费用快照正确', final, fd?.status === 'COMPLETED' && fd?.payableAmount != null && fd?.receivableAmount != null, `status=${fd?.status || 'missing'}`, OPTIONS); context.addStep(MODULE, '附件引用安全边界自动化缺口', null, false, '当前 Controller 无附件上传/引用 API,未伪造附件成功', OPTIONS) } +export async function run(context) { const suffix = context.marker.replace(/\D/g, '').slice(-10); const list = await req(context, 'GET', `${BASE}?page=1&size=5&keyword=${encodeURIComponent(context.marker)}`); if (missing(list) || list.http === 0) { context.addStep(MODULE, '拖车预约服务契约已部署', list, false, `入口 ${BASE} 不可用:HTTP=${list.http}, code=${codeOf(list)}`, OPTIONS); return } const page = context.apiSuccess(MODULE, '拖车预约分页 keyword 查询', list, '', OPTIONS); context.assert(MODULE, '拖车预约分页结构正确', list, Array.isArray(page?.records), `records=${page?.records?.length ?? 'n/a'}`, OPTIONS); for (const status of ['DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED']) { const r = await req(context, 'GET', `${BASE}?page=1&size=5&status=${status}`); const d = data(r); context.addStep(MODULE, `拖车预约 ${status} 筛选`, r, r.http === 200 && codeOf(r) === '200' && Array.isArray(d?.records) && d.records.every((x) => x.status === status), `HTTP=${r.http}, code=${codeOf(r)}, records=${d?.records?.length ?? 'n/a'}`, OPTIONS) } + const createResponse = await req(context, 'POST', BASE, payload(context, suffix)); 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); if (!appointmentId) return; context.createdResources.truckAppointment = { id: appointmentId, marker: context.marker, finalStatus: 'CANCELLED' }; const path = `${BASE}/${appointmentId}`; let version = Number(appointment.version); const edit = await req(context, 'PUT', path, { ...payload(context, suffix, version), vehicleNo: `粤C-${suffix.slice(-6)}` }); const edited = context.apiSuccess(MODULE, '编辑拖车预约', edit, `version=${version}`, OPTIONS); version = Number(edited?.version); const stale = await req(context, 'PUT', path, { ...payload(context, suffix, 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 req(context, 'GET', path); const ad = data(after); context.apiSuccess(MODULE, '冲突后回查拖车预约', after, '', OPTIONS); context.assert(MODULE, '拖车预约冲突未部分更新', after, ad?.vehicleNo === edited?.vehicleNo, `vehicleNo=${ad?.vehicleNo || 'missing'}`, OPTIONS); version = Number(ad?.version); const action = async (name, endpoint, expected) => { const r = await req(context, 'POST', `${path}/${endpoint}`, { expectedVersion: expected, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-${endpoint}` }); const d = context.apiSuccess(MODULE, name, r, '', OPTIONS); context.assert(MODULE, `${name} 状态返回`, r, Boolean(d?.status), `status=${d?.status || 'missing'}`, OPTIONS); version = Number(d?.version); return d }; for (const [name, endpoint] of [['提交预约','submit'],['派车','dispatch'],['开始提货','pickup'],['到仓','arrive'],['开始装货','load'],['离场','depart'],['完成','complete']]) await action(name, endpoint, version); const final = await req(context, 'GET', path); const fd = data(final); context.apiSuccess(MODULE, '回查 COMPLETED 拖车预约', final, '', OPTIONS); context.assert(MODULE, '拖车预约状态终态和费用快照正确', final, fd?.status === 'COMPLETED' && fd?.payableAmount != null && fd?.receivableAmount != null, `status=${fd?.status || 'missing'}`, OPTIONS); context.addStep(MODULE, '附件引用安全边界自动化缺口', null, false, '当前 Controller 无附件上传/引用 API,未伪造附件成功', OPTIONS) }