This commit is contained in:
2026-08-17 14:46:44 +08:00
parent b914bb7532
commit a34b5d5ba0
38 changed files with 249 additions and 115 deletions
@@ -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;
@@ -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) {}
}
@@ -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
@@ -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;
}
@@ -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");
+1 -1
View File
@@ -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 资源结算
@@ -14,8 +14,7 @@ public interface WarehouseSupplierClient {
ApiResponse<Snapshot> 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) {}
}
@@ -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<PickItem> 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) {
@@ -49,6 +49,8 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
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<LoadingPickTaskEntity>
</if>
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<LoadingPickTaskEntity>
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<LoadingPickTaskEntity>
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; }
@@ -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);
}
@@ -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())) {
@@ -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.}
@@ -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]}
@@ -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");
}
@@ -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:");
@@ -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);
}
@@ -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");
@@ -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
@@ -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
@@ -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("类别不匹配");
}
}
@@ -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)
})
@@ -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'/)
@@ -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),
@@ -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,
@@ -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/)
})
+4 -7
View File
@@ -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)
@@ -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,
@@ -22,11 +22,9 @@ public class InternalSupplierController {
@GetMapping("/{supplierId}")
public ApiResponse<InternalSnapshot> 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));
}
}
@@ -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) {}
}
@@ -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;
@@ -17,7 +17,6 @@ public class SupplierRepository {
public PageResult<SupplierEntity> page(long tenantId, PageQuery query) {
LambdaQueryWrapper<SupplierEntity> 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())
@@ -18,8 +18,6 @@ public class SupplierService {
@Resource private SupplierRepository repository;
public PageResult<SupplierEntity> 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()); }
}
@@ -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$$
@@ -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` |
@@ -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' } } }]
@@ -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");
}
}
@@ -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() {
@@ -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("类别不匹配");
}
}