优化
This commit is contained in:
@@ -6144,14 +6144,14 @@ ON DUPLICATE KEY UPDATE
|
||||
deleted = 0,
|
||||
updated_at = CURRENT_TIMESTAMP(3);
|
||||
|
||||
-- 平台托盘初始化主数据;内部状态仅供预占/占用,不作为平台维护字段。
|
||||
-- 平台托盘初始化主数据;托盘是可复用主数据,不维护动态占用状态。
|
||||
INSERT INTO wh_pallet (
|
||||
id, tenant_id, warehouse_id, pallet_code, pallet_type, capacity_cbm,
|
||||
weight_kg, color, status, remark, created_by, updated_by
|
||||
weight_kg, color, remark, created_by, updated_by
|
||||
)
|
||||
VALUES
|
||||
(193000000000200101,0,193000000000200001,'TP-001','木托盘',1.200000,8.000,'黄色','AVAILABLE',NULL,0,0),
|
||||
(193000000000200102,0,193000000000200002,'TP-002','胶托盘',2.400000,12.000,'黑色','AVAILABLE',NULL,0,0)
|
||||
(193000000000200101,0,193000000000200001,'TP-001','木托盘',1.200000,8.000,'黄色',NULL,0,0),
|
||||
(193000000000200102,0,193000000000200002,'TP-002','胶托盘',2.400000,12.000,'黑色',NULL,0,0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
tenant_id = 0,
|
||||
warehouse_id = VALUES(warehouse_id),
|
||||
|
||||
+6
@@ -16,6 +16,7 @@ import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.List;
|
||||
|
||||
@Validated
|
||||
@RestController
|
||||
@@ -26,6 +27,11 @@ public class PlatformOceanBookingController {
|
||||
|
||||
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')")
|
||||
public ApiResponse<PageResult<OceanBookingEntity>> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.page(query)); }
|
||||
@GetMapping("/channels") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')")
|
||||
public ApiResponse<List<ChannelOption>> channels(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long originWarehouseId) {
|
||||
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.channels(originWarehouseId));
|
||||
}
|
||||
@GetMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')")
|
||||
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(oceanBookingId)); }
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ public final class OceanBookingDtos {
|
||||
public record DraftData(@NotNull @Positive Long customerId,
|
||||
@NotBlank @Size(max = 128) String customerName,
|
||||
@NotBlank @Size(max = 64) String channelCode,
|
||||
@NotBlank @Size(max = 128) String channelName,
|
||||
@NotNull @Positive Long warehouseId,
|
||||
@NotBlank @Size(max = 128) String warehouseName,
|
||||
@Size(max = 128) String billOfLadingNo,
|
||||
@@ -92,4 +91,7 @@ public final class OceanBookingDtos {
|
||||
List<OceanBookingContainerRequirementEntity> containerRequirements,
|
||||
List<OceanBookingContainerAllocationEntity> containerAllocations,
|
||||
List<OceanBookingStatusHistoryEntity> histories) {}
|
||||
|
||||
public record ChannelOption(Long priceBookVersionId, String channelCode, String channelName,
|
||||
String priceModel, List<String> pricingTaxModes) {}
|
||||
}
|
||||
|
||||
+51
-7
@@ -10,6 +10,9 @@ import com.baiyue.firstleg.oceanbooking.domain.OceanBookingStatus;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.*;
|
||||
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow;
|
||||
import com.baiyue.firstleg.service.LoadingPickService;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -25,12 +28,14 @@ import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class OceanBookingService {
|
||||
private static final Set<String> PARTY_TYPES = Set.of("SHIPPER", "CONSIGNEE", "NOTIFY_PARTY");
|
||||
@Resource private OceanBookingRepository repository;
|
||||
@Resource private PriceBookQueryRepository priceBooks;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private LoadingPickService loadingPickService;
|
||||
|
||||
@@ -40,21 +45,36 @@ public class OceanBookingService {
|
||||
return repository.page(new Query(query.customerId(), status, trim(query.keyword()), query.page(), query.size()));
|
||||
}
|
||||
|
||||
public List<ChannelOption> channels(long originWarehouseId) {
|
||||
if (originWarehouseId <= 0) throw badRequest("请选择有效起运仓");
|
||||
Long versionId = priceBooks.activeVersionId();
|
||||
if (versionId == null) return List.of();
|
||||
List<ChannelRow> rows = priceBooks.channels(versionId, originWarehouseId);
|
||||
Map<String, List<String>> taxModes = priceBooks.channelTaxModes(versionId, originWarehouseId).stream()
|
||||
.filter(row -> row != null && StringUtils.hasText(row.getChannelCode())
|
||||
&& StringUtils.hasText(row.getPricingTaxMode()))
|
||||
.collect(java.util.stream.Collectors.groupingBy(row -> row.getChannelCode().trim(),
|
||||
java.util.stream.Collectors.mapping(ChannelTaxModeRow::getPricingTaxMode,
|
||||
java.util.stream.Collectors.toList())));
|
||||
return rows.stream().map(row -> channelOption(row, taxModes)).toList();
|
||||
}
|
||||
|
||||
public Detail detail(long oceanBookingId) { return detail(required(oceanBookingId)); }
|
||||
|
||||
@Transactional
|
||||
public Detail create(long operatorId, String operatorName, String key, CreateRequest request) {
|
||||
if (request.expectedVersion() != 0) throw concurrent();
|
||||
validateDraft(request.data());
|
||||
validateDraftStructure(request.data());
|
||||
String action = "CREATE";
|
||||
String hash = hash(action, null, request);
|
||||
Detail replay = claimOrReplay(null, operatorId, action, key, hash);
|
||||
if (replay != null) return replay;
|
||||
ChannelOption channel = requireActiveChannel(request.data().warehouseId(), request.data().channelCode());
|
||||
OceanBookingEntity value = new OceanBookingEntity();
|
||||
value.setId(IdWorker.getId());
|
||||
value.setBookingNo("OB" + value.getId());
|
||||
value.setStatus(OceanBookingStatus.DRAFT.name());
|
||||
copy(value, request.data());
|
||||
copy(value, request.data(), channel.channelName());
|
||||
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
|
||||
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
|
||||
repository.insert(value);
|
||||
@@ -67,13 +87,14 @@ public class OceanBookingService {
|
||||
@Transactional
|
||||
public Detail updateDraft(long oceanBookingId, long operatorId, String operatorName, String key,
|
||||
UpdateDraftRequest request) {
|
||||
validateDraft(request.data());
|
||||
validateDraftStructure(request.data());
|
||||
String action = "UPDATE_DRAFT";
|
||||
Detail replay = claimOrReplay(oceanBookingId, operatorId, action, key, hash(action, oceanBookingId, request));
|
||||
if (replay != null) return replay;
|
||||
OceanBookingEntity before = requiredVersion(oceanBookingId, request.expectedVersion());
|
||||
requireStatus(before, OceanBookingStatus.DRAFT);
|
||||
OceanBookingEntity value = new OceanBookingEntity(); value.setId(oceanBookingId); copy(value, request.data());
|
||||
ChannelOption channel = requireActiveChannel(request.data().warehouseId(), request.data().channelCode());
|
||||
OceanBookingEntity value = new OceanBookingEntity(); value.setId(oceanBookingId); copy(value, request.data(), channel.channelName());
|
||||
if (repository.updateDraft(value, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent();
|
||||
repository.replaceParties(oceanBookingId, parties(oceanBookingId, request.data().parties()));
|
||||
repository.replaceRequirements(oceanBookingId, requirements(oceanBookingId, request.data().containerRequirements()));
|
||||
@@ -90,6 +111,7 @@ public class OceanBookingService {
|
||||
if (replay != null) return replay;
|
||||
OceanBookingEntity before = requiredVersion(id, request.expectedVersion());
|
||||
requireStatus(before, OceanBookingStatus.SUBMITTED);
|
||||
requireActiveChannel(before.getWarehouseId(), before.getChannelCode());
|
||||
validateConfirmation(before, repository.requirements(id));
|
||||
if (repository.transition(id, OceanBookingStatus.SUBMITTED.name(), OceanBookingStatus.CONFIRMED.name(),
|
||||
null, null, request.expectedVersion(), operatorId, name) != 1) throw concurrent();
|
||||
@@ -263,7 +285,7 @@ public class OceanBookingService {
|
||||
private OceanBookingEntity requiredVersion(long id, int version) { OceanBookingEntity value = required(id); if (!Integer.valueOf(version).equals(value.getVersion())) throw concurrent(); return value; }
|
||||
private static void requireStatus(OceanBookingEntity value, OceanBookingStatus status) { if (!status.name().equals(value.getStatus())) throw conflict("订舱状态不允许当前操作"); }
|
||||
|
||||
private void validateDraft(DraftData data) {
|
||||
private void validateDraftStructure(DraftData data) {
|
||||
if (data.estimatedSailingAt() != null || data.estimatedArrivalAt() != null
|
||||
|| StringUtils.hasText(data.billOfLadingNo()) || StringUtils.hasText(data.vesselName())
|
||||
|| StringUtils.hasText(data.voyageNo())) {
|
||||
@@ -284,9 +306,31 @@ public class OceanBookingService {
|
||||
}
|
||||
}
|
||||
private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); }
|
||||
private static void copy(OceanBookingEntity v, DraftData d) {
|
||||
private ChannelOption requireActiveChannel(Long originWarehouseId, String channelCode) {
|
||||
if (originWarehouseId == null || originWarehouseId <= 0) throw badRequest("请选择有效起运仓");
|
||||
String normalized = normalizeChannel(channelCode);
|
||||
Long versionId = priceBooks.activeVersionId();
|
||||
if (versionId == null) throw conflict("当前没有 ACTIVE 价格版本");
|
||||
ChannelRow row = priceBooks.channels(versionId, originWarehouseId).stream()
|
||||
.filter(value -> normalized.equals(normalizeChannel(value.getChannelCode())))
|
||||
.findFirst().orElseThrow(() -> conflict("所选物流渠道已失效,请重新选择"));
|
||||
if (!StringUtils.hasText(row.getChannelName())) throw conflict("所选物流渠道名称配置无效");
|
||||
return channelOption(row, java.util.Map.of());
|
||||
}
|
||||
|
||||
private static ChannelOption channelOption(ChannelRow row, java.util.Map<String, List<String>> taxModes) {
|
||||
return new ChannelOption(row.getPriceBookVersionId(), row.getChannelCode(), row.getChannelName(),
|
||||
row.getPriceModel(), List.copyOf(taxModes.getOrDefault(row.getChannelCode().trim(), List.of())));
|
||||
}
|
||||
|
||||
private static String normalizeChannel(String value) {
|
||||
if (!StringUtils.hasText(value)) throw badRequest("channelCode不能为空");
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private static void copy(OceanBookingEntity v, DraftData d, String resolvedChannelName) {
|
||||
v.setCustomerId(d.customerId()); v.setCustomerName(d.customerName().trim());
|
||||
v.setChannelCode(d.channelCode().trim()); v.setChannelName(d.channelName().trim());
|
||||
v.setChannelCode(d.channelCode().trim()); v.setChannelName(resolvedChannelName);
|
||||
v.setWarehouseId(d.warehouseId()); v.setWarehouseName(d.warehouseName().trim());
|
||||
v.setBillOfLadingNo(null);
|
||||
v.setShippingCompanyId(d.shippingCompanyId()); v.setShippingCompanyName(trim(d.shippingCompanyName()));
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
openapi: 3.0.3
|
||||
info: {title: Phase 3 Ocean Booking API, version: 1.1.0}
|
||||
paths:
|
||||
/api/platform/v1/ocean-bookings/channels:
|
||||
get:
|
||||
summary: List logistics channels from the current ACTIVE price book for an origin warehouse
|
||||
description: 'Requires platform:firstleg:ocean-booking:list. Returns only channels with an ACTIVE price row; the selected channel name is frozen server-side on draft save.'
|
||||
parameters:
|
||||
- {name: originWarehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||
responses: {'200': {description: Available channel options, content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/ChannelOption'}}}}}}
|
||||
/api/platform/v1/ocean-bookings:
|
||||
post:
|
||||
summary: Create an ocean booking draft with immutable channel and warehouse snapshots
|
||||
@@ -33,12 +40,11 @@ components:
|
||||
data: {$ref: '#/components/schemas/DraftData'}
|
||||
DraftData:
|
||||
type: object
|
||||
required: [customerId, customerName, channelCode, channelName, warehouseId, warehouseName, parties, containerRequirements]
|
||||
required: [customerId, customerName, channelCode, warehouseId, warehouseName, parties, containerRequirements]
|
||||
properties:
|
||||
customerId: {type: integer, format: int64, minimum: 1}
|
||||
customerName: {type: string, maxLength: 128}
|
||||
channelCode: {type: string, maxLength: 64}
|
||||
channelName: {type: string, maxLength: 128}
|
||||
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}
|
||||
billOfLadingNo: {type: string, nullable: true, deprecated: true, description: Must be omitted until release}
|
||||
@@ -57,6 +63,15 @@ components:
|
||||
capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0}
|
||||
maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0}
|
||||
quantity: {type: integer, minimum: 1}
|
||||
ChannelOption:
|
||||
type: object
|
||||
required: [priceBookVersionId, channelCode, channelName, priceModel, pricingTaxModes]
|
||||
properties:
|
||||
priceBookVersionId: {type: integer, format: int64}
|
||||
channelCode: {type: string, maxLength: 64}
|
||||
channelName: {type: string, maxLength: 128}
|
||||
priceModel: {type: string}
|
||||
pricingTaxModes: {type: array, items: {type: string}}
|
||||
ActionRequest:
|
||||
type: object
|
||||
required: [expectedVersion]
|
||||
|
||||
+13
-1
@@ -8,6 +8,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -18,6 +20,7 @@ class PlatformOceanBookingControllerContractTest {
|
||||
.containsExactly("/api/platform/v1/ocean-bookings");
|
||||
Map<String, String> permissions = Map.ofEntries(
|
||||
Map.entry("page", "platform:firstleg:ocean-booking:list"),
|
||||
Map.entry("channels", "platform:firstleg:ocean-booking:list"),
|
||||
Map.entry("detail", "platform:firstleg:ocean-booking:list"),
|
||||
Map.entry("create", "platform:firstleg:ocean-booking:create"),
|
||||
Map.entry("updateDraft", "platform:firstleg:ocean-booking:edit"),
|
||||
@@ -34,7 +37,7 @@ class PlatformOceanBookingControllerContractTest {
|
||||
Method method = method(name);
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('" + permission + "')");
|
||||
if (!name.equals("page") && !name.equals("detail")) {
|
||||
if (!name.equals("page") && !name.equals("detail") && !name.equals("channels")) {
|
||||
assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull();
|
||||
}
|
||||
});
|
||||
@@ -49,6 +52,15 @@ class PlatformOceanBookingControllerContractTest {
|
||||
.allMatch(parameter -> parameter.getName().equals("oceanBookingId"))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void openApiFreezesServerResolvedChannelContract() throws Exception {
|
||||
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")
|
||||
.doesNotContain("required: [customerId, customerName, channelCode, channelName");
|
||||
}
|
||||
|
||||
private static Method method(String name) {
|
||||
return Arrays.stream(PlatformOceanBookingController.class.getDeclaredMethods())
|
||||
.filter(method -> method.getName().equals(name)).findFirst().orElseThrow();
|
||||
|
||||
+131
@@ -3,9 +3,14 @@ package com.baiyue.firstleg.oceanbooking.service;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ActionRequest;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ChangeRequest;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ContainerRequirementRequest;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.CreateRequest;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Detail;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DraftData;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.AllocationRequest;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.PartyRequest;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ReleaseRequest;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.UpdateDraftRequest;
|
||||
import com.baiyue.firstleg.service.LoadingPickService;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
|
||||
@@ -14,6 +19,8 @@ import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -23,7 +30,9 @@ import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
@@ -32,6 +41,7 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
class OceanBookingServiceTest {
|
||||
@Mock private OceanBookingRepository repository;
|
||||
@Mock private PriceBookQueryRepository priceBooks;
|
||||
@Mock private LoadingPickService loadingPickService;
|
||||
@InjectMocks private OceanBookingService service;
|
||||
|
||||
@@ -172,12 +182,133 @@ class OceanBookingServiceTest {
|
||||
verify(repository, never()).insert(any(OceanBookingContainerAllocationEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listsOnlyChannelsFromCurrentActivePriceBook() {
|
||||
ChannelRow channel = channel("SEA-US", "美西海运");
|
||||
when(priceBooks.activeVersionId()).thenReturn(51L);
|
||||
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel));
|
||||
when(priceBooks.channelTaxModes(51L, 701L)).thenReturn(List.of());
|
||||
|
||||
var result = service.channels(701L);
|
||||
|
||||
assertThat(result).singleElement().satisfies(value -> {
|
||||
assertThat(value.channelCode()).isEqualTo("SEA-US");
|
||||
assertThat(value.channelName()).isEqualTo("美西海运");
|
||||
assertThat(value.priceBookVersionId()).isEqualTo(51L);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void freezesAuthoritativeChannelNameWhenCreatingDraft() {
|
||||
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-channel", new CreateRequest(0, draft()));
|
||||
|
||||
assertThat(result.oceanBooking().getChannelCode()).isEqualTo("SEA-US");
|
||||
assertThat(result.oceanBooking().getChannelName()).isEqualTo("价格簿美西海运");
|
||||
}
|
||||
|
||||
@Test
|
||||
void replaysCreateAfterChannelIsNoLongerActive() throws Exception {
|
||||
ChannelRow channel = channel("SEA-US", "价格簿美西海运");
|
||||
CreateRequest request = new CreateRequest(0, draft());
|
||||
when(priceBooks.activeVersionId()).thenReturn(51L, (Long) null);
|
||||
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 created = service.create(9L, "admin", "create-replay-channel", request);
|
||||
|
||||
OceanBookingOperationEntity operation = new OceanBookingOperationEntity();
|
||||
operation.setRequestHash(hashForCreate(request)); operation.setStatus("SUCCESS");
|
||||
operation.setResultJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(created));
|
||||
when(repository.claimOperation(isNull(), eq(9L), eq("CREATE"), eq("create-replay-channel"), anyString()))
|
||||
.thenReturn(false);
|
||||
when(repository.operation(9L, "CREATE", "create-replay-channel")).thenReturn(operation);
|
||||
|
||||
Detail replay = service.create(9L, "admin", "create-replay-channel", request);
|
||||
|
||||
assertThat(replay.oceanBooking().getChannelName()).isEqualTo("价格簿美西海运");
|
||||
verify(priceBooks, times(1)).activeVersionId();
|
||||
}
|
||||
|
||||
@Test
|
||||
void replaysDraftUpdateAfterChannelIsNoLongerActive() throws Exception {
|
||||
UpdateDraftRequest request = new UpdateDraftRequest(5, draft());
|
||||
Detail expected = new Detail(booking("DRAFT", 6), List.of(), List.of(), List.of(), List.of());
|
||||
OceanBookingOperationEntity operation = new OceanBookingOperationEntity();
|
||||
operation.setRequestHash(hashForUpdate(request)); operation.setStatus("SUCCESS");
|
||||
operation.setResultJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(expected));
|
||||
when(repository.claimOperation(eq(1L), eq(9L), eq("UPDATE_DRAFT"), eq("update-replay-channel"), anyString()))
|
||||
.thenReturn(false);
|
||||
when(repository.operation(9L, "UPDATE_DRAFT", "update-replay-channel")).thenReturn(operation);
|
||||
|
||||
Detail replay = service.updateDraft(1L, 9L, "admin", "update-replay-channel", request);
|
||||
|
||||
assertThat(replay.oceanBooking().getVersion()).isEqualTo(6);
|
||||
verifyNoInteractions(priceBooks);
|
||||
verify(repository, never()).find(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsConfirmationWhenFrozenChannelIsNoLongerActive() {
|
||||
OceanBookingEntity submitted = booking("SUBMITTED", 3);
|
||||
submitted.setWarehouseId(701L); submitted.setWarehouseName("深圳仓"); submitted.setChannelName("美西海运");
|
||||
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
|
||||
requirement.setCapacityCbm(java.math.BigDecimal.ONE); requirement.setMaxWeightKg(java.math.BigDecimal.ONE);
|
||||
when(repository.find(1L)).thenReturn(submitted);
|
||||
when(repository.requirements(1L)).thenReturn(List.of(requirement));
|
||||
when(priceBooks.activeVersionId()).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> service.confirm(1L, 9L, "admin", "confirm-channel", new ActionRequest(3, "确认")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("ACTIVE");
|
||||
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), any(), any(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
private static ChannelRow channel(String code, String name) {
|
||||
ChannelRow value = new ChannelRow(); value.setPriceBookVersionId(51L); value.setChannelCode(code);
|
||||
value.setChannelName(name); value.setPriceModel("FBA_WAREHOUSE_RATE"); return value;
|
||||
}
|
||||
|
||||
private static DraftData draft() {
|
||||
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,
|
||||
81L, "船公司", null, null, null, null, "CNSZX", "深圳", "USLAX", "洛杉矶",
|
||||
null, null, null, null, null, null, parties, requirements);
|
||||
}
|
||||
|
||||
private String hashForSubmit() throws Exception {
|
||||
var method = OceanBookingService.class.getDeclaredMethod("hash", String.class, Long.class, Object.class);
|
||||
method.setAccessible(true);
|
||||
return (String) method.invoke(service, "SUBMIT", 1L, new ActionRequest(2, "提交"));
|
||||
}
|
||||
|
||||
private String hashForCreate(CreateRequest request) throws Exception {
|
||||
var method = OceanBookingService.class.getDeclaredMethod("hash", String.class, Long.class, Object.class);
|
||||
method.setAccessible(true);
|
||||
return (String) method.invoke(service, "CREATE", (Long) null, request);
|
||||
}
|
||||
|
||||
private String hashForUpdate(UpdateDraftRequest request) throws Exception {
|
||||
var method = OceanBookingService.class.getDeclaredMethod("hash", String.class, Long.class, Object.class);
|
||||
method.setAccessible(true);
|
||||
return (String) method.invoke(service, "UPDATE_DRAFT", 1L, request);
|
||||
}
|
||||
|
||||
private static OceanBookingEntity booking(String status, int version) {
|
||||
OceanBookingEntity value = new OceanBookingEntity(); value.setId(1L); value.setBookingNo("OB1");
|
||||
value.setStatus(status); value.setCustomerId(100L); value.setCustomerName("客户");
|
||||
|
||||
@@ -26,4 +26,8 @@ test('统一初始化 SQL 提供人工验收所需公共仓储主数据', () =>
|
||||
"'CUS-001'", "'CUSTOMER'", "'SHIPPER'", "'EXP-TEST'", "'IT 测试快递'",
|
||||
]) assert.ok(sql.includes(marker), `missing initialization marker: ${marker}`)
|
||||
assert.match(sql, /'EXP-TEST', 'IT 测试快递'[\s\S]*?'ACTIVE'/)
|
||||
|
||||
const palletSeed = sql.match(/INSERT INTO wh_pallet \([\s\S]*?remark = VALUES\(remark\);/)?.[0]
|
||||
assert.ok(palletSeed, 'missing pallet initialization seed')
|
||||
assert.doesNotMatch(palletSeed, /\bstatus\b|'AVAILABLE'/)
|
||||
})
|
||||
|
||||
@@ -100,7 +100,7 @@ async function createConfirmedContainer(context, source, label) {
|
||||
const summary = source.detail.summary
|
||||
const draft = {
|
||||
customerId: source.tenantId, customerName: summary.customerName || 'IT 测试客户',
|
||||
channelCode: summary.channelCode, channelName: summary.channelName, warehouseId: source.warehouseId,
|
||||
channelCode: summary.channelCode, warehouseId: source.warehouseId,
|
||||
warehouseName: summary.warehouseName, shippingCompanyId: null, shippingCompanyName: `人工船司-${value}`,
|
||||
bookingAgentId: null, bookingAgentName: `人工代理-${value}`, departurePortCode: 'CNSZX',
|
||||
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
|
||||
|
||||
@@ -20,7 +20,7 @@ function oceanPayload(context, template, index) {
|
||||
const source = template.oceanBooking
|
||||
return { expectedVersion: 0, data: {
|
||||
customerId: source.customerId, customerName: source.customerName,
|
||||
channelCode: source.channelCode, channelName: source.channelName,
|
||||
channelCode: source.channelCode,
|
||||
warehouseId: source.warehouseId, warehouseName: source.warehouseName,
|
||||
shippingCompanyId: source.shippingCompanyId,
|
||||
shippingCompanyName: source.shippingCompanyName, bookingAgentId: source.bookingAgentId,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const adminPageUrl = new URL('../../../apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue', import.meta.url)
|
||||
const adminTestUrl = new URL('../../../apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts', import.meta.url)
|
||||
const adminApiUrl = new URL('../../../apps/admin-web/src/modules/ocean-bookings/api/platformOceanBookings.ts', import.meta.url)
|
||||
const openapiUrl = new URL('../../firstleg/src/main/resources/openapi/', import.meta.url)
|
||||
|
||||
function source(url) { return readFileSync(url, 'utf8') }
|
||||
|
||||
test('Admin ocean booking selects a warehouse-scoped logistics channel instead of accepting free text', () => {
|
||||
const page = source(adminPageUrl)
|
||||
const api = source(adminApiUrl)
|
||||
const pageTest = source(adminTestUrl)
|
||||
|
||||
assert.doesNotMatch(page, /<el-input[^>]*v-model="form\.data\.channelCode"/)
|
||||
assert.doesNotMatch(page, /<el-input[^>]*v-model="form\.data\.channelName"/)
|
||||
assert.match(page, /<el-form-item[^>]*label="物流渠道"[^>]*>[\s\S]*?<el-select[^>]*:model-value="form\.data\.channelCode"[^>]*@update:model-value="selectChannel\(/)
|
||||
assert.match(`${page}\n${api}`, /originWarehouseId/)
|
||||
assert.match(api, /basePath\s*=\s*['"]\/platform\/v1\/ocean-bookings['"][\s\S]*\$\{basePath\}\/channels/)
|
||||
assert.doesNotMatch(api, /price-books[\s\S]*for \(const version of versions\)/)
|
||||
assert.match(page, /shippingCompanyId/)
|
||||
assert.match(page, /channelCode/)
|
||||
assert.doesNotMatch(pageTest, /expect\(source\)\.toContain\(['"]v-model="form\.data\.channel(?:Code|Name)"['"]\)/)
|
||||
assert.match(pageTest, /expect\(source\)\.toContain\(['"]:model-value="form\.data\.channelCode" filterable['"]\)/)
|
||||
assert.match(pageTest, /expect\(source\)\.not\.toContain\(['"]<el-input v-model="form\.data\.channelCode"['"]\)/)
|
||||
})
|
||||
|
||||
test('backend OpenAPI freezes a platform channel query scoped by originWarehouseId', () => {
|
||||
const documents = readdirSync(openapiUrl)
|
||||
.filter((name) => name.endsWith('.yaml'))
|
||||
.map((name) => source(new URL(name, openapiUrl)))
|
||||
const platformChannelContract = documents.find((value) =>
|
||||
/\/api\/platform\/v1\/ocean-bookings\/channels/.test(value) && /originWarehouseId/.test(value)
|
||||
&& /server validates it against the current ACTIVE price book/.test(value))
|
||||
assert.ok(platformChannelContract,
|
||||
'BLOCKED_CONTRACT: platform logistics-channel query by originWarehouseId is not frozen in OpenAPI')
|
||||
})
|
||||
|
||||
test('container confirmation acceptance locks the selected channel onto the generated container', () => {
|
||||
const fixture = source(new URL('./phase3-container-loading.mjs', import.meta.url))
|
||||
assert.match(fixture, /containerAllocations\?\.\[0\]/)
|
||||
assert.match(fixture, /containerDetail\?\.channelCode === channelCode/)
|
||||
assert.match(fixture, /selectPlatformChannelIsolationPair/)
|
||||
assert.doesNotMatch(fixture, /\$\{channelCode\}-OTHER|foreignChannelCode/)
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import { oceanBookingDraft, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs'
|
||||
import { oceanBookingDraft, publishPlatformLogisticsChannels, resolveOceanBookingMaster } from './ocean-booking-fixture.mjs'
|
||||
|
||||
test('ocean booking fixture resolves the current inbound customer channel and warehouse', async () => {
|
||||
const steps = []
|
||||
@@ -31,14 +31,15 @@ test('ocean booking fixture blocks creation instead of inventing missing master
|
||||
assert.match(steps[0][4], /missing=customerId,customerName,channelCode,channelName,warehouseId,warehouseName/)
|
||||
})
|
||||
|
||||
test('shared booking draft carries all required DTO fields without release transport data', () => {
|
||||
test('shared booking draft omits server-owned channelName and release transport data', () => {
|
||||
const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-3' }, '3', {
|
||||
customerId: '21', customerName: 'IT 测试客户', channelCode: 'SEA-US', channelName: '美西海运',
|
||||
warehouseId: '41', warehouseName: '深圳仓',
|
||||
})
|
||||
for (const key of ['customerId', 'customerName', 'channelCode', 'channelName', 'warehouseId', 'warehouseName']) {
|
||||
for (const key of ['customerId', 'customerName', 'channelCode', 'warehouseId', 'warehouseName']) {
|
||||
assert.ok(draft[key], `${key} is required`)
|
||||
}
|
||||
assert.equal(draft.channelName, undefined)
|
||||
assert.equal(draft.containerRequirements[0].capacityCbm, 68)
|
||||
assert.equal(draft.containerRequirements[0].maxWeightKg, 28000)
|
||||
for (const key of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) {
|
||||
@@ -46,6 +47,45 @@ test('shared booking draft carries all required DTO fields without release trans
|
||||
}
|
||||
})
|
||||
|
||||
test('platform channel producer queries the selected origin warehouse and publishes current-marker resources', async () => {
|
||||
const calls = []
|
||||
const context = {
|
||||
marker: 'ADMIN-RUN-CHANNELS', createdResources: {},
|
||||
request: async (method, path) => {
|
||||
calls.push({ method, path })
|
||||
return { body: { code: '200', data: [
|
||||
{ channelCode: 'SEA-A', channelName: '海运 A' },
|
||||
{ channelCode: 'SEA-B', channelName: '海运 B' },
|
||||
] } }
|
||||
},
|
||||
apiSuccess: (_module, _name, response) => response.body.data,
|
||||
assert: (_module, _name, _response, condition) => assert.equal(condition, true),
|
||||
}
|
||||
const catalog = await publishPlatformLogisticsChannels(context, '41', 'test')
|
||||
assert.deepEqual(calls, [{ method: 'GET', path: '/api/platform/v1/ocean-bookings/channels?originWarehouseId=41' }])
|
||||
assert.equal(catalog.marker, context.marker)
|
||||
assert.equal(catalog.originWarehouseId, '41')
|
||||
assert.deepEqual(catalog.channels.map((value) => value.channelCode), ['SEA-A', 'SEA-B'])
|
||||
assert.deepEqual(context.createdResources.platformLogisticsChannels, catalog)
|
||||
})
|
||||
|
||||
test('all direct ocean booking payload builders omit channelName under the frozen DraftData contract', () => {
|
||||
const regions = [
|
||||
['manual-state-loading-pda.mjs', 'const draft = {', 'const createResponse'],
|
||||
['manual-state-ocean-trucking.mjs', 'function oceanPayload', 'async function createOcean'],
|
||||
['phase3-loading.mjs', 'const bookingDraft = {', 'const bookingCreate'],
|
||||
['phase3-order-splitting.mjs', 'function bookingPayload', 'async function loadOneFragment'],
|
||||
['phase3-container-loading.mjs', 'const bookingDraft = {', "context.assert(MODULE, '订舱渠道与仓库快照完整'"],
|
||||
]
|
||||
for (const [name, start, end] of regions) {
|
||||
const value = readFileSync(new URL(name, import.meta.url), 'utf8')
|
||||
const region = value.slice(value.indexOf(start), value.indexOf(end))
|
||||
assert.doesNotMatch(region, /channelName\s*:/, name)
|
||||
}
|
||||
const container = readFileSync(new URL('phase3-container-loading.mjs', import.meta.url), 'utf8')
|
||||
assert.doesNotMatch(container, /data:\s*\{[^}]*channelName\s*:/s)
|
||||
})
|
||||
|
||||
test('all independent ocean booking producers use the shared master-data contract', () => {
|
||||
for (const name of ['ocean-bookings.mjs', 'phase3-port-schedules.mjs', 'phase3-carrier-schedule-queries.mjs']) {
|
||||
const source = readFileSync(new URL(name, import.meta.url), 'utf8')
|
||||
@@ -53,6 +93,6 @@ test('all independent ocean booking producers use the shared master-data contrac
|
||||
assert.match(source, /oceanBookingDraft\(context, suffix, master/, name)
|
||||
}
|
||||
const container = readFileSync(new URL('phase3-container-loading.mjs', import.meta.url), 'utf8')
|
||||
for (const snippet of ['channelCode: channelCode', 'channelName', 'warehouseId', 'warehouseName',
|
||||
for (const snippet of ['channelCode', 'publishPlatformLogisticsChannels', 'warehouseId', 'warehouseName',
|
||||
'capacityCbm: 100', 'maxWeightKg: 100000']) assert.match(container, new RegExp(snippet), snippet)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,25 @@
|
||||
function text(value) { return value == null ? '' : String(value).trim() }
|
||||
const PLATFORM_CHANNELS = '/api/platform/v1/ocean-bookings/channels'
|
||||
|
||||
export async function publishPlatformLogisticsChannels(context, originWarehouseId, module) {
|
||||
const warehouseId = text(originWarehouseId)
|
||||
const sourcePath = `${PLATFORM_CHANNELS}?originWarehouseId=${encodeURIComponent(warehouseId)}`
|
||||
const response = await context.request('GET', sourcePath)
|
||||
const data = context.apiSuccess(module, '按起运仓查询平台正式物流渠道', response,
|
||||
`originWarehouseId=${warehouseId}`, { scenario: 'ocean-booking-channel-catalog', deploymentBlocker: true })
|
||||
const channels = (Array.isArray(data) ? data : []).flatMap((value) => {
|
||||
const channelCode = text(value?.channelCode)
|
||||
const channelName = text(value?.channelName)
|
||||
return channelCode && channelName ? [{ channelCode, channelName }] : []
|
||||
})
|
||||
const resource = { marker: context.marker, originWarehouseId: warehouseId, sourcePath, channels }
|
||||
context.createdResources.platformLogisticsChannels = resource
|
||||
context.assert(module, '平台渠道目录归属本轮起运仓且不含空渠道', response,
|
||||
Boolean(warehouseId) && channels.length > 0,
|
||||
`originWarehouseId=${warehouseId}, channels=${channels.map((value) => value.channelCode).join('|') || 'none'}`,
|
||||
{ scenario: 'ocean-booking-channel-catalog', deploymentBlocker: true })
|
||||
return resource
|
||||
}
|
||||
|
||||
export async function resolveOceanBookingMaster(context, module) {
|
||||
const resources = context.createdResources || {}
|
||||
@@ -32,8 +53,9 @@ export async function resolveOceanBookingMaster(context, module) {
|
||||
}
|
||||
|
||||
export function oceanBookingDraft(context, suffix, master, labels = {}) {
|
||||
const { channelName: _readOnlyChannelName, ...writableMaster } = master
|
||||
return {
|
||||
...master,
|
||||
...writableMaster,
|
||||
shippingCompanyId: null, shippingCompanyName: labels.shippingCompanyName || `验收船公司-${suffix}`,
|
||||
bookingAgentId: null, bookingAgentName: labels.bookingAgentName || `验收代理-${suffix}`,
|
||||
vesselName: labels.vesselName, voyageNo: labels.voyageNo,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { publishPlatformLogisticsChannels } from './ocean-booking-fixture.mjs'
|
||||
|
||||
const MODULE = 'phase3-container-loading'
|
||||
const OPTIONS = { scenario: 'phase3-container-smart-loading-pda', deploymentBlocker: true }
|
||||
const CONTAINERS = '/api/platform/v1/containers'
|
||||
@@ -46,6 +48,32 @@ export function selectContainerLoadingFixture(context) {
|
||||
return candidates.find((candidate) => candidate.marker === context.marker) || candidates[0]
|
||||
}
|
||||
|
||||
export function selectPlatformChannelIsolationPair(catalog, marker, originWarehouseId, primaryChannelCode) {
|
||||
const catalogMarker = id(catalog?.marker)
|
||||
const catalogWarehouseId = id(catalog?.originWarehouseId)
|
||||
const sourcePath = id(catalog?.sourcePath)
|
||||
if (catalogMarker !== id(marker) || catalogWarehouseId !== id(originWarehouseId)
|
||||
|| !sourcePath.startsWith('/api/platform/')) {
|
||||
return { pair: null, reason: 'BLOCKED_FIXTURE: missing current-marker platform channel catalog for origin warehouse' }
|
||||
}
|
||||
const channels = []
|
||||
const seen = new Set()
|
||||
for (const value of Array.isArray(catalog?.channels) ? catalog.channels : []) {
|
||||
const channelCode = id(value?.channelCode).trim()
|
||||
const channelName = id(value?.channelName).trim()
|
||||
const status = id(value?.status).trim().toUpperCase()
|
||||
if (!channelCode || !channelName || (status && status !== 'ACTIVE') || seen.has(channelCode)) continue
|
||||
seen.add(channelCode)
|
||||
channels.push({ channelCode, channelName })
|
||||
}
|
||||
const primary = channels.find((value) => value.channelCode === id(primaryChannelCode))
|
||||
const foreign = channels.find((value) => value.channelCode !== id(primaryChannelCode))
|
||||
if (!primary || !foreign) {
|
||||
return { pair: null, reason: `BLOCKED_FIXTURE: ACTIVE platform channels=${channels.length}, distinct pair unavailable` }
|
||||
}
|
||||
return { pair: { primary, foreign }, reason: null }
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
const source = selectContainerLoadingFixture(context)
|
||||
const tenantId = id(source?.tenantId)
|
||||
@@ -70,11 +98,19 @@ export async function run(context) {
|
||||
|
||||
const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16)
|
||||
const channelCode = id(inbound?.summary?.channelCode || source?.channelCode)
|
||||
const channelName = inbound?.summary?.channelName || source?.channelName || channelCode
|
||||
const warehouseName = inbound?.summary?.warehouseName || source?.warehouseName
|
||||
if (!channelCode || !warehouseName) return
|
||||
const channelCatalog = await publishPlatformLogisticsChannels(context, warehouseId, MODULE)
|
||||
const selectedChannel = channelCatalog.channels.find((value) => value.channelCode === channelCode)
|
||||
context.assert(MODULE, '入库渠道存在于当前起运仓 ACTIVE 平台渠道目录', inboundResponse,
|
||||
Boolean(selectedChannel),
|
||||
`originWarehouseId=${warehouseId}, channelCode=${channelCode}, catalog=${channelCatalog.channels.map((value) => value.channelCode).join('|') || 'none'}`,
|
||||
OPTIONS)
|
||||
if (!selectedChannel) return
|
||||
const channelName = selectedChannel.channelName
|
||||
const bookingDraft = {
|
||||
customerId: tenantId, customerName: inbound?.summary?.customerName || `验收客户-${tenantId}`,
|
||||
channelCode: channelCode || 'FBA', channelName, warehouseId, warehouseName,
|
||||
channelCode, warehouseId, warehouseName,
|
||||
shippingCompanyId: null, shippingCompanyName: `验收船公司-${suffix}`,
|
||||
bookingAgentId: null, bookingAgentName: `验收代理-${suffix}`,
|
||||
departurePortCode: 'CNSZX', departurePortName: '深圳',
|
||||
@@ -87,7 +123,6 @@ export async function run(context) {
|
||||
}
|
||||
context.assert(MODULE, '订舱渠道与仓库快照完整', inboundResponse, Boolean(channelCode && channelName && warehouseName),
|
||||
`channelCode=${channelCode || 'missing'}, warehouseId=${warehouseId}, warehouseName=${warehouseName || 'missing'}`, OPTIONS)
|
||||
if (!channelCode || !warehouseName) return
|
||||
const bookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings',
|
||||
{ expectedVersion: 0, data: bookingDraft }, { 'Idempotency-Key': `${context.marker}-loading-booking-create` })
|
||||
const bookingCreated = context.apiSuccess(MODULE, '创建含渠道仓库快照的隔离订舱', bookingCreate, '', OPTIONS)
|
||||
@@ -118,35 +153,42 @@ export async function run(context) {
|
||||
`candidateFound=${Boolean(candidate)}, available=${candidate?.availablePackageCount ?? 'missing'}`, OPTIONS)
|
||||
if (!candidate?.inboundDetailId) return
|
||||
|
||||
const foreignChannelCode = `${channelCode}-OTHER`.slice(0, 64)
|
||||
const channelIsolation = selectPlatformChannelIsolationPair(
|
||||
context.createdResources?.platformLogisticsChannels, context.marker, warehouseId, channelCode)
|
||||
if (!channelIsolation.pair) {
|
||||
context.addStep(MODULE, '两个真实有效渠道的柜次隔离 fixture', null, false,
|
||||
`${channelIsolation.reason}; originWarehouseId=${warehouseId}, primaryChannelCode=${channelCode}`, OPTIONS)
|
||||
} else {
|
||||
const foreignChannel = channelIsolation.pair.foreign
|
||||
const foreignBookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings', {
|
||||
expectedVersion: 0,
|
||||
data: { ...bookingDraft, channelCode: foreignChannelCode, channelName: `异渠道-${suffix}`, remark: `${context.marker}-other-channel` },
|
||||
data: { ...bookingDraft, channelCode: foreignChannel.channelCode, remark: `${context.marker}-other-channel` },
|
||||
}, { 'Idempotency-Key': `${context.marker}-other-channel-booking-create`.slice(0, 128) })
|
||||
const foreignBookingCreated = context.apiSuccess(MODULE, '创建异渠道隔离订舱', foreignBookingCreate, '', OPTIONS)
|
||||
const foreignBookingCreated = context.apiSuccess(MODULE, '创建真实异渠道隔离订舱', foreignBookingCreate, '', OPTIONS)
|
||||
const foreignBookingId = id(foreignBookingCreated?.oceanBooking?.id)
|
||||
if (!foreignBookingId) return
|
||||
const foreignSubmit = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${foreignBookingId}/submit`, {
|
||||
expectedVersion: Number(foreignBookingCreated?.oceanBooking?.version), reason: context.marker,
|
||||
}, { 'Idempotency-Key': `${context.marker}-other-channel-booking-submit`.slice(0, 128) })
|
||||
const foreignSubmitted = context.apiSuccess(MODULE, '提交异渠道隔离订舱', foreignSubmit, '', OPTIONS)
|
||||
const foreignSubmitted = context.apiSuccess(MODULE, '提交真实异渠道隔离订舱', foreignSubmit, '', OPTIONS)
|
||||
const foreignConfirm = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${foreignBookingId}/confirm`, {
|
||||
expectedVersion: Number(foreignSubmitted?.oceanBooking?.version), reason: context.marker,
|
||||
}, { 'Idempotency-Key': `${context.marker}-other-channel-booking-confirm`.slice(0, 128) })
|
||||
const foreignConfirmed = context.apiSuccess(MODULE, '确认异渠道订舱并自动生成柜次', foreignConfirm, '', OPTIONS)
|
||||
const foreignConfirmed = context.apiSuccess(MODULE, '确认真实异渠道订舱并自动生成柜次', foreignConfirm, '', OPTIONS)
|
||||
const foreignContainerId = id(foreignConfirmed?.containerAllocations?.[0]?.containerBookingId)
|
||||
if (!foreignContainerId) return
|
||||
const foreignPlanResponse = await request(context, 'POST', `${SMART}/plans`, {
|
||||
bookingId: foreignContainerId, remark: `${context.marker}-other-channel`,
|
||||
})
|
||||
const foreignPlan = context.apiSuccess(MODULE, '创建异渠道智能装柜计划', foreignPlanResponse, '', OPTIONS)
|
||||
const foreignPlan = context.apiSuccess(MODULE, '创建真实异渠道智能装柜计划', foreignPlanResponse, '', OPTIONS)
|
||||
const foreignAssignResponse = await request(context, 'POST', `${SMART}/plans/${foreignPlan?.id}/assignments`, {
|
||||
inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1,
|
||||
expectedVersion: Number(foreignPlan?.version), operationId: `${context.marker}-other-channel-assign`.slice(0, 128),
|
||||
})
|
||||
context.addStep(MODULE, '异渠道入库单拒绝装入当前柜次', foreignAssignResponse,
|
||||
context.addStep(MODULE, '两个真实有效渠道之间拒绝跨渠道装柜', foreignAssignResponse,
|
||||
foreignAssignResponse.http === 200 && ['A0422', '422'].includes(codeOf(foreignAssignResponse)),
|
||||
`HTTP=${foreignAssignResponse.http}, code=${codeOf(foreignAssignResponse)}`, OPTIONS)
|
||||
`primary=${channelCode}, foreign=${foreignChannel.channelCode}, HTTP=${foreignAssignResponse.http}, code=${codeOf(foreignAssignResponse)}`, OPTIONS)
|
||||
}
|
||||
|
||||
const containerDetailResponse = await context.request('GET', `${CONTAINERS}/${containerId}`)
|
||||
const containerDetail = context.apiSuccess(MODULE, '回查货柜详情', containerDetailResponse, '', OPTIONS)
|
||||
|
||||
@@ -120,7 +120,7 @@ export async function run(context) {
|
||||
const bookingDraft = {
|
||||
customerId: tenantId, customerName: initial?.summary?.customerName,
|
||||
warehouseId, warehouseName: initial?.summary?.warehouseName,
|
||||
channelCode: initial?.summary?.channelCode, channelName: initial?.summary?.channelName,
|
||||
channelCode: initial?.summary?.channelCode,
|
||||
shippingCompanyName: `P3-SHIPPING-${suffix}`, bookingAgentName: `P3-AGENT-${suffix}`,
|
||||
departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX',
|
||||
destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID',
|
||||
|
||||
@@ -120,7 +120,7 @@ function bookingPayload(marker, sequence, source) {
|
||||
const suffix = `${marker}-${sequence}`.replace(/[^A-Za-z0-9]/g, '').slice(-18)
|
||||
return {
|
||||
customerId: source.tenantId, customerName: source.customerName,
|
||||
channelCode: source.channelCode, channelName: source.channelName,
|
||||
channelCode: source.channelCode,
|
||||
warehouseId: source.warehouseId, warehouseName: source.warehouseName,
|
||||
shippingCompanyId: null, shippingCompanyName: `SPLIT-SHIPPING-${suffix}`.slice(0, 128),
|
||||
bookingAgentId: null, bookingAgentName: `SPLIT-AGENT-${suffix}`.slice(0, 128),
|
||||
|
||||
@@ -4,7 +4,7 @@ import test from 'node:test'
|
||||
|
||||
import { selectCustomsFlow } from './phase3-customs.mjs'
|
||||
import { buildFinalizeResource } from './phase3-loading-finalize.mjs'
|
||||
import { selectContainerLoadingFixture } from './phase3-container-loading.mjs'
|
||||
import { selectContainerLoadingFixture, selectPlatformChannelIsolationPair } from './phase3-container-loading.mjs'
|
||||
import { AcceptanceRun, normalizeModuleOrder, parseArguments } from '../admin-test-env-acceptance.mjs'
|
||||
|
||||
function source(name) {
|
||||
@@ -141,7 +141,8 @@ test('container loading creates and confirms booking before scoped candidates',
|
||||
"const planResponse = await request(context, 'POST', `${SMART}/plans`",
|
||||
"const assignResponse = await request",
|
||||
])
|
||||
assert.match(value, /channelCode: channelCode/)
|
||||
assert.match(value, /channelCode, warehouseId, warehouseName/)
|
||||
assert.match(value, /publishPlatformLogisticsChannels\(context, warehouseId, MODULE\)/)
|
||||
assert.match(value, /warehouseId, warehouseName/)
|
||||
const draft = value.slice(value.indexOf('const bookingDraft = {'), value.indexOf('context.assert(MODULE, \'订舱渠道与仓库快照完整\''))
|
||||
assert.doesNotMatch(draft, /billOfLadingNo|vesselName|voyageNo|estimatedSailingAt|estimatedArrivalAt/)
|
||||
@@ -182,12 +183,46 @@ test('container loading keeps an unmatched candidate so marker failure stays obs
|
||||
assert.equal(selected.marker, 'ADMIN-RUN-CAB')
|
||||
})
|
||||
|
||||
test('channel isolation accepts only two active channels from the current platform warehouse catalog', () => {
|
||||
const selected = selectPlatformChannelIsolationPair({
|
||||
marker: 'ADMIN-RUN-AUTO1', originWarehouseId: '41',
|
||||
sourcePath: '/api/platform/v1/frozen-channel-contract?originWarehouseId=41',
|
||||
channels: [
|
||||
{ channelCode: 'SEA-A', channelName: '海运 A', status: 'ACTIVE' },
|
||||
{ channelCode: 'SEA-B', channelName: '海运 B', status: 'ACTIVE' },
|
||||
{ channelCode: 'SEA-C', channelName: '海运 C', status: 'DISABLED' },
|
||||
],
|
||||
}, 'ADMIN-RUN-AUTO1', '41', 'SEA-A')
|
||||
assert.deepEqual(selected.pair, {
|
||||
primary: { channelCode: 'SEA-A', channelName: '海运 A' },
|
||||
foreign: { channelCode: 'SEA-B', channelName: '海运 B' },
|
||||
})
|
||||
assert.equal(selected.reason, null)
|
||||
})
|
||||
|
||||
test('channel isolation reports BLOCKED_FIXTURE instead of inventing a second channel', () => {
|
||||
for (const catalog of [
|
||||
{ marker: 'OLD-RUN', originWarehouseId: '41', sourcePath: '/api/platform/v1/channels', channels: [] },
|
||||
{ marker: 'ADMIN-RUN-AUTO1', originWarehouseId: '42', sourcePath: '/api/platform/v1/channels', channels: [] },
|
||||
{ marker: 'ADMIN-RUN-AUTO1', originWarehouseId: '41', sourcePath: '/api/merchant/v1/first-leg/pricing/channels', channels: [] },
|
||||
{ marker: 'ADMIN-RUN-AUTO1', originWarehouseId: '41', sourcePath: '/api/platform/v1/channels',
|
||||
channels: [{ channelCode: 'SEA-A', channelName: '海运 A', status: 'ACTIVE' }] },
|
||||
]) {
|
||||
const selected = selectPlatformChannelIsolationPair(catalog, 'ADMIN-RUN-AUTO1', '41', 'SEA-A')
|
||||
assert.equal(selected.pair, null)
|
||||
assert.match(selected.reason, /^BLOCKED_FIXTURE:/)
|
||||
}
|
||||
const value = source('./phase3-container-loading.mjs')
|
||||
assert.doesNotMatch(value, /foreignChannelCode|\$\{channelCode\}-OTHER|异渠道-\$\{suffix\}/)
|
||||
assert.match(value, /context\.createdResources\?\.platformLogisticsChannels/)
|
||||
})
|
||||
|
||||
test('container loading enforces channel isolation and stable PDA identity across container number changes', () => {
|
||||
const value = source('./phase3-container-loading.mjs')
|
||||
assertOrder(value, [
|
||||
'确认订舱并自动生成真实柜次',
|
||||
'创建异渠道隔离订舱',
|
||||
'异渠道入库单拒绝装入当前柜次',
|
||||
'创建真实异渠道隔离订舱',
|
||||
'两个真实有效渠道之间拒绝跨渠道装柜',
|
||||
'无真实柜号时计划可排柜但 PDA 任务不开放',
|
||||
'在放舱前绑定本轮真实箱号',
|
||||
'订舱 CONFIRMED 即使已有真实柜号也不开放 PDA 任务',
|
||||
|
||||
Reference in New Issue
Block a user