This commit is contained in:
2026-08-08 13:32:20 +08:00
parent 96788e10de
commit 3203adb5a7
73 changed files with 44331 additions and 26 deletions
@@ -3473,8 +3473,12 @@ BEGIN
END IF;
IF EXISTS (
SELECT 1 FROM iam_permission
WHERE (id = 5103040 AND permission_code <> 'platform:download:list')
OR (id = 5103041 AND permission_code <> 'platform:download:all')
WHERE (id = 5103040 AND NOT (
(permission_code = 'platform:download:list' AND parent_id = 5103000)
OR (permission_code = 'platform:location:list' AND parent_id = 5103000)))
OR (id = 5103041 AND NOT (
(permission_code = 'platform:download:all' AND parent_id = 5103040)
OR (permission_code = 'platform:location:create' AND parent_id = 5103040)))
OR (permission_code = 'platform:download:list' AND id <> 5103040)
OR (permission_code = 'platform:download:all' AND id <> 5103041)
) THEN
@@ -3952,6 +3956,7 @@ CREATE TABLE IF NOT EXISTS wh_overseas_storage_fee_rule (
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户账号ID,沿用tenant_id物理字段',
customer_name VARCHAR(128) NOT NULL COMMENT '客户公司名称快照',
currency CHAR(3) NOT NULL COMMENT 'ISO 4217币种三字码',
initialization_key VARCHAR(191) NULL COMMENT '初始化幂等键',
created_by BIGINT UNSIGNED NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID',
@@ -3973,8 +3978,10 @@ CREATE TABLE IF NOT EXISTS wh_overseas_storage_fee_tier (
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户账号ID,沿用tenant_id物理字段',
rule_id BIGINT UNSIGNED NOT NULL COMMENT '阶梯费用规则ID',
start_day SMALLINT UNSIGNED NOT NULL COMMENT '起始天,含边界',
end_day SMALLINT UNSIGNED NOT NULL COMMENT '结束天,含边界',
unit_price DECIMAL(18,6) NOT NULL COMMENT '仓储费标准',
end_day SMALLINT UNSIGNED NULL COMMENT '结束天,含边界;NULL表示以上',
fee_type VARCHAR(32) NOT NULL DEFAULT 'STORAGE' COMMENT '费用类型',
billing_unit VARCHAR(16) NOT NULL DEFAULT 'CBM' COMMENT '计费单位',
unit_price DECIMAL(18,6) NOT NULL COMMENT '仓储费标准,可为0',
sort_no SMALLINT UNSIGNED NOT NULL COMMENT '阶梯排序号',
created_by BIGINT UNSIGNED NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -3987,12 +3994,30 @@ CREATE TABLE IF NOT EXISTS wh_overseas_storage_fee_tier (
GENERATED ALWAYS AS (CASE WHEN deleted = 0 THEN start_day ELSE NULL END) STORED
COMMENT '未删除阶梯的起始天唯一键',
PRIMARY KEY (id),
UNIQUE KEY uk_overseas_fee_tier_start (rule_id, active_start_day),
UNIQUE KEY uk_overseas_fee_tier_start (rule_id, fee_type, active_start_day),
KEY idx_overseas_fee_tier_rule (tenant_id, rule_id, deleted, sort_no, id),
CONSTRAINT chk_overseas_fee_tier_days CHECK (start_day >= 1 AND end_day >= start_day),
CONSTRAINT chk_overseas_fee_tier_price CHECK (unit_price > 0)
CONSTRAINT chk_overseas_fee_tier_days CHECK (start_day >= 1 AND (end_day IS NULL OR end_day >= start_day)),
CONSTRAINT chk_overseas_fee_tier_price CHECK (unit_price >= 0)
) ENGINE=InnoDB COMMENT='海外仓阶梯仓储费明细';
-- 兼容已存在的旧版海外仓费用表:补齐新字段及免费档/无上限档约束。
ALTER TABLE wh_overseas_storage_fee_rule
ADD COLUMN IF NOT EXISTS initialization_key VARCHAR(191) NULL COMMENT '初始化幂等键';
ALTER TABLE wh_overseas_storage_fee_tier
ADD COLUMN IF NOT EXISTS fee_type VARCHAR(32) NOT NULL DEFAULT 'STORAGE' COMMENT '费用类型',
ADD COLUMN IF NOT EXISTS billing_unit VARCHAR(16) NOT NULL DEFAULT 'CBM' COMMENT '计费单位';
ALTER TABLE wh_overseas_storage_fee_tier
MODIFY COLUMN end_day SMALLINT UNSIGNED NULL COMMENT '结束天,含边界;NULL表示以上',
MODIFY COLUMN unit_price DECIMAL(18,6) NOT NULL COMMENT '仓储费标准,可为0';
ALTER TABLE wh_overseas_storage_fee_tier
DROP CHECK chk_overseas_fee_tier_days,
DROP CHECK chk_overseas_fee_tier_price,
ADD CONSTRAINT chk_overseas_fee_tier_days CHECK (start_day >= 1 AND (end_day IS NULL OR end_day >= start_day)),
ADD CONSTRAINT chk_overseas_fee_tier_price CHECK (unit_price >= 0);
ALTER TABLE wh_overseas_storage_fee_tier
DROP INDEX uk_overseas_fee_tier_start,
ADD UNIQUE KEY uk_overseas_fee_tier_start (rule_id, fee_type, active_start_day);
USE baiyue_iam;
INSERT INTO iam_permission (
id, tenant_id, app_code, permission_type, parent_id, permission_code,
@@ -0,0 +1,10 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.*; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.TruckAppointmentDtos.*; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import com.baiyue.firstleg.service.*; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime;
@Tag(name="拖车预约") @Validated @RestController @RequestMapping("/api/platform/v1/truck-appointments") public class PlatformTruckAppointmentController { @Resource TruckAppointmentService service;
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse<PageResult<TruckAppointmentEntity>> page(@AuthenticationPrincipal AuthenticatedUser u,@Valid Query q){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.page(q));}
@GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:view')") public ApiResponse<TruckAppointmentEntity> detail(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.detail(id));}
@PostMapping @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:create')") @BusinessAudit(event="TRUCK_APPOINTMENT_CREATE",resourceType="TRUCK_APPOINTMENT",captureBefore=false) public ApiResponse<TruckAppointmentEntity> create(@AuthenticationPrincipal AuthenticatedUser u,@Valid @RequestBody SaveRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.create(u.userId(),u.username(),r));}
@PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:edit')") public ApiResponse<TruckAppointmentEntity> edit(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable long id,@Valid @RequestBody SaveRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.edit(id,u.userId(),u.username(),r));}
@PostMapping("/{id}/{action}") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:operate')") @BusinessAudit(event="TRUCK_APPOINTMENT_ACTION",resourceType="TRUCK_APPOINTMENT") public ApiResponse<TruckAppointmentEntity> action(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable long id,@PathVariable String action,@RequestHeader(value="Idempotency-Key",required=false) String idem,@RequestHeader(value="X-Request-Hash",required=false) String hash,@Valid @RequestBody ActionRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.action(id,action.toUpperCase(),r,idem,hash,u.userId(),u.username()));}
@PostMapping("/{id}/reschedule") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:operate')") public ApiResponse<TruckAppointmentEntity> reschedule(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable long id,@RequestParam LocalDateTime plannedLoadingAt,@Valid @RequestBody ActionRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.reschedule(id,plannedLoadingAt,r,u.userId(),u.username()));}
}
@@ -0,0 +1,6 @@
package com.baiyue.firstleg.domain;
public enum TruckAppointmentStatus {
DRAFT, PENDING_DISPATCH, DISPATCHED, PICKUP_IN_PROGRESS, ARRIVED_WAREHOUSE,
LOADING, DEPARTED, COMPLETED, RESCHEDULED, EXCEPTION, CANCELLED
}
@@ -0,0 +1,7 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime;
public final class TruckAppointmentDtos { private TruckAppointmentDtos(){}
public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long containerBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} }
public record SaveRequest(@Positive Long outboundOrderId,@Positive Long oceanBookingId,@Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@Positive Long customerId,@Size(max=128) String customerName,@Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@DecimalMin("0") BigDecimal receivableAmount,@Size(max=8) String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){}
public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason){}
}
@@ -0,0 +1,5 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Getter; import lombok.Setter; import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_truck_appointment_attachment") public class TruckAppointmentAttachmentEntity {
@TableId(type=IdType.ASSIGN_ID) private Long id; private Long appointmentId; private Long fileId; private String attachmentType; private String fileName; private String fileHash; private String visibility; private Long uploadedBy; private LocalDateTime uploadedAt;
}
@@ -0,0 +1,54 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import com.baiyue.firstleg.domain.TruckAppointmentStatus;
@Getter @Setter @TableName("fl_truck_appointment")
public class TruckAppointmentEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String appointmentNo;
private Long outboundOrderId;
private Long oceanBookingId;
private Long containerBookingId;
private Long loadingPlanId;
private Long warehouseId;
private Long customerId;
private String customerName;
private Long carrierId;
private String carrierName;
private String fleetContact;
private String fleetPhone;
private String driverName;
private String driverPhone;
private String driverIdCard;
private String vehicleNo;
private String storeName;
private String storeContact;
private String storePhone;
private String storeAddress;
private String storeCity;
private String storeZipcode;
private Integer packageCount;
private BigDecimal grossWeightKg;
private BigDecimal volumeCbm;
private Integer palletCount;
private String poNo;
private LocalDateTime plannedLoadingAt;
private LocalDateTime actualLoadingAt;
private Integer transitMinutes;
private BigDecimal payableAmount;
private BigDecimal receivableAmount;
private String currency;
private TruckAppointmentStatus status;
private Integer revision;
private Integer version;
private String remark;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,5 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Getter; import lombok.Setter; import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_truck_appointment_history") public class TruckAppointmentHistoryEntity {
@TableId(type=IdType.ASSIGN_ID) private Long id; private Long appointmentId; private Integer revision; private String action; private String fromStatus; private String toStatus; private String reason; private Long operatorId; private String operatorName; private String requestId; private LocalDateTime createdAt;
}
@@ -0,0 +1,5 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Getter; import lombok.Setter; import java.time.LocalDateTime;
@Getter @Setter @TableName("fl_truck_appointment_operation") public class TruckAppointmentOperationEntity {
@TableId(type=IdType.ASSIGN_ID) private Long id; private Long appointmentId; private String idempotencyKey; private String requestHash; private String action; private String responseSnapshot; private LocalDateTime createdAt;
}
@@ -0,0 +1 @@
package com.baiyue.firstleg.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity; import org.apache.ibatis.annotations.Mapper; @Mapper public interface TruckAppointmentAttachmentMapper extends BaseMapper<TruckAppointmentAttachmentEntity>{}
@@ -0,0 +1 @@
package com.baiyue.firstleg.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.TruckAppointmentHistoryEntity; import org.apache.ibatis.annotations.Mapper; @Mapper public interface TruckAppointmentHistoryMapper extends BaseMapper<TruckAppointmentHistoryEntity>{}
@@ -0,0 +1,8 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.TruckAppointmentDtos.Query; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import org.apache.ibatis.annotations.*;
@Mapper public interface TruckAppointmentMapper extends BaseMapper<TruckAppointmentEntity>{
@Select("<script>SELECT * FROM fl_truck_appointment WHERE (#{q.keyword} IS NULL OR appointment_no LIKE CONCAT('%',#{q.keyword},'%') OR vehicle_no LIKE CONCAT('%',#{q.keyword},'%') OR carrier_name LIKE CONCAT('%',#{q.keyword},'%')) AND (#{q.status} IS NULL OR status=#{q.status}) AND (#{q.containerBookingId} IS NULL OR container_booking_id=#{q.containerBookingId}) ORDER BY updated_at DESC,id DESC</script>") Page<TruckAppointmentEntity> page(Page<TruckAppointmentEntity> p,@Param("q") Query q);
@Update("UPDATE fl_truck_appointment SET status=#{toStatus}, revision=revision+1, version=version+1, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND status=#{fromStatus} AND version=#{version}") int transition(@Param("id") long id,@Param("fromStatus") String from,@Param("toStatus") String to,@Param("version") int version);
@Update("UPDATE fl_truck_appointment SET planned_loading_at=#{at}, revision=revision+1, version=version+1, status='RESCHEDULED', remark=#{reason}, updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{id} AND version=#{version}") int reschedule(@Param("id") long id,@Param("at") java.time.LocalDateTime at,@Param("reason") String reason,@Param("version") int version);
@Update("UPDATE fl_truck_appointment SET vehicle_no=#{e.vehicleNo},driver_name=#{e.driverName},driver_phone=#{e.driverPhone},payable_amount=#{e.payableAmount},receivable_amount=#{e.receivableAmount},planned_loading_at=#{e.plannedLoadingAt},remark=#{e.remark},version=version+1,revision=revision+1,updated_at=CURRENT_TIMESTAMP(3) WHERE id=#{e.id} AND version=#{version}") int updateEdit(@Param("e") TruckAppointmentEntity e,@Param("version") int version);
}
@@ -0,0 +1 @@
package com.baiyue.firstleg.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.TruckAppointmentOperationEntity; import org.apache.ibatis.annotations.Mapper; @Mapper public interface TruckAppointmentOperationMapper extends BaseMapper<TruckAppointmentOperationEntity>{}
@@ -0,0 +1,65 @@
package com.baiyue.firstleg.oceanbooking.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.service.OceanBookingService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@Validated
@RestController
@Tag(name = "海运订舱")
@RequestMapping("/api/platform/v1/ocean-bookings")
public class PlatformOceanBookingController {
@Resource private OceanBookingService service;
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')")
public ApiResponse<PageResult<OceanBookingEntity>> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.page(query)); }
@GetMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:view')")
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(oceanBookingId)); }
@PostMapping @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:create')")
@BusinessAudit(event="OCEAN_BOOKING_CREATE", resourceType="OCEAN_BOOKING", description="创建海运订舱单", captureBefore=false)
public ApiResponse<Detail> create(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CreateRequest request) { FirstlegScopeGuard.platform(user, request.data().customerId()); return ApiResponse.success(service.create(user.userId(), user.username(), key, request)); }
@PutMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')")
@BusinessAudit(event="OCEAN_BOOKING_UPDATE", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="编辑海运订舱草稿")
public ApiResponse<Detail> updateDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpdateDraftRequest request) { FirstlegScopeGuard.platform(user, request.data().customerId()); return ApiResponse.success(service.updateDraft(oceanBookingId, user.userId(), user.username(), key, request)); }
@PostMapping("/{oceanBookingId}/submit") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:submit')")
@BusinessAudit(event="OCEAN_BOOKING_SUBMIT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="提交海运订舱")
public ApiResponse<Detail> submit(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.submit(oceanBookingId, user.userId(), user.username(), key, request)); }
@PostMapping("/{oceanBookingId}/confirm") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:confirm')")
@BusinessAudit(event="OCEAN_BOOKING_CONFIRM", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="确认海运订舱")
public ApiResponse<Detail> confirm(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.confirm(oceanBookingId, user.userId(), user.username(), key, request)); }
@PostMapping("/{oceanBookingId}/release") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:release')")
@BusinessAudit(event="OCEAN_BOOKING_RELEASE", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="确认放舱")
public ApiResponse<Detail> release(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.release(oceanBookingId, user.userId(), user.username(), key, request)); }
@PostMapping("/{oceanBookingId}/fail") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:confirm')")
@BusinessAudit(event="OCEAN_BOOKING_FAIL", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="标记海运订舱失败")
public ApiResponse<Detail> fail(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.fail(oceanBookingId, user.userId(), user.username(), key, request)); }
@PostMapping("/{oceanBookingId}/cancel") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:cancel')")
@BusinessAudit(event="OCEAN_BOOKING_CANCEL", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="取消海运订舱")
public ApiResponse<Detail> cancel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.cancel(oceanBookingId, user.userId(), user.username(), key, request)); }
@PostMapping("/{oceanBookingId}/change-request") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:change')")
@BusinessAudit(event="OCEAN_BOOKING_CHANGE_REQUEST", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="申请变更船期")
public ApiResponse<Detail> requestChange(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ChangeRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.requestChange(oceanBookingId, user.userId(), user.username(), key, request)); }
@PostMapping("/{oceanBookingId}/change-confirm") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:change')")
@BusinessAudit(event="OCEAN_BOOKING_CHANGE_CONFIRM", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="确认变更船期")
public ApiResponse<Detail> confirmChange(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.confirmChange(oceanBookingId, user.userId(), user.username(), key, request)); }
@PostMapping("/{oceanBookingId}/change-fail") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:change')")
@BusinessAudit(event="OCEAN_BOOKING_CHANGE_FAIL", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="标记变更船期失败")
public ApiResponse<Detail> failChange(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.failChange(oceanBookingId, user.userId(), user.username(), key, request)); }
@PostMapping("/{oceanBookingId}/allocations") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:allocate')")
@BusinessAudit(event="OCEAN_BOOKING_ALLOCATE_CONTAINER", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="分配订舱柜次")
public ApiResponse<Detail> allocate(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody AllocationRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.allocate(oceanBookingId, user.userId(), user.username(), key, request)); }
}
@@ -0,0 +1,11 @@
package com.baiyue.firstleg.oceanbooking.domain;
public enum OceanBookingStatus {
DRAFT,
SUBMITTED,
CONFIRMED,
RELEASED,
FAILED,
CHANGE_PENDING,
CANCELLED
}
@@ -0,0 +1,81 @@
package com.baiyue.firstleg.oceanbooking.dto;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
public final class OceanBookingDtos {
private OceanBookingDtos() {}
public record Query(@Positive Long customerId, @Size(max = 24) String status,
@Size(max = 128) String keyword, @Min(1) long page,
@Min(1) @Max(200) long size) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
public record PartyRequest(@NotBlank @Size(max = 24) String partyType,
@NotBlank @Size(max = 255) String partyName,
@Size(max = 1000) String address,
@Size(max = 128) String contactName,
@Size(max = 64) String contactPhone) {}
public record ContainerRequirementRequest(@NotBlank @Size(max = 32) String containerType,
@NotBlank @Size(max = 32) String containerKind,
@NotNull @Positive Integer quantity) {}
public record DraftData(@NotNull @Positive Long customerId,
@NotBlank @Size(max = 128) String customerName,
@Size(max = 128) String billOfLadingNo,
@Positive Long shippingCompanyId,
@Size(max = 128) String shippingCompanyName,
@Positive Long bookingAgentId,
@Size(max = 128) String bookingAgentName,
@Size(max = 128) String vesselName,
@Size(max = 64) String voyageNo,
@Size(max = 32) String departurePortCode,
@Size(max = 128) String departurePortName,
@Size(max = 32) String destinationPortCode,
@Size(max = 128) String destinationPortName,
@Size(max = 128) String placeOfDelivery,
@Size(max = 32) String serviceTerm,
@Size(max = 32) String freightTerm,
LocalDateTime estimatedSailingAt,
LocalDateTime estimatedArrivalAt,
@Size(max = 1000) String remark,
@Valid @NotEmpty @Size(max = 3) List<PartyRequest> parties,
@Valid @NotEmpty @Size(max = 20) List<ContainerRequirementRequest> containerRequirements) {}
public record CreateRequest(@NotNull @Min(0) Integer expectedVersion,
@Valid @NotNull DraftData data) {}
public record UpdateDraftRequest(@NotNull @Min(0) Integer expectedVersion,
@Valid @NotNull DraftData data) {}
public record ActionRequest(@NotNull @Min(0) Integer expectedVersion,
@Size(max = 1000) String reason) {}
public record ChangeRequest(@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 128) String vesselName,
@NotBlank @Size(max = 64) String voyageNo,
LocalDateTime estimatedSailingAt,
LocalDateTime estimatedArrivalAt,
@NotBlank @Size(max = 1000) String reason) {}
public record AllocationRequest(@NotNull @Positive Long requirementId,
@NotNull @Positive Long containerBookingId,
@NotNull @Min(0) Integer expectedVersion) {}
public record Detail(OceanBookingEntity oceanBooking,
List<OceanBookingPartyEntity> parties,
List<OceanBookingContainerRequirementEntity> containerRequirements,
List<OceanBookingContainerAllocationEntity> containerAllocations,
List<OceanBookingStatusHistoryEntity> histories) {}
}
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.oceanbooking.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_ocean_booking_container_allocation")
public class OceanBookingContainerAllocationEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long oceanBookingId;
private Long requirementId;
private Long containerBookingId;
private Long allocatedBy;
private String allocatedByName;
@TableField(fill = FieldFill.INSERT) private LocalDateTime allocatedAt;
}
@@ -0,0 +1,19 @@
package com.baiyue.firstleg.oceanbooking.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@TableName("fl_ocean_booking_container_requirement")
public class OceanBookingContainerRequirementEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long oceanBookingId;
private String containerType;
private String containerKind;
private Integer quantity;
private Integer sortNo;
}
@@ -0,0 +1,54 @@
package com.baiyue.firstleg.oceanbooking.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_ocean_booking")
public class OceanBookingEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private String bookingNo;
private String status;
private Long customerId;
private String customerName;
private String billOfLadingNo;
private Long shippingCompanyId;
private String shippingCompanyName;
private Long bookingAgentId;
private String bookingAgentName;
private String vesselName;
private String voyageNo;
private String departurePortCode;
private String departurePortName;
private String destinationPortCode;
private String destinationPortName;
private String placeOfDelivery;
private String serviceTerm;
private String freightTerm;
private LocalDateTime estimatedSailingAt;
private LocalDateTime estimatedArrivalAt;
private String pendingVesselName;
private String pendingVoyageNo;
private LocalDateTime pendingEstimatedSailingAt;
private LocalDateTime pendingEstimatedArrivalAt;
private String changeReason;
private String failureReason;
private String cancellationReason;
private String remark;
private Long createdBy;
private String createdByName;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
private Long updatedBy;
private String updatedByName;
@TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updatedAt;
@Version @TableField(fill = FieldFill.INSERT) private Integer version;
}
@@ -0,0 +1,27 @@
package com.baiyue.firstleg.oceanbooking.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_ocean_booking_operation")
public class OceanBookingOperationEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long oceanBookingId;
private Long operatorId;
private String operation;
private String idempotencyKey;
private String requestHash;
private String status;
private String resultJson;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
private LocalDateTime completedAt;
}
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.oceanbooking.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@TableName("fl_ocean_booking_party")
public class OceanBookingPartyEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long oceanBookingId;
private String partyType;
private String partyName;
private String address;
private String contactName;
private String contactPhone;
}
@@ -0,0 +1,28 @@
package com.baiyue.firstleg.oceanbooking.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_ocean_booking_status_history")
public class OceanBookingStatusHistoryEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
private Long oceanBookingId;
private String fromStatus;
private String toStatus;
private String action;
private String reason;
private String beforeSnapshot;
private String afterSnapshot;
private Long operatorId;
private String operatorName;
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
}
@@ -0,0 +1,5 @@
package com.baiyue.firstleg.oceanbooking.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface OceanBookingContainerAllocationMapper extends BaseMapper<OceanBookingContainerAllocationEntity> {}
@@ -0,0 +1,5 @@
package com.baiyue.firstleg.oceanbooking.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface OceanBookingContainerRequirementMapper extends BaseMapper<OceanBookingContainerRequirementEntity> {}
@@ -0,0 +1,92 @@
package com.baiyue.firstleg.oceanbooking.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
@Mapper
public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
@Select("""
<script>
SELECT * FROM fl_ocean_booking
WHERE (#{query.customerId} IS NULL OR customer_id=#{query.customerId})
AND (#{query.status} IS NULL OR status=#{query.status})
AND (#{query.keyword} IS NULL OR booking_no LIKE CONCAT('%',#{query.keyword},'%')
OR customer_name LIKE CONCAT('%',#{query.keyword},'%')
OR bill_of_lading_no LIKE CONCAT('%',#{query.keyword},'%')
OR vessel_name LIKE CONCAT('%',#{query.keyword},'%')
OR voyage_no LIKE CONCAT('%',#{query.keyword},'%'))
ORDER BY updated_at DESC,id DESC
</script>
""")
Page<OceanBookingEntity> selectPage(Page<OceanBookingEntity> page, @Param("query") Query query);
@Update("""
UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},
bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},
shipping_company_name=#{value.shippingCompanyName},booking_agent_id=#{value.bookingAgentId},
booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},
departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},
destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},
place_of_delivery=#{value.placeOfDelivery},service_term=#{value.serviceTerm},freight_term=#{value.freightTerm},
estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},
remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{value.id} AND status='DRAFT' AND version=#{expectedVersion}
""")
int updateDraft(@Param("value") OceanBookingEntity value, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Update("""
UPDATE fl_ocean_booking SET status=#{toStatus},failure_reason=#{failureReason},
cancellation_reason=#{cancellationReason},updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{oceanBookingId} AND status=#{fromStatus} AND version=#{expectedVersion}
""")
int transition(@Param("oceanBookingId") long oceanBookingId, @Param("fromStatus") String fromStatus,
@Param("toStatus") String toStatus, @Param("failureReason") String failureReason,
@Param("cancellationReason") String cancellationReason,
@Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_ocean_booking SET status='CHANGE_PENDING',pending_vessel_name=#{vesselName},
pending_voyage_no=#{voyageNo},pending_estimated_sailing_at=#{estimatedSailingAt},
pending_estimated_arrival_at=#{estimatedArrivalAt},change_reason=#{reason},
updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{oceanBookingId} AND status='CONFIRMED' AND version=#{expectedVersion}
""")
int requestChange(@Param("oceanBookingId") long oceanBookingId, @Param("vesselName") String vesselName,
@Param("voyageNo") String voyageNo, @Param("estimatedSailingAt") LocalDateTime estimatedSailingAt,
@Param("estimatedArrivalAt") LocalDateTime estimatedArrivalAt, @Param("reason") String reason,
@Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_ocean_booking SET status='CONFIRMED',vessel_name=pending_vessel_name,voyage_no=pending_voyage_no,
estimated_sailing_at=pending_estimated_sailing_at,estimated_arrival_at=pending_estimated_arrival_at,
pending_vessel_name=NULL,pending_voyage_no=NULL,pending_estimated_sailing_at=NULL,
pending_estimated_arrival_at=NULL,updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3),version=version+1
WHERE id=#{oceanBookingId} AND status='CHANGE_PENDING' AND version=#{expectedVersion}
""")
int confirmChange(@Param("oceanBookingId") long oceanBookingId, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Update("""
UPDATE fl_ocean_booking SET version=version+1,updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{oceanBookingId} AND version=#{expectedVersion}
AND status IN ('SUBMITTED','CONFIRMED','CHANGE_PENDING')
""")
int touchForAllocation(@Param("oceanBookingId") long oceanBookingId,
@Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
}
@@ -0,0 +1,25 @@
package com.baiyue.firstleg.oceanbooking.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface OceanBookingOperationMapper extends BaseMapper<OceanBookingOperationEntity> {
@Insert("INSERT IGNORE INTO fl_ocean_booking_operation "
+ "(id,ocean_booking_id,operator_id,operation,idempotency_key,request_hash,status,created_at) "
+ "VALUES(#{id},#{oceanBookingId},#{operatorId},#{operation},#{key},#{hash},'PROCESSING',CURRENT_TIMESTAMP(3))")
int claim(@Param("id") long id, @Param("oceanBookingId") Long oceanBookingId,
@Param("operatorId") long operatorId, @Param("operation") String operation,
@Param("key") String key, @Param("hash") String hash);
@Update("UPDATE fl_ocean_booking_operation SET ocean_booking_id=#{oceanBookingId},status='SUCCESS',"
+ "result_json=#{resultJson},completed_at=CURRENT_TIMESTAMP(3) WHERE operator_id=#{operatorId} "
+ "AND operation=#{operation} AND idempotency_key=#{key} AND status='PROCESSING'")
int complete(@Param("oceanBookingId") long oceanBookingId, @Param("operatorId") long operatorId,
@Param("operation") String operation, @Param("key") String key,
@Param("resultJson") String resultJson);
}
@@ -0,0 +1,5 @@
package com.baiyue.firstleg.oceanbooking.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface OceanBookingPartyMapper extends BaseMapper<OceanBookingPartyEntity> {}
@@ -0,0 +1,5 @@
package com.baiyue.firstleg.oceanbooking.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface OceanBookingStatusHistoryMapper extends BaseMapper<OceanBookingStatusHistoryEntity> {}
@@ -0,0 +1,63 @@
package com.baiyue.firstleg.oceanbooking.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query;
import com.baiyue.firstleg.oceanbooking.entity.*;
import com.baiyue.firstleg.oceanbooking.mapper.*;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class OceanBookingRepository {
@Resource private OceanBookingMapper bookings;
@Resource private OceanBookingPartyMapper parties;
@Resource private OceanBookingContainerRequirementMapper requirements;
@Resource private OceanBookingContainerAllocationMapper allocations;
@Resource private OceanBookingStatusHistoryMapper histories;
@Resource private OceanBookingOperationMapper operations;
@Resource private ContainerBookingMapper containerBookings;
public PageResult<OceanBookingEntity> page(Query query) {
return PageResult.from(bookings.selectPage(new Page<>(query.page(), query.size()), query));
}
public OceanBookingEntity find(long id) { return bookings.selectById(id); }
public void insert(OceanBookingEntity value) { bookings.insert(value); }
public int updateDraft(OceanBookingEntity value, int version, long operatorId, String operatorName) {
return bookings.updateDraft(value, version, operatorId, operatorName);
}
public int transition(long id, String from, String to, String failureReason, String cancellationReason,
int version, long operatorId, String operatorName) {
return bookings.transition(id, from, to, failureReason, cancellationReason, version, operatorId, operatorName);
}
public int requestChange(long id, OceanBookingEntity pending, String reason, int version,
long operatorId, String operatorName) {
return bookings.requestChange(id, pending.getPendingVesselName(), pending.getPendingVoyageNo(),
pending.getPendingEstimatedSailingAt(), pending.getPendingEstimatedArrivalAt(), reason,
version, operatorId, operatorName);
}
public int confirmChange(long id, int version, long operatorId, String operatorName) {
return bookings.confirmChange(id, version, operatorId, operatorName);
}
public int touchForAllocation(long id, int version, long operatorId, String operatorName) {
return bookings.touchForAllocation(id, version, operatorId, operatorName);
}
public List<OceanBookingPartyEntity> parties(long id) { return parties.selectList(new LambdaQueryWrapper<OceanBookingPartyEntity>().eq(OceanBookingPartyEntity::getOceanBookingId, id).orderByAsc(OceanBookingPartyEntity::getId)); }
public List<OceanBookingContainerRequirementEntity> requirements(long id) { return requirements.selectList(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerRequirementEntity::getSortNo)); }
public List<OceanBookingContainerAllocationEntity> allocations(long id) { return allocations.selectList(new LambdaQueryWrapper<OceanBookingContainerAllocationEntity>().eq(OceanBookingContainerAllocationEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerAllocationEntity::getAllocatedAt)); }
public List<OceanBookingStatusHistoryEntity> histories(long id) { return histories.selectList(new LambdaQueryWrapper<OceanBookingStatusHistoryEntity>().eq(OceanBookingStatusHistoryEntity::getOceanBookingId, id).orderByAsc(OceanBookingStatusHistoryEntity::getCreatedAt)); }
public void replaceParties(long id, List<OceanBookingPartyEntity> values) { parties.delete(new LambdaQueryWrapper<OceanBookingPartyEntity>().eq(OceanBookingPartyEntity::getOceanBookingId, id)); values.forEach(parties::insert); }
public void replaceRequirements(long id, List<OceanBookingContainerRequirementEntity> values) { requirements.delete(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id)); values.forEach(requirements::insert); }
public OceanBookingContainerRequirementEntity requirement(long id, long requirementId) { return requirements.selectOne(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).eq(OceanBookingContainerRequirementEntity::getId, requirementId)); }
public ContainerBookingEntity containerBooking(long id) { return containerBookings.selectById(id); }
public void insert(OceanBookingContainerAllocationEntity value) { allocations.insert(value); }
public void insert(OceanBookingStatusHistoryEntity value) { histories.insert(value); }
public boolean claimOperation(Long id, long operatorId, String action, String key, String hash) { return operations.claim(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId(), id, operatorId, action, key, hash) == 1; }
public OceanBookingOperationEntity operation(long operatorId, String action, String key) { return operations.selectOne(new LambdaQueryWrapper<OceanBookingOperationEntity>().eq(OceanBookingOperationEntity::getOperatorId, operatorId).eq(OceanBookingOperationEntity::getOperation, action).eq(OceanBookingOperationEntity::getIdempotencyKey, key)); }
public int completeOperation(long id, long operatorId, String action, String key, String json) { return operations.complete(id, operatorId, action, key, json); }
}
@@ -0,0 +1,272 @@
package com.baiyue.firstleg.oceanbooking.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.oceanbooking.domain.OceanBookingStatus;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*;
import com.baiyue.firstleg.oceanbooking.entity.*;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Set;
@Service
public class OceanBookingService {
private static final Set<String> PARTY_TYPES = Set.of("SHIPPER", "CONSIGNEE", "NOTIFY_PARTY");
@Resource private OceanBookingRepository repository;
@Resource private ObjectMapper objectMapper;
public PageResult<OceanBookingEntity> page(Query query) {
String status = trim(query.status());
if (status != null) parseStatus(status);
return repository.page(new Query(query.customerId(), status, trim(query.keyword()), query.page(), query.size()));
}
public Detail detail(long oceanBookingId) { return detail(required(oceanBookingId)); }
@Transactional
public Detail create(long operatorId, String operatorName, String key, CreateRequest request) {
if (request.expectedVersion() != 0) throw concurrent();
validateDraft(request.data());
String action = "CREATE";
String hash = hash(action, null, request);
Detail replay = claimOrReplay(null, operatorId, action, key, hash);
if (replay != null) return replay;
OceanBookingEntity value = new OceanBookingEntity();
value.setId(IdWorker.getId());
value.setBookingNo("OB" + value.getId());
value.setStatus(OceanBookingStatus.DRAFT.name());
copy(value, request.data());
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
repository.insert(value);
repository.replaceParties(value.getId(), parties(value.getId(), request.data().parties()));
repository.replaceRequirements(value.getId(), requirements(value.getId(), request.data().containerRequirements()));
addHistory(value.getId(), null, OceanBookingStatus.DRAFT.name(), action, null, null, value, operatorId, operatorName);
return complete(value.getId(), operatorId, action, key, detail(value.getId()));
}
@Transactional
public Detail updateDraft(long oceanBookingId, long operatorId, String operatorName, String key,
UpdateDraftRequest request) {
validateDraft(request.data());
String action = "UPDATE_DRAFT";
Detail replay = claimOrReplay(oceanBookingId, operatorId, action, key, hash(action, oceanBookingId, request));
if (replay != null) return replay;
OceanBookingEntity before = requiredVersion(oceanBookingId, request.expectedVersion());
requireStatus(before, OceanBookingStatus.DRAFT);
OceanBookingEntity value = new OceanBookingEntity(); value.setId(oceanBookingId); copy(value, request.data());
if (repository.updateDraft(value, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent();
repository.replaceParties(oceanBookingId, parties(oceanBookingId, request.data().parties()));
repository.replaceRequirements(oceanBookingId, requirements(oceanBookingId, request.data().containerRequirements()));
return complete(oceanBookingId, operatorId, action, key, detail(oceanBookingId));
}
@Transactional public Detail submit(long id, long operatorId, String name, String key, ActionRequest request) {
return transition(id, operatorId, name, key, "SUBMIT", OceanBookingStatus.DRAFT,
OceanBookingStatus.SUBMITTED, request, false);
}
@Transactional public Detail confirm(long id, long operatorId, String name, String key, ActionRequest request) {
return transition(id, operatorId, name, key, "CONFIRM", OceanBookingStatus.SUBMITTED,
OceanBookingStatus.CONFIRMED, request, false);
}
@Transactional public Detail release(long id, long operatorId, String name, String key, ActionRequest request) {
return transition(id, operatorId, name, key, "RELEASE", OceanBookingStatus.CONFIRMED,
OceanBookingStatus.RELEASED, request, false);
}
@Transactional
public Detail fail(long id, long operatorId, String name, String key, ActionRequest request) {
if (!StringUtils.hasText(request.reason())) throw badRequest("失败原因不能为空");
String action = "FAIL";
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
if (replay != null) return replay;
OceanBookingEntity before = requiredVersion(id, request.expectedVersion());
requireStatus(before, OceanBookingStatus.SUBMITTED);
if (repository.transition(id, OceanBookingStatus.SUBMITTED.name(), OceanBookingStatus.FAILED.name(), trim(request.reason()), null,
request.expectedVersion(), operatorId, name) != 1) throw concurrent();
OceanBookingEntity after = required(id);
addHistory(id, OceanBookingStatus.SUBMITTED.name(), after.getStatus(), action, request.reason(), before, after, operatorId, name);
return complete(id, operatorId, action, key, detail(after));
}
@Transactional
public Detail failChange(long id, long operatorId, String name, String key, ActionRequest request) {
if (!StringUtils.hasText(request.reason())) throw badRequest("改船失败原因不能为空");
return transition(id, operatorId, name, key, "FAIL_CHANGE", OceanBookingStatus.CHANGE_PENDING,
OceanBookingStatus.FAILED, request, true);
}
@Transactional
public Detail cancel(long id, long operatorId, String name, String key, ActionRequest request) {
if (!StringUtils.hasText(request.reason())) throw badRequest("取消原因不能为空");
String action = "CANCEL";
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
if (replay != null) return replay;
OceanBookingEntity before = requiredVersion(id, request.expectedVersion());
OceanBookingStatus from = parseStatus(before.getStatus());
if (from == OceanBookingStatus.RELEASED || from == OceanBookingStatus.CANCELLED) {
throw conflict("订舱已越过不可逆节点或已经取消");
}
if (repository.transition(id, from.name(), OceanBookingStatus.CANCELLED.name(), null,
trim(request.reason()), request.expectedVersion(), operatorId, name) != 1) throw concurrent();
OceanBookingEntity after = required(id);
addHistory(id, from.name(), after.getStatus(), action, request.reason(), before, after, operatorId, name);
return complete(id, operatorId, action, key, detail(after));
}
@Transactional
public Detail requestChange(long id, long operatorId, String name, String key, ChangeRequest request) {
validateRange(request.estimatedSailingAt(), request.estimatedArrivalAt());
String action = "REQUEST_CHANGE";
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
if (replay != null) return replay;
OceanBookingEntity before = requiredVersion(id, request.expectedVersion());
requireStatus(before, OceanBookingStatus.CONFIRMED);
OceanBookingEntity pending = new OceanBookingEntity();
pending.setPendingVesselName(request.vesselName().trim());
pending.setPendingVoyageNo(request.voyageNo().trim());
pending.setPendingEstimatedSailingAt(request.estimatedSailingAt());
pending.setPendingEstimatedArrivalAt(request.estimatedArrivalAt());
if (repository.requestChange(id, pending, request.reason().trim(), request.expectedVersion(), operatorId, name) != 1) throw concurrent();
OceanBookingEntity after = required(id);
addHistory(id, before.getStatus(), after.getStatus(), action, request.reason(), before, after, operatorId, name);
return complete(id, operatorId, action, key, detail(after));
}
@Transactional
public Detail confirmChange(long id, long operatorId, String name, String key, ActionRequest request) {
String action = "CONFIRM_CHANGE";
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
if (replay != null) return replay;
OceanBookingEntity before = requiredVersion(id, request.expectedVersion());
requireStatus(before, OceanBookingStatus.CHANGE_PENDING);
if (repository.confirmChange(id, request.expectedVersion(), operatorId, name) != 1) throw concurrent();
OceanBookingEntity after = required(id);
addHistory(id, before.getStatus(), after.getStatus(), action, request.reason(), before, after, operatorId, name);
return complete(id, operatorId, action, key, detail(after));
}
@Transactional
public Detail allocate(long id, long operatorId, String name, String key, AllocationRequest request) {
String action = "ALLOCATE_CONTAINER";
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
if (replay != null) return replay;
requiredVersion(id, request.expectedVersion());
OceanBookingContainerRequirementEntity requirement = repository.requirement(id, request.requirementId());
if (requirement == null) throw new BizException(ResultCode.NOT_FOUND, "箱型需求不存在");
ContainerBookingEntity container = repository.containerBooking(request.containerBookingId());
if (container == null) throw new BizException(ResultCode.NOT_FOUND, "柜次不存在");
if (!requirement.getContainerType().equals(container.getContainerType())) throw conflict("柜次箱型与订舱需求不一致");
long used = repository.allocations(id).stream().filter(v -> request.requirementId().equals(v.getRequirementId())).count();
if (used >= requirement.getQuantity()) throw conflict("该箱型需求已分配完毕");
if (repository.touchForAllocation(id, request.expectedVersion(), operatorId, name) != 1) throw concurrent();
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
allocation.setId(IdWorker.getId()); allocation.setOceanBookingId(id);
allocation.setRequirementId(request.requirementId()); allocation.setContainerBookingId(request.containerBookingId());
allocation.setAllocatedBy(operatorId); allocation.setAllocatedByName(name);
try { repository.insert(allocation); } catch (DuplicateKeyException ex) { throw conflict("柜次已被其他订舱占用"); }
return complete(id, operatorId, action, key, detail(id));
}
private Detail transition(long id, long operatorId, String name, String key, String action,
OceanBookingStatus from, OceanBookingStatus to, ActionRequest request,
boolean failure) {
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
if (replay != null) return replay;
OceanBookingEntity before = requiredVersion(id, request.expectedVersion());
requireStatus(before, from);
if (from == OceanBookingStatus.DRAFT && to == OceanBookingStatus.SUBMITTED) validateSubmission(id);
if (repository.transition(id, from.name(), to.name(), failure ? trim(request.reason()) : null, null,
request.expectedVersion(), operatorId, name) != 1) throw concurrent();
OceanBookingEntity after = required(id);
addHistory(id, from.name(), to.name(), action, request.reason(), before, after, operatorId, name);
return complete(id, operatorId, action, key, detail(after));
}
private Detail claimOrReplay(Long id, long operatorId, String action, String key, String hash) {
validateKey(key);
if (repository.claimOperation(id, operatorId, action, key, hash)) return null;
OceanBookingOperationEntity existing = repository.operation(operatorId, action, key);
if (existing == null || !hash.equals(existing.getRequestHash())) throw conflict("幂等键已用于其他请求");
if (!"SUCCESS".equals(existing.getStatus()) || !StringUtils.hasText(existing.getResultJson())) throw conflict("相同请求正在处理中,请稍后重试");
try { return objectMapper.readValue(existing.getResultJson(), Detail.class); }
catch (JsonProcessingException ex) { throw new IllegalStateException("无法读取订舱幂等结果", ex); }
}
private Detail complete(long id, long operatorId, String action, String key, Detail detail) {
try {
if (repository.completeOperation(id, operatorId, action, key, objectMapper.writeValueAsString(detail)) != 1) {
throw conflict("订舱幂等请求完成状态冲突");
}
return detail;
} catch (JsonProcessingException ex) { throw new IllegalStateException("无法保存订舱幂等结果", ex); }
}
private void addHistory(long id, String from, String to, String action, String reason,
Object before, Object after, long operatorId, String operatorName) {
OceanBookingStatusHistoryEntity history = new OceanBookingStatusHistoryEntity();
history.setId(IdWorker.getId()); history.setOceanBookingId(id); history.setFromStatus(from);
history.setToStatus(to); history.setAction(action); history.setReason(trim(reason));
history.setBeforeSnapshot(json(before)); history.setAfterSnapshot(json(after));
history.setOperatorId(operatorId); history.setOperatorName(operatorName); repository.insert(history);
}
private Detail detail(OceanBookingEntity value) { return new Detail(value, repository.parties(value.getId()), repository.requirements(value.getId()), repository.allocations(value.getId()), repository.histories(value.getId())); }
private OceanBookingEntity required(long id) { OceanBookingEntity value = repository.find(id); if (value == null) throw new BizException(ResultCode.NOT_FOUND, "订舱单不存在"); return value; }
private OceanBookingEntity requiredVersion(long id, int version) { OceanBookingEntity value = required(id); if (!Integer.valueOf(version).equals(value.getVersion())) throw concurrent(); return value; }
private static void requireStatus(OceanBookingEntity value, OceanBookingStatus status) { if (!status.name().equals(value.getStatus())) throw conflict("订舱状态不允许当前操作"); }
private void validateDraft(DraftData data) {
validateRange(data.estimatedSailingAt(), data.estimatedArrivalAt());
Set<String> types = new HashSet<>();
for (PartyRequest party : data.parties()) {
String type = party.partyType().trim().toUpperCase();
if (!PARTY_TYPES.contains(type) || !types.add(type)) throw badRequest("订舱方必须各包含一条SHIPPER、CONSIGNEE和NOTIFY_PARTY");
}
if (!types.equals(PARTY_TYPES)) throw badRequest("订舱方必须各包含一条SHIPPER、CONSIGNEE和NOTIFY_PARTY");
}
private void validateSubmission(long id) {
Set<String> types = repository.parties(id).stream().map(OceanBookingPartyEntity::getPartyType)
.collect(java.util.stream.Collectors.toSet());
if (!types.equals(PARTY_TYPES) || repository.requirements(id).isEmpty()) {
throw conflict("订舱方或箱型需求不完整,不能提交");
}
}
private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); }
private static void copy(OceanBookingEntity v, DraftData d) {
v.setCustomerId(d.customerId()); v.setCustomerName(d.customerName().trim()); v.setBillOfLadingNo(trim(d.billOfLadingNo()));
v.setShippingCompanyId(d.shippingCompanyId()); v.setShippingCompanyName(trim(d.shippingCompanyName()));
v.setBookingAgentId(d.bookingAgentId()); v.setBookingAgentName(trim(d.bookingAgentName()));
v.setVesselName(trim(d.vesselName())); v.setVoyageNo(trim(d.voyageNo()));
v.setDeparturePortCode(trim(d.departurePortCode())); v.setDeparturePortName(trim(d.departurePortName()));
v.setDestinationPortCode(trim(d.destinationPortCode())); v.setDestinationPortName(trim(d.destinationPortName()));
v.setPlaceOfDelivery(trim(d.placeOfDelivery())); v.setServiceTerm(trim(d.serviceTerm())); v.setFreightTerm(trim(d.freightTerm()));
v.setEstimatedSailingAt(d.estimatedSailingAt()); v.setEstimatedArrivalAt(d.estimatedArrivalAt()); v.setRemark(trim(d.remark()));
}
private static List<OceanBookingPartyEntity> parties(long id, List<PartyRequest> requests) { return requests.stream().map(r -> { OceanBookingPartyEntity v = new OceanBookingPartyEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setPartyType(r.partyType().trim().toUpperCase()); v.setPartyName(r.partyName().trim()); v.setAddress(trim(r.address())); v.setContactName(trim(r.contactName())); v.setContactPhone(trim(r.contactPhone())); return v; }).toList(); }
private static List<OceanBookingContainerRequirementEntity> requirements(long id, List<ContainerRequirementRequest> requests) { int[] sort = {0}; return requests.stream().map(r -> { OceanBookingContainerRequirementEntity v = new OceanBookingContainerRequirementEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setContainerType(r.containerType().trim().toUpperCase()); v.setContainerKind(r.containerKind().trim().toUpperCase()); v.setQuantity(r.quantity()); v.setSortNo(sort[0]++); return v; }).toList(); }
private String hash(String action, Long id, Object request) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((action + '|' + id + '|' + objectMapper.writeValueAsString(request)).getBytes(StandardCharsets.UTF_8))); } catch (Exception ex) { throw new IllegalStateException("无法计算订舱请求摘要", ex); } }
private String json(Object value) { if (value == null) return null; try { return objectMapper.writeValueAsString(value); } catch (JsonProcessingException ex) { throw new IllegalStateException("无法保存订舱历史快照", ex); } }
private static OceanBookingStatus parseStatus(String status) { try { return OceanBookingStatus.valueOf(status); } catch (RuntimeException ex) { throw badRequest("订舱状态无效"); } }
private static void validateKey(String key) { if (!StringUtils.hasText(key) || key.length() > 128) throw badRequest("Idempotency-Key不能为空且不能超过128个字符"); }
private static String trim(String value) { if (value == null) return null; String result = value.trim(); return result.isEmpty() ? null : result; }
private static BizException badRequest(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
private static BizException concurrent() { return conflict("数据已被其他人修改,请刷新后重试"); }
}
@@ -0,0 +1,6 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baiyue.firstleg.dto.TruckAppointmentDtos.Query; import com.baiyue.firstleg.entity.*; import com.baiyue.firstleg.mapper.*; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository;
@Repository public class TruckAppointmentRepository { @Resource TruckAppointmentMapper mapper; @Resource TruckAppointmentHistoryMapper history; @Resource TruckAppointmentAttachmentMapper attachments; @Resource TruckAppointmentOperationMapper operations;
public PageResult<TruckAppointmentEntity> page(Query q){return PageResult.from(mapper.page(new Page<>(q.page(),q.size()),q));} public TruckAppointmentEntity find(long id){return mapper.selectById(id);} public void insert(TruckAppointmentEntity e){mapper.insert(e);} public int transition(long id,String from,String to,int v){return mapper.transition(id,from,to,v);} public int reschedule(long id,java.time.LocalDateTime at,String reason,int v){return mapper.reschedule(id,at,reason,v);} public void history(TruckAppointmentHistoryEntity e){history.insert(e);} public void operation(TruckAppointmentOperationEntity e){operations.insert(e);} public void attachment(TruckAppointmentAttachmentEntity e){attachments.insert(e);}
public int updateEdit(TruckAppointmentEntity e,int v){return mapper.updateEdit(e,v);}
}
@@ -0,0 +1,14 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.domain.TruckAppointmentStatus; import com.baiyue.firstleg.dto.TruckAppointmentDtos.*; import com.baiyue.firstleg.entity.*; import com.baiyue.firstleg.repository.TruckAppointmentRepository; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime;
@Service public class TruckAppointmentService { @Resource TruckAppointmentRepository repo;
public PageResult<TruckAppointmentEntity> page(Query q){return repo.page(q);} public TruckAppointmentEntity detail(long id){return required(id);}
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0"); TruckAppointmentEntity e=new TruckAppointmentEntity(); e.setId(IdWorker.getId()); e.setAppointmentNo("TA"+e.getId()); e.setStatus(TruckAppointmentStatus.DRAFT); e.setRevision(0); e.setVersion(0); e.setCreatedAt(LocalDateTime.now()); e.setUpdatedAt(e.getCreatedAt()); copy(e,r); repo.insert(e); record(e,null,e.getStatus().name(),"CREATE",null,uid,name); return e;}
@Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑"); copy(e,r); if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试"); record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name); return required(id);}
@Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String hash,long uid,String name){TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); TruckAppointmentStatus from=e.getStatus(),to=next(from,action); if("RESCHEDULE".equals(action)){throw conflict("改约请使用专用接口");} if(repo.transition(id,from.name(),to.name(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试"); e.setStatus(to);e.setVersion(e.getVersion()+1);e.setRevision(e.getRevision()+1); record(e,from.name(),to.name(),action,r.reason(),uid,name); operation(e,action,idem,hash); return e;}
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,long uid,String name){TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");e.setStatus(TruckAppointmentStatus.RESCHEDULED);e.setPlannedLoadingAt(at);record(e,e.getStatus().name(),"RESCHEDULED","RESCHEDULE",r.reason(),uid,name);return e;}
private TruckAppointmentStatus next(TruckAppointmentStatus s,String a){return switch(a){case"SUBMIT"->s==TruckAppointmentStatus.DRAFT?TruckAppointmentStatus.PENDING_DISPATCH:badStatus();case"DISPATCH"->s==TruckAppointmentStatus.PENDING_DISPATCH||s==TruckAppointmentStatus.RESCHEDULED?TruckAppointmentStatus.DISPATCHED:badStatus();case"PICKUP"->s==TruckAppointmentStatus.DISPATCHED?TruckAppointmentStatus.PICKUP_IN_PROGRESS:badStatus();case"ARRIVE"->s==TruckAppointmentStatus.PICKUP_IN_PROGRESS?TruckAppointmentStatus.ARRIVED_WAREHOUSE:badStatus();case"LOAD"->s==TruckAppointmentStatus.ARRIVED_WAREHOUSE?TruckAppointmentStatus.LOADING:badStatus();case"DEPART"->s==TruckAppointmentStatus.LOADING?TruckAppointmentStatus.DEPARTED:badStatus();case"COMPLETE"->s==TruckAppointmentStatus.DEPARTED?TruckAppointmentStatus.COMPLETED:badStatus();case"EXCEPTION"->s!=TruckAppointmentStatus.COMPLETED&&s!=TruckAppointmentStatus.CANCELLED?TruckAppointmentStatus.EXCEPTION:badStatus();case"CANCEL"->s!=TruckAppointmentStatus.COMPLETED?TruckAppointmentStatus.CANCELLED:badStatus();default->badStatus();};}
private TruckAppointmentStatus badStatus(){throw conflict("不允许的状态动作");} private TruckAppointmentEntity required(long id){TruckAppointmentEntity e=repo.find(id);if(e==null)throw new BizException(ResultCode.NOT_FOUND,"拖车预约不存在");return e;} private void checkVersion(TruckAppointmentEntity e,int v){if(e.getVersion()!=v)throw conflict("版本已变化,请刷新后重试");} private static BizException conflict(String m){return new BizException(ResultCode.CONFLICT,m);} private static BizException bad(String m){return new BizException(ResultCode.BAD_REQUEST,m);}
private void copy(TruckAppointmentEntity e,SaveRequest r){e.setOutboundOrderId(r.outboundOrderId());e.setOceanBookingId(r.oceanBookingId());e.setContainerBookingId(r.containerBookingId());e.setLoadingPlanId(r.loadingPlanId());e.setWarehouseId(r.warehouseId());e.setCustomerId(r.customerId());e.setCustomerName(r.customerName());e.setCarrierId(r.carrierId());e.setCarrierName(r.carrierName());e.setFleetContact(r.fleetContact());e.setFleetPhone(r.fleetPhone());e.setDriverName(r.driverName());e.setDriverPhone(r.driverPhone());e.setDriverIdCard(r.driverIdCard());e.setVehicleNo(r.vehicleNo());e.setStoreName(r.storeName());e.setStoreContact(r.storeContact());e.setStorePhone(r.storePhone());e.setStoreAddress(r.storeAddress());e.setStoreCity(r.storeCity());e.setStoreZipcode(r.storeZipcode());e.setPackageCount(r.packageCount());e.setGrossWeightKg(r.grossWeightKg());e.setVolumeCbm(r.volumeCbm());e.setPalletCount(r.palletCount());e.setPoNo(r.poNo());e.setPlannedLoadingAt(r.plannedLoadingAt());e.setTransitMinutes(r.transitMinutes());e.setPayableAmount(r.payableAmount());e.setReceivableAmount(r.receivableAmount());e.setCurrency(r.currency()==null?"RMB":r.currency());e.setRemark(r.remark());}
private void record(TruckAppointmentEntity e,String from,String to,String action,String reason,long uid,String name){TruckAppointmentHistoryEntity h=new TruckAppointmentHistoryEntity();h.setId(IdWorker.getId());h.setAppointmentId(e.getId());h.setRevision(e.getRevision());h.setAction(action);h.setFromStatus(from);h.setToStatus(to);h.setReason(reason);h.setOperatorId(uid);h.setOperatorName(name);h.setCreatedAt(LocalDateTime.now());repo.history(h);}
private void operation(TruckAppointmentEntity e,String action,String key,String hash){if(key==null||key.isBlank())return;TruckAppointmentOperationEntity o=new TruckAppointmentOperationEntity();o.setId(IdWorker.getId());o.setAppointmentId(e.getId());o.setAction(action);o.setIdempotencyKey(key);o.setRequestHash(hash==null?"":hash);o.setCreatedAt(LocalDateTime.now());repo.operation(o);}
}
@@ -0,0 +1,222 @@
SET NAMES utf8mb4;
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_ocean_booking (
id BIGINT UNSIGNED NOT NULL,
booking_no VARCHAR(40) NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '当前产品客户ID',
customer_name VARCHAR(128) NOT NULL,
bill_of_lading_no VARCHAR(128) NULL,
shipping_company_id BIGINT UNSIGNED NULL,
shipping_company_name VARCHAR(128) NULL,
booking_agent_id BIGINT UNSIGNED NULL,
booking_agent_name VARCHAR(128) NULL,
vessel_name VARCHAR(128) NULL,
voyage_no VARCHAR(64) NULL,
departure_port_code VARCHAR(32) NULL,
departure_port_name VARCHAR(128) NULL,
destination_port_code VARCHAR(32) NULL,
destination_port_name VARCHAR(128) NULL,
place_of_delivery VARCHAR(128) NULL,
service_term VARCHAR(32) NULL,
freight_term VARCHAR(32) NULL,
estimated_sailing_at DATETIME(3) NULL,
estimated_arrival_at DATETIME(3) NULL,
pending_vessel_name VARCHAR(128) NULL,
pending_voyage_no VARCHAR(64) NULL,
pending_estimated_sailing_at DATETIME(3) NULL,
pending_estimated_arrival_at DATETIME(3) NULL,
change_reason VARCHAR(1000) NULL,
failure_reason VARCHAR(1000) NULL,
cancellation_reason VARCHAR(1000) NULL,
remark VARCHAR(1000) NULL,
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT UNSIGNED NOT NULL,
updated_by_name VARCHAR(64) NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
version INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_booking_no (booking_no),
KEY idx_ocean_booking_customer (customer_id,status,updated_at,id),
KEY idx_ocean_booking_voyage (vessel_name,voyage_no,updated_at),
CONSTRAINT chk_ocean_booking_customer CHECK (customer_id > 0),
CONSTRAINT chk_ocean_booking_status CHECK (status IN
('DRAFT','SUBMITTED','CONFIRMED','RELEASED','FAILED','CHANGE_PENDING','CANCELLED')),
CONSTRAINT chk_ocean_booking_schedule CHECK
(estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at),
CONSTRAINT chk_ocean_booking_pending_schedule CHECK
(pending_estimated_sailing_at IS NULL OR pending_estimated_arrival_at IS NULL
OR pending_estimated_arrival_at >= pending_estimated_sailing_at)
) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载';
CREATE TABLE IF NOT EXISTS fl_ocean_booking_party (
id BIGINT UNSIGNED NOT NULL,
ocean_booking_id BIGINT UNSIGNED NOT NULL,
party_type VARCHAR(24) NOT NULL COMMENT 'SHIPPER/CONSIGNEE/NOTIFY_PARTY',
party_name VARCHAR(255) NOT NULL,
address VARCHAR(1000) NULL,
contact_name VARCHAR(128) NULL,
contact_phone VARCHAR(64) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type),
CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
CONSTRAINT chk_ocean_booking_party_type CHECK (party_type IN ('SHIPPER','CONSIGNEE','NOTIFY_PARTY'))
) ENGINE=InnoDB COMMENT='订舱提单关系方快照';
CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement (
id BIGINT UNSIGNED NOT NULL,
ocean_booking_id BIGINT UNSIGNED NOT NULL,
container_type VARCHAR(32) NOT NULL,
container_kind VARCHAR(32) NOT NULL,
quantity INT UNSIGNED NOT NULL,
sort_no INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY idx_ocean_requirement_booking (ocean_booking_id,sort_no,id),
CONSTRAINT fk_ocean_requirement_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
CONSTRAINT chk_ocean_requirement_quantity CHECK (quantity > 0)
) ENGINE=InnoDB COMMENT='订舱箱型箱种及数量需求';
CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_allocation (
id BIGINT UNSIGNED NOT NULL,
ocean_booking_id BIGINT UNSIGNED NOT NULL,
requirement_id BIGINT UNSIGNED NOT NULL,
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID',
allocated_by BIGINT UNSIGNED NOT NULL,
allocated_by_name VARCHAR(64) NULL,
allocated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_allocation_container (container_booking_id),
KEY idx_ocean_allocation_booking (ocean_booking_id,requirement_id,allocated_at),
CONSTRAINT fk_ocean_allocation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
CONSTRAINT fk_ocean_allocation_requirement FOREIGN KEY (requirement_id)
REFERENCES fl_ocean_booking_container_requirement(id),
CONSTRAINT fk_ocean_allocation_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)
) ENGINE=InnoDB COMMENT='订舱需求与现有柜次的唯一分配关系';
CREATE TABLE IF NOT EXISTS fl_ocean_booking_status_history (
id BIGINT UNSIGNED NOT NULL,
ocean_booking_id BIGINT UNSIGNED NOT NULL,
from_status VARCHAR(24) NULL,
to_status VARCHAR(24) NOT NULL,
action VARCHAR(40) NOT NULL,
reason VARCHAR(1000) NULL,
before_snapshot JSON NULL,
after_snapshot JSON NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operator_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_ocean_history_booking (ocean_booking_id,created_at,id),
CONSTRAINT fk_ocean_history_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id)
) ENGINE=InnoDB COMMENT='海运订舱不可变状态历史';
CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation (
id BIGINT UNSIGNED NOT NULL,
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '创建请求完成前允许为空',
operator_id BIGINT UNSIGNED NOT NULL,
operation VARCHAR(40) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING',
result_json JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key),
KEY idx_ocean_operation_booking (ocean_booking_id,created_at),
CONSTRAINT fk_ocean_operation_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
CONSTRAINT chk_ocean_operation_status CHECK (status IN ('PROCESSING','SUCCESS'))
) ENGINE=InnoDB COMMENT='海运订舱写操作幂等与请求哈希';
DROP TRIGGER IF EXISTS trg_ocean_booking_before_update;
DELIMITER $$
CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW
BEGIN
IF OLD.status IN ('RELEASED','CANCELLED') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱或已取消订舱不可修改';
END IF;
IF OLD.status <> NEW.status AND NOT (
(OLD.status='DRAFT' AND NEW.status IN ('SUBMITTED','CANCELLED'))
OR (OLD.status='SUBMITTED' AND NEW.status IN ('CONFIRMED','FAILED','CANCELLED'))
OR (OLD.status='CONFIRMED' AND NEW.status IN ('RELEASED','CHANGE_PENDING','CANCELLED'))
OR (OLD.status='CHANGE_PENDING' AND NEW.status IN ('CONFIRMED','FAILED','CANCELLED'))
OR (OLD.status='FAILED' AND NEW.status='CANCELLED')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海运订舱状态迁移非法';
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_ocean_party_before_insert;
DROP TRIGGER IF EXISTS trg_ocean_party_before_update;
DROP TRIGGER IF EXISTS trg_ocean_party_before_delete;
DELIMITER $$
CREATE TRIGGER trg_ocean_party_before_insert BEFORE INSERT ON fl_ocean_booking_party FOR EACH ROW
BEGIN IF (SELECT status FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id) <> 'DRAFT' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='仅草稿可维护订舱方'; END IF; END$$
CREATE TRIGGER trg_ocean_party_before_update BEFORE UPDATE ON fl_ocean_booking_party FOR EACH ROW
BEGIN IF (SELECT status FROM fl_ocean_booking WHERE id=OLD.ocean_booking_id) <> 'DRAFT' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='仅草稿可维护订舱方'; END IF; END$$
CREATE TRIGGER trg_ocean_party_before_delete BEFORE DELETE ON fl_ocean_booking_party FOR EACH ROW
BEGIN IF (SELECT status FROM fl_ocean_booking WHERE id=OLD.ocean_booking_id) <> 'DRAFT' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='仅草稿可维护订舱方'; END IF; END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_ocean_requirement_before_insert;
DROP TRIGGER IF EXISTS trg_ocean_requirement_before_update;
DROP TRIGGER IF EXISTS trg_ocean_requirement_before_delete;
DELIMITER $$
CREATE TRIGGER trg_ocean_requirement_before_insert BEFORE INSERT ON fl_ocean_booking_container_requirement FOR EACH ROW
BEGIN IF (SELECT status FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id) <> 'DRAFT' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='仅草稿可维护箱型需求'; END IF; END$$
CREATE TRIGGER trg_ocean_requirement_before_update BEFORE UPDATE ON fl_ocean_booking_container_requirement FOR EACH ROW
BEGIN IF (SELECT status FROM fl_ocean_booking WHERE id=OLD.ocean_booking_id) <> 'DRAFT' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='仅草稿可维护箱型需求'; END IF; END$$
CREATE TRIGGER trg_ocean_requirement_before_delete BEFORE DELETE ON fl_ocean_booking_container_requirement FOR EACH ROW
BEGIN IF (SELECT status FROM fl_ocean_booking WHERE id=OLD.ocean_booking_id) <> 'DRAFT' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='仅草稿可维护箱型需求'; END IF; END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_ocean_allocation_before_insert;
DELIMITER $$
CREATE TRIGGER trg_ocean_allocation_before_insert BEFORE INSERT ON fl_ocean_booking_container_allocation FOR EACH ROW
BEGIN
IF (SELECT ocean_booking_id FROM fl_ocean_booking_container_requirement WHERE id=NEW.requirement_id) <> NEW.ocean_booking_id THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='箱型需求不属于当前订舱';
END IF;
IF (SELECT status FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id) NOT IN
('SUBMITTED','CONFIRMED','CHANGE_PENDING') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='当前订舱状态不可分配柜次';
END IF;
END$$
DELIMITER ;
DROP TRIGGER IF EXISTS trg_ocean_history_before_update;
DROP TRIGGER IF EXISTS trg_ocean_history_before_delete;
DELIMITER $$
CREATE TRIGGER trg_ocean_history_before_update BEFORE UPDATE ON fl_ocean_booking_status_history FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订舱状态历史不可修改'; END$$
CREATE TRIGGER trg_ocean_history_before_delete BEFORE DELETE ON fl_ocean_booking_status_history FOR EACH ROW
BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订舱状态历史不可删除'; END$$
DELIMITER ;
USE baiyue_iam;
INSERT INTO iam_permission
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,
route_path,http_method,sort_no,status,built_in,deleted)
VALUES
(5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list','订舱管理','/first-leg/ocean-bookings','GET',80,'ACTIVE',1,0),
(5102141,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:view','查看订舱',NULL,'GET',10,'ACTIVE',1,0),
(5102142,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:create','创建订舱',NULL,'POST',20,'ACTIVE',1,0),
(5102143,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:edit','编辑订舱',NULL,'PUT',30,'ACTIVE',1,0),
(5102144,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:submit','提交订舱',NULL,'POST',40,'ACTIVE',1,0),
(5102145,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:confirm','确认订舱',NULL,'POST',50,'ACTIVE',1,0),
(5102146,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:release','确认放舱',NULL,'POST',60,'ACTIVE',1,0),
(5102147,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:cancel','取消订舱',NULL,'POST',70,'ACTIVE',1,0),
(5102148,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:change','变更船期',NULL,'POST',80,'ACTIVE',1,0),
(5102149,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:allocate','分配柜次',NULL,'POST',90,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),parent_id=VALUES(parent_id),
route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),status='ACTIVE',built_in=1,deleted=0;
INSERT IGNORE INTO iam_role_permission(role_id,permission_id,tenant_id)
SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission
ON permission.tenant_id=0 AND permission.permission_code LIKE 'platform:firstleg:ocean-booking:%'
WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR');
USE baiyue_firstleg;
@@ -0,0 +1,31 @@
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS fl_truck_appointment (
id BIGINT UNSIGNED NOT NULL, appointment_no VARCHAR(40) NOT NULL, outbound_order_id BIGINT UNSIGNED NULL,
ocean_booking_id BIGINT UNSIGNED NULL, container_booking_id BIGINT UNSIGNED NULL, loading_plan_id BIGINT UNSIGNED NULL,
warehouse_id BIGINT UNSIGNED NULL, customer_id BIGINT UNSIGNED NULL, customer_name VARCHAR(128) NULL,
carrier_id BIGINT UNSIGNED NULL, carrier_name VARCHAR(128) NULL, fleet_contact VARCHAR(64) NULL, fleet_phone VARCHAR(32) NULL,
driver_name VARCHAR(64) NULL, driver_phone VARCHAR(32) NULL, driver_id_card VARCHAR(32) NULL, vehicle_no VARCHAR(32) NULL,
store_name VARCHAR(128) NULL, store_contact VARCHAR(64) NULL, store_phone VARCHAR(32) NULL, store_address VARCHAR(255) NULL,
store_city VARCHAR(64) NULL, store_zipcode VARCHAR(16) NULL, package_count INT UNSIGNED NULL, gross_weight_kg DECIMAL(18,3) NULL,
volume_cbm DECIMAL(18,4) NULL, pallet_count INT UNSIGNED NULL, po_no VARCHAR(255) NULL, planned_loading_at DATETIME(3) NULL,
actual_loading_at DATETIME(3) NULL, transit_minutes INT UNSIGNED NULL, payable_amount DECIMAL(18,2) NULL, receivable_amount DECIMAL(18,2) NULL,
currency VARCHAR(8) NOT NULL DEFAULT 'RMB', status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', revision INT UNSIGNED NOT NULL DEFAULT 0,
version INT UNSIGNED NOT NULL DEFAULT 0, remark VARCHAR(1000) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no),
KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_container(container_booking_id), KEY idx_truck_appointment_customer(customer_id),
CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED'))
) ENGINE=InnoDB COMMENT='三期拖车预约派车聚合';
CREATE TABLE IF NOT EXISTS fl_truck_appointment_history (
id BIGINT UNSIGNED NOT NULL, appointment_id BIGINT UNSIGNED NOT NULL, revision INT UNSIGNED NOT NULL, action VARCHAR(32) NOT NULL,
from_status VARCHAR(32) NULL, to_status VARCHAR(32) NULL, reason VARCHAR(500) NULL, operator_id BIGINT UNSIGNED NOT NULL,
operator_name VARCHAR(64) NULL, request_id VARCHAR(128) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id), KEY idx_truck_history(appointment_id,revision)
) ENGINE=InnoDB COMMENT='拖车状态与改约历史';
CREATE TABLE IF NOT EXISTS fl_truck_appointment_attachment (
id BIGINT UNSIGNED NOT NULL, appointment_id BIGINT UNSIGNED NOT NULL, file_id BIGINT UNSIGNED NOT NULL, attachment_type VARCHAR(32) NOT NULL,
file_name VARCHAR(255) NULL, file_hash CHAR(64) NULL, visibility VARCHAR(24) NOT NULL DEFAULT 'INTERNAL', uploaded_by BIGINT UNSIGNED NOT NULL,
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id), UNIQUE KEY uk_truck_attachment(appointment_id,file_id), KEY idx_truck_attachment(appointment_id)
) ENGINE=InnoDB COMMENT='拖车附件文件引用';
CREATE TABLE IF NOT EXISTS fl_truck_appointment_operation (
id BIGINT UNSIGNED NOT NULL, appointment_id BIGINT UNSIGNED NOT NULL, idempotency_key VARCHAR(128) NOT NULL, request_hash CHAR(64) NOT NULL,
action VARCHAR(32) NOT NULL, response_snapshot JSON NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id), UNIQUE KEY uk_truck_operation(idempotency_key), KEY idx_truck_operation_appointment(appointment_id)
) ENGINE=InnoDB COMMENT='拖车操作幂等记录';
@@ -0,0 +1,6 @@
package com.baiyue.firstleg.controller;
import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import org.springframework.security.access.prepost.PreAuthorize; import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; import static org.assertj.core.api.Assertions.assertThat;
class TruckAppointmentControllerContractTest {
@Test void exposesDedicatedEndpointAndPermissions() {String p=PlatformTruckAppointmentController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0];assertThat(p).isEqualTo("/api/platform/v1/truck-appointments"); for(String n:new String[]{"page","detail","create","edit","action","reschedule"}) assertThat(java.util.Arrays.stream(PlatformTruckAppointmentController.class.getDeclaredMethods()).anyMatch(m->m.getName().equals(n))).isTrue();}
@Test void schemaHasAggregateTablesAndStates() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED");}
}
@@ -0,0 +1,56 @@
package com.baiyue.firstleg.oceanbooking.controller;
import com.baiyue.common.audit.BusinessAudit;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformOceanBookingControllerContractTest {
@Test
void usesDedicatedResourceAndPermissions() {
assertThat(PlatformOceanBookingController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/platform/v1/ocean-bookings");
Map<String, String> permissions = Map.ofEntries(
Map.entry("page", "platform:firstleg:ocean-booking:list"),
Map.entry("detail", "platform:firstleg:ocean-booking:view"),
Map.entry("create", "platform:firstleg:ocean-booking:create"),
Map.entry("updateDraft", "platform:firstleg:ocean-booking:edit"),
Map.entry("submit", "platform:firstleg:ocean-booking:submit"),
Map.entry("confirm", "platform:firstleg:ocean-booking:confirm"),
Map.entry("fail", "platform:firstleg:ocean-booking:confirm"),
Map.entry("release", "platform:firstleg:ocean-booking:release"),
Map.entry("cancel", "platform:firstleg:ocean-booking:cancel"),
Map.entry("requestChange", "platform:firstleg:ocean-booking:change"),
Map.entry("confirmChange", "platform:firstleg:ocean-booking:change"),
Map.entry("failChange", "platform:firstleg:ocean-booking:change"),
Map.entry("allocate", "platform:firstleg:ocean-booking:allocate"));
permissions.forEach((name, permission) -> {
Method method = method(name);
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + permission + "')");
if (!name.equals("page") && !name.equals("detail")) {
assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull();
}
});
}
@Test
void pathVariablesUseExplicitAggregateNames() {
assertThat(Arrays.stream(PlatformOceanBookingController.class.getDeclaredMethods())
.filter(method -> !method.getName().equals("page") && !method.getName().equals("create"))
.flatMap(method -> Arrays.stream(method.getParameters()))
.filter(parameter -> parameter.isAnnotationPresent(org.springframework.web.bind.annotation.PathVariable.class))
.allMatch(parameter -> parameter.getName().equals("oceanBookingId"))).isTrue();
}
private static Method method(String name) {
return Arrays.stream(PlatformOceanBookingController.class.getDeclaredMethods())
.filter(method -> method.getName().equals(name)).findFirst().orElseThrow();
}
}
@@ -0,0 +1,39 @@
package com.baiyue.firstleg.oceanbooking.repository;
import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingMapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class OceanBookingMapperContractTest {
@Test
void listUsesPlatformAggregateFilters() throws Exception {
String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("selectPage",
com.baomidou.mybatisplus.extension.plugins.pagination.Page.class,
com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query.class)
.getAnnotation(Select.class).value());
assertThat(sql).contains("customer_id=#{query.customerId}")
.contains("status=#{query.status}")
.contains("booking_no LIKE")
.contains("ORDER BY updated_at DESC,id DESC");
}
@Test
void everyMutationUsesExpectedVersionAndStateGuard() {
String sql = Arrays.stream(OceanBookingMapper.class.getDeclaredMethods())
.map(method -> method.getAnnotation(Update.class))
.filter(java.util.Objects::nonNull)
.flatMap(annotation -> Arrays.stream(annotation.value()))
.reduce("", (left, right) -> left + '\n' + right);
assertThat(sql)
.contains("status='DRAFT' AND version=#{expectedVersion}")
.contains("status=#{fromStatus} AND version=#{expectedVersion}")
.contains("status='CONFIRMED' AND version=#{expectedVersion}")
.contains("status='CHANGE_PENDING' AND version=#{expectedVersion}")
.contains("status IN ('SUBMITTED','CONFIRMED','CHANGE_PENDING')");
}
}
@@ -0,0 +1,58 @@
package com.baiyue.firstleg.oceanbooking.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class OceanBookingSqlContractTest {
@Test
void definesDedicatedAggregateWithoutChangingContainerSemantics() throws Exception {
String sql = sql();
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking (")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_party")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_allocation")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_status_history")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation")
.contains("container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID'")
.contains("REFERENCES fl_container_booking(id)")
.doesNotContain("ALTER TABLE fl_container_booking");
}
@Test
void enforcesStateIdempotencyAndAuditContracts() throws Exception {
assertThat(sql())
.contains("OLD.status='DRAFT' AND NEW.status IN ('SUBMITTED','CANCELLED')")
.contains("OLD.status='CONFIRMED' AND NEW.status IN ('RELEASED','CHANGE_PENDING','CANCELLED')")
.contains("OLD.status='CHANGE_PENDING' AND NEW.status IN ('CONFIRMED','FAILED','CANCELLED')")
.contains("OLD.status IN ('RELEASED','CANCELLED')")
.contains("UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key)")
.contains("request_hash CHAR(64) NOT NULL")
.contains("订舱状态历史不可修改")
.contains("UNIQUE KEY uk_ocean_allocation_container (container_booking_id)");
}
@Test
void declaresDedicatedPlatformPermissions() throws Exception {
assertThat(sql())
.contains("platform:firstleg:ocean-booking:list")
.contains("platform:firstleg:ocean-booking:view")
.contains("platform:firstleg:ocean-booking:create")
.contains("platform:firstleg:ocean-booking:edit")
.contains("platform:firstleg:ocean-booking:submit")
.contains("platform:firstleg:ocean-booking:confirm")
.contains("platform:firstleg:ocean-booking:release")
.contains("platform:firstleg:ocean-booking:cancel")
.contains("platform:firstleg:ocean-booking:change")
.contains("platform:firstleg:ocean-booking:allocate");
}
private static String sql() throws Exception {
return new ClassPathResource("db/phase3-ocean-booking-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,121 @@
package com.baiyue.firstleg.oceanbooking.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ActionRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ChangeRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Detail;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.ArgumentCaptor;
import org.mockito.MockitoAnnotations;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class OceanBookingServiceTest {
@Mock private OceanBookingRepository repository;
@InjectMocks private OceanBookingService service;
@BeforeEach
void setUp() throws Exception {
MockitoAnnotations.openMocks(this);
var field = OceanBookingService.class.getDeclaredField("objectMapper");
field.setAccessible(true); field.set(service, new ObjectMapper().findAndRegisterModules());
when(repository.parties(anyLong())).thenReturn(List.of());
when(repository.requirements(anyLong())).thenReturn(List.of());
when(repository.allocations(anyLong())).thenReturn(List.of());
when(repository.histories(anyLong())).thenReturn(List.of());
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(1);
}
@Test
void advancesDraftToSubmittedWithVersionAndHistory() {
when(repository.find(1L)).thenReturn(booking("DRAFT", 2), booking("SUBMITTED", 3));
var shipper = new com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity(); shipper.setPartyType("SHIPPER");
var consignee = new com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity(); consignee.setPartyType("CONSIGNEE");
var notify = new com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity(); notify.setPartyType("NOTIFY_PARTY");
when(repository.parties(1L)).thenReturn(List.of(shipper, consignee, notify));
when(repository.requirements(1L)).thenReturn(List.of(new com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity()));
when(repository.transition(1L, "DRAFT", "SUBMITTED", null, null, 2, 9L, "admin")).thenReturn(1);
Detail result = service.submit(1L, 9L, "admin", "submit-1", new ActionRequest(2, "提交"));
assertThat(result.oceanBooking().getStatus()).isEqualTo("SUBMITTED");
ArgumentCaptor<OceanBookingStatusHistoryEntity> history =
ArgumentCaptor.forClass(OceanBookingStatusHistoryEntity.class);
verify(repository).insert(history.capture());
assertThat(history.getValue().getFromStatus()).isEqualTo("DRAFT");
assertThat(history.getValue().getToStatus()).isEqualTo("SUBMITTED");
assertThat(history.getValue().getAction()).isEqualTo("SUBMIT");
}
@Test
void rejectsCancellationAfterRelease() {
when(repository.find(1L)).thenReturn(booking("RELEASED", 4));
assertThatThrownBy(() -> service.cancel(1L, 9L, "admin", "cancel-1",
new ActionRequest(4, "错误"))).isInstanceOf(BizException.class)
.hasMessageContaining("不可逆");
verify(repository, never()).transition(anyLong(), anyString(), anyString(), any(), any(), anyInt(), anyLong(), anyString());
}
@Test
void keepsCurrentVoyagePendingUntilChangeConfirmation() {
OceanBookingEntity current = booking("CONFIRMED", 5);
current.setVesselName("OLD"); current.setVoyageNo("V1");
OceanBookingEntity pending = booking("CHANGE_PENDING", 6);
pending.setVesselName("OLD"); pending.setVoyageNo("V1"); pending.setPendingVesselName("NEW"); pending.setPendingVoyageNo("V2");
when(repository.find(1L)).thenReturn(current, pending);
when(repository.requestChange(eq(1L), any(), eq("改船"), eq(5), eq(9L), eq("admin"))).thenReturn(1);
Detail result = service.requestChange(1L, 9L, "admin", "change-1",
new ChangeRequest(5, "NEW", "V2", LocalDateTime.of(2026, 9, 1, 0, 0),
LocalDateTime.of(2026, 9, 20, 0, 0), "改船"));
assertThat(result.oceanBooking().getStatus()).isEqualTo("CHANGE_PENDING");
assertThat(result.oceanBooking().getVesselName()).isEqualTo("OLD");
assertThat(result.oceanBooking().getPendingVesselName()).isEqualTo("NEW");
}
@Test
void replaysCompletedRequestBeforeVersionValidation() throws Exception {
OceanBookingEntity submitted = booking("SUBMITTED", 3);
Detail replay = new Detail(submitted, List.of(), List.of(), List.of(), List.of());
OceanBookingOperationEntity operation = new OceanBookingOperationEntity();
operation.setRequestHash(hashForSubmit()); operation.setStatus("SUCCESS");
operation.setResultJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(replay));
when(repository.claimOperation(eq(1L), eq(9L), eq("SUBMIT"), eq("submit-replay"), anyString())).thenReturn(false);
when(repository.operation(9L, "SUBMIT", "submit-replay")).thenReturn(operation);
Detail result = service.submit(1L, 9L, "admin", "submit-replay", new ActionRequest(2, "提交"));
assertThat(result.oceanBooking().getStatus()).isEqualTo("SUBMITTED");
verify(repository, never()).find(anyLong());
}
private String hashForSubmit() throws Exception {
var method = OceanBookingService.class.getDeclaredMethod("hash", String.class, Long.class, Object.class);
method.setAccessible(true);
return (String) method.invoke(service, "SUBMIT", 1L, new ActionRequest(2, "提交"));
}
private static OceanBookingEntity booking(String status, int version) {
OceanBookingEntity value = new OceanBookingEntity(); value.setId(1L); value.setBookingNo("OB1");
value.setStatus(status); value.setCustomerId(100L); value.setCustomerName("客户"); value.setVersion(version);
return value;
}
}
@@ -0,0 +1,28 @@
package com.baiyue.iam.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
/** Internal Warehouse contract for a customer's default overseas storage fees. */
@FeignClient(name = "warehouse-service", contextId = "overseasStorageFeeClient",
url = "${baiyue.clients.warehouse.url:}",
path = "/internal/warehouse/v1/customer-overseas-storage-fees")
public interface OverseasStorageFeeClient {
@PostMapping("/defaults")
ApiResponse<InitializationResponse> initialize(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody InitializationRequest request);
@DeleteMapping("/defaults")
ApiResponse<Void> compensate(@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam long tenantId,
@RequestParam String initializationKey);
record InitializationRequest(long tenantId, long operatorId, String initializationKey) { }
record InitializationResponse(Long configId, boolean createdByThisInitialization) { }
}
@@ -41,6 +41,7 @@ public class MerchantOnboardingService {
@Resource private IamTenantQualificationMapper qualificationMapper;
@Resource private IamTenantReviewMapper reviewMapper;
@Resource private CustomerDimensionRuleInitializationService dimensionRuleInitialization;
@Resource private OverseasStorageFeeInitializationService overseasStorageFeeInitialization;
@Transactional
public MerchantRegistrationResponse register(MerchantRegistrationRequest request) {
@@ -120,6 +121,7 @@ public class MerchantOnboardingService {
addressMapper.insert(address);
}
dimensionRuleInitialization.initialize(tenantId, request.authUserId());
overseasStorageFeeInitialization.initialize(tenantId, request.authUserId());
return new MerchantRegistrationResponse(tenantId, membershipId, tenant.getStatus());
}
@@ -161,6 +163,7 @@ public class MerchantOnboardingService {
"IAM registration compensation is unsafe: onboarding has progressed");
}
overseasStorageFeeInitialization.compensateRegistration(tenant.getId());
dimensionRuleInitialization.compensateRegistration(tenant.getId());
membershipRoleMapper.delete(new LambdaQueryWrapper<IamMembershipRole>()
@@ -0,0 +1,96 @@
package com.baiyue.iam.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.client.OverseasStorageFeeClient;
import jakarta.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
/** Creates the nine editable default fee tiers for a customer. */
@Service
public class OverseasStorageFeeInitializationService {
private static final Logger log = LoggerFactory.getLogger(OverseasStorageFeeInitializationService.class);
private static final int MAX_ATTEMPTS = 3;
private static final String KEY_PREFIX = "iam-customer-create-overseas-fee:";
@Resource private OverseasStorageFeeClient client;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
public InitializationResult initialize(long tenantId, long operatorId) {
String key = initializationKey(tenantId);
RuntimeException last = null;
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
ApiResponse<OverseasStorageFeeClient.InitializationResponse> response = client.initialize(
internalToken, new OverseasStorageFeeClient.InitializationRequest(tenantId, operatorId, key));
if (response != null && response.successful() && response.data() != null) {
InitializationResult result = new InitializationResult(
response.data().createdByThisInitialization(), key);
registerRollbackCompensation(tenantId, result);
return result;
}
last = unavailable(response);
} catch (RuntimeException failure) {
last = failure;
log.warn("海外仓费用初始化调用异常,tenantId={}, attempt={}", tenantId, attempt, failure);
}
}
compensateAfterUncertainInitialization(tenantId, key);
throw unavailable(last);
}
public void compensateRegistration(long tenantId) {
compensateRequired(tenantId, initializationKey(tenantId));
}
private void registerRollbackCompensation(long tenantId, InitializationResult result) {
if (!result.created() || !TransactionSynchronizationManager.isSynchronizationActive()) return;
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override public void afterCompletion(int status) {
if (status != STATUS_ROLLED_BACK) return;
try { compensateRequired(tenantId, result.initializationKey()); }
catch (RuntimeException ex) {
log.error("客户创建事务回滚后海外仓费用补偿失败,tenantId={}, initializationKey={}", tenantId,
result.initializationKey(), ex);
}
}
});
}
private void compensateAfterUncertainInitialization(long tenantId, String key) {
try { compensateRequired(tenantId, key); }
catch (RuntimeException ex) { log.error("海外仓费用初始化结果不确定且补偿失败,tenantId={}, initializationKey={}", tenantId, key, ex); }
}
private void compensateRequired(long tenantId, String key) {
RuntimeException last = null;
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
ApiResponse<Void> response = client.compensate(internalToken, tenantId, key);
if (response != null && response.successful()) return;
last = unavailable(response);
} catch (RuntimeException ex) { last = ex; }
}
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "海外仓费用补偿失败,请根据客户ID修复数据一致性: " + tenantId);
}
private static String initializationKey(long tenantId) { return KEY_PREFIX + tenantId; }
private static BizException unavailable(ApiResponse<?> response) {
String detail = response == null || response.message() == null || response.message().isBlank()
? "Warehouse 服务未返回有效响应" : response.message();
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "海外仓费用初始化失败: " + detail);
}
private static BizException unavailable(RuntimeException cause) {
String detail = cause == null || cause.getMessage() == null || cause.getMessage().isBlank()
? "Warehouse 服务暂时不可用" : cause.getMessage();
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "海外仓费用初始化失败,已重试 3 次: " + detail);
}
public record InitializationResult(boolean created, String initializationKey) { }
}
@@ -63,6 +63,8 @@ public class TenantService {
@Resource
private CustomerDimensionRuleInitializationService dimensionRuleInitialization;
@Resource
private OverseasStorageFeeInitializationService overseasStorageFeeInitialization;
@Resource
private TenantAdministratorProvisioningService administratorProvisioning;
@Transactional
@@ -180,6 +182,7 @@ public class TenantService {
tenant.setUpdatedBy(operatorId);
repository.insertTenant(tenant);
dimensionRuleInitialization.initialize(tenant.getId(), operatorId);
overseasStorageFeeInitialization.initialize(tenant.getId(), operatorId);
var administrator = administratorProvisioning.provisionPlatformCreatedTenant(tenant, operatorId);
return new CreationResponse(response(tenant), administrator.username(), administrator.temporaryPassword());
@@ -23,8 +23,12 @@ BEGIN
END IF;
IF EXISTS (
SELECT 1 FROM iam_permission
WHERE (id = 5103040 AND permission_code <> 'platform:download:list')
OR (id = 5103041 AND permission_code <> 'platform:download:all')
WHERE (id = 5103040 AND NOT (
(permission_code = 'platform:download:list' AND parent_id = 5103000)
OR (permission_code = 'platform:location:list' AND parent_id = 5103000)))
OR (id = 5103041 AND NOT (
(permission_code = 'platform:download:all' AND parent_id = 5103040)
OR (permission_code = 'platform:location:create' AND parent_id = 5103040)))
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '库位权限迁移失败:下载中心稳定 ID 被未知权限占用';
@@ -0,0 +1,67 @@
package com.baiyue.iam.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class LocationPermissionSqlContractTest {
@Test
void acceptsKnownPollutionAndRestoresCanonicalPermissionIds() throws Exception {
assertRepairContract(moduleSql());
assertRepairContract(unifiedSql());
}
private static void assertRepairContract(String sql) {
assertThat(sql)
.contains("permission_code = 'platform:download:list' AND parent_id = 5103000")
.contains("permission_code = 'platform:location:list' AND parent_id = 5103000")
.contains("permission_code = 'platform:download:all' AND parent_id = 5103040")
.contains("permission_code = 'platform:location:create' AND parent_id = 5103040")
.contains("库位权限迁移失败:下载中心稳定 ID 被未知权限占用")
.contains("(5103040, 0, 'PLATFORM', 'MENU', 5103000, 'platform:download:list'")
.contains("(5103041, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:download:all'")
.contains("(5103060, 0, 'PLATFORM', 'MENU', 5103000, 'platform:location:list'")
.contains("(5103061, 0, 'PLATFORM', 'BUTTON', 5103060, 'platform:location:create'");
int snapshot = sql.indexOf("INSERT IGNORE INTO tmp_location_role_grant");
int delete = firstPositive(
sql.indexOf("DELETE role_permission", snapshot),
sql.indexOf("DELETE rp", snapshot));
int canonicalLocation = sql.indexOf("(5103060, 0, 'PLATFORM'", delete);
int restore = sql.indexOf("FROM tmp_location_role_grant", canonicalLocation);
int cleanup = sql.indexOf("DROP TEMPORARY TABLE IF EXISTS tmp_location_role_grant", restore);
assertThat(snapshot).isGreaterThan(0);
assertThat(delete).isGreaterThan(snapshot);
assertThat(canonicalLocation).isGreaterThan(delete);
assertThat(restore).isGreaterThan(canonicalLocation);
assertThat(cleanup).isGreaterThan(restore);
}
private static String moduleSql() throws Exception {
return new ClassPathResource("db/phase2-location-permissions.sql")
.getContentAsString(StandardCharsets.UTF_8);
}
private static int firstPositive(int left, int right) {
if (left < 0) {
return right;
}
if (right < 0) {
return left;
}
return Math.min(left, right);
}
private static String unifiedSql() throws Exception {
Path path = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql");
if (!Files.exists(path)) {
path = Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql").normalize();
}
return Files.readString(path, StandardCharsets.UTF_8);
}
}
@@ -40,6 +40,7 @@ class MerchantOnboardingServiceTest {
@MockBean private IamTenantQualificationMapper qualifications;
@MockBean private IamTenantReviewMapper reviews;
@MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization;
@MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization;
@Resource private MerchantOnboardingService service;
@Test
void clientRegistrationCreatesOwnerAndAssignsGlobalMerchantOwnerRole() {
@@ -65,6 +66,7 @@ class MerchantOnboardingServiceTest {
verify(memberships).insert(membership.capture());
verify(membershipRoles).insert(roleGrant.capture());
verify(dimensionRuleInitialization).initialize(result.tenantId(), 9001L);
verify(overseasStorageFeeInitialization).initialize(result.tenantId(), 9001L);
assertEquals("PROFILE_REQUIRED", result.tenantStatus());
assertEquals(result.tenantId(), tenant.getValue().getId());
@@ -0,0 +1,42 @@
package com.baiyue.iam.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.client.OverseasStorageFeeClient;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(OverseasStorageFeeInitializationService.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
class OverseasStorageFeeInitializationServiceTest {
@MockBean private OverseasStorageFeeClient client;
@Resource private OverseasStorageFeeInitializationService service;
@AfterEach void clearTransaction() {
if (TransactionSynchronizationManager.isSynchronizationActive()) TransactionSynchronizationManager.clearSynchronization();
}
@Test void retriesThreeTimesWithDeterministicKey() {
when(client.initialize(anyString(), any())).thenThrow(new RuntimeException("timeout"));
when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null));
BizException error = assertThrows(BizException.class, () -> service.initialize(208L, 9L));
assertEquals(ResultCode.SERVICE_UNAVAILABLE, error.getResultCode());
verify(client, times(3)).initialize(anyString(), any(OverseasStorageFeeClient.InitializationRequest.class));
verify(client).compensate("internal-token", 208L, "iam-customer-create-overseas-fee:208");
}
}
@@ -26,6 +26,7 @@ class TenantCustomerExportTest {
@MockBean private AuthAccountClient authAccountClient;
@MockBean private TenantApprovalInitializationCoordinator approvalInitialization;
@MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization;
@MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization;
@MockBean private TenantAdministratorProvisioningService administratorProvisioning;
@Resource private TenantService service;
@Test
@@ -54,6 +54,7 @@ class TenantServiceTest {
@MockBean private IamRoleMapper roles;
@MockBean private AuthAccountClient authClient;
@MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization;
@MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization;
@SpyBean private TenantAdministratorProvisioningService provisioning;
@Resource private TenantService service;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,183 @@
{
"reportName": "后台管理测试环境 HTTP API 验收",
"marker": "ADMIN-API-20260808053139613",
"gateway": "http://8.163.30.128:9000",
"generatedAt": "2026-08-08T05:31:40.523Z",
"startedAt": "2026-08-08T05:31:39.613Z",
"success": false,
"fatal": null,
"summary": {
"total": 7,
"passed": 6,
"failed": 1
},
"modules": [
{
"name": "overseas-storage-fees",
"startedAt": "2026-08-08T05:31:39.991Z",
"finishedAt": "2026-08-08T05:31:40.523Z",
"success": false,
"passed": 5,
"failed": 1,
"fatal": null
}
],
"scenarios": [],
"deploymentBlockers": [
{
"module": "overseas-storage-fees",
"scenario": "customer-overseas-storage-fee-lifecycle",
"name": "查询客户海外仓费用配置",
"evidence": "tenantId=2085962119627001858",
"code": "S0500",
"requestId": "5ac21bf6-d89e-457b-ba67-e3e26cfae24f",
"traceId": "f7ff48a216604bcebe6b439b86664e3e"
}
],
"createdResources": {
"overseasStorageFeeCustomer": {
"tenantId": "2085962119627001858",
"marker": "ADMIN-API-20260808053139613",
"ownedByModule": true
}
},
"excluded": [
{
"scope": "dashboard",
"reason": "首页为静态演示数据,不调用后端 API"
},
{
"scope": "hidden-prototypes",
"reason": "隐藏原型页面尚无已确认后端契约"
}
],
"securityNote": "报告不保存登录密码、Access Token、Refresh Token、图片二进制或 OCR 原文全文。",
"steps": [
{
"module": "authentication",
"scenario": null,
"name": "平台管理员登录",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/auth/login"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "ce3d57ae-2efd-4538-ba8c-7e9cd66b3f41, 4b748553-b0c9-4444-9da2-581a34b48c03",
"traceId": "a5d46d17762248e5aefef4f0a42f6938",
"evidence": "",
"checkedAt": "2026-08-08T05:31:39.990Z"
},
{
"module": "overseas-storage-fees",
"scenario": null,
"name": "检查测试联系电话可用性",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/iam/tenants/contact-phone-availability?contactPhone=16167099991"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "eb2ed61a-3879-4556-bbdc-82f2647db1e3, b894c681-0997-4b20-bc8a-304a5f31bfe0",
"traceId": "7e280a48c44b4e4c898dbafc3215f64e",
"evidence": "attempt=1",
"checkedAt": "2026-08-08T05:31:40.036Z"
},
{
"module": "overseas-storage-fees",
"scenario": "customer-overseas-storage-fee-lifecycle",
"name": "创建海外仓费用隔离客户",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/platform/v1/iam/tenants"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "ba5aaf07-8135-4bfb-93ef-05ab2d77ec53, 3573b84d-f37d-4138-9dd8-60db25742106",
"traceId": "d7d1160a98e64a5d80ea9c29a9043d7d",
"evidence": "",
"checkedAt": "2026-08-08T05:31:40.388Z"
},
{
"module": "overseas-storage-fees",
"scenario": "customer-overseas-storage-fee-lifecycle",
"name": "海外仓费用隔离客户创建并带 marker",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/platform/v1/iam/tenants"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "ba5aaf07-8135-4bfb-93ef-05ab2d77ec53, 3573b84d-f37d-4138-9dd8-60db25742106",
"traceId": "d7d1160a98e64a5d80ea9c29a9043d7d",
"evidence": "tenantId=2085962119627001858",
"checkedAt": "2026-08-08T05:31:40.388Z"
},
{
"module": "overseas-storage-fees",
"scenario": "customer-overseas-storage-fee-lifecycle",
"name": "查询客户海外仓费用配置",
"passed": false,
"deploymentBlocker": true,
"request": {
"method": "GET",
"path": "/api/platform/v1/iam/tenants/2085962119627001858/overseas-storage-fees"
},
"http": 200,
"code": "S0500",
"message": "Internal server error",
"requestId": "5ac21bf6-d89e-457b-ba67-e3e26cfae24f",
"traceId": "f7ff48a216604bcebe6b439b86664e3e",
"evidence": "tenantId=2085962119627001858",
"checkedAt": "2026-08-08T05:31:40.416Z"
},
{
"module": "overseas-storage-fees",
"scenario": "customer-overseas-storage-fee-lifecycle",
"name": "费用配置契约不可用时清理隔离客户",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/platform/v1/iam/tenants/2085962119627001858/archive"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "61927608-9447-4f22-aef7-3b485a49aeab, d8f074c2-3f63-447d-8b8e-c774c7448dcb",
"traceId": "1663d1977f2f406aaaadf99f5f706194",
"evidence": "tenantId=2085962119627001858",
"checkedAt": "2026-08-08T05:31:40.523Z"
},
{
"module": "overseas-storage-fees",
"scenario": "customer-overseas-storage-fee-lifecycle",
"name": "隔离客户清理后停用",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/platform/v1/iam/tenants/2085962119627001858/archive"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "61927608-9447-4f22-aef7-3b485a49aeab, d8f074c2-3f63-447d-8b8e-c774c7448dcb",
"traceId": "1663d1977f2f406aaaadf99f5f706194",
"evidence": "status=DISABLED",
"checkedAt": "2026-08-08T05:31:40.523Z"
}
]
}
+881
View File
@@ -0,0 +1,881 @@
{
"reportName": "后台管理测试环境 HTTP API 验收",
"marker": "ADMIN-API-20260808052841329",
"gateway": "http://8.163.30.128:9000",
"generatedAt": "2026-08-08T05:28:43.083Z",
"startedAt": "2026-08-08T05:28:41.329Z",
"success": false,
"fatal": null,
"summary": {
"total": 44,
"passed": 42,
"failed": 2
},
"modules": [
{
"name": "customers",
"startedAt": "2026-08-08T05:28:41.701Z",
"finishedAt": "2026-08-08T05:28:42.618Z",
"success": true,
"passed": 38,
"failed": 0,
"fatal": null
},
{
"name": "overseas-storage-fees",
"startedAt": "2026-08-08T05:28:42.620Z",
"finishedAt": "2026-08-08T05:28:43.083Z",
"success": false,
"passed": 3,
"failed": 2,
"fatal": null
}
],
"scenarios": [
{
"name": "customer-dimension-rules",
"module": "customers",
"success": true,
"deploymentBlocked": false,
"passed": 13,
"failed": 0
}
],
"deploymentBlockers": [
{
"module": "overseas-storage-fees",
"scenario": "customer-overseas-storage-fee-lifecycle",
"name": "查询客户海外仓费用配置",
"evidence": "tenantId=2085961371849707522",
"code": "S0500",
"requestId": "9a0ffc8d-64cf-4db8-aff5-5e5069e07a5c",
"traceId": "8609dab1dc4848a69a31a80316301fae"
},
{
"module": "overseas-storage-fees",
"scenario": "customer-overseas-storage-fee-lifecycle",
"name": "查询新隔离客户海外仓费用配置",
"evidence": "tenantId=2085961375817519106",
"code": "S0500",
"requestId": "f3e84b20-6334-49ed-871a-9d1d6df15f42",
"traceId": "25bc4f8838024520973270e4724c3069"
}
],
"createdResources": {
"customer": {
"tenantId": "2085961371849707522",
"marker": "ADMIN-API-20260808052841329",
"contactPhone": "16166921702"
},
"overseasStorageFeeCustomer": {
"tenantId": "2085961375817519106",
"marker": "ADMIN-API-20260808052841329",
"ownedByModule": true
}
},
"excluded": [
{
"scope": "dashboard",
"reason": "首页为静态演示数据,不调用后端 API"
},
{
"scope": "hidden-prototypes",
"reason": "隐藏原型页面尚无已确认后端契约"
}
],
"securityNote": "报告不保存登录密码、Access Token、Refresh Token、图片二进制或 OCR 原文全文。",
"steps": [
{
"module": "authentication",
"scenario": null,
"name": "平台管理员登录",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/auth/login"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "7bd48d5c-0996-4c05-ae5c-a018e4136f71, c3051067-539f-486b-96c5-de71fa4a995e",
"traceId": "cb33a5d8cefd4240acdd7ae6b1aebd3c",
"evidence": "",
"checkedAt": "2026-08-08T05:28:41.701Z"
},
{
"module": "customers",
"scenario": null,
"name": "检查测试联系电话可用性",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/iam/tenants/contact-phone-availability?contactPhone=16166921702"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "44c4b252-bead-4b01-b827-910a733142c5, 4d2c591a-9b8f-41c1-a03f-3b4ac596977b",
"traceId": "2c3aedac6f7c4860840e8519438b9c48",
"evidence": "attempt=1",
"checkedAt": "2026-08-08T05:28:41.745Z"
},
{
"module": "customers",
"scenario": null,
"name": "创建客户",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/platform/v1/iam/tenants"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "5edebd18-27c0-4e6d-91b8-3db63f1f1128, 13710f1e-4520-4b85-9558-6ceb62e5c920",
"traceId": "682263346ca84afbb7007ae9e9518b6e",
"evidence": "",
"checkedAt": "2026-08-08T05:28:42.108Z"
},
{
"module": "customers",
"scenario": null,
"name": "创建响应客户字段完整",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/platform/v1/iam/tenants"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "5edebd18-27c0-4e6d-91b8-3db63f1f1128, 13710f1e-4520-4b85-9558-6ceb62e5c920",
"traceId": "682263346ca84afbb7007ae9e9518b6e",
"evidence": "missing=none",
"checkedAt": "2026-08-08T05:28:42.108Z"
},
{
"module": "customers",
"scenario": null,
"name": "新客户基础字段正确",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/platform/v1/iam/tenants"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "5edebd18-27c0-4e6d-91b8-3db63f1f1128, 13710f1e-4520-4b85-9558-6ceb62e5c920",
"traceId": "682263346ca84afbb7007ae9e9518b6e",
"evidence": "tenantId=2085961371849707522, status=PROFILE_REQUIRED",
"checkedAt": "2026-08-08T05:28:42.108Z"
},
{
"module": "customers",
"scenario": "customer-owner-provisioning",
"name": "创建客户生成初始密码",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/platform/v1/iam/tenants"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "5edebd18-27c0-4e6d-91b8-3db63f1f1128, 13710f1e-4520-4b85-9558-6ceb62e5c920",
"traceId": "682263346ca84afbb7007ae9e9518b6e",
"evidence": "generated=true, length=16",
"checkedAt": "2026-08-08T05:28:42.108Z"
},
{
"module": "customers",
"scenario": "customer-owner-provisioning",
"name": "管理员用户名默认使用联系电话",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/platform/v1/iam/tenants"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "5edebd18-27c0-4e6d-91b8-3db63f1f1128, 13710f1e-4520-4b85-9558-6ceb62e5c920",
"traceId": "682263346ca84afbb7007ae9e9518b6e",
"evidence": "matches=true",
"checkedAt": "2026-08-08T05:28:42.108Z"
},
{
"module": "customers",
"scenario": null,
"name": "待完善客户的审核字段保持为空",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/platform/v1/iam/tenants"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "5edebd18-27c0-4e6d-91b8-3db63f1f1128, 13710f1e-4520-4b85-9558-6ceb62e5c920",
"traceId": "682263346ca84afbb7007ae9e9518b6e",
"evidence": "creditCode=omitted, approvedAt=omitted",
"checkedAt": "2026-08-08T05:28:42.108Z"
},
{
"module": "customers",
"scenario": "customer-dimension-rules",
"name": "新客户尺寸规则可查询",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/first-leg/customer-dimension-config?tenantId=2085961371849707522"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "3aef291a-01f8-4832-8ba8-419a6c0f0ead, c3e56f7e-9999-4b22-8d04-e983808f52ec",
"traceId": "f4a2481bd696413cbda66b15aa193b2a",
"evidence": "tenantId=2085961371849707522",
"checkedAt": "2026-08-08T05:28:42.131Z"
},
{
"module": "customers",
"scenario": "customer-dimension-rules",
"name": "固定四箱规顺序完整且无重复",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/first-leg/customer-dimension-config?tenantId=2085961371849707522"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "3aef291a-01f8-4832-8ba8-419a6c0f0ead, c3e56f7e-9999-4b22-8d04-e983808f52ec",
"traceId": "f4a2481bd696413cbda66b15aa193b2a",
"evidence": "count=4, order=SMALL,MEDIUM,LARGE,EXTRA_LARGE",
"checkedAt": "2026-08-08T05:28:42.131Z"
},
{
"module": "customers",
"scenario": "customer-dimension-rules",
"name": "四箱规默认内径、加边和备注正确",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/first-leg/customer-dimension-config?tenantId=2085961371849707522"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "3aef291a-01f8-4832-8ba8-419a6c0f0ead, c3e56f7e-9999-4b22-8d04-e983808f52ec",
"traceId": "f4a2481bd696413cbda66b15aa193b2a",
"evidence": "defaults=matched",
"checkedAt": "2026-08-08T05:28:42.131Z"
},
{
"module": "customers",
"scenario": "customer-dimension-rules",
"name": "客户 ID、规则 ID 为字符串且初始版本为0",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/first-leg/customer-dimension-config?tenantId=2085961371849707522"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "3aef291a-01f8-4832-8ba8-419a6c0f0ead, c3e56f7e-9999-4b22-8d04-e983808f52ec",
"traceId": "f4a2481bd696413cbda66b15aa193b2a",
"evidence": "groupTenantIdType=string, rows=string/v0,string/v0,string/v0,string/v0",
"checkedAt": "2026-08-08T05:28:42.131Z"
},
{
"module": "customers",
"scenario": "customer-dimension-rules",
"name": "默认规则外径与 HALF_UP 三位 CBM 正确",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/first-leg/customer-dimension-config?tenantId=2085961371849707522"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "3aef291a-01f8-4832-8ba8-419a6c0f0ead, c3e56f7e-9999-4b22-8d04-e983808f52ec",
"traceId": "f4a2481bd696413cbda66b15aa193b2a",
"evidence": "derivedRows=4",
"checkedAt": "2026-08-08T05:28:42.131Z"
},
{
"module": "customers",
"scenario": "customer-dimension-rules",
"name": "PUT 每行只发送约定7字段并整组保存",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/first-leg/customer-dimension-config?tenantId=2085961371849707522"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "3aef291a-01f8-4832-8ba8-419a6c0f0ead, c3e56f7e-9999-4b22-8d04-e983808f52ec",
"traceId": "f4a2481bd696413cbda66b15aa193b2a",
"evidence": "fields=boxType,innerLengthCm,innerWidthCm,innerHeightCm,edgeAdditionCm,remark,version",
"checkedAt": "2026-08-08T05:28:42.132Z"
},
{
"module": "customers",
"scenario": "customer-dimension-rules",
"name": "一次 PUT 保存四条三位小数规则",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "PUT",
"path": "/api/platform/v1/first-leg/customer-dimension-config?tenantId=2085961371849707522"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "d10274b5-a589-45cb-96ff-184e834a23e3, 2e91ab01-b824-4144-9a57-dcb4c821991b",
"traceId": "9c2435c5174d49519b7cecee19aa94b1",
"evidence": "rows=4",
"checkedAt": "2026-08-08T05:28:42.169Z"
},
{
"module": "customers",
"scenario": "customer-dimension-rules",
"name": "修改后四行版本+1且派生外径/CBM正确",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "PUT",
"path": "/api/platform/v1/first-leg/customer-dimension-config?tenantId=2085961371849707522"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "d10274b5-a589-45cb-96ff-184e834a23e3, 2e91ab01-b824-4144-9a57-dcb4c821991b",
"traceId": "9c2435c5174d49519b7cecee19aa94b1",
"evidence": "versions=1,1,1,1",
"checkedAt": "2026-08-08T05:28:42.169Z"
},
{
"module": "customers",
"scenario": "customer-dimension-rules",
"name": "旧 version 重放 PUT 返回冲突",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "PUT",
"path": "/api/platform/v1/first-leg/customer-dimension-config?tenantId=2085961371849707522"
},
"http": 200,
"code": "A0409",
"message": "箱规尺寸配置已被修改,请刷新后重试",
"requestId": "31327608-24e2-438d-9cc6-3e7c461d2e77",
"traceId": "f83b068272aa446786b61e9a0cfdb0f4",
"evidence": "HTTP=200, code=A0409",
"checkedAt": "2026-08-08T05:28:42.194Z"
},
{
"module": "customers",
"scenario": "customer-dimension-rules",
"name": "冲突后重新查询四条规则",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/first-leg/customer-dimension-config?tenantId=2085961371849707522"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "6d6ca223-59a9-4693-9817-343c650152de, 090f7249-8175-4767-8fa5-ed9058fecd0a",
"traceId": "9d3ad7981cec4142998d83ea9f966dea",
"evidence": "",
"checkedAt": "2026-08-08T05:28:42.221Z"
},
{
"module": "customers",
"scenario": "customer-dimension-rules",
"name": "冲突请求未造成四行部分更新",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/first-leg/customer-dimension-config?tenantId=2085961371849707522"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "6d6ca223-59a9-4693-9817-343c650152de, 090f7249-8175-4767-8fa5-ed9058fecd0a",
"traceId": "9d3ad7981cec4142998d83ea9f966dea",
"evidence": "unchangedRows=4",
"checkedAt": "2026-08-08T05:28:42.221Z"
},
{
"module": "customers",
"scenario": "customer-dimension-rules",
"name": "恢复本轮修改前的四箱规配置",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "PUT",
"path": "/api/platform/v1/first-leg/customer-dimension-config?tenantId=2085961371849707522"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "406330a2-9577-46d9-8274-ed6b9ce129c5, 4729591c-097b-406c-bb5a-2b1b84cc293e",
"traceId": "e6e77578acc24e3987cd12ffd6dcfb32",
"evidence": "restore=original-values",
"checkedAt": "2026-08-08T05:28:42.261Z"
},
{
"module": "customers",
"scenario": "customer-dimension-rules",
"name": "尺寸规则恢复后回显与初始值一致",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "PUT",
"path": "/api/platform/v1/first-leg/customer-dimension-config?tenantId=2085961371849707522"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "406330a2-9577-46d9-8274-ed6b9ce129c5, 4729591c-097b-406c-bb5a-2b1b84cc293e",
"traceId": "e6e77578acc24e3987cd12ffd6dcfb32",
"evidence": "restoredRows=4",
"checkedAt": "2026-08-08T05:28:42.261Z"
},
{
"module": "customers",
"scenario": null,
"name": "查询客户列表",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/iam/tenants?page=1&size=20&keyword=%E5%90%8E%E5%8F%B0%E5%AE%A2%E6%88%B7%E9%AA%8C%E6%94%B6-808052841329"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "5adda92b-b3e6-41d2-ad79-5b84fd6b12f6, 0e4251ef-b331-4092-b724-2dff88e735ee",
"traceId": "ed3d9847e4a04517b2c712f4a10a5525",
"evidence": "",
"checkedAt": "2026-08-08T05:28:42.287Z"
},
{
"module": "customers",
"scenario": null,
"name": "列表包含新客户且字段完整",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/iam/tenants?page=1&size=20&keyword=%E5%90%8E%E5%8F%B0%E5%AE%A2%E6%88%B7%E9%AA%8C%E6%94%B6-808052841329"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "5adda92b-b3e6-41d2-ad79-5b84fd6b12f6, 0e4251ef-b331-4092-b724-2dff88e735ee",
"traceId": "ed3d9847e4a04517b2c712f4a10a5525",
"evidence": "found=true, missing=none",
"checkedAt": "2026-08-08T05:28:42.287Z"
},
{
"module": "customers",
"scenario": null,
"name": "查询客户详情",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/iam/tenants/2085961371849707522"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "d3661a19-b409-402e-aa1a-d25e0930f3a9, 2493a21c-cbc8-4a94-a057-dd4632bc6ea0",
"traceId": "39f989a102f44916a2af220e04baeb2a",
"evidence": "",
"checkedAt": "2026-08-08T05:28:42.314Z"
},
{
"module": "customers",
"scenario": null,
"name": "客户详情基本信息正确",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/iam/tenants/2085961371849707522"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "d3661a19-b409-402e-aa1a-d25e0930f3a9, 2493a21c-cbc8-4a94-a057-dd4632bc6ea0",
"traceId": "39f989a102f44916a2af220e04baeb2a",
"evidence": "tenantId=2085961371849707522",
"checkedAt": "2026-08-08T05:28:42.314Z"
},
{
"module": "customers",
"scenario": null,
"name": "编辑客户",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "PUT",
"path": "/api/platform/v1/iam/tenants/2085961371849707522"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "c546898f-751a-4bb8-ba7b-179e06d18b3d, 4523c600-6a82-466f-b6d1-73944b672219",
"traceId": "fa08ed1091c349aab073e4e185dc70d7",
"evidence": "",
"checkedAt": "2026-08-08T05:28:42.355Z"
},
{
"module": "customers",
"scenario": null,
"name": "客户编辑正确回显",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "PUT",
"path": "/api/platform/v1/iam/tenants/2085961371849707522"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "c546898f-751a-4bb8-ba7b-179e06d18b3d, 4523c600-6a82-466f-b6d1-73944b672219",
"traceId": "fa08ed1091c349aab073e4e185dc70d7",
"evidence": "tenantId=2085961371849707522",
"checkedAt": "2026-08-08T05:28:42.355Z"
},
{
"module": "customers",
"scenario": null,
"name": "回查客户编辑结果",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/iam/tenants/2085961371849707522"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "af8fac55-9b8f-4914-904c-2478a8edf931, 9765471b-ddb6-4354-93cf-670493bd935c",
"traceId": "74d23dd3f576492798326b0d6a97da24",
"evidence": "",
"checkedAt": "2026-08-08T05:28:42.374Z"
},
{
"module": "customers",
"scenario": null,
"name": "客户编辑已持久化",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/iam/tenants/2085961371849707522"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "af8fac55-9b8f-4914-904c-2478a8edf931, 9765471b-ddb6-4354-93cf-670493bd935c",
"traceId": "74d23dd3f576492798326b0d6a97da24",
"evidence": "tenantId=2085961371849707522",
"checkedAt": "2026-08-08T05:28:42.374Z"
},
{
"module": "customers",
"scenario": null,
"name": "业务概况查询资质",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/iam/tenants/2085961371849707522/qualifications"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "391847b4-47ad-4265-bb9a-a7ab7cb86da5, 1e512be6-590a-422e-b12a-1bbec5afecbd",
"traceId": "2a215ea41bfd4065af1be9971e63dcd8",
"evidence": "",
"checkedAt": "2026-08-08T05:28:42.409Z"
},
{
"module": "customers",
"scenario": null,
"name": "资质概况结构正确",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/iam/tenants/2085961371849707522/qualifications"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "391847b4-47ad-4265-bb9a-a7ab7cb86da5, 1e512be6-590a-422e-b12a-1bbec5afecbd",
"traceId": "2a215ea41bfd4065af1be9971e63dcd8",
"evidence": "count=0",
"checkedAt": "2026-08-08T05:28:42.409Z"
},
{
"module": "customers",
"scenario": null,
"name": "业务概况查询成员",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/iam/tenants/2085961371849707522/members"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "201da765-eb27-4ad8-af8e-d97370b5e050, 3e8d56b1-b94f-4fed-b585-7e5c10c2a6fc",
"traceId": "c58cea6082e84438a593b47657cfab40",
"evidence": "",
"checkedAt": "2026-08-08T05:28:42.447Z"
},
{
"module": "customers",
"scenario": "customer-owner-provisioning",
"name": "联系电话账号是企业管理员",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/iam/tenants/2085961371849707522/members"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "201da765-eb27-4ad8-af8e-d97370b5e050, 3e8d56b1-b94f-4fed-b585-7e5c10c2a6fc",
"traceId": "c58cea6082e84438a593b47657cfab40",
"evidence": "ownerFound=true, matches=true",
"checkedAt": "2026-08-08T05:28:42.447Z"
},
{
"module": "customers",
"scenario": null,
"name": "业务概况查询运单数量",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/first-leg/tenant-counts?tenantIds=2085961371849707522"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "0f31ccec-a742-48ce-aa47-5f5dee45b9ac, 5c0a7602-b610-4682-8c01-3e85c7b249cc",
"traceId": "101a71cc84be447a8ca353f8b55201bb",
"evidence": "",
"checkedAt": "2026-08-08T05:28:42.471Z"
},
{
"module": "customers",
"scenario": null,
"name": "业务概况返回当前客户运单数",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/first-leg/tenant-counts?tenantIds=2085961371849707522"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "0f31ccec-a742-48ce-aa47-5f5dee45b9ac, 5c0a7602-b610-4682-8c01-3e85c7b249cc",
"traceId": "101a71cc84be447a8ca353f8b55201bb",
"evidence": "orderCount=0",
"checkedAt": "2026-08-08T05:28:42.472Z"
},
{
"module": "customers",
"scenario": null,
"name": "查询客户运单记录",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/first-leg?tenantId=2085961371849707522&page=1&size=10"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "bb6310db-021f-4954-9fde-9dd80df67183, ec930cc9-84bc-4f0f-a746-82f171d86ace",
"traceId": "3914438d25234a4e9222726070bb26b8",
"evidence": "",
"checkedAt": "2026-08-08T05:28:42.504Z"
},
{
"module": "customers",
"scenario": null,
"name": "运单记录分页结构正确",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/first-leg?tenantId=2085961371849707522&page=1&size=10"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "bb6310db-021f-4954-9fde-9dd80df67183, ec930cc9-84bc-4f0f-a746-82f171d86ace",
"traceId": "3914438d25234a4e9222726070bb26b8",
"evidence": "records=0, total=0",
"checkedAt": "2026-08-08T05:28:42.504Z"
},
{
"module": "customers",
"scenario": "customer-draft-archive",
"name": "归档测试客户",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/platform/v1/iam/tenants/2085961371849707522/archive"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "f9208ab6-bb8e-4f48-9060-8052fb163cee, 780b8eda-83fb-443a-83bf-2b8c6a9e0ef7",
"traceId": "a2bc3cf1a9a341c69a9d2eecc240a048",
"evidence": "tenantId=2085961371849707522",
"checkedAt": "2026-08-08T05:28:42.618Z"
},
{
"module": "customers",
"scenario": "customer-draft-archive",
"name": "测试客户归档后处于停用状态",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/platform/v1/iam/tenants/2085961371849707522/archive"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "f9208ab6-bb8e-4f48-9060-8052fb163cee, 780b8eda-83fb-443a-83bf-2b8c6a9e0ef7",
"traceId": "a2bc3cf1a9a341c69a9d2eecc240a048",
"evidence": "status=DISABLED",
"checkedAt": "2026-08-08T05:28:42.618Z"
},
{
"module": "overseas-storage-fees",
"scenario": "customer-overseas-storage-fee-lifecycle",
"name": "查询客户海外仓费用配置",
"passed": false,
"deploymentBlocker": true,
"request": {
"method": "GET",
"path": "/api/platform/v1/iam/tenants/2085961371849707522/overseas-storage-fees"
},
"http": 200,
"code": "S0500",
"message": "Internal server error",
"requestId": "9a0ffc8d-64cf-4db8-aff5-5e5069e07a5c",
"traceId": "8609dab1dc4848a69a31a80316301fae",
"evidence": "tenantId=2085961371849707522",
"checkedAt": "2026-08-08T05:28:42.651Z"
},
{
"module": "overseas-storage-fees",
"scenario": null,
"name": "检查测试联系电话可用性",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/iam/tenants/contact-phone-availability?contactPhone=16166922651"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "ab6a02bd-d0ae-499d-be09-07bd9d5bf19d, f27bf257-dd9f-425b-b559-10d365e3a43d",
"traceId": "775a52c1cd494ad1b9146f98b35d55e4",
"evidence": "attempt=1",
"checkedAt": "2026-08-08T05:28:42.696Z"
},
{
"module": "overseas-storage-fees",
"scenario": "customer-overseas-storage-fee-lifecycle",
"name": "创建海外仓费用隔离客户",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/platform/v1/iam/tenants"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "f4e72bf7-6583-40df-b3e6-702d69a9e418, b626c897-25d9-4268-abfe-2a63d5a1e261",
"traceId": "f677a0ec39564dadbb22bceb196f19fb",
"evidence": "",
"checkedAt": "2026-08-08T05:28:43.055Z"
},
{
"module": "overseas-storage-fees",
"scenario": "customer-overseas-storage-fee-lifecycle",
"name": "海外仓费用隔离客户创建并带 marker",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/platform/v1/iam/tenants"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "f4e72bf7-6583-40df-b3e6-702d69a9e418, b626c897-25d9-4268-abfe-2a63d5a1e261",
"traceId": "f677a0ec39564dadbb22bceb196f19fb",
"evidence": "tenantId=2085961375817519106",
"checkedAt": "2026-08-08T05:28:43.055Z"
},
{
"module": "overseas-storage-fees",
"scenario": "customer-overseas-storage-fee-lifecycle",
"name": "查询新隔离客户海外仓费用配置",
"passed": false,
"deploymentBlocker": true,
"request": {
"method": "GET",
"path": "/api/platform/v1/iam/tenants/2085961375817519106/overseas-storage-fees"
},
"http": 200,
"code": "S0500",
"message": "Internal server error",
"requestId": "f3e84b20-6334-49ed-871a-9d1d6df15f42",
"traceId": "25bc4f8838024520973270e4724c3069",
"evidence": "tenantId=2085961375817519106",
"checkedAt": "2026-08-08T05:28:43.083Z"
}
]
}
@@ -0,0 +1,789 @@
{
"reportName": "后台管理测试环境 HTTP API 验收",
"marker": "ADMIN-API-20260808042004771",
"gateway": "http://8.163.30.128:9000",
"generatedAt": "2026-08-08T04:20:31.871Z",
"startedAt": "2026-08-08T04:20:04.771Z",
"success": false,
"fatal": null,
"summary": {
"total": 39,
"passed": 36,
"failed": 3
},
"modules": [
{
"name": "orders",
"startedAt": "2026-08-08T04:20:06.120Z",
"finishedAt": "2026-08-08T04:20:31.869Z",
"success": false,
"passed": 35,
"failed": 2,
"fatal": "订单审核后的入库单契约未部署,无法向 OCR/墨家模块交接"
},
{
"name": "inbound-measurement",
"startedAt": "2026-08-08T04:20:31.870Z",
"finishedAt": "2026-08-08T04:20:31.871Z",
"success": false,
"passed": 0,
"failed": 1,
"fatal": null
}
],
"scenarios": [
{
"name": "mojia-carton-measurement",
"module": "inbound-measurement",
"success": false,
"deploymentBlocked": true,
"passed": 0,
"failed": 1
}
],
"deploymentBlockers": [
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "审核后生成隔离入库单",
"evidence": "20秒内未返回 inboundId",
"code": "S0503",
"requestId": "b2c13096-0eeb-41ff-a9c5-f59571ff93ac",
"traceId": "d033a920d751440bab00d46525147427"
},
{
"module": "inbound-measurement",
"scenario": "mojia-carton-measurement",
"name": "取得隔离的待测量入库单",
"evidence": "orders 模块未提供 marker=ADMIN-API-20260808042004771 的 inboundId/tenantId;为避免修改随机历史订单,本模块停止写入",
"code": null,
"requestId": null,
"traceId": null
}
],
"createdResources": {
"orders": {
"marker": "ADMIN-API-20260808042004771",
"tenantId": "10001",
"orderId": "2085944124604219394",
"productImageFileId": "2085944119717855233",
"boxTypes": [
"SMALL",
"MEDIUM",
"LARGE",
"EXTRA_LARGE"
],
"inboundId": null,
"orderNo": "FL2085944124604219394"
}
},
"excluded": [
{
"scope": "dashboard",
"reason": "首页为静态演示数据,不调用后端 API"
},
{
"scope": "hidden-prototypes",
"reason": "隐藏原型页面尚无已确认后端契约"
}
],
"securityNote": "报告不保存登录密码、Access Token、Refresh Token、图片二进制或 OCR 原文全文。",
"steps": [
{
"module": "authentication",
"scenario": null,
"name": "平台管理员登录",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/auth/login"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "fd633a51-88e5-4480-acb6-af024e1414a1, 471e9ee4-7cce-47ff-927b-6f944cfde322",
"traceId": "f8deb6844ea44759a033c7a0bb505c67",
"evidence": "",
"checkedAt": "2026-08-08T04:20:06.119Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 测试账号登录",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/auth/login"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "e323e25c-a97e-4f0a-b0ea-921ce9659769, 23faaf1d-977c-4303-a38c-4806efa3f9a3",
"traceId": "c94d0164b0e24bfa8948b8fd5fa5337b",
"evidence": "username=merchant_admin",
"checkedAt": "2026-08-08T04:20:06.478Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 查询当前企业资料",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/iam/tenant/profile"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "daebe4e3-0570-40be-8ee4-1a45114bd5ce, 544284da-1c49-4128-a06a-67bd938d963f",
"traceId": "b23c3c5d961448499cc734720b58a671",
"evidence": "",
"checkedAt": "2026-08-08T04:20:06.768Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 测试企业已审核且客户 ID 有效",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/iam/tenant/profile"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "daebe4e3-0570-40be-8ee4-1a45114bd5ce, 544284da-1c49-4128-a06a-67bd938d963f",
"traceId": "b23c3c5d961448499cc734720b58a671",
"evidence": "tenantId=10001, status=ACTIVE",
"checkedAt": "2026-08-08T04:20:06.768Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "Admin 查询客户固定箱型",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/first-leg/customer-dimension-config?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "e5aedbe9-f86a-4a2b-9b40-3124f4e4d2b4, 517cb219-b255-46ec-b0c8-4cf27719260a",
"traceId": "fb867d00d90b4dedad3ee1ba8c57fe0d",
"evidence": "tenantId=10001",
"checkedAt": "2026-08-08T04:20:07.184Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "固定四箱型顺序、内径及每边加2cm正确",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/first-leg/customer-dimension-config?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "e5aedbe9-f86a-4a2b-9b40-3124f4e4d2b4, 517cb219-b255-46ec-b0c8-4cf27719260a",
"traceId": "fb867d00d90b4dedad3ee1ba8c57fe0d",
"evidence": "types=SMALL,MEDIUM,LARGE,EXTRA_LARGE",
"checkedAt": "2026-08-08T04:20:07.184Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 查询国内收货仓",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "6750a16f-8e40-4552-88df-024958b69c93, 13e6c9ed-cce5-49db-bc46-e702b1ddd670",
"traceId": "00f17820d45c469fa76650b01633e3a2",
"evidence": "",
"checkedAt": "2026-08-08T04:20:07.911Z"
},
{
"module": "orders",
"scenario": null,
"name": "存在可用国内收货仓",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "6750a16f-8e40-4552-88df-024958b69c93, 13e6c9ed-cce5-49db-bc46-e702b1ddd670",
"traceId": "00f17820d45c469fa76650b01633e3a2",
"evidence": "count=2",
"checkedAt": "2026-08-08T04:20:07.911Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 查询 FBA 目的仓",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=FBA"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "f9b86d50-6b37-4efc-9046-fd2c4a0e5944, 3bb55f48-f394-425c-8add-0e86cb6e14e9",
"traceId": "af08ebd097ae4dfda9eb5ffc6fab07a9",
"evidence": "",
"checkedAt": "2026-08-08T04:20:08.017Z"
},
{
"module": "orders",
"scenario": null,
"name": "存在可用 FBA 目的仓",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=FBA"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "f9b86d50-6b37-4efc-9046-fd2c4a0e5944, 3bb55f48-f394-425c-8add-0e86cb6e14e9",
"traceId": "af08ebd097ae4dfda9eb5ffc6fab07a9",
"evidence": "count=200",
"checkedAt": "2026-08-08T04:20:08.017Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 查询可用计费渠道",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=193000000000200001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "6d0f39b1-e72a-4000-93fe-6ccf175d90ab, 98ab9721-78bb-46d4-884a-bfbcff8a0501",
"traceId": "3f482aec77d24c11bb6352ab7575dbd1",
"evidence": "",
"checkedAt": "2026-08-08T04:20:08.099Z"
},
{
"module": "orders",
"scenario": null,
"name": "存在带计价税制的可用渠道",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=193000000000200001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "6d0f39b1-e72a-4000-93fe-6ccf175d90ab, 98ab9721-78bb-46d4-884a-bfbcff8a0501",
"traceId": "3f482aec77d24c11bb6352ab7575dbd1",
"evidence": "count=15",
"checkedAt": "2026-08-08T04:20:08.100Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 查询发货客户候选",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/partners/candidates?type=SHIPPER"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "d87b98d5-855c-4b5b-bf5b-2b873e57002f, 59990ae4-0290-43d4-88ea-36bafc7ad0aa",
"traceId": "432202b9a3684d1098a3fcae454aab0a",
"evidence": "",
"checkedAt": "2026-08-08T04:20:08.150Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "Client 上传产品图片",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/merchant/v1/files/product-images"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "0484fb9b-e833-4aae-adf4-63b3c527ed40, 26e252e3-d5e7-4bae-aa22-bbca91576893",
"traceId": "aceb9173ad64475091cb0bb016e33843",
"evidence": "contentType=image/png",
"checkedAt": "2026-08-08T04:20:08.555Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "产品图片上传返回字符串文件 ID",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/merchant/v1/files/product-images"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "0484fb9b-e833-4aae-adf4-63b3c527ed40, 26e252e3-d5e7-4bae-aa22-bbca91576893",
"traceId": "aceb9173ad64475091cb0bb016e33843",
"evidence": "fileIdType=string, contentType=image/png",
"checkedAt": "2026-08-08T04:20:08.555Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "Client 可下载本轮产品图片",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/files/product-images/2085944119717855233"
},
"http": 200,
"code": null,
"message": null,
"requestId": "5b1e2446-67bb-4859-b49e-e5106cd64be9, 136b2647-2650-452c-a693-932b45c2e4ba",
"traceId": "e853f240f9294ea886cbab63a4f396cf",
"evidence": "bytes=68, contentType=image/png",
"checkedAt": "2026-08-08T04:20:08.636Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "Client 创建四箱型隔离订单",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/merchant/v1/orders/first-leg"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "d1bd5de3-fc83-4941-a2d9-20a26f7af54f, fe8e5746-04c2-4d8b-bb52-33af5e84db18",
"traceId": "d609351e49dc46b79ac61fb3e2dbabeb",
"evidence": "marker=ADMIN-API-20260808042004771",
"checkedAt": "2026-08-08T04:20:09.788Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 创建订单幂等重放",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/merchant/v1/orders/first-leg"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "023d6b8b-a8e4-4640-9341-a1efd3c52cb0, 6dcd5da5-cbf2-46ec-9bd2-6b994ee1077f",
"traceId": "bb93beaf25a44f82ad02201f3ab96877",
"evidence": "",
"checkedAt": "2026-08-08T04:20:09.979Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 创建幂等结果一致",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/merchant/v1/orders/first-leg"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "023d6b8b-a8e4-4640-9341-a1efd3c52cb0, 6dcd5da5-cbf2-46ec-9bd2-6b994ee1077f",
"traceId": "bb93beaf25a44f82ad02201f3ab96877",
"evidence": "orderId=2085944124604219394, replayedId=2085944124604219394",
"checkedAt": "2026-08-08T04:20:09.979Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 查询隔离订单草稿",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/orders/first-leg/2085944124604219394"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "b8a73339-02bb-4612-98a1-9b55970ea5b0, c79086c0-7ea4-4d05-9688-076c8221130a",
"traceId": "455f42de5cb448679dfa6122c07c7581",
"evidence": "",
"checkedAt": "2026-08-08T04:20:10.045Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 草稿身份、marker 和状态正确",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/orders/first-leg/2085944124604219394"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "b8a73339-02bb-4612-98a1-9b55970ea5b0, c79086c0-7ea4-4d05-9688-076c8221130a",
"traceId": "455f42de5cb448679dfa6122c07c7581",
"evidence": "orderNo=FL2085944124604219394, status=DRAFT",
"checkedAt": "2026-08-08T04:20:10.045Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "四箱型产品字段及图片契约完整回显",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/orders/first-leg/2085944124604219394"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "b8a73339-02bb-4612-98a1-9b55970ea5b0, c79086c0-7ea4-4d05-9688-076c8221130a",
"traceId": "455f42de5cb448679dfa6122c07c7581",
"evidence": "cargoes=4, imageFileId=2085944119717855233",
"checkedAt": "2026-08-08T04:20:10.045Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 提交隔离订单",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/merchant/v1/orders/first-leg/2085944124604219394/submit"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "5fa8d0fa-ed17-44d7-b2df-123feeb87436, 5b616c43-257d-420e-af21-4f41a01201c0",
"traceId": "ac3b77467df44218aba192ca2f5d7295",
"evidence": "orderId=2085944124604219394",
"checkedAt": "2026-08-08T04:20:10.292Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 回查已提交订单",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/orders/first-leg/2085944124604219394"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "c0da5e81-4419-410e-8e9a-605dc35d5d1e, a4e6650c-92c8-479b-a255-aebd44f85f87",
"traceId": "8a5d14f6373141d1adaae3846cbf61e0",
"evidence": "",
"checkedAt": "2026-08-08T04:20:10.334Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 订单已进入待审核状态",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/orders/first-leg/2085944124604219394"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "c0da5e81-4419-410e-8e9a-605dc35d5d1e, a4e6650c-92c8-479b-a255-aebd44f85f87",
"traceId": "8a5d14f6373141d1adaae3846cbf61e0",
"evidence": "status=SUBMITTED",
"checkedAt": "2026-08-08T04:20:10.334Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 查询统一订单列表",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders?page=1&size=100&tenantId=10001&orderNo=FL2085944124604219394"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "4636f94e-ff1d-433e-9a83-f6492f3f2386, 3963a123-ccb3-4450-bdc8-4639d9ed91ce",
"traceId": "74cddcb66c14469f9e12a4909403ec89",
"evidence": "",
"checkedAt": "2026-08-08T04:20:10.516Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 统一订单列表只核对本轮订单",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders?page=1&size=100&tenantId=10001&orderNo=FL2085944124604219394"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "4636f94e-ff1d-433e-9a83-f6492f3f2386, 3963a123-ccb3-4450-bdc8-4639d9ed91ce",
"traceId": "74cddcb66c14469f9e12a4909403ec89",
"evidence": "orderId=2085944124604219394, found=true",
"checkedAt": "2026-08-08T04:20:10.516Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 查询统一订单详情",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/2085944124604219394?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "828a2eb0-4a40-47a3-8509-6ecbb804f53e, 356a9c43-b508-435d-8a99-9e0e0ca1418f",
"traceId": "d18d0ddf28494353ad4b13fa5698539c",
"evidence": "",
"checkedAt": "2026-08-08T04:20:10.565Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 统一订单详情身份与待审核状态正确",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/2085944124604219394?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "828a2eb0-4a40-47a3-8509-6ecbb804f53e, 356a9c43-b508-435d-8a99-9e0e0ca1418f",
"traceId": "d18d0ddf28494353ad4b13fa5698539c",
"evidence": "mainStatus=PENDING_REVIEW, timeline=2",
"checkedAt": "2026-08-08T04:20:10.565Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 查询头程订单列表",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/first-leg?tenantId=10001&keyword=FL2085944124604219394&page=1&size=100"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "d81ef117-c3bc-4eaa-8f2e-0ccce6c164df, a0fab974-297e-4398-bfc7-f6ecd36523e5",
"traceId": "7efef225789f452fb051f636a57d144c",
"evidence": "",
"checkedAt": "2026-08-08T04:20:10.624Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 头程订单列表只核对本轮订单",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/first-leg?tenantId=10001&keyword=FL2085944124604219394&page=1&size=100"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "d81ef117-c3bc-4eaa-8f2e-0ccce6c164df, a0fab974-297e-4398-bfc7-f6ecd36523e5",
"traceId": "7efef225789f452fb051f636a57d144c",
"evidence": "orderId=2085944124604219394, found=true",
"checkedAt": "2026-08-08T04:20:10.624Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 查询头程订单详情",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/first-leg/2085944124604219394?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "0d1425a4-7713-4ec9-95b5-5f353610ec7d, ca155238-4d80-4718-a09b-d793471b5730",
"traceId": "0b2f6fe9b5a6448e9b8a3286886d7067",
"evidence": "",
"checkedAt": "2026-08-08T04:20:10.679Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 头程订单详情聚合结构完整",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/first-leg/2085944124604219394?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "0d1425a4-7713-4ec9-95b5-5f353610ec7d, ca155238-4d80-4718-a09b-d793471b5730",
"traceId": "0b2f6fe9b5a6448e9b8a3286886d7067",
"evidence": "cargoes=4, history=2",
"checkedAt": "2026-08-08T04:20:10.679Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "四箱型产品字段及图片契约完整回显",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/first-leg/2085944124604219394?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "0d1425a4-7713-4ec9-95b5-5f353610ec7d, ca155238-4d80-4718-a09b-d793471b5730",
"traceId": "0b2f6fe9b5a6448e9b8a3286886d7067",
"evidence": "cargoes=4, imageFileId=2085944119717855233",
"checkedAt": "2026-08-08T04:20:10.679Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 为本轮订单添加内部备注",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/platform/v1/orders/first-leg/2085944124604219394/internal-notes?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "02b2cced-f7b0-4c8e-b98e-64a3d0fc9de9, c41ea1c4-3408-4643-941a-0dac9b2db008",
"traceId": "7cdf27b5f53a4297bea4648e6f6d6199",
"evidence": "",
"checkedAt": "2026-08-08T04:20:10.727Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 审核通过本轮订单",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/platform/v1/orders/first-leg/2085944124604219394/approve?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "9e0d8f5a-5f2c-40d2-b9df-db00e773050e, 3ec6fdff-7d5a-47b5-81b1-e47bb6724520",
"traceId": "666ab113e35347039018a0a97d051d49",
"evidence": "orderId=2085944124604219394",
"checkedAt": "2026-08-08T04:20:10.787Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "审核后生成隔离入库单",
"passed": false,
"deploymentBlocker": true,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/2085944124604219394?tenantId=10001"
},
"http": 200,
"code": "S0503",
"message": "统一订单履约详情响应无效",
"requestId": "b2c13096-0eeb-41ff-a9c5-f59571ff93ac",
"traceId": "d033a920d751440bab00d46525147427",
"evidence": "20秒内未返回 inboundId",
"checkedAt": "2026-08-08T04:20:31.869Z"
},
{
"module": "orders",
"scenario": null,
"name": "模块执行",
"passed": false,
"deploymentBlocker": false,
"request": null,
"http": null,
"code": null,
"message": null,
"requestId": null,
"traceId": null,
"evidence": "订单审核后的入库单契约未部署,无法向 OCR/墨家模块交接",
"checkedAt": "2026-08-08T04:20:31.869Z"
},
{
"module": "inbound-measurement",
"scenario": "mojia-carton-measurement",
"name": "取得隔离的待测量入库单",
"passed": false,
"deploymentBlocker": true,
"request": null,
"http": null,
"code": null,
"message": null,
"requestId": null,
"traceId": null,
"evidence": "orders 模块未提供 marker=ADMIN-API-20260808042004771 的 inboundId/tenantId;为避免修改随机历史订单,本模块停止写入",
"checkedAt": "2026-08-08T04:20:31.871Z"
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+59 -3
View File
@@ -11,7 +11,7 @@ import process from 'node:process'
const DEFAULT_GATEWAY = 'http://8.163.30.128:9000'
const DEFAULT_USERNAME = 'platform_admin'
const DEFAULT_PASSWORD = 'ChangeMe123!'
const DEFAULT_MODULES = ['customers', 'system', 'master-data', 'orders', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'supplemental']
const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'orders', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'supplemental']
const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG'
const CUSTOMER_FIELDS = [
'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative',
@@ -391,6 +391,45 @@ class AcceptanceRun {
}
}
async prepareInboundMeasurementFixture() {
const originalMarker = this.marker
const fixtureMarker = `${originalMarker}-MOJIA`
const createdKeys = ['orders', 'ocrInbound', 'testInbound']
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']
const createdSnapshot = new Map(createdKeys.map((key) => [key, {
present: Object.hasOwn(this.createdResources, key), value: this.createdResources[key],
}]))
const contextSnapshot = new Map(contextKeys.map((key) => [key, {
present: Object.hasOwn(this.context, key), value: this.context[key],
}]))
this.marker = fixtureMarker
const extension = await import(new URL('./admin-test-env-modules/orders.mjs', import.meta.url))
await this.executeModule('orders-inbound-measurement-fixture', () => extension.run(this))
const fixture = this.createdResources.orders
this.createdResources.orchestrationFixtures = {
...(this.createdResources.orchestrationFixtures || {}),
inboundMeasurement: fixture ? {
marker: fixtureMarker,
tenantId: fixture.tenantId,
orderId: fixture.orderId,
inboundId: fixture.inboundId,
} : { marker: fixtureMarker, failed: true },
}
return () => {
this.marker = originalMarker
for (const [key, snapshot] of createdSnapshot) {
if (snapshot.present) this.createdResources[key] = snapshot.value
else delete this.createdResources[key]
}
for (const [key, snapshot] of contextSnapshot) {
if (snapshot.present) this.context[key] = snapshot.value
else delete this.context[key]
}
}
}
async run() {
const loginResponse = await this.request('POST', '/api/auth/login', { username: this.options.username, password: this.options.password }, '')
const login = this.apiSuccess('authentication', '平台管理员登录', loginResponse)
@@ -398,7 +437,19 @@ class AcceptanceRun {
else {
this.token = login.accessToken
const handlers = { customers: () => this.runCustomers() }
for (const module of this.options.modules) {
for (let moduleIndex = 0; moduleIndex < this.options.modules.length; moduleIndex += 1) {
const module = this.options.modules[moduleIndex]
let restoreFixture = null
const ocrIndex = this.options.modules.indexOf('inbound-weight-ocr')
if (module === 'inbound-measurement' && ocrIndex >= 0 && ocrIndex < moduleIndex) {
try {
restoreFixture = await this.prepareInboundMeasurementFixture()
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
this.addStep('orders-inbound-measurement-fixture', '创建独立测量入库单', null, false, reason,
{ scenario: 'inbound-resource-isolation' })
}
}
let handler = handlers[module]
if (!handler) {
try {
@@ -409,10 +460,15 @@ class AcceptanceRun {
const reason = error instanceof Error ? error.message : String(error)
this.addStep(module, '模块注册检查', null, false, `无法加载模块: ${reason}`)
this.modules.push({ name: module, success: false, passed: 0, failed: 1, fatal: reason })
if (restoreFixture) restoreFixture()
continue
}
}
await this.executeModule(module, handler)
try {
await this.executeModule(module, handler)
} finally {
if (restoreFixture) restoreFixture()
}
}
}
const failed = this.steps.filter((step) => !step.passed).length
@@ -0,0 +1,164 @@
const MODULE = 'overseas-storage-fees'
const SCENARIO = 'customer-overseas-storage-fee-lifecycle'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
const DEFAULT_TIERS = [
{ feeType: 'STORAGE', billingUnit: 'CBM', startDay: 1, endDay: 7, unitPrice: 0 },
{ feeType: 'STORAGE', billingUnit: 'CBM', startDay: 8, endDay: 30, unitPrice: 0.8 },
{ feeType: 'STORAGE', billingUnit: 'CBM', startDay: 31, endDay: 60, unitPrice: 1 },
{ feeType: 'STORAGE', billingUnit: 'CBM', startDay: 61, endDay: 90, unitPrice: 1.5 },
{ feeType: 'STORAGE', billingUnit: 'CBM', startDay: 91, endDay: null, unitPrice: 2 },
{ feeType: 'RETURN_STORAGE', billingUnit: 'PALLET', startDay: 1, endDay: 30, unitPrice: 1.5 },
{ feeType: 'RETURN_STORAGE', billingUnit: 'PALLET', startDay: 31, endDay: 60, unitPrice: 2 },
{ feeType: 'RETURN_STORAGE', billingUnit: 'PALLET', startDay: 61, endDay: 90, unitPrice: 3 },
{ feeType: 'RETURN_STORAGE', billingUnit: 'PALLET', startDay: 91, endDay: null, unitPrice: 4 },
]
function id(value) { return value == null ? '' : String(value) }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
function businessFailure(response) {
return [400, 401, 403, 404, 409].includes(response?.http)
|| (response?.http === 200 && codeOf(response) !== '200')
}
function missingRoute(response) {
return response?.http === 404 || response?.http === 405
|| /Not Found|No handler|No static resource/i.test(String(response?.raw || response?.body?.message || ''))
}
function close(a, b) { return Number(a) === Number(b) }
function tiersOf(view) { return Array.isArray(view?.tiers) ? view.tiers : [] }
function requestTiers(view) {
return tiersOf(view).map((tier) => ({
feeType: tier.feeType, billingUnit: tier.billingUnit,
startDay: Number(tier.startDay), endDay: tier.endDay == null ? null : Number(tier.endDay),
unitPrice: Number(tier.unitPrice),
}))
}
function tierSnapshot(view) {
return tiersOf(view).map((tier) => [tier.feeType, tier.billingUnit, tier.startDay, tier.endDay, String(tier.unitPrice)])
}
function defaultShape(view) {
const tiers = tiersOf(view)
const storage = tiers.filter((tier) => tier.feeType === 'STORAGE' && tier.billingUnit === 'CBM')
const returns = tiers.filter((tier) => tier.feeType === 'RETURN_STORAGE' && tier.billingUnit === 'PALLET')
const matches = (actual, expected) => actual.length === expected.length && expected.every((item) => actual.some((tier) => (
Number(tier.startDay) === item.startDay && (tier.endDay == null ? null : Number(tier.endDay)) === item.endDay
&& close(tier.unitPrice, item.unitPrice)
)))
return tiers.length === 9 && storage.length === 5 && returns.length === 4
&& matches(storage, DEFAULT_TIERS.filter((tier) => tier.feeType === 'STORAGE'))
&& matches(returns, DEFAULT_TIERS.filter((tier) => tier.feeType === 'RETURN_STORAGE'))
&& storage.some((tier) => close(tier.unitPrice, 0))
&& storage.some((tier) => tier.endDay == null && Number(tier.startDay) === 91)
&& returns.some((tier) => tier.endDay == null && Number(tier.startDay) === 91)
}
async function createFixtureCustomer(context) {
const suffix = context.marker.replace(/\D/g, '').slice(-12)
const phone = await context.findAvailablePhone(MODULE)
const payload = {
tenantName: `海外仓费用验收-${suffix}`, legalName: `海外仓费用验收有限公司-${suffix}`,
legalRepresentative: '费用验收法人', contactName: '费用验收联系人', contactPhone: phone,
contactEmail: `storage-fee-${suffix}@example.com`, servicePackage: '海外仓费用验收套餐',
contactWechat: `fee_${suffix}`, salesOwnerName: '费用验收销售', paymentTermDays: 30,
enterpriseType: 'LIMITED_COMPANY', establishedDate: '2020-01-02', registeredCapital: 1000000,
registeredCapitalCurrency: 'CNY', registeredAddress: '深圳市南山区海外仓费用验收地址', internalRemark: context.marker,
}
const response = await context.request('POST', '/api/platform/v1/iam/tenants', payload)
const data = context.apiSuccess(MODULE, '创建海外仓费用隔离客户', response, '', OPTIONS)
const customer = data?.tenant || data?.customer || data
const tenantId = id(customer?.id || data?.tenantId)
context.assert(MODULE, '海外仓费用隔离客户创建并带 marker', response,
Boolean(tenantId) && String(customer?.internalRemark || '').includes(context.marker), `tenantId=${tenantId || 'missing'}`, OPTIONS)
if (!tenantId) return null
context.createdResources.overseasStorageFeeCustomer = { tenantId, marker: context.marker, ownedByModule: true }
return tenantId
}
export async function run(context) {
const start = context.steps.length
// Always create a module-owned customer. The customers module may archive its
// own fixture, and this lifecycle must never depend on that or on history.
const tenantId = await createFixtureCustomer(context)
const ownedCustomer = Boolean(tenantId)
if (!tenantId) {
context.addStep(MODULE, '取得本轮客户 ID', null, false, '未找到可隔离客户,禁止使用历史客户', OPTIONS)
return
}
const path = `/api/platform/v1/iam/tenants/${encodeURIComponent(tenantId)}/overseas-storage-fees`
const getResponse = await context.request('GET', path)
const view = context.apiSuccess(MODULE, '查询客户海外仓费用配置', getResponse, `tenantId=${tenantId}`, OPTIONS)
if (!view) {
if (ownedCustomer) {
const archiveResponse = await context.request('POST', `/api/platform/v1/iam/tenants/${encodeURIComponent(tenantId)}/archive`)
const archived = context.apiSuccess(MODULE, '费用配置契约不可用时清理隔离客户', archiveResponse, `tenantId=${tenantId}`, OPTIONS)
context.assert(MODULE, '隔离客户清理后停用', archiveResponse, archived?.status === 'DISABLED', `status=${archived?.status ?? 'missing'}`, OPTIONS)
}
return
}
const activePath = `/api/platform/v1/iam/tenants/${encodeURIComponent(tenantId)}/overseas-storage-fees`
const initial = {
id: id(view.id), customerId: id(view.customerId), customerName: view.customerName,
currency: view.currency, tiers: requestTiers(view), version: Number(view.version), snapshot: tierSnapshot(view),
}
context.assert(MODULE, '客户费用配置身份、默认 USD 与9档结构正确', getResponse,
initial.id && initial.customerId === tenantId && initial.currency === 'USD' && defaultShape(view),
`ruleId=${initial.id || 'missing'}, customerId=${initial.customerId}, currency=${initial.currency}, tiers=${tiersOf(view).length}`, OPTIONS)
const wrongCustomerResponse = await context.request('PUT', activePath, {
customerId: `${BigInt(tenantId) + 1n}`, customerName: initial.customerName, currency: initial.currency,
tiers: initial.tiers, version: initial.version,
})
context.addStep(MODULE, '请求体客户 ID 与路径不一致时安全失败', wrongCustomerResponse,
businessFailure(wrongCustomerResponse), `HTTP=${wrongCustomerResponse.http}, code=${codeOf(wrongCustomerResponse)}`, OPTIONS)
const editedTiers = initial.tiers.map((tier, index) => ({ ...tier, unitPrice: Number((tier.unitPrice + 0.125 + index / 1000).toFixed(3)) }))
const updatePayload = {
customerId: tenantId, customerName: `${initial.customerName}-${context.marker.slice(-8)}`,
currency: 'EUR', tiers: editedTiers, version: initial.version,
}
const updateResponse = await context.request('PUT', activePath, updatePayload)
context.apiSuccess(MODULE, '整组修改客户海外仓费用', updateResponse, `tenantId=${tenantId}, tiers=9`, OPTIONS)
const afterUpdateResponse = await context.request('GET', activePath)
const afterUpdate = context.apiSuccess(MODULE, '回查整组修改后的客户费用', afterUpdateResponse, '', OPTIONS)
const editedCorrect = afterUpdate?.currency === 'EUR' && afterUpdate?.customerName === updatePayload.customerName
&& Number(afterUpdate?.version) === initial.version + 1 && tierSnapshot(afterUpdate).every((row, index) => row.join('|') === tierSnapshot({ tiers: editedTiers })[index].join('|'))
context.assert(MODULE, '修改后币种、价格、9档及版本整体回显', afterUpdateResponse, editedCorrect,
`currency=${afterUpdate?.currency}, version=${afterUpdate?.version}, tiers=${tiersOf(afterUpdate).length}`, OPTIONS)
const staleResponse = await context.request('PUT', activePath, { ...updatePayload, customerName: `${updatePayload.customerName}-STALE`, version: initial.version })
const staleRejected = staleResponse.http === 200 && ['A0409', '409'].includes(codeOf(staleResponse))
context.addStep(MODULE, '旧 version 整组保存返回并发冲突', staleResponse, staleRejected,
`HTTP=${staleResponse.http}, code=${codeOf(staleResponse)}`, OPTIONS)
const afterConflictResponse = await context.request('GET', activePath)
const afterConflict = context.apiSuccess(MODULE, '冲突后回查费用配置', afterConflictResponse, '', OPTIONS)
context.assert(MODULE, '冲突未造成部分更新', afterConflictResponse,
afterConflict?.customerName === updatePayload.customerName && afterConflict?.currency === 'EUR'
&& tierSnapshot(afterConflict).every((row, index) => row.join('|') === tierSnapshot({ tiers: editedTiers })[index].join('|')),
`version=${afterConflict?.version}, unchanged=${afterConflict?.customerName === updatePayload.customerName}`, OPTIONS)
const restorePayload = {
customerId: tenantId, customerName: initial.customerName, currency: initial.currency,
tiers: initial.tiers, version: Number(afterConflict?.version),
}
const restoreResponse = await context.request('PUT', activePath, restorePayload)
context.apiSuccess(MODULE, '恢复本轮客户费用原始配置', restoreResponse, 'restore=initial', OPTIONS)
const finalResponse = await context.request('GET', activePath)
const final = context.apiSuccess(MODULE, '回查恢复后的客户费用', finalResponse, '', OPTIONS)
context.assert(MODULE, '恢复后9档、USD、免费档和无上限档一致', finalResponse,
final?.customerName === initial.customerName && final?.currency === 'USD' && defaultShape(final)
&& Number(final?.version) === Number(afterConflict?.version) + 1,
`currency=${final?.currency}, version=${final?.version}, tiers=${tiersOf(final).length}`, OPTIONS)
const invalidCustomerResponse = await context.request('GET', '/api/platform/v1/iam/tenants/999999999999999999/overseas-storage-fees')
context.addStep(MODULE, '错误客户 ID 查询安全失败', invalidCustomerResponse, businessFailure(invalidCustomerResponse),
`HTTP=${invalidCustomerResponse.http}, code=${codeOf(invalidCustomerResponse)}`, OPTIONS)
if (ownedCustomer) {
const archiveResponse = await context.request('POST', `/api/platform/v1/iam/tenants/${encodeURIComponent(tenantId)}/archive`)
const archived = context.apiSuccess(MODULE, '清理费用隔离客户', archiveResponse, `tenantId=${tenantId}`, OPTIONS)
context.assert(MODULE, '费用隔离客户清理后停用', archiveResponse, archived?.status === 'DISABLED', `status=${archived?.status ?? 'missing'}`, OPTIONS)
}
const steps = context.steps.slice(start)
context.scenarios.push({ name: SCENARIO, module: MODULE, success: steps.every((step) => step.passed), deploymentBlocked: steps.some((step) => step.deploymentBlocker), passed: steps.filter((step) => step.passed).length, failed: steps.filter((step) => !step.passed).length })
}
@@ -0,0 +1,37 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.OverseasStorageFeeDtos;
import com.baiyue.warehouse.service.OverseasStorageFeeService;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@RestController
@RequestMapping("/internal/warehouse/v1/customer-overseas-storage-fees")
public class InternalOverseasStorageFeeController {
@Resource private OverseasStorageFeeService service;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
private byte[] expected;
@PostConstruct void init() { expected = internalToken.getBytes(StandardCharsets.UTF_8); }
@PostMapping("/defaults")
public ApiResponse<OverseasStorageFeeDtos.DefaultInitializationResponse> defaults(
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@RequestBody OverseasStorageFeeDtos.DefaultInitializationRequest request) {
verify(token); return ApiResponse.success(service.initializeDefaults(request));
}
@DeleteMapping("/defaults")
public ApiResponse<Void> compensate(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@RequestBody OverseasStorageFeeDtos.CompensationRequest request) {
verify(token); service.compensateDefaults(request); return ApiResponse.success(null);
}
private void verify(String token) {
if (!MessageDigest.isEqual(expected, token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8)))
throw new BizException(ResultCode.FORBIDDEN, "无效的内部服务凭据");
}
}
@@ -0,0 +1,38 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.warehouse.dto.OverseasStorageFeeDtos;
import com.baiyue.warehouse.service.OverseasStorageFeeService;
import com.baiyue.common.security.AuthenticatedUser;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/platform/v1/iam/tenants/{tenantId}/overseas-storage-fees")
public class PlatformCustomerOverseasStorageFeeController {
@Resource private OverseasStorageFeeService service;
@GetMapping
@PreAuthorize("hasAuthority('platform:customer:view')")
public ApiResponse<OverseasStorageFeeDtos.View> get(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId) { return ApiResponse.success(service.detailByTenant(tenantId)); }
@PutMapping
@PreAuthorize("hasAuthority('platform:customer:edit')")
public ApiResponse<Void> put(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId,
@Valid @RequestBody OverseasStorageFeeDtos.UpdateRequest request) {
if (request.customerId() != tenantId) {
throw new com.baiyue.common.core.exception.BizException(
com.baiyue.common.core.api.ResultCode.VALIDATION_FAILED, "客户ID与路径不一致");
}
if (request.tiers() == null || request.tiers().size() != 9
|| request.tiers().stream().filter(t -> "STORAGE".equals(t.feeType())).count() != 5
|| request.tiers().stream().filter(t -> "RETURN_STORAGE".equals(t.feeType())).count() != 4) {
throw new com.baiyue.common.core.exception.BizException(
com.baiyue.common.core.api.ResultCode.VALIDATION_FAILED, "客户费用必须包含仓租5档和退货仓储费4档");
}
OverseasStorageFeeDtos.View view = service.detailByTenant(tenantId);
service.update(view.id(), request); return ApiResponse.success(null);
}
}
@@ -41,11 +41,16 @@ public final class OverseasStorageFeeDtos {
@Schema(name = "OverseasStorageFeeTierRequest", description = "仓储费阶梯")
public record TierRequest(
@NotBlank @Pattern(regexp = "^[A-Z_]+$") String feeType,
@NotBlank @Pattern(regexp = "^[A-Z]+$") String billingUnit,
@NotNull @Min(1) @Max(36500) Integer startDay,
@NotNull @Min(1) @Max(36500) Integer endDay,
@NotNull @DecimalMin(value = "0", inclusive = false) @Digits(integer = 12, fraction = 6)
@Min(1) @Max(36500) Integer endDay,
@NotNull @DecimalMin(value = "0", inclusive = true) @Digits(integer = 12, fraction = 6)
BigDecimal unitPrice
) {
public TierRequest(Integer startDay, Integer endDay, BigDecimal unitPrice) {
this("STORAGE", "CBM", startDay, endDay, unitPrice);
}
}
@Schema(name = "OverseasStorageFeeCreateRequest", description = "新增海外仓阶梯费用")
@@ -68,7 +73,11 @@ public final class OverseasStorageFeeDtos {
}
@Schema(name = "OverseasStorageFeeTierView", description = "仓储费阶梯明细")
public record TierView(Long id, Integer startDay, Integer endDay, BigDecimal unitPrice, Integer sortNo) {
public record TierView(Long id, Integer startDay, Integer endDay, BigDecimal unitPrice, Integer sortNo,
String feeType, String billingUnit) {
public TierView(Long id, Integer startDay, Integer endDay, BigDecimal unitPrice, Integer sortNo) {
this(id, startDay, endDay, unitPrice, sortNo, "STORAGE", "CBM");
}
}
@Schema(name = "OverseasStorageFeeView", description = "海外仓阶梯费用详情")
@@ -83,4 +92,9 @@ public final class OverseasStorageFeeDtos {
LocalDateTime updatedAt
) {
}
public record DefaultInitializationRequest(@NotNull @Positive Long tenantId, @NotBlank String initializationKey,
Long operatorId, String customerName) {}
public record DefaultInitializationResponse(String configId, boolean createdByThisInitialization) {}
public record CompensationRequest(@NotNull @Positive Long tenantId, @NotBlank String initializationKey) {}
}
@@ -17,4 +17,5 @@ public class OverseasStorageFeeRuleEntity extends BaseEntity {
private Long tenantId;
private String customerName;
private String currency;
private String initializationKey;
}
@@ -20,6 +20,8 @@ public class OverseasStorageFeeTierEntity extends BaseEntity {
private Long ruleId;
private Integer startDay;
private Integer endDay;
private String feeType;
private String billingUnit;
private BigDecimal unitPrice;
private Integer sortNo;
}
@@ -38,6 +38,11 @@ public class OverseasStorageFeeRepository {
public OverseasStorageFeeRuleEntity find(long id) {
return ruleMapper.selectById(id);
}
public OverseasStorageFeeRuleEntity findByTenantAndKey(long tenantId, String key) {
return ruleMapper.selectOne(new LambdaQueryWrapper<OverseasStorageFeeRuleEntity>()
.eq(OverseasStorageFeeRuleEntity::getTenantId, tenantId)
.eq(OverseasStorageFeeRuleEntity::getInitializationKey, key));
}
public boolean existsCustomer(long customerId, Long excludedId) {
return ruleMapper.selectCount(new LambdaQueryWrapper<OverseasStorageFeeRuleEntity>()
@@ -18,6 +18,7 @@ import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.math.BigDecimal;
/** 海外仓阶梯费用管理服务。 */
@Service
@@ -40,6 +41,35 @@ public class OverseasStorageFeeService {
return view(rule, repository.findTiers(List.of(id)));
}
public OverseasStorageFeeDtos.View detailByTenant(long tenantId) {
OverseasStorageFeeRuleEntity rule = repository.page(new OverseasStorageFeeDtos.PageQuery(1, 1, tenantId, null))
.records().stream().findFirst().orElseThrow(() -> new BizException(ResultCode.NOT_FOUND, "海外仓费用不存在"));
return detail(rule.getId());
}
@Transactional
public OverseasStorageFeeDtos.DefaultInitializationResponse initializeDefaults(
OverseasStorageFeeDtos.DefaultInitializationRequest request) {
OverseasStorageFeeRuleEntity existing = repository.findByTenantAndKey(request.tenantId(), request.initializationKey());
if (existing != null && !Boolean.TRUE.equals(existing.getDeleted())) {
return new OverseasStorageFeeDtos.DefaultInitializationResponse(String.valueOf(existing.getId()), false);
}
OverseasStorageFeeRuleEntity rule = new OverseasStorageFeeRuleEntity();
rule.setInitializationKey(request.initializationKey());
copy(rule, request.tenantId(), request.customerName() == null ? "" : request.customerName(), "USD");
repository.insertRule(rule);
repository.insertTiers(tierEntities(rule.getId(), request.tenantId(), defaultTiers()));
return new OverseasStorageFeeDtos.DefaultInitializationResponse(String.valueOf(rule.getId()), true);
}
@Transactional
public void compensateDefaults(OverseasStorageFeeDtos.CompensationRequest request) {
OverseasStorageFeeRuleEntity rule = repository.findByTenantAndKey(request.tenantId(), request.initializationKey());
if (rule == null || rule.getVersion() != 0 || !isDefault(rule)) return;
repository.deleteTiers(rule.getId());
repository.deleteRule(rule.getId(), 0);
}
@Transactional
@BusinessAudit(event = "OVERSEAS_STORAGE_FEE_CREATE", resourceType = "OVERSEAS_STORAGE_FEE",
description = "新增海外仓阶梯费用", captureBefore = false)
@@ -110,19 +140,31 @@ public class OverseasStorageFeeService {
throw new BizException(ResultCode.VALIDATION_FAILED, "阶梯费用明细必须为1至100组");
}
if (source.stream().anyMatch(tier -> tier == null || tier.startDay() == null
|| tier.endDay() == null || tier.unitPrice() == null
|| tier.unitPrice().signum() <= 0)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "阶梯天数和仓储费标准必须为有效正数");
|| tier.unitPrice() == null || tier.unitPrice().signum() < 0)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "阶梯天数和仓储费标准必须有效");
}
if (source.stream().anyMatch(t -> !("STORAGE".equals(t.feeType()) && "CBM".equals(t.billingUnit()))
&& !("RETURN_STORAGE".equals(t.feeType()) && "PALLET".equals(t.billingUnit())))) {
throw new BizException(ResultCode.VALIDATION_FAILED, "费用类型和计费单位不匹配");
}
List<OverseasStorageFeeDtos.TierRequest> tiers = new ArrayList<>(source);
tiers.sort(Comparator.comparingInt(OverseasStorageFeeDtos.TierRequest::startDay));
int expectedStart = 1;
for (OverseasStorageFeeDtos.TierRequest tier : tiers) {
if (tier.startDay() != expectedStart || tier.endDay() < tier.startDay()) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"阶梯必须从第1天开始,且区间连续、不能重叠或倒置");
tiers.sort(Comparator.comparing(OverseasStorageFeeDtos.TierRequest::feeType)
.thenComparingInt(OverseasStorageFeeDtos.TierRequest::startDay));
for (String feeType : List.of("STORAGE", "RETURN_STORAGE")) {
List<OverseasStorageFeeDtos.TierRequest> group = tiers.stream().filter(t -> feeType.equals(t.feeType())).toList();
if (source.size() == 9 && (("STORAGE".equals(feeType) && group.size() != 5)
|| ("RETURN_STORAGE".equals(feeType) && group.size() != 4)))
throw new BizException(ResultCode.VALIDATION_FAILED, "仓租必须5档,退货仓储费必须4档");
int expectedStart = 1;
for (int i = 0; i < group.size(); i++) {
OverseasStorageFeeDtos.TierRequest tier = group.get(i);
if (tier.startDay() != expectedStart || (tier.endDay() != null && tier.endDay() < tier.startDay()))
throw new BizException(ResultCode.VALIDATION_FAILED, "阶梯必须从第1天开始且连续");
if (tier.endDay() == null && i != group.size() - 1)
throw new BizException(ResultCode.VALIDATION_FAILED, "无上限阶梯必须为最后一档");
if (tier.endDay() == null) break;
expectedStart = tier.endDay() + 1;
}
expectedStart = tier.endDay() + 1;
}
return List.copyOf(tiers);
}
@@ -144,6 +186,8 @@ public class OverseasStorageFeeService {
tier.setRuleId(ruleId);
tier.setStartDay(request.startDay());
tier.setEndDay(request.endDay());
tier.setFeeType(request.feeType());
tier.setBillingUnit(request.billingUnit());
tier.setUnitPrice(request.unitPrice());
tier.setSortNo(index + 1);
result.add(tier);
@@ -155,7 +199,35 @@ public class OverseasStorageFeeService {
List<OverseasStorageFeeTierEntity> tiers) {
return new OverseasStorageFeeDtos.View(rule.getId(), rule.getTenantId(), rule.getCustomerName(),
rule.getCurrency(), tiers.stream().map(tier -> new OverseasStorageFeeDtos.TierView(
tier.getId(), tier.getStartDay(), tier.getEndDay(), tier.getUnitPrice(), tier.getSortNo()))
tier.getId(), tier.getStartDay(), tier.getEndDay(), tier.getUnitPrice(), tier.getSortNo(),
tier.getFeeType(), tier.getBillingUnit()))
.toList(), rule.getVersion(), rule.getCreatedAt(), rule.getUpdatedAt());
}
private static List<OverseasStorageFeeDtos.TierRequest> defaultTiers() {
return List.of(
new OverseasStorageFeeDtos.TierRequest("STORAGE", "CBM", 1, 7, BigDecimal.ZERO),
new OverseasStorageFeeDtos.TierRequest("STORAGE", "CBM", 8, 30, new BigDecimal("0.8")),
new OverseasStorageFeeDtos.TierRequest("STORAGE", "CBM", 31, 60, BigDecimal.ONE),
new OverseasStorageFeeDtos.TierRequest("STORAGE", "CBM", 61, 90, new BigDecimal("1.5")),
new OverseasStorageFeeDtos.TierRequest("STORAGE", "CBM", 91, null, new BigDecimal("2")),
new OverseasStorageFeeDtos.TierRequest("RETURN_STORAGE", "PALLET", 1, 30, new BigDecimal("1.5")),
new OverseasStorageFeeDtos.TierRequest("RETURN_STORAGE", "PALLET", 31, 60, new BigDecimal("2")),
new OverseasStorageFeeDtos.TierRequest("RETURN_STORAGE", "PALLET", 61, 90, new BigDecimal("3")),
new OverseasStorageFeeDtos.TierRequest("RETURN_STORAGE", "PALLET", 91, null, new BigDecimal("4")));
}
private boolean isDefault(OverseasStorageFeeRuleEntity rule) {
if (!"USD".equals(rule.getCurrency())) return false;
List<OverseasStorageFeeTierEntity> actual = repository.findTiers(List.of(rule.getId()));
List<OverseasStorageFeeDtos.TierRequest> expected = defaultTiers();
if (actual.size() != expected.size()) return false;
for (int i = 0; i < expected.size(); i++) {
var e = expected.get(i); var a = actual.get(i);
if (!e.feeType().equals(a.getFeeType()) || !e.billingUnit().equals(a.getBillingUnit())
|| !e.startDay().equals(a.getStartDay()) || !java.util.Objects.equals(e.endDay(), a.getEndDay())
|| e.unitPrice().compareTo(a.getUnitPrice()) != 0) return false;
}
return true;
}
}
@@ -48,6 +48,22 @@ CALL add_column_if_missing('wh_warehouse', 'updated_by', 'BIGINT UNSIGNED NULL')
CALL add_column_if_missing('wh_warehouse', 'updated_at', 'DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)');
CALL add_column_if_missing('wh_warehouse', 'deleted', 'TINYINT(1) NOT NULL DEFAULT 0');
CALL add_column_if_missing('wh_warehouse', 'version', 'INT NOT NULL DEFAULT 0');
CALL add_column_if_missing('wh_overseas_storage_fee_rule', 'initialization_key', 'VARCHAR(191) NULL');
CALL add_column_if_missing('wh_overseas_storage_fee_tier', 'fee_type', 'VARCHAR(32) NOT NULL DEFAULT ''STORAGE''');
CALL add_column_if_missing('wh_overseas_storage_fee_tier', 'billing_unit', 'VARCHAR(16) NOT NULL DEFAULT ''CBM''');
-- 海外仓费用旧表兼容:允许免费档和“第 N 天以上”,并同步检查约束。
ALTER TABLE wh_overseas_storage_fee_tier
MODIFY COLUMN end_day SMALLINT UNSIGNED NULL COMMENT '结束天,含边界;NULL表示以上',
MODIFY COLUMN unit_price DECIMAL(18,6) NOT NULL COMMENT '仓储费标准,可为0';
ALTER TABLE wh_overseas_storage_fee_tier
DROP CHECK chk_overseas_fee_tier_days,
DROP CHECK chk_overseas_fee_tier_price,
ADD CONSTRAINT chk_overseas_fee_tier_days CHECK (start_day >= 1 AND (end_day IS NULL OR end_day >= start_day)),
ADD CONSTRAINT chk_overseas_fee_tier_price CHECK (unit_price >= 0);
ALTER TABLE wh_overseas_storage_fee_tier
DROP INDEX uk_overseas_fee_tier_start,
ADD UNIQUE KEY uk_overseas_fee_tier_start (rule_id, fee_type, active_start_day);
CALL add_column_if_missing('wh_warehouse_address', 'tenant_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER id');
CALL add_column_if_missing('wh_warehouse_address', 'warehouse_id', 'BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER tenant_id');
@@ -75,3 +75,27 @@ CREATE TABLE IF NOT EXISTS wh_service_capability (
) ENGINE=InnoDB COMMENT='仓库服务能力表:声明仓库可提供的收货、仓储、拣包、退货等能力及截单时间';
-- 不使用跨服务数据库外键;仓库聚合内的归属由服务代码通过 tenant_id 与 warehouse_id 共同校验。
CREATE TABLE IF NOT EXISTS wh_overseas_storage_fee_rule (
id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL, customer_name VARCHAR(128) NOT NULL,
currency CHAR(3) NOT NULL DEFAULT 'USD', initialization_key VARCHAR(191) NULL,
created_by BIGINT UNSIGNED NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT UNSIGNED NULL, updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0,
active_customer_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN deleted=0 THEN tenant_id ELSE NULL END) STORED,
PRIMARY KEY(id), UNIQUE KEY uk_overseas_fee_active_customer(active_customer_id), KEY idx_overseas_fee_page(deleted,updated_at,id)
) ENGINE=InnoDB COMMENT='客户海外仓费用规则';
CREATE TABLE IF NOT EXISTS wh_overseas_storage_fee_tier (
id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL, rule_id BIGINT UNSIGNED NOT NULL,
start_day SMALLINT UNSIGNED NOT NULL, end_day SMALLINT UNSIGNED NULL,
fee_type VARCHAR(32) NOT NULL DEFAULT 'STORAGE', billing_unit VARCHAR(16) NOT NULL DEFAULT 'CBM',
unit_price DECIMAL(18,6) NOT NULL, sort_no SMALLINT UNSIGNED NOT NULL,
created_by BIGINT UNSIGNED NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT UNSIGNED NULL, updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0,
active_start_day SMALLINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN deleted=0 THEN start_day ELSE NULL END) STORED,
PRIMARY KEY(id), UNIQUE KEY uk_overseas_fee_tier_start(rule_id,fee_type,active_start_day), KEY idx_overseas_fee_tier_rule(tenant_id,rule_id,deleted,sort_no,id),
CONSTRAINT chk_overseas_fee_tier_days CHECK (start_day>=1 AND (end_day IS NULL OR end_day>=start_day)),
CONSTRAINT chk_overseas_fee_tier_price CHECK (unit_price>=0)
) ENGINE=InnoDB COMMENT='客户海外仓费用阶梯';
@@ -94,6 +94,19 @@ class OverseasStorageFeeServiceTest {
verify(repository, never()).updateRule(any());
}
@Test
void initializesNineDefaultTiersIdempotently() {
doAnswer(invocation -> { OverseasStorageFeeRuleEntity rule = invocation.getArgument(0); rule.setId(77L); return null; })
.when(repository).insertRule(any());
var request = new OverseasStorageFeeDtos.DefaultInitializationRequest(10001L, "fee:10001", 1L, "客户");
var first = service.initializeDefaults(request);
assertThat(first.createdByThisInitialization()).isTrue();
verify(repository).insertTiers(org.mockito.ArgumentMatchers.argThat(list -> list.size() == 9));
when(repository.findByTenantAndKey(10001L, "fee:10001")).thenReturn(rule());
var second = service.initializeDefaults(request);
assertThat(second.createdByThisInitialization()).isFalse();
}
private static OverseasStorageFeeDtos.TierRequest tier(int start, int end, String price) {
return new OverseasStorageFeeDtos.TierRequest(start, end, new BigDecimal(price));
}