优化
This commit is contained in:
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+9
-6
@@ -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]}
|
||||
|
||||
+5
-3
@@ -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");
|
||||
}
|
||||
|
||||
+7
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
+16
-6
@@ -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/)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
-4
@@ -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' } } }]
|
||||
|
||||
+21
@@ -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");
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -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("类别不匹配");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user