diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 622883624..2544a3a4e 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -8663,8 +8663,8 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID', booking_no VARCHAR(40) NOT NULL COMMENT '平台海运订舱编号', status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、SUBMITTED已提交、CONFIRMED已确认、RELEASED已放舱、FAILED失败、CHANGE_PENDING船期待变更、CANCELLED已取消', - customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', - customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照', + customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照', + customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照', bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号', shipping_company_id BIGINT UNSIGNED NULL COMMENT '船公司逻辑ID快照', shipping_company_name VARCHAR(128) NULL COMMENT '船公司名称快照', @@ -8677,8 +8677,8 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照', destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照', place_of_delivery VARCHAR(128) NULL COMMENT '最终交货地快照', - service_term VARCHAR(32) NULL COMMENT '运输服务条款快照', - freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照', + service_term VARCHAR(32) NOT NULL COMMENT '运输服务条款快照', + freight_term VARCHAR(32) NOT NULL COMMENT '海运费支付条款快照', estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间', estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间', pending_vessel_name VARCHAR(128) NULL COMMENT '待确认变更船名', diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java index f499f46e4..16357506b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java @@ -37,10 +37,10 @@ public class PlatformOceanBookingController { @PostMapping @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:create')") @BusinessAudit(event="OCEAN_BOOKING_CREATE", resourceType="OCEAN_BOOKING", description="创建海运订舱单", captureBefore=false) - public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CreateRequest request) { FirstlegScopeGuard.platform(user, request.data().customerId()); return ApiResponse.success(service.create(user.userId(), user.username(), key, request)); } + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CreateRequest request) { validateOptionalCustomer(user, request.data().customerId()); return ApiResponse.success(service.create(user.userId(), user.username(), key, request)); } @PutMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')") @BusinessAudit(event="OCEAN_BOOKING_UPDATE", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="编辑海运订舱草稿") - public ApiResponse updateDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpdateDraftRequest request) { FirstlegScopeGuard.platform(user, request.data().customerId()); return ApiResponse.success(service.updateDraft(oceanBookingId, user.userId(), user.username(), key, request)); } + public ApiResponse updateDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpdateDraftRequest request) { validateOptionalCustomer(user, request.data().customerId()); return ApiResponse.success(service.updateDraft(oceanBookingId, user.userId(), user.username(), key, request)); } @PostMapping("/{oceanBookingId}/submit") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:submit')") @BusinessAudit(event="OCEAN_BOOKING_SUBMIT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="提交海运订舱") public ApiResponse submit(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.submit(oceanBookingId, user.userId(), user.username(), key, request)); } @@ -68,4 +68,5 @@ public class PlatformOceanBookingController { @PostMapping("/{oceanBookingId}/allocations") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:allocate')") @BusinessAudit(event="OCEAN_BOOKING_ALLOCATE_CONTAINER", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="分配订舱柜次") public ApiResponse allocate(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody AllocationRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.allocate(oceanBookingId, user.userId(), user.username(), key, request)); } + private static void validateOptionalCustomer(AuthenticatedUser user, Long customerId) { FirstlegScopeGuard.platform(user); if (customerId != null) FirstlegScopeGuard.platform(user, customerId); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java index 06277cdfd..c7d480efc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java @@ -39,8 +39,8 @@ public final class OceanBookingDtos { @NotNull @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg, @NotNull @Positive Integer quantity) {} - public record DraftData(@NotNull @Positive Long customerId, - @NotBlank @Size(max = 128) String customerName, + public record DraftData(@Positive Long customerId, + @Size(max = 128) String customerName, @NotBlank @Size(max = 64) String channelCode, @NotNull @Positive Long warehouseId, @NotBlank @Size(max = 128) String warehouseName, @@ -56,8 +56,8 @@ public final class OceanBookingDtos { @Size(max = 32) String destinationPortCode, @Size(max = 128) String destinationPortName, @Size(max = 128) String placeOfDelivery, - @Size(max = 32) String serviceTerm, - @Size(max = 32) String freightTerm, + @NotBlank @Size(max = 32) String serviceTerm, + @NotBlank @Size(max = 32) String freightTerm, LocalDateTime estimatedSailingAt, LocalDateTime estimatedArrivalAt, @Size(max = 1000) String remark, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index f2cbfe7e1..febed8f48 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -286,6 +286,12 @@ public class OceanBookingService { private static void requireStatus(OceanBookingEntity value, OceanBookingStatus status) { if (!status.name().equals(value.getStatus())) throw conflict("订舱状态不允许当前操作"); } private void validateDraftStructure(DraftData data) { + boolean hasCustomerId = data.customerId() != null; + boolean hasCustomerName = StringUtils.hasText(data.customerName()); + if (hasCustomerId != hasCustomerName) throw badRequest("客户ID和客户名称必须同时填写或同时为空"); + if (!StringUtils.hasText(data.serviceTerm()) || !StringUtils.hasText(data.freightTerm())) { + throw badRequest("Service Term和Freight Term不能为空"); + } if (data.estimatedSailingAt() != null || data.estimatedArrivalAt() != null || StringUtils.hasText(data.billOfLadingNo()) || StringUtils.hasText(data.vesselName()) || StringUtils.hasText(data.voyageNo())) { @@ -329,7 +335,7 @@ public class OceanBookingService { } private static void copy(OceanBookingEntity v, DraftData d, String resolvedChannelName) { - v.setCustomerId(d.customerId()); v.setCustomerName(d.customerName().trim()); + v.setCustomerId(d.customerId()); v.setCustomerName(trim(d.customerName())); v.setChannelCode(d.channelCode().trim()); v.setChannelName(resolvedChannelName); v.setWarehouseId(d.warehouseId()); v.setWarehouseName(d.warehouseName().trim()); v.setBillOfLadingNo(null); diff --git a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql index 4e3c9478b..e7ff8ae84 100644 --- a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql +++ b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql @@ -5,8 +5,8 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID', booking_no VARCHAR(40) NOT NULL COMMENT '平台海运订舱编号', status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、SUBMITTED已提交、CONFIRMED已确认、RELEASED已放舱、FAILED失败、CHANGE_PENDING船期待变更、CANCELLED已取消', - customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', - customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照', + customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照', + customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照', channel_code VARCHAR(64) NOT NULL COMMENT '订舱渠道编码快照', channel_name VARCHAR(128) NOT NULL COMMENT '订舱渠道名称快照', warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '国内中转仓ID快照', @@ -23,8 +23,8 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照', destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照', place_of_delivery VARCHAR(128) NULL COMMENT '最终交货地快照', - service_term VARCHAR(32) NULL COMMENT '运输服务条款快照', - freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照', + service_term VARCHAR(32) NOT NULL COMMENT '运输服务条款快照', + freight_term VARCHAR(32) NOT NULL COMMENT '海运费支付条款快照', estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间', estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间', pending_vessel_name VARCHAR(128) NULL COMMENT '待确认变更船名', diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index f7b375a5b..94739036c 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -40,13 +40,15 @@ components: data: {$ref: '#/components/schemas/DraftData'} DraftData: type: object - required: [customerId, customerName, channelCode, warehouseId, warehouseName, parties, containerRequirements] + required: [channelCode, warehouseId, warehouseName, serviceTerm, freightTerm, parties, containerRequirements] properties: - customerId: {type: integer, format: int64, minimum: 1} - customerName: {type: string, maxLength: 128} + customerId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional customer snapshot; must be supplied together with customerName.} + customerName: {type: string, maxLength: 128, nullable: true, description: Optional customer snapshot; must be supplied together with customerId.} channelCode: {type: string, maxLength: 64, description: Selected from GET /api/platform/v1/ocean-bookings/channels; server validates it against the current ACTIVE price book and freezes channelName.} warehouseId: {type: integer, format: int64, minimum: 1} warehouseName: {type: string, maxLength: 128} + serviceTerm: {type: string, maxLength: 32, description: Required free-text Service Term snapshot, for example CY-CY.} + freightTerm: {type: string, maxLength: 32, description: Required free-text Freight Term snapshot, for example NINGBO.} billOfLadingNo: {type: string, nullable: true, deprecated: true, description: Must be omitted until release} vesselName: {type: string, nullable: true, deprecated: true, description: Must be omitted until release} voyageNo: {type: string, nullable: true, deprecated: true, description: Must be omitted until release} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java index 133726ac0..6a1e24b47 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java @@ -57,7 +57,11 @@ class PlatformOceanBookingControllerContractTest { String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml")); assertThat(yaml).contains("/api/platform/v1/ocean-bookings/channels:", "originWarehouseId", "platform:firstleg:ocean-booking:list", "ChannelOption") - .contains("required: [customerId, customerName, channelCode, warehouseId, warehouseName") + .contains("required: [channelCode, warehouseId, warehouseName, serviceTerm, freightTerm, parties, containerRequirements]") + .contains("customerId: {type: integer, format: int64, minimum: 1, nullable: true") + .contains("customerName: {type: string, maxLength: 128, nullable: true") + .contains("serviceTerm: {type: string, maxLength: 32, description: Required free-text") + .contains("freightTerm: {type: string, maxLength: 32, description: Required free-text") .doesNotContain("required: [customerId, customerName, channelCode, channelName"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java index f106d67b9..ae728915a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java @@ -18,6 +18,10 @@ class OceanBookingSqlContractTest { .contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_allocation") .contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_status_history") .contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation") + .contains("customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照'") + .contains("customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'") + .contains("service_term VARCHAR(32) NOT NULL COMMENT '运输服务条款快照'") + .contains("freight_term VARCHAR(32) NOT NULL COMMENT '海运费支付条款快照'") .contains("container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID'") .contains("REFERENCES fl_container_booking(id)") .doesNotContain("ALTER TABLE fl_container_booking"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index 6aa70a1dd..3006b68a7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -214,6 +214,49 @@ class OceanBookingServiceTest { assertThat(result.oceanBooking().getChannelName()).isEqualTo("价格簿美西海运"); } + @Test + void savesEmptyCustomerSnapshotAndFreeTextTransportTerms() { + ChannelRow channel = channel("SEA-US", "价格簿美西海运"); + when(priceBooks.activeVersionId()).thenReturn(51L); + when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel)); + AtomicReference inserted = new AtomicReference<>(); + doAnswer(invocation -> { inserted.set(invocation.getArgument(0)); return null; }) + .when(repository).insert(any(OceanBookingEntity.class)); + when(repository.find(anyLong())).thenAnswer(invocation -> inserted.get()); + + Detail result = service.create(9L, "admin", "create-without-customer", + new CreateRequest(0, draft(null, null, "CY-CY", "NINGBO"))); + + assertThat(result.oceanBooking().getCustomerId()).isNull(); + assertThat(result.oceanBooking().getCustomerName()).isNull(); + assertThat(result.oceanBooking().getServiceTerm()).isEqualTo("CY-CY"); + assertThat(result.oceanBooking().getFreightTerm()).isEqualTo("NINGBO"); + } + + @Test + void rejectsPartialCustomerSnapshot() { + assertThatThrownBy(() -> service.create(9L, "admin", "partial-customer-id", + new CreateRequest(0, draft(100L, null, null, null)))) + .isInstanceOf(BizException.class).hasMessageContaining("客户ID和客户名称必须同时填写或同时为空"); + assertThatThrownBy(() -> service.create(9L, "admin", "partial-customer-name", + new CreateRequest(0, draft(null, "客户", null, null)))) + .isInstanceOf(BizException.class).hasMessageContaining("客户ID和客户名称必须同时填写或同时为空"); + + verifyNoInteractions(priceBooks); + } + + @Test + void rejectsMissingRequiredTransportTerms() { + assertThatThrownBy(() -> service.create(9L, "admin", "missing-service-term", + new CreateRequest(0, draft(100L, "客户", null, "NINGBO")))) + .isInstanceOf(BizException.class).hasMessageContaining("Service Term和Freight Term不能为空"); + assertThatThrownBy(() -> service.create(9L, "admin", "blank-freight-term", + new CreateRequest(0, draft(100L, "客户", "CY-CY", " ")))) + .isInstanceOf(BizException.class).hasMessageContaining("Service Term和Freight Term不能为空"); + + verifyNoInteractions(priceBooks); + } + @Test void replaysCreateAfterChannelIsNoLongerActive() throws Exception { ChannelRow channel = channel("SEA-US", "价格簿美西海运"); @@ -280,15 +323,19 @@ class OceanBookingServiceTest { } private static DraftData draft() { + return draft(100L, "客户", "CY-CY", "NINGBO"); + } + + private static DraftData draft(Long customerId, String customerName, String serviceTerm, String freightTerm) { List parties = List.of( new PartyRequest("SHIPPER", "发货人", null, null, null), new PartyRequest("CONSIGNEE", "收货人", null, null, null), new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null)); List requirements = List.of( new ContainerRequirementRequest("40HQ", "DRY", BigDecimal.ONE, BigDecimal.TEN, 1)); - return new DraftData(100L, "客户", "SEA-US", 701L, "深圳仓", null, + return new DraftData(customerId, customerName, "SEA-US", 701L, "深圳仓", null, 81L, "船公司", null, null, null, null, "CNSZX", "深圳", "USLAX", "洛杉矶", - null, null, null, null, null, null, parties, requirements); + null, serviceTerm, freightTerm, null, null, null, parties, requirements); } private String hashForSubmit() throws Exception { diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index cb33ebcf6..790c5c650 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -47,7 +47,7 @@ export async function run(context) { | 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` | | 三期柜次概览、渠道隔离、装柜、PDA 与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-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-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.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` | | 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` | diff --git a/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs b/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs index 1ff5aa980..5a50432e3 100644 --- a/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' -import { oceanBookingDraft, publishPlatformLogisticsChannels, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs' +import { oceanBookingDraft, oceanBookingDraftWithoutCustomer, publishPlatformLogisticsChannels, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs' test('ocean booking fixture resolves the current inbound customer channel and warehouse', async () => { const steps = [] @@ -40,11 +40,31 @@ test('shared booking draft omits server-owned channelName and release transport assert.ok(draft[key], `${key} is required`) } assert.equal(draft.channelName, undefined) + assert.equal(draft.serviceTerm, 'CY-CY') + assert.equal(draft.freightTerm, 'NINGBO') assert.equal(draft.containerRequirements[0].capacityCbm, 68) assert.equal(draft.containerRequirements[0].maxWeightKg, 28000) for (const key of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) { - assert.equal(draft[key], undefined, `${key} belongs to release, not draft`) + assert.equal(Object.hasOwn(draft, key), false, `${key} belongs to release, not draft`) } + const fixtureSource = readFileSync(new URL('./ocean-booking-fixture.mjs', import.meta.url), 'utf8') + const builderSource = fixtureSource.slice(fixtureSource.indexOf('export function oceanBookingDraft'), fixtureSource.indexOf('export function oceanBookingDraftWithoutCustomer')) + for (const key of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) { + assert.doesNotMatch(builderSource, new RegExp(`${key}\\s*:`), `${key} must not be emitted by the shared draft builder`) + } +}) + +test('shared booking draft can omit customer identity without inventing placeholders', () => { + const draft = oceanBookingDraftWithoutCustomer({ marker: 'ADMIN-RUN-OPTIONAL' }, 'optional', { + customerId: '21', customerName: '不应写入', channelCode: 'SEA-US', channelName: '美西海运', + warehouseId: '41', warehouseName: '深圳仓', + }) + assert.equal(Object.hasOwn(draft, 'customerId'), false) + assert.equal(Object.hasOwn(draft, 'customerName'), false) + assert.equal(draft.channelCode, 'SEA-US') + assert.equal(draft.warehouseId, '41') + assert.equal(draft.containerRequirements[0].capacityCbm, 68) + assert.equal(draft.containerRequirements[0].maxWeightKg, 28000) }) test('platform channel producer queries the selected origin warehouse and publishes current-marker resources', async () => { diff --git a/scripts/admin-test-env-modules/ocean-booking-fixture.mjs b/scripts/admin-test-env-modules/ocean-booking-fixture.mjs index 8582ca4f7..8879ac72c 100644 --- a/scripts/admin-test-env-modules/ocean-booking-fixture.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-fixture.mjs @@ -58,10 +58,8 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) { ...writableMaster, shippingCompanyId: null, shippingCompanyName: labels.shippingCompanyName || `验收船公司-${suffix}`, bookingAgentId: null, bookingAgentName: labels.bookingAgentName || `验收代理-${suffix}`, - vesselName: labels.vesselName, voyageNo: labels.voyageNo, departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', - destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID', - estimatedSailingAt: labels.estimatedSailingAt, estimatedArrivalAt: labels.estimatedArrivalAt, + destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', remark: context.marker, parties: [{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` }, { partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` }, @@ -69,3 +67,10 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) { containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 28000, quantity: 1 }], } } + +export function oceanBookingDraftWithoutCustomer(context, suffix, master, labels = {}) { + const draft = oceanBookingDraft(context, suffix, master, labels) + delete draft.customerId + delete draft.customerName + return draft +} diff --git a/scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs b/scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs new file mode 100644 index 000000000..90384dd07 --- /dev/null +++ b/scripts/admin-test-env-modules/ocean-booking-prototype-contract.test.mjs @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const adminPage = readFileSync(new URL('../../../apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue', import.meta.url), 'utf8') +const adminTest = readFileSync(new URL('../../../apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts', import.meta.url), 'utf8') +const adminTypes = readFileSync(new URL('../../../apps/admin-web/src/modules/ocean-bookings/types.ts', import.meta.url), 'utf8') +const prototype = readFileSync(new URL('../../../docs/悦/货代SAAS平台后台/prototype-src/modules/06-containers.html', import.meta.url), 'utf8') +const dto = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java', import.meta.url), 'utf8') +const openapi = readFileSync(new URL('../../firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml', import.meta.url), 'utf8') +const schema = readFileSync(new URL('../../firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql', import.meta.url), 'utf8') +const unifiedSql = readFileSync(new URL('../../deploy/sql/头程数据库统一增量-MySQL8.sql', import.meta.url), 'utf8') +const fixture = readFileSync(new URL('./ocean-booking-fixture.mjs', import.meta.url), 'utf8') +const bookingModule = readFileSync(new URL('./ocean-bookings.mjs', import.meta.url), 'utf8') +const containerLoading = readFileSync(new URL('./phase3-container-loading.mjs', import.meta.url), 'utf8') + +const releaseFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt'] + +test('prototype evidence keeps service term freight term and optional customer fields', () => { + assert.match(prototype, /主要运输条款 Service Term \*<\/label>