This commit is contained in:
2026-08-14 19:59:26 +08:00
parent 7d4256dbd2
commit 993b3dfe03
15 changed files with 282 additions and 30 deletions
@@ -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 '待确认变更船名',
@@ -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<Detail> 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<Detail> 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<Detail> 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<Detail> 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<Detail> 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<Detail> 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); }
}
@@ -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,
@@ -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);
@@ -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 '待确认变更船名',
@@ -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}
@@ -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");
}
@@ -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");
@@ -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<OceanBookingEntity> 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<PartyRequest> parties = List.of(
new PartyRequest("SHIPPER", "发货人", null, null, null),
new PartyRequest("CONSIGNEE", "收货人", null, null, null),
new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null));
List<ContainerRequirementRequest> 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 {
+1 -1
View File
@@ -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` |
@@ -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 () => {
@@ -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
}
@@ -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><select><option>CY-CY<\/option>/)
assert.match(prototype, /运费条款 Freight Term \*<\/label><select><option>NINGBO<\/option>/)
assert.match(prototype, /<label>客户\/公司名称<\/label><input id="booking-customer"/)
})
test('Admin keeps source-backed free text terms without invented option dictionaries', () => {
assert.match(adminPage, /v-model="form\.data\.serviceTerm"[^>]*maxlength="32"/)
assert.match(adminPage, /v-model="form\.data\.freightTerm"[^>]*maxlength="32"/)
assert.doesNotMatch(adminPage, /serviceTermOptions|freightTermOptions/)
for (const invented of ['CY-DOOR', 'DOOR-CY', 'DOOR-DOOR', 'PREPAID', 'COLLECT']) {
assert.doesNotMatch(adminPage, new RegExp(invented), `${invented} must not be an invented Admin option`)
}
assert.match(adminTest, /not\.toContain\('serviceTermOptions'\)/)
assert.match(adminTest, /not\.toContain\('freightTermOptions'\)/)
})
test('service and freight terms are required across Java OpenAPI and SQL', () => {
assert.match(dto, /@NotBlank @Size\(max = 32\) String serviceTerm/)
assert.match(dto, /@NotBlank @Size\(max = 32\) String freightTerm/)
const required = openapi.match(/DraftData:\s*\n\s*type: object\s*\n\s*required: \[([^\]]*)\]/)?.[1] || ''
assert.match(required, /serviceTerm/)
assert.match(required, /freightTerm/)
assert.match(openapi, /serviceTerm: \{type: string, maxLength: 32, description: Required free-text Service Term snapshot/)
assert.match(openapi, /freightTerm: \{type: string, maxLength: 32, description: Required free-text Freight Term snapshot/)
assert.doesNotMatch(openapi, /(?:serviceTerm|freightTerm): \{[^\n]*nullable: true/)
for (const sql of [schema, unifiedSql]) {
assert.match(sql, /service_term VARCHAR\(32\) NOT NULL/)
assert.match(sql, /freight_term VARCHAR\(32\) NOT NULL/)
}
})
test('customer is optional across Admin DraftData DTO and OpenAPI', () => {
assert.match(adminPage, /label="客户(可选)"[\s\S]*?filterable clearable/)
assert.doesNotMatch(adminPage, /customerId:\s*\[\{\s*required:\s*true/)
assert.match(adminTypes, /customerId:\s*string \| null;\s*customerName:\s*string \| null/)
assert.match(dto, /record DraftData\(@Positive Long customerId,\s*@Size\(max = 128\) String customerName/)
assert.doesNotMatch(dto, /record DraftData\(@NotNull @Positive Long customerId/)
const required = openapi.match(/DraftData:\s*\n\s*type: object\s*\n\s*required: \[([^\]]*)\]/)?.[1] || ''
assert.ok(required, 'BLOCKED_CONTRACT: DraftData.required is missing from OpenAPI')
assert.doesNotMatch(required, /customerId|customerName/)
assert.match(openapi, /customerId: \{[^\n]*nullable: true/)
assert.match(openapi, /customerName: \{[^\n]*nullable: true/)
})
test('draft save omits release-only transport values while release still requires all five', () => {
const draftType = adminTypes.slice(adminTypes.indexOf('export interface OceanBookingDraftData'), adminTypes.indexOf('export interface OceanBookingResource'))
for (const field of releaseFields) {
assert.doesNotMatch(adminPage, new RegExp(`v-model="form\\.data\\.${field}"`),
`${field} must be read-only outside the release dialog`)
assert.doesNotMatch(draftType, new RegExp(`${field}\\??:`), `${field} must not belong to OceanBookingDraftData`)
assert.match(adminPage, new RegExp(`(?:v-model="releaseForm\\.${field}"|releaseForm\\.${field})`))
}
assert.match(adminTest, /\['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt'\]/)
assert.match(adminTest, /not\.toContain\(`v-model="form\.data\.\$\{field\}"`\)/)
const saveRegion = adminPage.slice(adminPage.indexOf('async function save'), adminPage.indexOf('function requireReason'))
assert.doesNotMatch(saveRegion, /data:\s*form\.data/)
const releaseCall = adminPage.slice(adminPage.indexOf('releaseOceanBooking(id.value'), adminPage.indexOf('releaseDialogOpen.value = false'))
for (const field of releaseFields) assert.match(releaseCall, new RegExp(field))
})
test('HTTP fixtures cover unassigned CONFIRMED persistence and assigned customer isolation', () => {
const unassignedFlow = bookingModule.slice(bookingModule.indexOf('async function createConfirmedUnassignedBooking'), bookingModule.indexOf('async function releaseLoadingBooking'))
assert.match(fixture, /oceanBookingDraftWithoutCustomer/)
assert.match(fixture, /delete draft\.customerId[\s\S]*delete draft\.customerName/)
assert.match(fixture, /serviceTerm: 'CY-CY', freightTerm: 'NINGBO'/)
assert.doesNotMatch(fixture, /serviceTerm: 'CY\/CY'|freightTerm: 'PREPAID'/)
assert.match(bookingModule, /createdResources\.oceanBookingUnassignedCustomer/)
assert.match(bookingModule, /未指定客户订舱保持空客户并在 CONFIRMED 生成稳定柜次/)
assert.match(bookingModule, /按真实订舱号查询未指定客户订舱/)
assert.match(bookingModule, /keyword=\$\{encodeURIComponent\(bookingNo\)\}/)
assert.doesNotMatch(unassignedFlow, /keyword=\$\{encodeURIComponent\(context\.marker\)\}/)
assert.doesNotMatch(bookingModule, /keyword=\$\{encodeURIComponent\(context\.marker\)\}/)
assert.match(bookingModule, /指定客户订舱持久化本轮客户范围快照/)
assert.match(bookingModule, /phase3Loading[\s\S]*phase3ContainerLoading/)
assert.match(containerLoading, /source\?\.marker !== context\.marker/)
assert.match(containerLoading, /id\(candidate\?\.accountId\) === tenantId/)
assert.match(containerLoading, /id\(item\.accountId\) === tenantId/)
})
@@ -1,7 +1,7 @@
const MODULE = 'ocean-bookings'
const OPTIONS = { scenario: 'ocean-booking-lifecycle', deploymentBlocker: true }
const BASE = '/api/platform/v1/ocean-bookings'
import { resolveOceanBookingMaster, oceanBookingDraft } from './ocean-booking-fixture.mjs'
import { oceanBookingDraft, oceanBookingDraftWithoutCustomer, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs'
function id(value) { return value == null ? '' : String(value) }
function codeOf(r) { return r?.body?.code == null ? 'missing' : String(r.body.code) }
@@ -19,6 +19,50 @@ function data(r) { return String(r?.body?.code) === '200' ? r.body.data : null }
async function createBooking(context, suffix, key, master) { return request(context, 'POST', BASE, { expectedVersion: 0, data: oceanBookingDraft(context, suffix, master) }, { 'Idempotency-Key': key }) }
function step(context, name, r, pass, evidence, options = OPTIONS) { context.addStep(MODULE, name, r, pass, evidence, { ...options, deploymentBlocker: options.deploymentBlocker && !pass }) }
async function createConfirmedUnassignedBooking(context, suffix, master) {
const draft = oceanBookingDraftWithoutCustomer(context, `${suffix}U`, master)
const key = `${context.marker}-ocean-unassigned-create`.slice(0, 128)
const createdResponse = await request(context, 'POST', BASE, { expectedVersion: 0, data: draft }, { 'Idempotency-Key': key })
const created = data(createdResponse)
const bookingId = id(created?.oceanBooking?.id)
context.apiSuccess(MODULE, '创建未指定客户订舱', createdResponse,
`marker=${context.marker}, customerFields=${'customerId' in draft || 'customerName' in draft ? 'present' : 'omitted'}`, OPTIONS)
context.assert(MODULE, '未指定客户订舱不伪造客户快照', createdResponse,
Boolean(bookingId) && created?.oceanBooking?.customerId == null && created?.oceanBooking?.customerName == null,
`bookingId=${bookingId || 'missing'}, customerId=${id(created?.oceanBooking?.customerId) || 'null'}, customerName=${created?.oceanBooking?.customerName || 'null'}`, OPTIONS)
if (!bookingId) return null
const path = `${BASE}/${bookingId}`
const submitResponse = await request(context, 'POST', `${path}/submit`, {
expectedVersion: Number(created.oceanBooking.version), reason: context.marker,
}, { 'Idempotency-Key': `${context.marker}-ocean-unassigned-submit`.slice(0, 128) })
const submitted = context.apiSuccess(MODULE, '提交未指定客户订舱', submitResponse, '', OPTIONS)
const confirmResponse = await request(context, 'POST', `${path}/confirm`, {
expectedVersion: Number(submitted?.oceanBooking?.version), reason: context.marker,
}, { 'Idempotency-Key': `${context.marker}-ocean-unassigned-confirm`.slice(0, 128) })
context.apiSuccess(MODULE, '确认未指定客户订舱并生成柜次', confirmResponse, '', OPTIONS)
const detailResponse = await request(context, 'GET', path)
const detail = context.apiSuccess(MODULE, '回查未指定客户订舱持久化结果', detailResponse, '', OPTIONS)
const containerBookingId = id(detail?.containerAllocations?.[0]?.containerBookingId)
const bookingNo = id(detail?.oceanBooking?.bookingNo || created?.oceanBooking?.bookingNo)
context.assert(MODULE, '未指定客户订舱保持空客户并在 CONFIRMED 生成稳定柜次', detailResponse,
detail?.oceanBooking?.status === 'CONFIRMED' && detail?.oceanBooking?.customerId == null
&& detail?.oceanBooking?.customerName == null && Boolean(containerBookingId) && Boolean(bookingNo),
`status=${detail?.oceanBooking?.status || 'missing'}, customerId=${id(detail?.oceanBooking?.customerId) || 'null'}, bookingNo=${bookingNo || 'missing'}, containerBookingId=${containerBookingId || 'missing'}`, OPTIONS)
const listResponse = await request(context, 'GET', `${BASE}?page=1&size=20&keyword=${encodeURIComponent(bookingNo)}`)
const page = context.apiSuccess(MODULE, '按真实订舱号查询未指定客户订舱', listResponse, `bookingNo=${bookingNo}`, OPTIONS)
context.assert(MODULE, '未指定客户订舱可按真实订舱号从正式列表查询', listResponse,
page?.records?.some?.((item) => id(item.id) === bookingId && item.customerId == null && item.customerName == null),
`bookingId=${bookingId}, bookingNo=${bookingNo}, records=${page?.records?.length ?? 'n/a'}`, OPTIONS)
const resource = { id: bookingId, marker: context.marker, customerId: null, customerName: null,
bookingNo, channelCode: draft.channelCode, warehouseId: draft.warehouseId, containerBookingId,
finalStatus: detail?.oceanBooking?.status || null }
context.createdResources.oceanBookingUnassignedCustomer = resource
return resource
}
async function releaseLoadingBooking(context, containerFlow, suffix) {
const flowOceanBookingId = id(containerFlow?.oceanBookingId)
if (!flowOceanBookingId || !id(containerFlow?.containerBookingId) || !id(containerFlow?.loadingPlanId)) return null
@@ -68,21 +112,26 @@ export async function run(context) {
}
const master = await resolveOceanBookingMaster(context, MODULE)
if (!master) return
const list = await request(context, 'GET', `${BASE}?page=1&size=5&keyword=${encodeURIComponent(context.marker)}`)
const list = await request(context, 'GET', `${BASE}?page=1&size=5`)
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)
const page = context.apiSuccess(MODULE, '订舱列表分页查询', 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'}`) }
await createConfirmedUnassignedBooking(context, suffix, master)
const key = `${context.marker}-ocean-create`
const createResponse = await createBooking(context, suffix, key, master)
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)
context.assert(MODULE, '指定客户订舱持久化本轮客户范围快照', createResponse,
id(detail?.oceanBooking?.customerId) === id(master.customerId) && detail?.oceanBooking?.customerName === master.customerName,
`expectedCustomerId=${id(master.customerId)}, actualCustomerId=${id(detail?.oceanBooking?.customerId) || 'missing'}`, OPTIONS)
if (!bookingId) return
context.createdResources.oceanBooking = { id: bookingId, customerId: master.customerId, marker: context.marker,
containerBookingId: null, loadingPlanId: null, finalStatus: 'DRAFT' }
@@ -44,3 +44,20 @@ test('test-environment full acceptance executes warehouse-scoped booking channel
assert.match(fixture, /\/api\/platform\/v1\/ocean-bookings\/channels/)
assert.match(fixture, /createdResources\.platformLogisticsChannels/)
})
test('test-environment full acceptance keeps assigned and unassigned customer booking chains', () => {
const loadingIndex = adminAcceptance.indexOf("'phase3-container-loading'")
const bookingIndex = adminAcceptance.indexOf("'ocean-bookings'")
assert.ok(loadingIndex >= 0 && bookingIndex > loadingIndex,
'ocean-bookings must run after the assigned-customer container fixture')
const bookingModule = readFileSync(resolve(scriptsDir, 'admin-test-env-modules', 'ocean-bookings.mjs'), 'utf8')
assert.match(bookingModule, /oceanBookingDraftWithoutCustomer/)
assert.match(bookingModule, /createdResources\.oceanBookingUnassignedCustomer/)
assert.match(bookingModule, /customerId == null[\s\S]*customerName == null/)
assert.match(bookingModule, /status === 'CONFIRMED'[\s\S]*containerBookingId/)
assert.match(bookingModule, /keyword=\$\{encodeURIComponent\(bookingNo\)\}/)
assert.doesNotMatch(bookingModule, /keyword=\$\{encodeURIComponent\(context\.marker\)\}/)
assert.match(bookingModule, /指定客户订舱持久化本轮客户范围快照/)
assert.match(bookingModule, /phase3Loading[\s\S]*phase3ContainerLoading/)
})