diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index ec6953f55..9f0c87d39 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -104,6 +104,13 @@ services: CARRIER_SCHEDULE_BATCH_SIZE: ${CARRIER_SCHEDULE_BATCH_SIZE:-20} CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:-2000} CARRIER_SCHEDULE_LEASE_DURATION: ${CARRIER_SCHEDULE_LEASE_DURATION:-PT5M} + TERMINAL_DRAYAGE_MAIL_ENABLED: ${TERMINAL_DRAYAGE_MAIL_ENABLED:-false} + TERMINAL_DRAYAGE_MAIL_HOST: ${TERMINAL_DRAYAGE_MAIL_HOST:-} + TERMINAL_DRAYAGE_MAIL_PORT: ${TERMINAL_DRAYAGE_MAIL_PORT:-587} + TERMINAL_DRAYAGE_MAIL_USERNAME: ${TERMINAL_DRAYAGE_MAIL_USERNAME:-} + TERMINAL_DRAYAGE_MAIL_PASSWORD: ${TERMINAL_DRAYAGE_MAIL_PASSWORD:-} + TERMINAL_DRAYAGE_MAIL_SSL: ${TERMINAL_DRAYAGE_MAIL_SSL:-false} + TERMINAL_DRAYAGE_MAIL_SCAN_DELAY: ${TERMINAL_DRAYAGE_MAIL_SCAN_DELAY:-10000} finance: <<: *common diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index d4ad1ad7d..a351a7a9a 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -130,6 +130,14 @@ if [ "$SERVICE" = "firstleg" ]; then echo "部署 firstleg 时,环境文件必须包含非空 LASTLEG_EVENT_BRIDGE_URL" >&2 exit 2 fi + if [ "$(env_file_value TERMINAL_DRAYAGE_MAIL_ENABLED)" = "true" ]; then + for key in TERMINAL_DRAYAGE_MAIL_HOST TERMINAL_DRAYAGE_MAIL_PORT TERMINAL_DRAYAGE_MAIL_USERNAME TERMINAL_DRAYAGE_MAIL_PASSWORD; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "启用码头车队邮件时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done + fi fi if [ "$SERVICE" = "lastleg-service" ]; then diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index ff2909c5c..a9ebf4e09 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -18569,7 +18569,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history ( KEY idx_terminal_drayage_history(container_booking_id,created_at,id), CONSTRAINT fk_terminal_drayage_history FOREIGN KEY(drayage_id) REFERENCES fl_terminal_drayage(id), CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN - ('SELECT_FLEET','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')) + ('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')) ) ENGINE=InnoDB COMMENT='目的港码头拖车不可变操作历史'; CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation ( id BIGINT UNSIGNED NOT NULL COMMENT '幂等操作ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID', @@ -18604,6 +18604,9 @@ ALTER TABLE fl_terminal_drayage MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间'; ALTER TABLE fl_terminal_drayage_history + DROP CHECK chk_terminal_drayage_history_action, + ADD CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN + ('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')), MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '历史ID', MODIFY COLUMN drayage_id BIGINT UNSIGNED NOT NULL COMMENT '码头拖车聚合ID', MODIFY COLUMN container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜ID快照', diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 0b2f42efb..872160af5 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -9,7 +9,7 @@ ## 模块入口 -散货托盘码:`service/LoadingPalletLabelService.java`、`mapper/LoadingPalletLabelMapper.java`、`controller/PlatformLoadingListController.java`,契约 `src/main/resources/openapi/loading-lists-v1.yaml` 的 `/pallet-labels/prepare` 与 `/pallet-labels/print`。仅在计划 `LOADING/COMPLETED` 且真实柜号有效时,为非一件代发的 FBA 明细按柜号、FBA 仓编码、三位序号生成冻结标签;重复生成须核对已有标签与明细一致,冲突直接拒绝。支持全部或按仓指定张数输出 100×100 mm PDF;装柜完成通过 `LoadingCompletedOutboxService.java` 传递来源托盘。 +散货托盘码:`service/LoadingPalletLabelService.java`、`mapper/LoadingPalletLabelMapper.java`、`controller/PlatformLoadingListController.java`,契约 `src/main/resources/openapi/loading-lists-v1.yaml` 的 `/pallet-labels/prepare` 与 `/pallet-labels/print`。仅在计划 `LOADING/COMPLETED` 且真实柜号有效时,为非一件代发的 FBA 明细按柜号、FBA 地址内部编号、三位序号生成冻结标签;内部编号从 Warehouse 启用主数据读取,缺失或同柜重复时拒绝生成。重复生成须核对已有标签与明细一致,冲突直接拒绝。支持全部或按仓指定张数输出 100×100 mm PDF;装柜完成通过 `LoadingCompletedOutboxService.java` 传递来源托盘。 | 目标 | 入口 | |---|---| diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java index d24820e4a..3985ff209 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java @@ -18,7 +18,7 @@ public interface WarehouseMasterDataClient { record Detail(Warehouse warehouse) { } - record Warehouse(Long id, Long tenantId, String warehouseCode, String warehouseName, + record Warehouse(Long id, Long tenantId, String warehouseCode, String internalCode, String warehouseName, String warehouseType, String status) { } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java index 86666ca92..c7611d355 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java @@ -133,13 +133,14 @@ public final class LoadingListDtos { public record PalletLabel( Long labelId, Long itemId, Long accountId, Long inboundId, String inboundCode, - String containerNo, String destinationWarehouseCode, String destinationWarehouseName, + String containerNo, String destinationWarehouseCode, String destinationWarehouseInternalCode, + String destinationWarehouseName, int palletIndex, String palletCode ) { } public record PalletLabelGroup( - String destinationWarehouseCode, String destinationWarehouseName, + String destinationWarehouseCode, String destinationWarehouseInternalCode, String destinationWarehouseName, int totalCount, List labels ) { public PalletLabelGroup { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java index 5c798ed19..df49db14f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java @@ -13,7 +13,7 @@ import java.util.List; public interface LoadingPalletLabelMapper { @Select(""" SELECT p.id AS plan_id,p.plan_no,p.plan_status,b.container_no,b.warehouse_id, - i.id AS item_id,i.account_id,i.inbound_id,i.inbound_code, + i.id AS item_id,i.account_id,i.inbound_id,i.inbound_code,i.destination_warehouse_id, i.destination_warehouse_code,i.destination_warehouse_name, COALESCE(NULLIF(i.pallet_count,0), CEIL(COALESCE(NULLIF(o.pallet_count,0), @@ -56,6 +56,7 @@ public interface LoadingPalletLabelMapper { class SourceRow { private Long planId; private String planNo; private String planStatus; private String containerNo; private Long warehouseId; private Long itemId; private Long accountId; private Long inboundId; + private Long destinationWarehouseId; private String inboundCode; private String destinationWarehouseCode; private String destinationWarehouseName; private Integer palletCount; private String fulfillmentType; private Integer loadSequence; private String destinationType; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index 769a69693..d1f32c420 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -577,7 +577,7 @@ public class LoadingListService { } /** 完成 worker 在远程结算前验证最终跨区快照能够完整生成。 */ - @Transactional(readOnly = true) + @Transactional public void requireCompletedEventReady(long planId) { loadingCompletedOutboxService.requirePublishable(planId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPalletLabelService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPalletLabelService.java index a9eafa154..ce875d97c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPalletLabelService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPalletLabelService.java @@ -6,6 +6,7 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.event.ContainerNumbers; import com.baiyue.firstleg.client.PrintPdfClient; +import com.baiyue.firstleg.client.WarehouseMasterDataClient; import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabel; import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelGroup; import com.baiyue.firstleg.dto.LoadingListDtos.PalletLabelManifest; @@ -34,12 +35,13 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; -/** 散货托盘码是柜号与 FBA 仓维度的稳定业务标识;一件代发继续使用装箱打托事实。 */ +/** 散货托盘码按真实柜号、FBA 地址内部编号和仓内序号组成;一件代发继续使用装箱打托事实。 */ @Service public class LoadingPalletLabelService { private static final BigDecimal PAGE_MM = new BigDecimal("100"); @Resource private LoadingPalletLabelMapper mapper; @Resource private PrintPdfClient printClient; + @Resource private WarehouseMasterDataClient warehouseClient; @Resource private ObjectMapper objectMapper; @Value("${baiyue.security.internal-service-token}") private String internalToken; @@ -58,11 +60,13 @@ public class LoadingPalletLabelService { List standard = sources.stream().filter(row -> !"ONE_CLICK_DROPSHIP".equals(row.getFulfillmentType())) .filter(row -> "FBA".equals(row.getDestinationType())) .filter(row -> Objects.requireNonNullElse(row.getPalletCount(), 0) > 0).toList(); - if (standard.stream().anyMatch(row -> !StringUtils.hasText(row.getDestinationWarehouseCode()))) { - throw new BizException(ResultCode.CONFLICT, "散货明细缺少 FBA 仓编码,不能生成托盘码"); + if (standard.stream().anyMatch(row -> row.getDestinationWarehouseId() == null + || !StringUtils.hasText(row.getDestinationWarehouseCode()))) { + throw new BizException(ResultCode.CONFLICT, "散货明细缺少 FBA 仓信息,不能生成托盘码"); } + Map internalCodes = resolveInternalCodes(standard); List existing = mapper.labels(planId); - List expected = expected(first, standard, containerNo); + List expected = expected(first, standard, containerNo, internalCodes); if (existing.isEmpty()) { expected.forEach(row -> { if (mapper.insert(row) != 1) throw new BizException(ResultCode.CONFLICT, "托盘码生成失败,请刷新后重试"); }); existing = mapper.labels(planId); @@ -91,19 +95,21 @@ public class LoadingPalletLabelService { catch (FeignException | NoFallbackAvailableException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用,请稍后重试"); } } - private List expected(SourceRow plan, List sources, String containerNo) { - Map indexes = new LinkedHashMap<>(); + private List expected(SourceRow plan, List sources, String containerNo, + Map internalCodes) { + Map indexes = new LinkedHashMap<>(); List result = new ArrayList<>(); for (SourceRow source : sources) { - String code = source.getDestinationWarehouseCode().trim().toUpperCase(Locale.ROOT); + String warehouseCode = source.getDestinationWarehouseCode().trim().toUpperCase(Locale.ROOT); + String internalCode = internalCodes.get(source.getDestinationWarehouseId()); for (int local = 0; local < source.getPalletCount(); local++) { - int index = indexes.merge(code, 1, Integer::sum); + int index = indexes.merge(source.getDestinationWarehouseId(), 1, Integer::sum); if (index > 999) throw new BizException(ResultCode.CONFLICT, "单柜同一 FBA 仓托盘数不能超过 999"); LabelRow row = new LabelRow(); row.setLabelId(IdWorker.getId()); row.setPlanId(plan.getPlanId()); row.setItemId(source.getItemId()); row.setAccountId(source.getAccountId()); row.setInboundId(source.getInboundId()); row.setInboundCode(source.getInboundCode()); row.setContainerNo(containerNo); - row.setDestinationWarehouseCode(code); row.setDestinationWarehouseName(source.getDestinationWarehouseName()); - row.setPalletIndex(index); row.setPalletCode(containerNo + "-" + code + "-" + String.format("%03d", index)); + row.setDestinationWarehouseCode(warehouseCode); row.setDestinationWarehouseName(source.getDestinationWarehouseName()); + row.setPalletIndex(index); row.setPalletCode(containerNo + "-" + internalCode + "-" + String.format("%03d", index)); if (row.getPalletCode().length() > 128) throw new BizException(ResultCode.CONFLICT, "托盘码长度超过系统限制"); result.add(row); } @@ -111,6 +117,37 @@ public class LoadingPalletLabelService { return result; } + private Map resolveInternalCodes(List sources) { + Map codes = new LinkedHashMap<>(); + Map owners = new LinkedHashMap<>(); + for (SourceRow source : sources) { + long warehouseId = source.getDestinationWarehouseId(); + if (codes.containsKey(warehouseId)) continue; + WarehouseMasterDataClient.Warehouse warehouse; + try { + ApiResponse response = warehouseClient.active(internalToken, 0L, warehouseId); + warehouse = response == null || !response.successful() || response.data() == null + ? null : response.data().warehouse(); + } catch (FeignException | NoFallbackAvailableException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓库主数据暂不可用,请稍后重试"); + } + if (warehouse == null || !"FBA".equals(warehouse.warehouseType())) { + throw new BizException(ResultCode.CONFLICT, "目的 FBA 仓不存在或未启用"); + } + String internalCode = normalize(warehouse.internalCode()); + if (internalCode == null) { + throw new BizException(ResultCode.CONFLICT, "FBA 仓“" + warehouse.warehouseCode() + "”未配置内部编号"); + } + internalCode = internalCode.toUpperCase(Locale.ROOT); + Long owner = owners.putIfAbsent(internalCode, warehouseId); + if (owner != null && owner != warehouseId) { + throw new BizException(ResultCode.CONFLICT, "同柜 FBA 仓内部编号重复,请先修正仓库主数据"); + } + codes.put(warehouseId, internalCode); + } + return codes; + } + private void requireSame(List expected, List actual) { List left = expected.stream().map(this::identity).toList(); List right = actual.stream().map(this::identity).toList(); @@ -122,12 +159,19 @@ public class LoadingPalletLabelService { Map> grouped = rows.stream().collect(java.util.stream.Collectors.groupingBy( LabelRow::getDestinationWarehouseCode, LinkedHashMap::new, java.util.stream.Collectors.toList())); List groups = grouped.values().stream().map(values -> new PalletLabelGroup( - values.get(0).getDestinationWarehouseCode(), values.get(0).getDestinationWarehouseName(), values.size(), + values.get(0).getDestinationWarehouseCode(), internalCode(values.get(0)), + values.get(0).getDestinationWarehouseName(), values.size(), values.stream().map(this::label).toList())).toList(); String containerNo = rows.isEmpty() ? plan.getContainerNo() : rows.get(0).getContainerNo(); return new PalletLabelManifest(plan.getPlanId(), plan.getPlanNo(), containerNo, rows.size(), groups); } - private PalletLabel label(LabelRow row) { return new PalletLabel(row.getLabelId(), row.getItemId(), row.getAccountId(), row.getInboundId(), row.getInboundCode(), row.getContainerNo(), row.getDestinationWarehouseCode(), row.getDestinationWarehouseName(), row.getPalletIndex(), row.getPalletCode()); } + private PalletLabel label(LabelRow row) { return new PalletLabel(row.getLabelId(), row.getItemId(), row.getAccountId(), row.getInboundId(), row.getInboundCode(), row.getContainerNo(), row.getDestinationWarehouseCode(), internalCode(row), row.getDestinationWarehouseName(), row.getPalletIndex(), row.getPalletCode()); } + private String internalCode(LabelRow row) { + String prefix = row.getContainerNo() + "-"; + String suffix = "-" + String.format("%03d", row.getPalletIndex()); + String palletCode = row.getPalletCode(); + return palletCode.substring(prefix.length(), palletCode.length() - suffix.length()); + } private static String normalize(String value) { return value == null || value.isBlank() ? null : value.trim(); } private ResponseEntity requirePdf(ResponseEntity response) { diff --git a/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql b/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql index 4edf7ade9..eaf6d8cdc 100644 --- a/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql +++ b/firstleg/src/main/resources/db/phase3-terminal-drayage-schema.sql @@ -67,7 +67,7 @@ CREATE TABLE IF NOT EXISTS fl_terminal_drayage_history ( KEY idx_terminal_drayage_history(container_booking_id,created_at,id), CONSTRAINT fk_terminal_drayage_history FOREIGN KEY(drayage_id) REFERENCES fl_terminal_drayage(id), CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN - ('SELECT_FLEET','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')) + ('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')) ) ENGINE=InnoDB COMMENT='目的港码头拖车不可变操作历史'; CREATE TABLE IF NOT EXISTS fl_terminal_drayage_operation ( diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index 773d54235..06cc71d0c 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -77,7 +77,7 @@ paths: /api/platform/v1/first-leg/loading-plans/{planId}/pallet-labels/prepare: post: summary: 生成并查询散货托盘码清单 - description: 预装确认且已有真实柜号后,按柜号-FBA仓编码-三位序号生成稳定清单;一件代发不参与。 + description: 预装确认且已有真实柜号后,按柜号-FBA地址内部编号-三位序号生成稳定清单;一件代发不参与。内部编号缺失时拒绝生成。 parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}] responses: '200': {description: 按 FBA 仓分组的散货托盘码清单, content: {application/json: {schema: {type: object}}}} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index 4f52501c2..f1fb2e42f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -77,7 +77,7 @@ class LoadingListControllerContractTest { .contains("/loading-plans/{planId}/lifecycle") .contains("/loading-plans/{planId}/pallet-labels/prepare") .contains("/loading-plans/{planId}/pallet-labels/print") - .contains("柜号-FBA仓编码-三位序号") + .contains("柜号-FBA地址内部编号-三位序号") .contains("不得以错误文案或 A0404 猜测阶段") .contains("confirmationStatus") .contains("canConfirm") 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 ab6b3acd1..643aef318 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 @@ -390,7 +390,7 @@ class OceanBookingServiceTest { when(priceBooks.channels(51L, 701L)).thenReturn(List.of(row)); when(warehouses.active(any(), eq(0L), eq(701L))).thenReturn(ApiResponse.success( new WarehouseMasterDataClient.Detail(new WarehouseMasterDataClient.Warehouse( - 701L, 0L, "SZX", "深圳中转仓", "DOMESTIC", "ACTIVE")))); + 701L, 0L, "SZX", null, "深圳中转仓", "DOMESTIC", "ACTIVE")))); when(suppliers.booking(81L, null)).thenReturn( new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("MATSON"))); when(partners.active(any(), eq(0L), eq(71L))).thenReturn(ApiResponse.success( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index ea1f0b0c9..09e195623 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -468,7 +468,7 @@ class ContainerServiceTest { String type, String name) { return ApiResponse.success(new WarehouseMasterDataClient.Detail( - new WarehouseMasterDataClient.Warehouse(warehouseId, 0L, "CN-SHA-01", name, type, "ACTIVE"))); + new WarehouseMasterDataClient.Warehouse(warehouseId, 0L, "CN-SHA-01", null, name, type, "ACTIVE"))); } private static ContainerBookingEntity booking(String status, int version) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index 748dd8fc8..fe004951c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -30,6 +30,7 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Bean; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.mock.web.MockMultipartFile; +import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -315,6 +316,16 @@ class LoadingListServiceTest { verify(loadingCompletedOutboxService).publish(10L); } + @Test + void completedEventReadinessMustAllowPalletLabelFreeze() throws Exception { + Transactional transaction = LoadingListService.class + .getDeclaredMethod("requireCompletedEventReady", long.class) + .getAnnotation(Transactional.class); + + assertThat(transaction).isNotNull(); + assertThat(transaction.readOnly()).isFalse(); + } + @Test void shouldRejectAutomaticCompletionWhenPdaItemsRemain() { when(pickRepository.allPlanItemsPicked(10L)).thenReturn(false); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java index 46759a201..2e34d2b2a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPalletLabelServiceTest.java @@ -1,6 +1,8 @@ package com.baiyue.firstleg.service; import com.baiyue.firstleg.mapper.LoadingPalletLabelMapper; +import com.baiyue.firstleg.client.WarehouseMasterDataClient; +import com.baiyue.common.core.api.ApiResponse; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; @@ -18,21 +20,28 @@ class LoadingPalletLabelServiceTest { LoadingPalletLabelMapper mapper = mock(LoadingPalletLabelMapper.class); LoadingPalletLabelService service = new LoadingPalletLabelService(); ReflectionTestUtils.setField(service, "mapper", mapper); + WarehouseMasterDataClient warehouseClient = mock(WarehouseMasterDataClient.class); + ReflectionTestUtils.setField(service, "warehouseClient", warehouseClient); + ReflectionTestUtils.setField(service, "internalToken", "test-token"); List saved = new ArrayList<>(); when(mapper.lockSources(10L)).thenReturn(List.of(source(3, "LAX9", 1, "STANDARD"), source(1, "ONT8", 2, "STANDARD"), source(2, "ONT8", 1, "STANDARD"), source(4, "ONT8", 4, "ONE_CLICK_DROPSHIP"))); when(mapper.labels(10L)).thenAnswer(ignored -> List.copyOf(saved)); when(mapper.insert(any())).thenAnswer(invocation -> { saved.add(invocation.getArgument(0)); return 1; }); + when(warehouseClient.active("test-token", 0L, 101L)).thenReturn(warehouse("LAX9", "154")); + when(warehouseClient.active("test-token", 0L, 102L)).thenReturn(warehouse("ONT8", "CADF")); var manifest = service.prepare(10L, 20L); assertThat(manifest.totalCount()).isEqualTo(4); assertThat(manifest.groups()).extracting(group -> group.destinationWarehouseCode() + ":" + group.totalCount()) .containsExactly("LAX9:1", "ONT8:3"); + assertThat(manifest.groups()).extracting(group -> group.destinationWarehouseInternalCode()) + .containsExactly("154", "CADF"); assertThat(saved).extracting(LoadingPalletLabelMapper.LabelRow::getPalletCode) - .containsExactly("MSCU6639870-LAX9-001", "MSCU6639870-ONT8-001", - "MSCU6639870-ONT8-002", "MSCU6639870-ONT8-003"); + .containsExactly("MSCU6639870-154-001", "MSCU6639870-CADF-001", + "MSCU6639870-CADF-002", "MSCU6639870-CADF-003"); } private LoadingPalletLabelMapper.SourceRow source(long itemId, String destination, int pallets, String fulfillment) { @@ -40,7 +49,13 @@ class LoadingPalletLabelServiceTest { row.setPlanStatus("LOADING"); row.setContainerNo("MSCU6639870"); row.setWarehouseId(20L); row.setItemId(itemId); row.setAccountId(100L); row.setInboundId(itemId + 1000); row.setInboundCode("RK-" + itemId); row.setDestinationWarehouseCode(destination); + row.setDestinationWarehouseId("LAX9".equals(destination) ? 101L : 102L); row.setDestinationWarehouseName(destination); row.setPalletCount(pallets); row.setFulfillmentType(fulfillment); row.setDestinationType("FBA"); return row; } + + private ApiResponse warehouse(String code, String internalCode) { + return ApiResponse.success(new WarehouseMasterDataClient.Detail(new WarehouseMasterDataClient.Warehouse( + "LAX9".equals(code) ? 101L : 102L, 0L, code, internalCode, code, "FBA", "ACTIVE"))); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java index e98489c6d..7eecedd62 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/TerminalDrayageSqlContractTest.java @@ -23,7 +23,10 @@ class TerminalDrayageSqlContractTest { if (name.equals("pendingMails")) assertThat(sql).contains("earlier.container_booking_id=m.container_booking_id", "earlier.id'SENT'"); } for (Path path : java.util.List.of(Path.of("src/main/resources/db/phase3-terminal-drayage-schema.sql"), Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"))) { - assertThat(Files.readString(path)).contains("CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail (", "CHECK(status IN ('PENDING','SENDING','SENT','FAILED'))"); + assertThat(Files.readString(path)).contains( + "CREATE TABLE IF NOT EXISTS fl_terminal_drayage_mail (", + "CHECK(status IN ('PENDING','SENDING','SENT','FAILED'))", + "('SELECT_FLEET','SEND_PICKUP_EMAIL','SCHEDULE_PICKUP','RECORD_PICKUP','RECORD_EMPTY_RETURN')"); } } @@ -38,7 +41,9 @@ class TerminalDrayageSqlContractTest { } String release = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); assertThat(release).contains("DROP CHECK chk_terminal_drayage_times", - "ADD CONSTRAINT chk_terminal_drayage_times CHECK("); + "ADD CONSTRAINT chk_terminal_drayage_times CHECK(", + "DROP CHECK chk_terminal_drayage_history_action", + "ADD CONSTRAINT chk_terminal_drayage_history_action CHECK(action IN"); } @Test diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index 560845a5c..584e27f08 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -130,6 +130,26 @@ test('development publishes every service on loopback without changing test defa assert.doesNotMatch(directDeployScript, /gateway\|gateway-overseas\) (?:export )?PUBLISHED_BIND_ADDRESS=0\.0\.0\.0/) }) +test('Firstleg deployment passes terminal drayage SMTP settings into the container', () => { + const firstlegService = serviceCompose.slice( + serviceCompose.indexOf(' firstleg:\n'), + serviceCompose.indexOf(' finance:\n') + ) + for (const key of [ + 'TERMINAL_DRAYAGE_MAIL_ENABLED', + 'TERMINAL_DRAYAGE_MAIL_HOST', + 'TERMINAL_DRAYAGE_MAIL_PORT', + 'TERMINAL_DRAYAGE_MAIL_USERNAME', + 'TERMINAL_DRAYAGE_MAIL_PASSWORD', + 'TERMINAL_DRAYAGE_MAIL_SSL', + 'TERMINAL_DRAYAGE_MAIL_SCAN_DELAY' + ]) { + assert.match(firstlegService, new RegExp(`^ ${key}: ` + '\\$\\{' + key, 'm')) + } + assert.match(directDeployScript, /TERMINAL_DRAYAGE_MAIL_ENABLED/) + assert.match(directDeployScript, /TERMINAL_DRAYAGE_MAIL_HOST TERMINAL_DRAYAGE_MAIL_PORT TERMINAL_DRAYAGE_MAIL_USERNAME TERMINAL_DRAYAGE_MAIL_PASSWORD/) +}) + test('parallel image publications isolate registry credentials per Jenkins job', () => { const imageStage = pipeline.slice( pipeline.indexOf("stage('构建并发布单服务镜像')"),