From a34b5d5ba0a58e887c681f254bd49207b0f1b4dd Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 17 Aug 2026 14:46:44 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 5 +- .../client/WarehouseSupplierClient.java | 2 +- .../FinanceFeeSourceEventServiceTest.java | 2 +- .../FinanceSettlementBehaviorTest.java | 2 +- .../finance/FinanceSupplierValidatorTest.java | 4 +- firstleg/AGENTS.md | 2 +- .../client/WarehouseSupplierClient.java | 5 +- .../baiyue/firstleg/dto/LoadingPickDtos.java | 4 +- .../mapper/LoadingPickTaskMapper.java | 12 ++++ .../firstleg/service/LoadingPickService.java | 7 ++- .../WarehouseSupplierSnapshotService.java | 15 +++-- .../resources/openapi/ocean-bookings-v1.yaml | 2 +- .../openapi/phase3-pda-convergence-v1.yaml | 14 ++++- .../WarehouseSupplierClientContractTest.java | 8 ++- .../PdaConvergenceOpenApiContractTest.java | 7 +++ .../customs/service/CustomsServiceTest.java | 2 +- .../LoadingPickPdaTaskSqlContractTest.java | 3 + .../service/ContainerServiceTest.java | 2 +- .../service/LoadingPickServiceTest.java | 63 +++++++++++++++++++ .../WarehouseSupplierSnapshotServiceTest.java | 22 +++++-- .../booking-supplier-scope-contract.test.mjs | 36 +++++++++++ .../finance-full-fixtures-contract.test.mjs | 2 +- .../finance-full-fixtures.mjs | 1 - .../admin-test-env-modules/phase3-customs.mjs | 2 +- .../suppliers-contract.test.mjs | 6 +- scripts/admin-test-env-modules/suppliers.mjs | 11 ++-- .../truck-appointments.mjs | 2 +- .../InternalSupplierController.java | 6 +- .../baiyue/warehouse/dto/SupplierDtos.java | 4 +- .../warehouse/entity/SupplierEntity.java | 1 - .../repository/SupplierRepository.java | 1 - .../warehouse/service/SupplierService.java | 13 ++-- .../main/resources/db/warehouse-supplier.sql | 6 +- .../openapi/supplier-frontend-handoff.md | 14 +---- .../main/resources/openapi/supplier-v1.yaml | 7 +-- ...nternalSupplierControllerContractTest.java | 21 +++++++ .../repository/SupplierSqlContractTest.java | 7 ++- .../service/SupplierServiceTest.java | 41 ++++++------ 38 files changed, 249 insertions(+), 115 deletions(-) create mode 100644 scripts/admin-test-env-modules/booking-supplier-scope-contract.test.mjs create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/controller/InternalSupplierControllerContractTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 1eb3345f2..cab092ad3 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -15144,7 +15144,7 @@ USE baiyue_warehouse; CREATE TABLE IF NOT EXISTS wh_supplier ( id BIGINT UNSIGNED NOT NULL COMMENT '供应商ID', tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0', name VARCHAR(160) NOT NULL COMMENT '供应商名称', category VARCHAR(20) NOT NULL COMMENT '供应商类别:BOOKING订舱、CUSTOMS报关、TRUCKING拖车、CERTIFICATE认证', - scope VARCHAR(20) NULL COMMENT '订舱分类范围:DOMESTIC/INTERNATIONAL', region VARCHAR(80) NOT NULL COMMENT '地区', + region VARCHAR(80) NOT NULL COMMENT '地区', legal_representative VARCHAR(80) NOT NULL COMMENT '法人代表', contact_name VARCHAR(80) NOT NULL COMMENT '联系人', phone VARCHAR(40) NOT NULL COMMENT '联系电话', email VARCHAR(160) NULL COMMENT '电子邮箱', address VARCHAR(255) NULL COMMENT '公司地址', business_license_no VARCHAR(64) NULL COMMENT '营业执照号', license_file_id VARCHAR(128) NULL COMMENT 'File服务营业执照文件ID', @@ -15154,7 +15154,6 @@ CREATE TABLE IF NOT EXISTS wh_supplier ( deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id), UNIQUE KEY uk_wh_supplier_name(tenant_id,name), KEY idx_wh_supplier_filter(tenant_id,category,status,region), CONSTRAINT chk_wh_supplier_category CHECK(category IN ('BOOKING','CUSTOMS','TRUCKING','CERTIFICATE')), - CONSTRAINT chk_wh_supplier_scope CHECK((category='BOOKING' AND scope IN ('DOMESTIC','INTERNATIONAL')) OR (category<>'BOOKING' AND scope IS NULL)), CONSTRAINT chk_wh_supplier_status CHECK(status IN ('ACTIVE','INACTIVE')), CONSTRAINT chk_wh_supplier_settlement CHECK(settlement_period IN ('MONTHLY_30','MONTHLY_45','MONTHLY_60','MONTHLY_90','PER_TRANSACTION','IMMEDIATE')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台公共供应商业务档案'; @@ -15167,7 +15166,6 @@ BEGIN END IF; END$$ DELIMITER ; -CALL supplier_add_column_if_missing('scope',"VARCHAR(20) NULL COMMENT '订舱分类范围:DOMESTIC/INTERNATIONAL' AFTER category"); CALL supplier_add_column_if_missing('license_file_id',"VARCHAR(128) NULL COMMENT 'File服务营业执照文件ID' AFTER business_license_no"); CALL supplier_add_column_if_missing('created_by',"BIGINT UNSIGNED NULL COMMENT '创建人ID'"); CALL supplier_add_column_if_missing('created_at',"DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间'"); @@ -15188,7 +15186,6 @@ BEGIN END$$ DELIMITER ; CALL supplier_add_check_if_missing('chk_wh_supplier_category',"category IN ('BOOKING','CUSTOMS','TRUCKING','CERTIFICATE')"); -CALL supplier_add_check_if_missing('chk_wh_supplier_scope',"(category='BOOKING' AND scope IN ('DOMESTIC','INTERNATIONAL')) OR (category<>'BOOKING' AND scope IS NULL)"); CALL supplier_add_check_if_missing('chk_wh_supplier_status',"status IN ('ACTIVE','INACTIVE')"); CALL supplier_add_check_if_missing('chk_wh_supplier_settlement',"settlement_period IN ('MONTHLY_30','MONTHLY_45','MONTHLY_60','MONTHLY_90','PER_TRANSACTION','IMMEDIATE')"); DROP PROCEDURE supplier_add_check_if_missing; diff --git a/finance/src/main/java/com/baiyue/finance/client/WarehouseSupplierClient.java b/finance/src/main/java/com/baiyue/finance/client/WarehouseSupplierClient.java index 71449119d..d554ee5fa 100644 --- a/finance/src/main/java/com/baiyue/finance/client/WarehouseSupplierClient.java +++ b/finance/src/main/java/com/baiyue/finance/client/WarehouseSupplierClient.java @@ -16,5 +16,5 @@ public interface WarehouseSupplierClient { @RequestParam(defaultValue = "true") boolean requireActive, @RequestParam String category); - record SupplierSnapshot(long supplierId, String name, String category, String scope, String status) {} + record SupplierSnapshot(long supplierId, String name, String category, String status) {} } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java index 57f8ddad0..254760a3a 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceEventServiceTest.java @@ -40,7 +40,7 @@ class FinanceFeeSourceEventServiceTest { lenient().when(supplierValidator.categoryFor(any(), any())).thenReturn("TRUCKING"); lenient().when(supplierValidator.requireActive(anyLong(), any())).thenReturn( new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot( - 71L, "现有供应商", "TRUCKING", "DOMESTIC", "ACTIVE")); + 71L, "现有供应商", "TRUCKING", "ACTIVE")); } @Test diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java index 65094240b..b492532e9 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java @@ -545,7 +545,7 @@ class FinanceSettlementBehaviorTest { org.mockito.Mockito.lenient().when(supplierValidator.categoryFor(any(), any())).thenReturn("BOOKING"); org.mockito.Mockito.lenient().when(supplierValidator.requireActive(anyLong(), any())) .thenReturn(new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot( - 20L, "供应商20", "BOOKING", "INTERNATIONAL", "ACTIVE")); + 20L, "供应商20", "BOOKING", "ACTIVE")); return supplierValidator; } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSupplierValidatorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSupplierValidatorTest.java index 5988a202c..3eaf869f2 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSupplierValidatorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSupplierValidatorTest.java @@ -28,7 +28,7 @@ class FinanceSupplierValidatorTest { @Test void requiresActiveWarehouseSupplierWithMatchingCategory() { - var supplier = new WarehouseSupplierClient.SupplierSnapshot(20L, "正式供应商", "TRUCKING", "DOMESTIC", "ACTIVE"); + var supplier = new WarehouseSupplierClient.SupplierSnapshot(20L, "正式供应商", "TRUCKING", "ACTIVE"); when(client.get("internal-token", 20L, true, "TRUCKING")).thenReturn(ApiResponse.success(supplier)); assertThat(validator.requireActive(20L, "TRUCKING").name()).isEqualTo("正式供应商"); @@ -37,7 +37,7 @@ class FinanceSupplierValidatorTest { @Test void rejectsMismatchedOrUnavailableSupplier() { - var mismatched = new WarehouseSupplierClient.SupplierSnapshot(20L, "订舱供应商", "BOOKING", "INTERNATIONAL", "ACTIVE"); + var mismatched = new WarehouseSupplierClient.SupplierSnapshot(20L, "订舱供应商", "BOOKING", "ACTIVE"); when(client.get("internal-token", 20L, true, "TRUCKING")).thenReturn(ApiResponse.success(mismatched)); assertCode(() -> validator.requireActive(20L, "TRUCKING"), "FIN_409"); diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 793f7684c..b8590df96 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -42,7 +42,7 @@ - 平台接口必须校验目标客户、权限、乐观锁、幂等键、历史和审计;商户查询只能使用登录态客户。 - 第三方回传必须签名、时间窗、防重放、请求哈希,并绑定订单货物和 File 证据;不得信任外部客户字段。 - 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。 -- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING + INTERNATIONAL`,拖车使用 `TRUCKING`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别、范围或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。 +- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),拖车使用 `TRUCKING`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。 - Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。 ## 三期装柜与 PDA 资源结算 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java index 7242156e6..da7ca114b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java @@ -14,8 +14,7 @@ public interface WarehouseSupplierClient { ApiResponse get(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable long supplierId, @RequestParam(defaultValue = "true") boolean requireActive, - @RequestParam(required = false) String category, - @RequestParam(required = false) String scope); + @RequestParam(required = false) String category); - record Snapshot(long supplierId, String name, String category, String scope, String status) {} + record Snapshot(long supplierId, String name, String category, String status) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java index 75edd385a..2016fbc7d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingPickDtos.java @@ -127,7 +127,8 @@ public final class LoadingPickDtos { public record Task(Long id, Long loadingPlanId, Long containerBookingId, Long inboundId, Integer inboundVersion, Integer loadingVersion, String cabinetNo, - String currentContainerNo, String taskStatus, + String currentContainerNo, String sourceType, String recordStatus, + Long mergedIntoContainerBookingId, String taskStatus, String workflowStatus, Integer version, Assignment assignment, Review review, List items) { } @@ -135,6 +136,7 @@ public final class LoadingPickDtos { /** PDA 工作台只展示当前作业员可领取或已领取的进行中任务。 */ public record PendingTask(Long taskId, Long loadingPlanId, Long containerBookingId, String cabinetNo, String currentContainerNo, + String sourceType, String recordStatus, Long mergedIntoContainerBookingId, Long warehouseId, String warehouseName, String taskStatus, String workflowStatus, Integer version, Integer assignmentVersion, Integer itemCount, Integer pendingItemCount) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java index f87d51062..95cae6f99 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickTaskMapper.java @@ -49,6 +49,8 @@ public interface LoadingPickTaskMapper extends BaseMapper SELECT task.id AS taskId, task.loading_plan_id AS loadingPlanId, task.container_booking_id AS containerBookingId, task.cabinet_no AS cabinetNo, booking.container_no AS containerNo, booking.warehouse_id AS warehouseId, + booking.source_type AS sourceType, booking.record_status AS recordStatus, + booking.merged_into_container_booking_id AS mergedIntoContainerBookingId, booking.warehouse_name AS warehouseName, task.task_status AS taskStatus, CASE WHEN assignment.pick_task_id IS NULL THEN 'UNASSIGNED' ELSE 'IN_PROGRESS' END AS workflowStatus, task.version AS version, COALESCE(assignment.version, 0) AS assignmentVersion, @@ -67,6 +69,7 @@ public interface LoadingPickTaskMapper extends BaseMapper GROUP BY task.id, task.loading_plan_id, task.container_booking_id, task.cabinet_no, booking.container_no, booking.warehouse_id, + booking.source_type, booking.record_status, booking.merged_into_container_booking_id, booking.warehouse_name, task.task_status, task.version, assignment.pick_task_id, assignment.version ORDER BY task.id DESC @@ -82,6 +85,9 @@ public interface LoadingPickTaskMapper extends BaseMapper private String containerNo; private Long containerBookingId; private String cabinetNo; + private String sourceType; + private String recordStatus; + private Long mergedIntoContainerBookingId; private Long warehouseId; private String warehouseName; private String taskStatus; @@ -101,6 +107,12 @@ public interface LoadingPickTaskMapper extends BaseMapper public void setContainerBookingId(Long value) { this.containerBookingId = value; } public String getCabinetNo() { return cabinetNo; } public void setCabinetNo(String value) { this.cabinetNo = value; } + public String getSourceType() { return sourceType; } + public void setSourceType(String sourceType) { this.sourceType = sourceType; } + public String getRecordStatus() { return recordStatus; } + public void setRecordStatus(String recordStatus) { this.recordStatus = recordStatus; } + public Long getMergedIntoContainerBookingId() { return mergedIntoContainerBookingId; } + public void setMergedIntoContainerBookingId(Long value) { this.mergedIntoContainerBookingId = value; } public Long getWarehouseId() { return warehouseId; } public void setWarehouseId(Long warehouseId) { this.warehouseId = warehouseId; } public String getWarehouseName() { return warehouseName; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index 6fd3650bb..f7109ea83 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -185,6 +185,7 @@ public class LoadingPickService { return repository.pendingTasks(operatorId, normalizedKeyword).stream() .map(row -> new PendingTask(row.getTaskId(), row.getLoadingPlanId(), row.getContainerBookingId(), row.getCabinetNo(), row.getContainerNo(), + row.getSourceType(), row.getRecordStatus(), row.getMergedIntoContainerBookingId(), row.getWarehouseId(), row.getWarehouseName(), row.getTaskStatus(), row.getWorkflowStatus(), row.getVersion(), row.getAssignmentVersion(), row.getItemCount(), row.getPendingItemCount())) .toList(); @@ -607,6 +608,9 @@ public class LoadingPickService { : assigned == null ? "UNASSIGNED" : repository.allTaskItemsResolved(task.getId()) ? "AWAITING_REVIEW" : "IN_PROGRESS"; var booking = smartLoadingRepository.booking(task.getContainerBookingId()); + if (booking == null || !"ACTIVE".equals(booking.getRecordStatus())) { + throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在或柜次已非有效存续记录"); + } var owners = repository.inboundOwners(task.getId()); Long detailInboundId = source.isEmpty() ? null : source.get(0).getInboundId(); var owner = detailInboundId == null ? null : owners.stream() @@ -616,7 +620,8 @@ public class LoadingPickService { return new Task(task.getId(), task.getLoadingPlanId(), task.getContainerBookingId(), owner == null ? null : owner.inboundId(), inbound == null ? null : inbound.version(), inbound == null ? null : inbound.version(), task.getCabinetNo(), - booking == null ? null : booking.getContainerNo(), task.getTaskStatus(), + booking.getContainerNo(), booking.getSourceType(), booking.getRecordStatus(), + booking.getMergedIntoContainerBookingId(), task.getTaskStatus(), workflowStatus, task.getVersion(), assignment, review, items); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotService.java index a543cee5d..4a498f761 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotService.java @@ -15,29 +15,32 @@ public class WarehouseSupplierSnapshotService { @Value("${baiyue.security.internal-service-token}") private String internalToken; public WarehouseSupplierClient.Snapshot booking(Long supplierId, String suppliedName) { - return resolve(supplierId, suppliedName, "BOOKING", "INTERNATIONAL"); + return resolve(supplierId, suppliedName, "BOOKING"); } public WarehouseSupplierClient.Snapshot trucking(Long supplierId, String suppliedName) { - return resolve(supplierId, suppliedName, "TRUCKING", null); + return resolve(supplierId, suppliedName, "TRUCKING"); } public WarehouseSupplierClient.Snapshot customs(Long supplierId, String suppliedName) { - return resolve(supplierId, suppliedName, "CUSTOMS", null); + return resolve(supplierId, suppliedName, "CUSTOMS"); } private WarehouseSupplierClient.Snapshot resolve(Long supplierId, String suppliedName, - String category, String scope) { + String category) { if (supplierId == null || supplierId <= 0) { if (StringUtils.hasText(suppliedName)) throw new BizException(ResultCode.BAD_REQUEST, "供应商名称必须来自有效的供应商ID,禁止自由文本"); return null; } try { - var response = client.get(internalToken, supplierId, true, category, scope); + var response = client.get(internalToken, supplierId, true, category); if (response == null || !response.successful() || response.data() == null + || response.data().supplierId() != supplierId + || !"ACTIVE".equals(response.data().status()) + || !category.equals(response.data().category()) || !StringUtils.hasText(response.data().name())) { - throw new BizException(ResultCode.CONFLICT, "所选供应商不存在、未启用或类别/范围不匹配"); + throw new BizException(ResultCode.CONFLICT, "所选供应商不存在、未启用或类别不匹配"); } if (StringUtils.hasText(suppliedName) && !suppliedName.trim().equals(response.data().name())) { diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index 8d556eb6a..6d1064d7a 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -80,7 +80,7 @@ components: warehouseId: {type: integer, format: int64, minimum: 1, description: ACTIVE domestic transit warehouse ID; warehouseName is resolved and frozen server-side.} shippingCompanyId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional shipping-company master-data ID; the carrier is independent from the logistics channel and booking-agent Supplier.} shippingCompanyName: {type: string, maxLength: 128, nullable: true, description: Optional shipping-company name snapshot.} - bookingAgentId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional ACTIVE Warehouse supplier ID validated as BOOKING with INTERNATIONAL scope.} + bookingAgentId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional ACTIVE Warehouse supplier ID validated only as category BOOKING; booking suppliers do not use domestic/international scope.} bookingAgentName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; the persisted snapshot always comes from Warehouse and cannot be supplied without bookingAgentId.} 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.} diff --git a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml index 9b4b96c52..bc37efbaf 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -34,7 +34,9 @@ paths: - {$ref: '#/components/parameters/TaskId'} - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} responses: - '200': {description: Task with current transport display fields} + '200': + description: Task with current transport display and ACTIVE container lifecycle fields + content: {application/json: {schema: {$ref: '#/components/schemas/Task'}}} /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/claim: post: summary: Atomically claim an unassigned task for the authenticated operator @@ -124,7 +126,7 @@ components: schemas: Task: type: object - required: [id, loadingPlanId, containerBookingId, inboundId, inboundVersion, loadingVersion, cabinetNo, taskStatus, workflowStatus, version, items] + required: [id, loadingPlanId, containerBookingId, inboundId, inboundVersion, loadingVersion, cabinetNo, sourceType, recordStatus, taskStatus, workflowStatus, version, items] properties: id: {type: integer, format: int64} loadingPlanId: {type: integer, format: int64} @@ -134,6 +136,9 @@ components: loadingVersion: {type: integer, minimum: 0, description: Compatibility alias of inboundVersion from the same fl_inbound_order row; /loading/complete uses this inbound loading-stage version} cabinetNo: {type: string} currentContainerNo: {type: string, nullable: true, description: Mutable transport display field; changing it never resynchronizes the task or item versions and it is never task identity} + sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: Source of the surviving stable container identity} + recordStatus: {type: string, enum: [ACTIVE], description: Task detail is served only from the ACTIVE container booking currently owned by the loading plan} + mergedIntoContainerBookingId: {type: integer, format: int64, nullable: true, description: Always null for the surviving ACTIVE operational container; populated only on hidden MERGED source history} taskStatus: {type: string, enum: [LOADING, COMPLETED]} workflowStatus: {type: string, enum: [UNASSIGNED, IN_PROGRESS, AWAITING_REVIEW, COMPLETED]} version: {type: integer, minimum: 0} @@ -164,13 +169,16 @@ components: version: {type: integer, minimum: 0} PendingTask: type: object - required: [taskId, loadingPlanId, containerBookingId, cabinetNo, warehouseId, taskStatus, workflowStatus, version, assignmentVersion, itemCount, pendingItemCount] + required: [taskId, loadingPlanId, containerBookingId, cabinetNo, sourceType, recordStatus, warehouseId, taskStatus, workflowStatus, version, assignmentVersion, itemCount, pendingItemCount] properties: taskId: {type: integer, format: int64} loadingPlanId: {type: integer, format: int64} containerBookingId: {type: integer, format: int64} cabinetNo: {type: string} currentContainerNo: {type: string, nullable: true} + sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING]} + recordStatus: {type: string, enum: [ACTIVE], description: Enforced by the server-side pending-task query; PDA clients must not infer or relax this filter} + mergedIntoContainerBookingId: {type: integer, format: int64, nullable: true, description: Always null for an ACTIVE pending task} warehouseId: {type: integer, format: int64} warehouseName: {type: string, nullable: true} taskStatus: {type: string, enum: [LOADING]} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/client/WarehouseSupplierClientContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/client/WarehouseSupplierClientContractTest.java index 3aaa1b6f7..8177aabe5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/client/WarehouseSupplierClientContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/client/WarehouseSupplierClientContractTest.java @@ -19,13 +19,13 @@ class WarehouseSupplierClientContractTest { FeignClient annotation = WarehouseSupplierClient.class.getAnnotation(FeignClient.class); assertThat(annotation.path()).isEqualTo("/internal/warehouse/v1/suppliers"); Method method = WarehouseSupplierClient.class.getDeclaredMethod("get", String.class, long.class, - boolean.class, String.class, String.class); + boolean.class, String.class); assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly("/{supplierId}"); assertThat(method.getParameterAnnotations()[0][0]).isInstanceOf(RequestHeader.class); assertThat(((RequestHeader) method.getParameterAnnotations()[0][0]).value()) .isEqualTo("X-Internal-Service-Token"); assertThat(method.getParameterAnnotations()[3][0]).isInstanceOf(RequestParam.class); - assertThat(method.getParameterAnnotations()[4][0]).isInstanceOf(RequestParam.class); + assertThat(method.getParameterCount()).isEqualTo(4); } @Test @@ -33,7 +33,9 @@ class WarehouseSupplierClientContractTest { String ocean = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml"), StandardCharsets.UTF_8); String truck = Files.readString(Path.of("src/main/resources/openapi/truck-appointments-v1.yaml"), StandardCharsets.UTF_8); String customs = Files.readString(Path.of("src/main/resources/openapi/customs-v1.yaml"), StandardCharsets.UTF_8); - assertThat(ocean).contains("bookingAgentId:", "BOOKING", "INTERNATIONAL", "persisted snapshot always comes from Warehouse"); + assertThat(ocean).contains("bookingAgentId:", "category BOOKING", + "do not use domestic/international scope", "persisted snapshot always comes from Warehouse") + .doesNotContain("BOOKING with INTERNATIONAL scope"); assertThat(truck).contains("carrierId:", "TRUCKING", "persisted snapshot comes from Warehouse"); assertThat(customs).contains("customsAgentId", "CUSTOMS", "free text cannot bypass supplier validation"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java index 64c03a7fa..6eed92941 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PdaConvergenceOpenApiContractTest.java @@ -36,6 +36,9 @@ class PdaConvergenceOpenApiContractTest { .contains("evidence identity is taskId+inboundId+loadingPlanId+containerBookingId") .contains("Current fl_inbound_order.version for inboundId") .contains("Compatibility alias of inboundVersion from the same fl_inbound_order row") + .contains("sourceType", "recordStatus", "mergedIntoContainerBookingId") + .contains("Task detail is served only from the ACTIVE container booking") + .contains("Enforced by the server-side pending-task query") .contains("CompleteLoadingRequest"); String taskLookup = yaml.substring( @@ -47,6 +50,10 @@ class PdaConvergenceOpenApiContractTest { assertThat(taskLookup) .contains("name: cabinetNo", "name: loadingPlanId") .doesNotContain("name: containerNo"); + String taskDetail = yaml.substring( + yaml.indexOf(" /api/platform/v1/first-leg/pda-loading/tasks/{taskId}:"), + yaml.indexOf(" /api/platform/v1/first-leg/pda-loading/tasks/{taskId}/claim:")); + assertThat(taskDetail).contains("#/components/schemas/Task", "ACTIVE container lifecycle fields"); assertThat(batchRequest) .contains("loadingPlanId:", "containerBookingId:") .doesNotContain("containerNo:"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsServiceTest.java index b3acfa696..3bd9a2a96 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/service/CustomsServiceTest.java @@ -34,7 +34,7 @@ class CustomsServiceTest { ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); ReflectionTestUtils.setField(service, "internalToken", "token"); when(suppliers.customs(anyLong(), anyString())) - .thenReturn(new com.baiyue.firstleg.client.WarehouseSupplierClient.Snapshot(88L, "线下报关代理", "CUSTOMS", null, "ACTIVE")); + .thenReturn(new com.baiyue.firstleg.client.WarehouseSupplierClient.Snapshot(88L, "线下报关代理", "CUSTOMS", "ACTIVE")); when(repository.completeOperation(anyLong(), anyString(), anyLong())).thenReturn(1); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java index dbe14da5b..941974e4c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java @@ -29,6 +29,9 @@ class LoadingPickPdaTaskSqlContractTest { assertThat(sql) .contains("task.task_status = 'LOADING'") .contains("booking.record_status = 'ACTIVE'") + .contains("booking.source_type AS sourceType") + .contains("booking.record_status AS recordStatus") + .contains("booking.merged_into_container_booking_id AS mergedIntoContainerBookingId") .contains("assignment.pick_task_id IS NULL OR assignment.assignee_id = #{operatorId}") .contains("booking.warehouse_id AS warehouseId") .doesNotContain("tenantId"); 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 054503d6f..07ff38cf0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -62,7 +62,7 @@ class ContainerServiceTest { when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation -> activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓")); when(suppliers.trucking(202L, "拖车公司A")) - .thenReturn(new WarehouseSupplierClient.Snapshot(202L, "拖车公司A", "TRUCKING", null, "ACTIVE")); + .thenReturn(new WarehouseSupplierClient.Snapshot(202L, "拖车公司A", "TRUCKING", "ACTIVE")); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 41f90b073..b75975979 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -17,6 +17,7 @@ import com.baiyue.firstleg.entity.LoadingPickItemEntity; import com.baiyue.firstleg.entity.LoadingPickOperationEntity; import com.baiyue.firstleg.entity.LoadingPickTaskEntity; import com.baiyue.firstleg.entity.LoadingPlanItemEntity; +import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper; import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link; @@ -30,6 +31,7 @@ import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEn import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; import org.mockito.InOrder; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; @@ -70,6 +72,16 @@ class LoadingPickServiceTest { @Resource private LoadingPickService service; + @BeforeEach + void activeOperationalContainer() { + ContainerBookingEntity booking = new ContainerBookingEntity(); + booking.setId(99L); + booking.setContainerNo("CSLU1234567"); + booking.setSourceType("SIMULATION"); + booking.setRecordStatus("ACTIVE"); + when(smartLoadingRepository.booking(99L)).thenReturn(booking); + } + @Test void opensTaskOnlyAfterReleasedBookingAndRealContainerNumber() { var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); @@ -212,6 +224,7 @@ class LoadingPickServiceTest { task.setContainerBookingId(40L); task.setCabinetNo("CAB-001"); var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity(); booking.setId(40L); booking.setContainerNo("MSCU-NEW"); + booking.setSourceType("SIMULATION"); booking.setRecordStatus("ACTIVE"); when(repository.taskByCabinet("CAB-001", 101L)).thenReturn(task); when(repository.taskByPlan(10L, 101L)).thenReturn(task); when(smartLoadingRepository.booking(40L)).thenReturn(booking); @@ -224,6 +237,53 @@ class LoadingPickServiceTest { assertThat(byCabinet.id()).isEqualTo(20L); assertThat(byPlan.id()).isEqualTo(20L); assertThat(byCabinet.currentContainerNo()).isEqualTo("MSCU-NEW"); + assertThat(byCabinet.sourceType()).isEqualTo("SIMULATION"); + assertThat(byCabinet.recordStatus()).isEqualTo("ACTIVE"); + assertThat(byCabinet.mergedIntoContainerBookingId()).isNull(); + } + + @Test + void pendingTasksExposeServerFilteredContainerLifecycle() { + var row = new com.baiyue.firstleg.mapper.LoadingPickTaskMapper.PendingTaskRow(); + row.setTaskId(20L); + row.setLoadingPlanId(10L); + row.setContainerBookingId(99L); + row.setCabinetNo("CAB-001"); + row.setContainerNo("MSCU-001"); + row.setSourceType("SIMULATION"); + row.setRecordStatus("ACTIVE"); + row.setMergedIntoContainerBookingId(null); + row.setWarehouseId(101L); + row.setTaskStatus("LOADING"); + row.setWorkflowStatus("UNASSIGNED"); + row.setVersion(0); + row.setAssignmentVersion(0); + row.setItemCount(1); + row.setPendingItemCount(1); + when(repository.pendingTasks(9L, null)).thenReturn(List.of(row)); + + var result = service.pendingTasks(9L, null).get(0); + + assertThat(result.sourceType()).isEqualTo("SIMULATION"); + assertThat(result.recordStatus()).isEqualTo("ACTIVE"); + assertThat(result.mergedIntoContainerBookingId()).isNull(); + } + + @Test + void taskDetailRejectsMergedContainerSource() { + LoadingPickTaskEntity task = task(); + ContainerBookingEntity merged = new ContainerBookingEntity(); + merged.setId(99L); + merged.setSourceType("OCEAN_BOOKING"); + merged.setRecordStatus("MERGED"); + merged.setMergedIntoContainerBookingId(88L); + when(repository.task(20L)).thenReturn(task); + when(smartLoadingRepository.booking(99L)).thenReturn(merged); + when(repository.items(20L, 101L)).thenReturn(List.of()); + when(repository.cartonLabels(20L)).thenReturn(List.of()); + + assertThatThrownBy(() -> service.task(20L, 101L)) + .isInstanceOf(BizException.class).hasMessageContaining("已非有效存续记录"); } @Test @@ -345,6 +405,9 @@ class LoadingPickServiceTest { assertThat(result.loadingVersion()).isEqualTo(7); assertThat(result.loadingPlanId()).isEqualTo(10L); assertThat(result.containerBookingId()).isEqualTo(99L); + assertThat(result.sourceType()).isEqualTo("SIMULATION"); + assertThat(result.recordStatus()).isEqualTo("ACTIVE"); + assertThat(result.mergedIntoContainerBookingId()).isNull(); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotServiceTest.java index 2fd9f845e..912ca4e59 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotServiceTest.java @@ -34,19 +34,29 @@ class WarehouseSupplierSnapshotServiceTest { } @Test - void resolvesActiveBookingSupplierWithFixedScopeAndServerName() { - when(client.get("internal-token", 7L, true, "BOOKING", "INTERNATIONAL")) - .thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(7L, "海运代理", "BOOKING", "INTERNATIONAL", "ACTIVE"))); + void resolvesActiveBookingSupplierByCategoryWithoutScopeAndUsesServerName() { + when(client.get("internal-token", 7L, true, "BOOKING")) + .thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(7L, "海运代理", "BOOKING", "ACTIVE"))); var snapshot = service.booking(7L, "海运代理"); assertThat(snapshot.name()).isEqualTo("海运代理"); - verify(client).get("internal-token", 7L, true, "BOOKING", "INTERNATIONAL"); + verify(client).get("internal-token", 7L, true, "BOOKING"); } @Test void rejectsClientNameThatDiffersFromWarehouseSnapshot() { - when(client.get(anyString(), eq(9L), eq(true), eq("CUSTOMS"), isNull())) - .thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(9L, "真实报关行", "CUSTOMS", null, "ACTIVE"))); + when(client.get(anyString(), eq(9L), eq(true), eq("CUSTOMS"))) + .thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot(9L, "真实报关行", "CUSTOMS", "ACTIVE"))); assertThatThrownBy(() -> service.customs(9L, "伪造名称")) .isInstanceOf(BizException.class).hasMessageContaining("不一致"); } + + @Test + void rejectsSupplierSnapshotWithWrongCategoryOrInactiveStatus() { + when(client.get("internal-token", 7L, true, "BOOKING")) + .thenReturn(ApiResponse.success(new WarehouseSupplierClient.Snapshot( + 7L, "错误代理", "TRUCKING", "ACTIVE"))); + + assertThatThrownBy(() -> service.booking(7L, null)) + .isInstanceOf(BizException.class).hasMessageContaining("类别不匹配"); + } } diff --git a/scripts/admin-test-env-modules/booking-supplier-scope-contract.test.mjs b/scripts/admin-test-env-modules/booking-supplier-scope-contract.test.mjs new file mode 100644 index 000000000..4327c7d18 --- /dev/null +++ b/scripts/admin-test-env-modules/booking-supplier-scope-contract.test.mjs @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const supplierFixture = readFileSync(new URL('./suppliers.mjs', import.meta.url), 'utf8') +const financeFixture = readFileSync(new URL('./finance-full-fixtures.mjs', import.meta.url), 'utf8') +const supplierOpenapi = readFileSync(new URL('../../warehouse/src/main/resources/openapi/supplier-v1.yaml', import.meta.url), 'utf8') +const firstlegSnapshot = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/service/WarehouseSupplierSnapshotService.java', import.meta.url), 'utf8') +const firstlegClient = readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java', import.meta.url), 'utf8') + +test('booking supplier fixtures keep ACTIVE and BOOKING but never invent a geographic scope gate', () => { + assert.match(supplierFixture, /category:'BOOKING'/) + assert.match(supplierFixture, /status:'ACTIVE'/) + assert.doesNotMatch(supplierFixture, /scope\s*:/) + + const supplierBody = financeFixture.slice(financeFixture.indexOf('export function buildSupplierBody'), + financeFixture.indexOf('async function createOrder')) + assert.match(supplierBody, /category,/) + assert.match(supplierBody, /status: 'ACTIVE'/) + assert.doesNotMatch(supplierBody, /scope|DOMESTIC|INTERNATIONAL/) +}) + +test('Warehouse supplier contract filters by active category without scope in query, save or snapshot', () => { + assert.match(supplierOpenapi, /name: requireActive/) + assert.match(supplierOpenapi, /name: category/) + assert.match(supplierOpenapi, /required: \[supplierId, name, category, status\]/) + assert.doesNotMatch(supplierOpenapi, /\bScope\b|name: scope|\bscope:/) +}) + +test('Firstleg booking supplier snapshot requires ACTIVE BOOKING and has no domestic/international argument', () => { + assert.match(firstlegSnapshot, /return resolve\(supplierId, suppliedName, "BOOKING"\)/) + assert.match(firstlegSnapshot, /!"ACTIVE"\.equals\(response\.data\(\)\.status\(\)\)/) + assert.match(firstlegSnapshot, /!category\.equals\(response\.data\(\)\.category\(\)\)/) + assert.doesNotMatch(firstlegSnapshot, /"INTERNATIONAL"|"DOMESTIC"|String scope/) + assert.doesNotMatch(firstlegClient, /String scope|String category,\s*String scope/s) +}) diff --git a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs index 097ade99d..a14901945 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs @@ -45,7 +45,7 @@ test('PAYABLE fee selection uses the formal feeCode and feeTypeName fields', () test('Finance PAYABLE fixture creates an isolated ACTIVE supplier through the formal API', () => { const body = buildSupplierBody('ADMIN-API-20260815-FINANCE', 'BOOKING') assert.equal(body.category, 'BOOKING') - assert.equal(body.scope, 'DOMESTIC') + assert.equal(Object.hasOwn(body, 'scope'), false) assert.equal(body.status, 'ACTIVE') assert.match(body.name, /^财务验收供应商-/) assert.match(source, /POST', '\/api\/platform\/v1\/suppliers\?tenantId=0'/) diff --git a/scripts/admin-test-env-modules/finance-full-fixtures.mjs b/scripts/admin-test-env-modules/finance-full-fixtures.mjs index f1b3c0794..c4dd4f788 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures.mjs @@ -41,7 +41,6 @@ export function buildSupplierBody(marker, category) { return { name: `财务验收供应商-${suffix}`, category, - ...(category === 'BOOKING' ? { scope: 'DOMESTIC' } : {}), region: '宁波', legalRepresentative: '财务验收法人', contactName: '财务验收联系人', phone: '13800000000', email: `finance-supplier-${suffix || 'fixture'}@example.com`, address: '财务验收地址', businessLicenseNo: `FIN${suffix}`.slice(0, 64), diff --git a/scripts/admin-test-env-modules/phase3-customs.mjs b/scripts/admin-test-env-modules/phase3-customs.mjs index d868351eb..ead7523a2 100644 --- a/scripts/admin-test-env-modules/phase3-customs.mjs +++ b/scripts/admin-test-env-modules/phase3-customs.mjs @@ -277,7 +277,7 @@ export async function run(context) { `status=${approved?.customsCase?.status || 'missing'}, version=${approved?.customsCase?.version ?? 'missing'}`, OPTIONS) const supplierCreateResponse = await context.request('POST', '/api/platform/v1/suppliers?tenantId=0', { - name: `报关代理-${context.marker}`.slice(0, 128), category: 'CUSTOMS', scope: null, region: '深圳', + name: `报关代理-${context.marker}`.slice(0, 128), category: 'CUSTOMS', region: '深圳', legalRepresentative: '验收法人', contactName: '报关联系人', phone: '13800000000', email: 'customs-acceptance@example.com', address: '深圳市验收地址', businessLicenseNo: `CUSTOMS${context.marker.replace(/\D/g, '')}`.slice(0, 64), licenseFileId: null, diff --git a/scripts/admin-test-env-modules/suppliers-contract.test.mjs b/scripts/admin-test-env-modules/suppliers-contract.test.mjs index f86e82d51..c31577730 100644 --- a/scripts/admin-test-env-modules/suppliers-contract.test.mjs +++ b/scripts/admin-test-env-modules/suppliers-contract.test.mjs @@ -6,11 +6,13 @@ const orchestrator=readFileSync(new URL('../admin-test-env-acceptance.mjs',impor const openapi=readFileSync(new URL('../../warehouse/src/main/resources/openapi/supplier-v1.yaml',import.meta.url),'utf8') test('supplier acceptance covers full CRUD-like lifecycle and boundary failures',()=>{ - for(const marker of ['列表分页与订舱筛选','查询四类供应商统计','新增供应商','查询供应商详情','编辑供应商','停用供应商','重复名称返回冲突','非订舱分类携带scope被拒绝','tenantId非0被拒绝','客户账号调用平台供应商接口被拒绝','requestId','traceId']) assert.match(source,new RegExp(marker)) + for(const marker of ['列表分页与订舱筛选','查询四类供应商统计','新增供应商','查询供应商详情','编辑供应商','停用供应商','重复名称返回冲突','tenantId非0被拒绝','客户账号调用平台供应商接口被拒绝','requestId','traceId']) assert.match(source,new RegExp(marker)) + assert.doesNotMatch(source,/scope\s*:/) }) test('supplier module is registered in the default full acceptance orchestration',()=>assert.match(orchestrator,/CONFIGURED_MODULES = \[[^\]]*'suppliers'/s)) test('supplier fixture is retained only as inactive because API has no delete contract',()=>{assert.match(source,/finalStatus:inactive\?'INACTIVE'/);assert.doesNotMatch(source,/context\.request\('DELETE'/)}) -test('supplier OpenAPI exposes the internal active category and booking scope validation contract',()=>{ +test('supplier OpenAPI exposes the internal active category validation contract without scope',()=>{ for(const marker of ['/internal/warehouse/v1/suppliers/{supplierId}','X-Internal-Service-Token','requireActive','InternalSnapshot','SupplierPageResponse','platform:supplier:list']) assert.match(openapi,new RegExp(marker.replace(/[{}]/g,'\\$&'))) + assert.doesNotMatch(openapi,/\bscope\b/) assert.doesNotMatch(openapi,/platform:supplier:view/) }) diff --git a/scripts/admin-test-env-modules/suppliers.mjs b/scripts/admin-test-env-modules/suppliers.mjs index e1f499bdc..abb38b91a 100644 --- a/scripts/admin-test-env-modules/suppliers.mjs +++ b/scripts/admin-test-env-modules/suppliers.mjs @@ -10,7 +10,7 @@ function records(page) { return Array.isArray(page?.records) ? page.records : [] function id(value) { return value == null ? '' : String(value) } function traced(response) { return Boolean(response?.requestId && response?.traceId) } function query(values = {}) { const p = new URLSearchParams(); for (const [k,v] of Object.entries(values)) if (v !== '' && v != null) p.set(k,String(v)); return `${BASE}${p.size ? `?${p}` : ''}` } -function payload(marker, overrides = {}) { return { name:`供应商验收-${marker}`,category:'BOOKING',scope:'DOMESTIC',region:'宁波',legalRepresentative:'验收法人',contactName:'验收联系人',phone:'13800000000',email:'supplier-acceptance@example.com',address:'验收地址',businessLicenseNo:`ACPT${marker}`.slice(0,64),licenseFileId:null,status:'ACTIVE',settlementPeriod:'MONTHLY_30',remark:marker,...overrides } } +function payload(marker, overrides = {}) { return { name:`供应商验收-${marker}`,category:'BOOKING',region:'宁波',legalRepresentative:'验收法人',contactName:'验收联系人',phone:'13800000000',email:'supplier-acceptance@example.com',address:'验收地址',businessLicenseNo:`ACPT${marker}`.slice(0,64),licenseFileId:null,status:'ACTIVE',settlementPeriod:'MONTHLY_30',remark:marker,...overrides } } export async function run(context) { const marker=context.marker.replace(/[^0-9A-Za-z]/g,'').slice(-20) @@ -31,14 +31,11 @@ export async function run(context) { try { const detailResponse=await context.request('GET',`${BASE}/${encodeURIComponent(supplierId)}?tenantId=0`) const detail=context.apiSuccess(MODULE,'查询供应商详情',detailResponse,'',OPTIONS) - context.assert(MODULE,'详情字段与平台作用域正确',detailResponse,id(detail?.id)===supplierId&&id(detail?.tenantId)==='0'&&detail?.name===createBody.name&&detail?.scope==='DOMESTIC',`id=${id(detail?.id)}, tenantId=${id(detail?.tenantId)}`,OPTIONS) + context.assert(MODULE,'详情字段与平台作用域正确',detailResponse,id(detail?.id)===supplierId&&id(detail?.tenantId)==='0'&&detail?.name===createBody.name&&!Object.hasOwn(detail,'scope'),`id=${id(detail?.id)}, tenantId=${id(detail?.tenantId)}, scopeExposed=${Object.hasOwn(detail||{},'scope')}`,OPTIONS) const duplicateResponse=await context.request('POST',`${BASE}?tenantId=0`,payload(`${marker}D`,{name:createBody.name})) context.assert(MODULE,'重复名称返回冲突并保留链路ID',duplicateResponse,code(duplicateResponse,'A0409')&&traced(duplicateResponse),`code=${duplicateResponse.body?.code}, requestId=${duplicateResponse.requestId||'missing'}, traceId=${duplicateResponse.traceId||'missing'}`,OPTIONS) - const scopeResponse=await context.request('POST',`${BASE}?tenantId=0`,payload(`${marker}S`,{name:`非法范围-${marker}`,category:'CUSTOMS',scope:'DOMESTIC'})) - context.assert(MODULE,'非订舱分类携带scope被拒绝',scopeResponse,code(scopeResponse,'A0422')&&traced(scopeResponse),`code=${scopeResponse.body?.code}, requestId=${scopeResponse.requestId||'missing'}, traceId=${scopeResponse.traceId||'missing'}`,OPTIONS) - const tenantResponse=await context.request('GET',query({tenantId:1,page:1,size:20})) context.assert(MODULE,'tenantId非0被拒绝',tenantResponse,code(tenantResponse,'A0400')&&traced(tenantResponse),`code=${tenantResponse.body?.code}, requestId=${tenantResponse.requestId||'missing'}, traceId=${tenantResponse.traceId||'missing'}`,OPTIONS) @@ -47,12 +44,12 @@ export async function run(context) { const forbidden=merchantToken?await context.request('GET',query({tenantId:0,page:1,size:20}),undefined,merchantToken):merchantLogin context.assert(MODULE,'客户账号调用平台供应商接口被拒绝',forbidden,Boolean(merchantToken)&&(forbidden.http===403||code(forbidden,'A0403'))&&traced(forbidden),`HTTP=${forbidden.http}, code=${forbidden.body?.code??'missing'}, requestId=${forbidden.requestId||'missing'}, traceId=${forbidden.traceId||'missing'}`,OPTIONS) - const updateBody=payload(marker,{name:`供应商验收已编辑-${marker}`,scope:'INTERNATIONAL',contactName:'编辑联系人',settlementPeriod:'MONTHLY_45'}) + const updateBody=payload(marker,{name:`供应商验收已编辑-${marker}`,contactName:'编辑联系人',settlementPeriod:'MONTHLY_45'}) const updateResponse=await context.request('PUT',`${BASE}/${encodeURIComponent(supplierId)}?tenantId=0`,updateBody) context.apiSuccess(MODULE,'编辑供应商',updateResponse,'',OPTIONS) const editedResponse=await context.request('GET',`${BASE}/${encodeURIComponent(supplierId)}?tenantId=0`) const edited=context.apiSuccess(MODULE,'回查编辑结果',editedResponse,'',OPTIONS) - context.assert(MODULE,'编辑字段已持久化',editedResponse,edited?.name===updateBody.name&&edited?.scope==='INTERNATIONAL'&&edited?.contactName==='编辑联系人'&&edited?.settlementPeriod==='MONTHLY_45',`name=${edited?.name||'missing'}`,OPTIONS) + context.assert(MODULE,'编辑字段已持久化',editedResponse,edited?.name===updateBody.name&&!Object.hasOwn(edited,'scope')&&edited?.contactName==='编辑联系人'&&edited?.settlementPeriod==='MONTHLY_45',`name=${edited?.name||'missing'}, scopeExposed=${Object.hasOwn(edited||{},'scope')}`,OPTIONS) const disableResponse=await context.request('PATCH',`${BASE}/${encodeURIComponent(supplierId)}/status?tenantId=0`,{status:'INACTIVE'}) context.apiSuccess(MODULE,'停用供应商',disableResponse,'',OPTIONS); inactive=ok(disableResponse) diff --git a/scripts/admin-test-env-modules/truck-appointments.mjs b/scripts/admin-test-env-modules/truck-appointments.mjs index 5ca4c3854..b92de6302 100644 --- a/scripts/admin-test-env-modules/truck-appointments.mjs +++ b/scripts/admin-test-env-modules/truck-appointments.mjs @@ -88,7 +88,7 @@ export function truckAppointmentPayload(context, suffix, identity, version = 0) async function ensureTruckSupplier(context, suffix) { const response = await request(context, 'POST', SUPPLIER_BASE, { - name: `拖车供应商-${suffix}`.slice(0, 128), category: 'TRUCKING', scope: null, + name: `拖车供应商-${suffix}`.slice(0, 128), category: 'TRUCKING', region: '深圳', legalRepresentative: '验收法人', contactName: '拖车联系人', phone: '13800000000', email: 'trucking-acceptance@example.com', address: '深圳市验收地址', businessLicenseNo: `TRUCK${suffix}`.slice(0, 64), licenseFileId: null, diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalSupplierController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalSupplierController.java index 975003125..380cda465 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalSupplierController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalSupplierController.java @@ -22,11 +22,9 @@ public class InternalSupplierController { @GetMapping("/{supplierId}") public ApiResponse get(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, @PathVariable long supplierId, @RequestParam(defaultValue="false") boolean requireActive, - @RequestParam(required=false) String category, @RequestParam(required=false) String scope) { + @RequestParam(required=false) String category) { if (token == null || !MessageDigest.isEqual(expected, token.getBytes(StandardCharsets.UTF_8))) throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); if (category != null && !category.matches("BOOKING|CUSTOMS|TRUCKING|CERTIFICATE")) throw new BizException(ResultCode.VALIDATION_FAILED, "供应商类别无效"); - if (scope != null && !scope.matches("DOMESTIC|INTERNATIONAL")) throw new BizException(ResultCode.VALIDATION_FAILED, "订舱范围无效"); - if (scope != null && !"BOOKING".equals(category)) throw new BizException(ResultCode.VALIDATION_FAILED, "scope仅适用于BOOKING"); - return ApiResponse.success(service.internalSnapshot(supplierId, requireActive, category, scope)); + return ApiResponse.success(service.internalSnapshot(supplierId, requireActive, category)); } } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/SupplierDtos.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/SupplierDtos.java index c96a682af..7b512c60b 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/dto/SupplierDtos.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/SupplierDtos.java @@ -13,7 +13,6 @@ public final class SupplierDtos { public record PageQuery(@Min(1) Integer page, @Min(1) @Max(200) Integer size, @Pattern(regexp = "BOOKING|CUSTOMS|TRUCKING|CERTIFICATE") String category, - @Pattern(regexp = "DOMESTIC|INTERNATIONAL") String scope, @Size(max = 80) String region, @Pattern(regexp = "ACTIVE|INACTIVE") String status, String keyword) { @@ -26,7 +25,6 @@ public final class SupplierDtos { public record SaveRequest( @NotBlank @Size(max = 160) String name, @NotBlank @Pattern(regexp = "BOOKING|CUSTOMS|TRUCKING|CERTIFICATE") String category, - @Pattern(regexp = "DOMESTIC|INTERNATIONAL") String scope, @NotBlank @Size(max = 80) String region, @NotBlank @Size(max = 80) String legalRepresentative, @NotBlank @Size(max = 80) String contactName, @@ -42,5 +40,5 @@ public final class SupplierDtos { public record StatusRequest(@NotNull @Pattern(regexp = "ACTIVE|INACTIVE") String status) {} public record Stats(long booking, long customs, long trucking, long certificate) {} - public record InternalSnapshot(long supplierId, String name, String category, String scope, String status) {} + public record InternalSnapshot(long supplierId, String name, String category, String status) {} } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/SupplierEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/SupplierEntity.java index 610c63ac9..af3501d59 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/entity/SupplierEntity.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/SupplierEntity.java @@ -19,7 +19,6 @@ public class SupplierEntity extends BaseEntity { private Long tenantId; private String name; private String category; - private String scope; private String region; private String legalRepresentative; private String contactName; diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/SupplierRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/SupplierRepository.java index dd0e81b0e..6ef7f5164 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/SupplierRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/SupplierRepository.java @@ -17,7 +17,6 @@ public class SupplierRepository { public PageResult page(long tenantId, PageQuery query) { LambdaQueryWrapper w = base(tenantId) .eq(StringUtils.hasText(query.category()), SupplierEntity::getCategory, query.category()) - .eq(StringUtils.hasText(query.scope()), SupplierEntity::getScope, query.scope()) .eq(StringUtils.hasText(query.region()), SupplierEntity::getRegion, query.region()) .eq(StringUtils.hasText(query.status()), SupplierEntity::getStatus, query.status()) .and(StringUtils.hasText(query.keyword()), c -> c.like(SupplierEntity::getName, query.keyword()) diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/SupplierService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/SupplierService.java index 1cf0c6f14..5094afbe9 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/SupplierService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/SupplierService.java @@ -18,8 +18,6 @@ public class SupplierService { @Resource private SupplierRepository repository; public PageResult page(PageQuery query) { - if (query.category() == null && query.scope() != null) throw new BizException(ResultCode.VALIDATION_FAILED, "scope仅适用于订舱供应商"); - if (query.category() != null && !"BOOKING".equals(query.category()) && query.scope() != null) throw new BizException(ResultCode.VALIDATION_FAILED, "scope仅适用于订舱供应商"); return repository.page(PUBLIC_TENANT_ID, query); } public SupplierEntity detail(long id) { return owned(id); } @@ -28,7 +26,6 @@ public class SupplierService { @Transactional @BusinessAudit(event = "SUPPLIER_CREATE", resourceType = "SUPPLIER", resourceId = "#id", description = "新增供应商", captureBefore = false) public Long create(long id, SaveRequest request) { - validateScope(request); ensureName(request.name(), null); SupplierEntity entity = new SupplierEntity(); entity.setId(id); entity.setTenantId(0L); copy(entity, request); try { repository.insert(entity); } catch (DataIntegrityViolationException e) { throw new BizException(ResultCode.CONFLICT, "供应商名称已存在"); } @@ -38,7 +35,7 @@ public class SupplierService { @Transactional @BusinessAudit(event = "SUPPLIER_UPDATE", resourceType = "SUPPLIER", resourceId = "#id", description = "编辑供应商") public void update(long id, SaveRequest request) { - SupplierEntity e = owned(id); validateScope(request); ensureName(request.name(), id); copy(e, request); + SupplierEntity e = owned(id); ensureName(request.name(), id); copy(e, request); try { repository.save(e); } catch (DataIntegrityViolationException exception) { throw new BizException(ResultCode.CONFLICT, "供应商名称已存在"); } } @@ -46,16 +43,14 @@ public class SupplierService { @BusinessAudit(event = "SUPPLIER_STATUS_CHANGE", resourceType = "SUPPLIER", resourceId = "#id", description = "启停供应商") public void changeStatus(long id, StatusRequest request) { SupplierEntity e = owned(id); if (!request.status().equals(e.getStatus())) { e.setStatus(request.status()); repository.save(e); } } - public InternalSnapshot internalSnapshot(long id, boolean requireActive, String category, String scope) { + public InternalSnapshot internalSnapshot(long id, boolean requireActive, String category) { SupplierEntity e = owned(id); if (requireActive && !"ACTIVE".equals(e.getStatus())) throw new BizException(ResultCode.CONFLICT, "供应商未启用"); if (category != null && !category.equals(e.getCategory())) throw new BizException(ResultCode.VALIDATION_FAILED, "供应商类别不匹配"); - if (scope != null && (!"BOOKING".equals(e.getCategory()) || !scope.equals(e.getScope()))) throw new BizException(ResultCode.VALIDATION_FAILED, "订舱范围不匹配"); - return new InternalSnapshot(e.getId(), e.getName(), e.getCategory(), e.getScope(), e.getStatus()); + return new InternalSnapshot(e.getId(), e.getName(), e.getCategory(), e.getStatus()); } private SupplierEntity owned(long id) { SupplierEntity e = repository.find(0L, id); if (e == null) throw new BizException(ResultCode.NOT_FOUND, "供应商不存在"); return e; } private void ensureName(String name, Long id) { if (repository.existsName(0L, name, id)) throw new BizException(ResultCode.CONFLICT, "供应商名称已存在"); } - private static void validateScope(SaveRequest r) { if (r.category().equals("BOOKING") != (r.scope() != null)) throw new BizException(ResultCode.VALIDATION_FAILED, "国内/国外范围仅适用于订舱供应商"); } - private static void copy(SupplierEntity e, SaveRequest r) { e.setName(r.name()); e.setCategory(r.category()); e.setScope(r.scope()); e.setRegion(r.region()); e.setLegalRepresentative(r.legalRepresentative()); e.setContactName(r.contactName()); e.setPhone(r.phone()); e.setEmail(r.email()); e.setAddress(r.address()); e.setBusinessLicenseNo(r.businessLicenseNo()); e.setLicenseFileId(r.licenseFileId()); e.setStatus(r.status()); e.setSettlementPeriod(r.settlementPeriod()); e.setRemark(r.remark()); } + private static void copy(SupplierEntity e, SaveRequest r) { e.setName(r.name()); e.setCategory(r.category()); e.setRegion(r.region()); e.setLegalRepresentative(r.legalRepresentative()); e.setContactName(r.contactName()); e.setPhone(r.phone()); e.setEmail(r.email()); e.setAddress(r.address()); e.setBusinessLicenseNo(r.businessLicenseNo()); e.setLicenseFileId(r.licenseFileId()); e.setStatus(r.status()); e.setSettlementPeriod(r.settlementPeriod()); e.setRemark(r.remark()); } } diff --git a/warehouse/src/main/resources/db/warehouse-supplier.sql b/warehouse/src/main/resources/db/warehouse-supplier.sql index 337e7e451..524af64a3 100644 --- a/warehouse/src/main/resources/db/warehouse-supplier.sql +++ b/warehouse/src/main/resources/db/warehouse-supplier.sql @@ -7,7 +7,6 @@ CREATE TABLE IF NOT EXISTS wh_supplier ( tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共作用域,固定为0', name VARCHAR(160) NOT NULL COMMENT '供应商名称', category VARCHAR(20) NOT NULL COMMENT '供应商类别:BOOKING订舱、CUSTOMS报关、TRUCKING拖车、CERTIFICATE认证', - scope VARCHAR(20) NULL COMMENT '订舱分类范围:DOMESTIC/INTERNATIONAL', region VARCHAR(80) NOT NULL COMMENT '地区', legal_representative VARCHAR(80) NOT NULL COMMENT '法人代表', contact_name VARCHAR(80) NOT NULL COMMENT '联系人', @@ -30,7 +29,6 @@ CREATE TABLE IF NOT EXISTS wh_supplier ( KEY idx_wh_supplier_filter (tenant_id,category,status,region), CONSTRAINT chk_wh_supplier_category CHECK (category IN ('BOOKING','CUSTOMS','TRUCKING','CERTIFICATE')), CONSTRAINT chk_wh_supplier_tenant CHECK (tenant_id = 0), - CONSTRAINT chk_wh_supplier_scope CHECK ((category='BOOKING' AND scope IN ('DOMESTIC','INTERNATIONAL')) OR (category<>'BOOKING' AND scope IS NULL)), CONSTRAINT chk_wh_supplier_status CHECK (status IN ('ACTIVE','INACTIVE')), CONSTRAINT chk_wh_supplier_settlement CHECK (settlement_period IN ('MONTHLY_30','MONTHLY_45','MONTHLY_60','MONTHLY_90','PER_TRANSACTION','IMMEDIATE')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台公共供应商业务档案'; @@ -46,7 +44,6 @@ BEGIN END IF; END$$ DELIMITER ; -CALL supplier_add_column_if_missing('scope', "VARCHAR(20) NULL COMMENT '订舱分类范围:DOMESTIC/INTERNATIONAL' AFTER category"); CALL supplier_add_column_if_missing('license_file_id', "VARCHAR(128) NULL COMMENT 'File服务营业执照文件ID' AFTER business_license_no"); CALL supplier_add_column_if_missing('created_by', "BIGINT UNSIGNED NULL COMMENT '创建人ID'"); CALL supplier_add_column_if_missing('created_at', "DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间'"); @@ -73,7 +70,6 @@ END$$ DELIMITER ; CALL supplier_add_check_if_missing('chk_wh_supplier_category', "category IN ('BOOKING','CUSTOMS','TRUCKING','CERTIFICATE')"); CALL supplier_add_check_if_missing('chk_wh_supplier_tenant', "tenant_id = 0"); -CALL supplier_add_check_if_missing('chk_wh_supplier_scope', "(category='BOOKING' AND scope IN ('DOMESTIC','INTERNATIONAL')) OR (category<>'BOOKING' AND scope IS NULL)"); CALL supplier_add_check_if_missing('chk_wh_supplier_status', "status IN ('ACTIVE','INACTIVE')"); CALL supplier_add_check_if_missing('chk_wh_supplier_settlement', "settlement_period IN ('MONTHLY_30','MONTHLY_45','MONTHLY_60','MONTHLY_90','PER_TRANSACTION','IMMEDIATE')"); DROP PROCEDURE supplier_add_check_if_missing; @@ -84,7 +80,7 @@ CREATE PROCEDURE assert_supplier_schema() BEGIN IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier') <> 1 OR (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND index_name IN ('PRIMARY','uk_wh_supplier_name','idx_wh_supplier_filter')) <> 3 - OR (SELECT COUNT(*) FROM information_schema.table_constraints WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND constraint_type='CHECK' AND constraint_name IN ('chk_wh_supplier_category','chk_wh_supplier_tenant','chk_wh_supplier_scope','chk_wh_supplier_status','chk_wh_supplier_settlement')) <> 5 THEN + OR (SELECT COUNT(*) FROM information_schema.table_constraints WHERE table_schema='baiyue_warehouse' AND table_name='wh_supplier' AND constraint_type='CHECK' AND constraint_name IN ('chk_wh_supplier_category','chk_wh_supplier_tenant','chk_wh_supplier_status','chk_wh_supplier_settlement')) <> 4 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='供应商表、索引或约束初始化不完整'; END IF; END$$ diff --git a/warehouse/src/main/resources/openapi/supplier-frontend-handoff.md b/warehouse/src/main/resources/openapi/supplier-frontend-handoff.md index caa72e29c..44d7e7b0b 100644 --- a/warehouse/src/main/resources/openapi/supplier-frontend-handoff.md +++ b/warehouse/src/main/resources/openapi/supplier-frontend-handoff.md @@ -45,7 +45,6 @@ | `page` | integer | 否 | 默认 `1`,最小 `1` | | `size` | integer | 否 | 默认 `20`,范围 `1..200` | | `category` | enum | 否 | 供应商分类 | -| `scope` | enum | 否 | 仅订舱分类适用 | | `region` | string | 否 | 地区精确匹配,最长 80 | | `status` | enum | 否 | 合作状态 | | `keyword` | string | 否 | 按供应商名称或联系人模糊匹配 | @@ -72,7 +71,6 @@ | `tenantId` | string | 固定为 `0` | | `name` | string | 供应商名称,平台内唯一 | | `category` | enum | 分类 | -| `scope` | enum/null | 订舱供应商必填;其他分类为 `null` | | `region` | string | 地区 | | `legalRepresentative` | string | 法人代表 | | `contactName` | string | 联系人 | @@ -101,7 +99,6 @@ { "name": "示例供应商有限公司", "category": "BOOKING", - "scope": "DOMESTIC", "region": "宁波", "legalRepresentative": "张三", "contactName": "李经理", @@ -116,7 +113,7 @@ } ``` -必填字段:`name`、`category`、`region`、`legalRepresentative`、`contactName`、`phone`、`status`、`settlementPeriod`。`BOOKING` 时 `scope` 必填;其他分类 `scope` 必须传 `null` 或省略。 +必填字段:`name`、`category`、`region`、`legalRepresentative`、`contactName`、`phone`、`status`、`settlementPeriod`。订舱供应商不区分国内或国外。 新增成功的 `data` 是供应商 ID 字符串。编辑成功的 `data` 为 `null`。 @@ -152,13 +149,6 @@ | `TRUCKING` | 拖车 | | `CERTIFICATE` | 证书 | -订舱范围: - -| 值 | 展示文案 | -|---|---| -| `DOMESTIC` | 国内 | -| `INTERNATIONAL` | 国外 | - 合作状态: | 值 | 展示文案 | @@ -187,7 +177,7 @@ | `A0403` | 非平台用户或缺少对应权限 | 显示无权限 | | `A0404` | 供应商不存在 | 提示记录不存在并刷新列表 | | `A0409` | 供应商名称重复或并发唯一键冲突 | 保留表单并提示名称已存在 | -| `A0422` | 字段校验失败、订舱范围规则不满足 | 使用 `message`/`data` 显示字段错误 | +| `A0422` | 字段校验失败 | 使用 `message`/`data` 显示字段错误 | | `S0500` | 未预期服务异常 | 展示通用错误并记录 `requestId/traceId` | | `S0503` | Warehouse 数据库不可用 | 展示稍后重试并记录 `requestId/traceId` | diff --git a/warehouse/src/main/resources/openapi/supplier-v1.yaml b/warehouse/src/main/resources/openapi/supplier-v1.yaml index 17d3ddd9d..141bafd47 100644 --- a/warehouse/src/main/resources/openapi/supplier-v1.yaml +++ b/warehouse/src/main/resources/openapi/supplier-v1.yaml @@ -11,7 +11,6 @@ paths: - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } - { name: category, in: query, schema: { $ref: '#/components/schemas/Category' } } - - { name: scope, in: query, description: 仅订舱供应商适用, schema: { $ref: '#/components/schemas/Scope' } } - { name: region, in: query, schema: { type: string } } - { name: status, in: query, schema: { $ref: '#/components/schemas/Status' } } - { name: keyword, in: query, description: 供应商名称或联系人, schema: { type: string } } @@ -57,13 +56,12 @@ paths: /internal/warehouse/v1/suppliers/{supplierId}: get: summary: 内部服务查询并校验供应商快照 - description: 仅服务间调用,需 X-Internal-Service-Token;不经 Gateway。可要求 ACTIVE,并校验类别与订舱范围。 + description: 仅服务间调用,需 X-Internal-Service-Token;不经 Gateway。可要求 ACTIVE,并校验供应商类别。 security: [{ internalServiceToken: [] }] parameters: - { name: supplierId, in: path, required: true, schema: { type: integer, format: int64, minimum: 1 } } - { name: requireActive, in: query, schema: { type: boolean, default: false } } - { name: category, in: query, schema: { $ref: '#/components/schemas/Category' } } - - { name: scope, in: query, schema: { $ref: '#/components/schemas/Scope' } } responses: '200': { description: 成功, content: { application/json: { schema: { $ref: '#/components/schemas/InternalSnapshotResponse' } } } } '403': { $ref: '#/components/responses/Forbidden' } @@ -74,7 +72,6 @@ components: RequiredTenantId: { name: tenantId, in: query, required: true, schema: { type: integer, format: int64, enum: [0] } } schemas: Category: { type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE] } - Scope: { type: string, enum: [DOMESTIC, INTERNATIONAL] } Status: { type: string, enum: [ACTIVE, INACTIVE] } SupplierSave: type: object @@ -82,7 +79,6 @@ components: properties: name: { type: string, maxLength: 160 } category: { $ref: '#/components/schemas/Category' } - scope: { $ref: '#/components/schemas/Scope', description: BOOKING 必填,其他分类必须为空 } region: { type: string, maxLength: 80 } legalRepresentative: { type: string, maxLength: 80 } contactName: { type: string, maxLength: 80 } @@ -140,7 +136,6 @@ components: supplierId: { type: integer, format: int64 } name: { type: string } category: { $ref: '#/components/schemas/Category' } - scope: { $ref: '#/components/schemas/Scope' } status: { $ref: '#/components/schemas/Status' } InternalSnapshotResponse: allOf: [{ $ref: '#/components/schemas/ApiResponse' }, { type: object, properties: { data: { $ref: '#/components/schemas/InternalSnapshot' } } }] diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalSupplierControllerContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalSupplierControllerContractTest.java new file mode 100644 index 000000000..d4e935580 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/InternalSupplierControllerContractTest.java @@ -0,0 +1,21 @@ +package com.baiyue.warehouse.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class InternalSupplierControllerContractTest { + @Test void validatesSupplierByActiveStatusAndCategoryWithoutBookingScope() { + assertThat(InternalSupplierController.class.getAnnotation(RequestMapping.class).value()) + .containsExactly("/internal/warehouse/v1/suppliers"); + Method get = Arrays.stream(InternalSupplierController.class.getDeclaredMethods()) + .filter(method -> method.getName().equals("get")) + .findFirst().orElseThrow(); + assertThat(Arrays.stream(get.getParameters()).map(parameter -> parameter.getName())) + .containsExactly("token", "supplierId", "requireActive", "category"); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/SupplierSqlContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/SupplierSqlContractTest.java index 71f649600..abe4f7e40 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/SupplierSqlContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/SupplierSqlContractTest.java @@ -8,17 +8,18 @@ import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; class SupplierSqlContractTest { - @Test void freezesPublicSupplierSchemaAndBookingScope() throws IOException { + @Test void freezesPublicSupplierSchemaWithoutBookingScope() throws IOException { try (var stream = getClass().getResourceAsStream("/db/warehouse-supplier.sql")) { assertThat(stream).isNotNull(); String sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8); assertThat(sql).contains("CREATE TABLE IF NOT EXISTS wh_supplier", "tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0", "chk_wh_supplier_tenant CHECK (tenant_id = 0)", - "category='BOOKING' AND scope IN ('DOMESTIC','INTERNATIONAL')", "license_file_id VARCHAR(128)", + "license_file_id VARCHAR(128)", "MONTHLY_30','MONTHLY_45','MONTHLY_60','MONTHLY_90','PER_TRANSACTION','IMMEDIATE", "created_by BIGINT UNSIGNED", "updated_by BIGINT UNSIGNED", "supplier_add_column_if_missing", "uk_wh_supplier_name", "idx_wh_supplier_filter", "assert_supplier_schema"); + assertThat(sql).doesNotContain("scope VARCHAR", "chk_wh_supplier_scope", "DOMESTIC/INTERNATIONAL"); } } @@ -27,6 +28,8 @@ class SupplierSqlContractTest { assertThat(sql).contains("CREATE TABLE IF NOT EXISTS wh_supplier", "uk_wh_supplier_name", "idx_wh_supplier_filter", "platform:supplier:list", "platform:supplier:create", "platform:supplier:edit", "platform:supplier:status", "assert_platform_supplier_permissions"); + String supplierSection = sql.substring(sql.indexOf("-- 平台公共供应商业务档案及权限")); + assertThat(supplierSection).doesNotContain("chk_wh_supplier_scope", "订舱分类范围"); } private static Path resolveUnifiedSql() { diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/SupplierServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/SupplierServiceTest.java index a6b564bf4..02d36189f 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/SupplierServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/SupplierServiceTest.java @@ -2,7 +2,6 @@ package com.baiyue.warehouse.service; import com.baiyue.common.core.exception.BizException; import com.baiyue.warehouse.dto.SupplierDtos.SaveRequest; -import com.baiyue.warehouse.dto.SupplierDtos.PageQuery; import com.baiyue.warehouse.dto.SupplierDtos.StatusRequest; import com.baiyue.warehouse.entity.SupplierEntity; import com.baiyue.warehouse.repository.SupplierRepository; @@ -13,8 +12,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -26,7 +25,7 @@ class SupplierServiceTest { private SaveRequest booking; @BeforeEach void setUp() { - booking = new SaveRequest("测试供应商", "BOOKING", "DOMESTIC", "宁波", "法人", "联系人", + booking = new SaveRequest("测试供应商", "BOOKING", "宁波", "法人", "联系人", "13800000000", "supplier@example.com", "地址", "91330000TEST", "192000000000000001", "ACTIVE", "MONTHLY_30", null); } @@ -37,24 +36,6 @@ class SupplierServiceTest { verify(repository).insert(any(SupplierEntity.class)); } - @Test void rejectsScopeForNonBookingSupplier() { - SaveRequest customs = new SaveRequest("报关供应商", "CUSTOMS", "DOMESTIC", "宁波", "法人", "联系人", - "13800000000", null, null, null, null, "ACTIVE", "PER_TRANSACTION", null); - assertThatThrownBy(() -> service.create(101L, customs)).isInstanceOf(BizException.class) - .hasMessageContaining("仅适用于订舱供应商"); - } - - @Test void requiresScopeForBookingSupplier() { - SaveRequest missingScope = new SaveRequest("订舱供应商", "BOOKING", null, "宁波", "法人", "联系人", - "13800000000", null, null, null, null, "ACTIVE", "PER_TRANSACTION", null); - assertThatThrownBy(() -> service.create(101L, missingScope)).isInstanceOf(BizException.class); - } - - @Test void rejectsScopeFilterWithoutBookingCategory() { - assertThatThrownBy(() -> service.page(new PageQuery(1, 20, "CUSTOMS", "DOMESTIC", null, null, null))) - .isInstanceOf(BizException.class).hasMessageContaining("scope仅适用于订舱供应商"); - } - @Test void repeatedStatusChangeIsIdempotent() { SupplierEntity entity = new SupplierEntity(); entity.setId(101L); entity.setTenantId(0L); entity.setStatus("ACTIVE"); when(repository.find(0L, 101L)).thenReturn(entity); @@ -64,9 +45,23 @@ class SupplierServiceTest { @Test void validatesInternalSupplierSnapshot() { SupplierEntity entity = new SupplierEntity(); entity.setId(101L); entity.setTenantId(0L); entity.setName("测试供应商"); - entity.setCategory("BOOKING"); entity.setScope("DOMESTIC"); entity.setStatus("ACTIVE"); + entity.setCategory("BOOKING"); entity.setStatus("ACTIVE"); when(repository.find(0L, 101L)).thenReturn(entity); - var snapshot = service.internalSnapshot(101L, true, "BOOKING", "DOMESTIC"); + var snapshot = service.internalSnapshot(101L, true, "BOOKING"); org.assertj.core.api.Assertions.assertThat(snapshot.supplierId()).isEqualTo(101L); + org.assertj.core.api.Assertions.assertThat(snapshot.category()).isEqualTo("BOOKING"); + org.assertj.core.api.Assertions.assertThat(snapshot.status()).isEqualTo("ACTIVE"); + } + + @Test void internalSnapshotRequiresActiveSupplierAndMatchingCategory() { + SupplierEntity inactive = new SupplierEntity(); inactive.setId(101L); inactive.setTenantId(0L); + inactive.setName("停用订舱供应商"); inactive.setCategory("BOOKING"); inactive.setStatus("INACTIVE"); + when(repository.find(0L, 101L)).thenReturn(inactive); + assertThatThrownBy(() -> service.internalSnapshot(101L, true, "BOOKING")) + .isInstanceOf(BizException.class).hasMessageContaining("未启用"); + + inactive.setStatus("ACTIVE"); + assertThatThrownBy(() -> service.internalSnapshot(101L, true, "CUSTOMS")) + .isInstanceOf(BizException.class).hasMessageContaining("类别不匹配"); } }