This commit is contained in:
2026-08-07 20:07:30 +08:00
parent c698290106
commit c4b0972dc9
31 changed files with 1089 additions and 204 deletions
@@ -5078,9 +5078,49 @@ WHERE tenant_id = 0 AND app_code = 'PLATFORM'
)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
-- 兼容已经执行过旧单行配置模型的开发/测试库。仅精确识别旧结构时丢弃旧值并重建;
-- 已是四箱规结构时不做 DDL,避免破坏 Admin 已编辑的数据。
DROP PROCEDURE IF EXISTS unified_upgrade_legacy_customer_dimension_config;
DELIMITER $$
CREATE PROCEDURE unified_upgrade_legacy_customer_dimension_config()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_customer_dimension_config') = 1
AND (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_customer_dimension_config'
AND column_name = 'box_type') = 0 THEN
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_customer_dimension_config'
AND column_name IN ('id', 'tenant_id', 'edge_addition_cm', 'remark', 'initialization_key',
'created_by', 'created_at', 'updated_by', 'updated_at', 'deleted', 'version')) <> 11
OR (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_customer_dimension_config') <> 11
OR (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_customer_dimension_config'
AND index_name = 'uk_customer_dimension_config_tenant'
AND non_unique = 0 AND seq_in_index = 1 AND column_name = 'tenant_id') <> 1 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:客户尺寸配置表不是可识别的旧单行结构';
END IF;
DROP TABLE baiyue_firstleg.fl_customer_dimension_config;
END IF;
END$$
DELIMITER ;
CALL unified_upgrade_legacy_customer_dimension_config();
DROP PROCEDURE IF EXISTS unified_upgrade_legacy_customer_dimension_config;
CREATE TABLE IF NOT EXISTS baiyue_firstleg.fl_customer_dimension_config (
id BIGINT UNSIGNED NOT NULL COMMENT '配置ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
box_type VARCHAR(24) NOT NULL COMMENT '固定箱规类型:SMALL/MEDIUM/LARGE/EXTRA_LARGE',
inner_length_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内长(厘米)',
inner_width_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内宽(厘米)',
inner_height_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内高(厘米)',
edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '每个尺寸方向的单侧加宽厘米数',
remark VARCHAR(500) NULL COMMENT '备注',
initialization_key VARCHAR(128) NOT NULL COMMENT '创建流程幂等键及补偿归属',
@@ -5091,10 +5131,45 @@ CREATE TABLE IF NOT EXISTS baiyue_firstleg.fl_customer_dimension_config (
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_customer_dimension_config_tenant (tenant_id),
UNIQUE KEY uk_customer_dimension_config_tenant_box (tenant_id, box_type),
KEY idx_customer_dimension_config_initialization (tenant_id, initialization_key),
CONSTRAINT chk_customer_dimension_box_type
CHECK (box_type IN ('SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE')),
CONSTRAINT chk_customer_dimension_inner_size
CHECK (inner_length_cm >= 0.001 AND inner_width_cm >= 0.001 AND inner_height_cm >= 0.001),
CONSTRAINT chk_customer_dimension_edge_addition
CHECK (edge_addition_cm >= 0.001 AND edge_addition_cm <= 100.000)
) ENGINE=InnoDB COMMENT='客户尺寸计算配置';
) ENGINE=InnoDB COMMENT='客户固定四箱规尺寸配置';
-- 新结构首次部署或旧结构受控重建后,为所有现有企业客户补齐缺失箱规。
-- 复跑命中 (tenant_id, box_type) 唯一键时只做空更新,不覆盖尺寸、备注、版本或审计字段。
INSERT INTO baiyue_firstleg.fl_customer_dimension_config (
id, tenant_id, box_type, inner_length_cm, inner_width_cm, inner_height_cm,
edge_addition_cm, remark, initialization_key, created_by, created_at,
updated_by, updated_at, deleted, version
)
SELECT UUID_SHORT(), tenant.tenant_id, defaults.box_type,
defaults.inner_length_cm, defaults.inner_width_cm, defaults.inner_height_cm,
2.000, defaults.remark,
CONCAT('unified-four-box-default-v1:', tenant.tenant_id),
COALESCE(tenant.created_by, 0), CURRENT_TIMESTAMP(3),
COALESCE(tenant.created_by, 0), CURRENT_TIMESTAMP(3), 0, 0
FROM (
SELECT id AS tenant_id, created_by
FROM baiyue_iam.iam_tenant
WHERE tenant_type = 'MERCHANT' AND deleted = 0 AND id > 0
) tenant
CROSS JOIN (
SELECT 'SMALL' AS box_type, 30.000 AS inner_length_cm, 20.000 AS inner_width_cm,
20.000 AS inner_height_cm, '标准小箱' AS remark
UNION ALL
SELECT 'MEDIUM', 40.000, 30.000, 30.000, '标准中箱'
UNION ALL
SELECT 'LARGE', 50.000, 40.000, 40.000, '标准大箱'
UNION ALL
SELECT 'EXTRA_LARGE', 60.000, 50.000, 50.000, '特大箱'
) defaults
ON DUPLICATE KEY UPDATE id = id;
DROP PROCEDURE IF EXISTS unified_assert_final_extensions;
DELIMITER $$
@@ -5425,10 +5500,35 @@ BEGIN
IF (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_customer_dimension_config'
AND index_name = 'uk_customer_dimension_config_tenant'
AND non_unique = 0) <> 1 THEN
AND index_name = 'uk_customer_dimension_config_tenant_box'
AND non_unique = 0
AND ((seq_in_index = 1 AND column_name = 'tenant_id')
OR (seq_in_index = 2 AND column_name = 'box_type'))) <> 2 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:客户尺寸计算配置唯一约束未创建';
SET MESSAGE_TEXT = '统一迁移失败:客户四箱规尺寸配置唯一约束未创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_customer_dimension_config'
AND column_name IN ('id', 'tenant_id', 'box_type', 'inner_length_cm', 'inner_width_cm',
'inner_height_cm', 'edge_addition_cm', 'remark', 'initialization_key',
'created_by', 'created_at', 'updated_by', 'updated_at', 'deleted', 'version')) <> 15 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:客户四箱规尺寸配置字段不完整';
END IF;
IF EXISTS (
SELECT tenant.id
FROM baiyue_iam.iam_tenant tenant
LEFT JOIN baiyue_firstleg.fl_customer_dimension_config config
ON config.tenant_id = tenant.id AND config.deleted = 0
WHERE tenant.tenant_type = 'MERCHANT' AND tenant.deleted = 0 AND tenant.id > 0
GROUP BY tenant.id
HAVING COUNT(config.id) <> 4
OR COUNT(DISTINCT CASE
WHEN config.box_type IN ('SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE')
THEN config.box_type END) <> 4
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:现有企业客户未完整持有固定四箱规尺寸配置';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission
WHERE id = 5100010 AND tenant_id = 0 AND app_code = 'PLATFORM'
+2 -2
View File
@@ -24,7 +24,7 @@
| 毒消息隔离 | `mq/ExportDlqHandler.java`;定位必须使用四个 User Properties,不解析损坏 body |
| 未被当前 Controller 使用的旧实现 | `service/QualificationFileStorage.java` |
| IAM 权限协作 | `client/IamQualificationClient.java`;商户登记 `/qualifications`,平台登记/替换 `/platform-qualifications` |
| 平台称重图片上传、OCR 重试与查看 | `controller/PlatformMeasurementImageController.java`(`/api/platform/v1/files/measurements`)→ `service/MeasurementImageService.java` |
| 平台称重图片上传、OCR 重试、原图与缩略图查看 | `controller/PlatformMeasurementImageController.java`(`/api/platform/v1/files/measurements`)→ `service/MeasurementImageService.java` |
| Firstleg 第三方测量证据图片 | `controller/InternalMeasurementImageController.java`(`/internal/file/v1/measurement-images`)→ `MeasurementImageService.uploadMeasurementEvidence/deleteMeasurementEvidence`;必须校验内部令牌 |
| 测量图片磁盘与元数据 | `service/MeasurementImageStorage.java`、`entity/FileObjectEntity.java`、`mapper/FileObjectMapper.java`;表结构 `src/main/resources/db/file.sql` |
| 重量 OCR 下游 | `client/WeightOcrClient.java` → `integration-service` 的 `/internal/integrations/weight-ocr/single`;尺寸测量证据不触发重量 OCR |
@@ -41,7 +41,7 @@
- 企业资质元数据后续若迁入 File,需同时实现 Entity、Mapper、Repository、事务和迁移验证,并同步检查 IAM 资质流程及平台/商户 Controller;不得复用测量图片 `file_object` 的已接入状态宣称资质元数据已迁移。
- 测量图片必须同时按 `tenantId + inboundId + fileId + businessType` 校验归属。平台称重图片使用 `INBOUND_WEIGHT_IMAGE` 并可调用 OCR;第三方尺寸证据使用 `INBOUND_MEASUREMENT_IMAGE`,只允许内部上传/删除且不得触发重量 OCR。
- 平台测量图片权限与 Firstleg 权限码保持一致:上传和重试要求 `platform:firstleg:inbound:weight:edit`,查看要求 `platform:firstleg:inbound:measure:view`;内部证据接口只信任共享内部令牌和明确操作人。
- OCR 失败时保留已落盘文件和 `file_object` 元数据供重试或人工录入;删除第三方证据采用逻辑删除并校验业务类型,不能删除称重图片或其他客户文件。
- OCR 失败时保留已落盘文件和 `file_object` 元数据供重试或人工录入;入库称重图片和第三方尺寸证据均由其业务服务确认引用关系后通过内部接口逻辑删除,其他客户文件不得使用该接口删除。
- 从父目录运行 `mvn -pl file -am test`,并按接口影响验证商户/平台上传、替换、预览、无权限、签名伪造和磁盘故障场景。
- 非本地环境必须显式配置 `FILE_STORAGE_ROOT` 并挂载持久化可写目录;服务启动时会校验目录,存储不可用时直接启动失败,避免健康检查正常而首次上传才返回 HTTP 200 + `S0503`。
- 文件写入失败属于基础设施故障,统一返回 `S0503`,服务日志保留实际目录和底层异常;禁止将磁盘故障包装成 `B0001`。
@@ -81,6 +81,24 @@ public class PlatformMeasurementImageController {
.body(image.content());
}
@GetMapping("/{tenantId}/{inboundId}/{fileId}/thumbnail")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:view')")
@Operation(summary = "查看入库单称重图片缩略图")
public ResponseEntity<byte[]> thumbnail(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId,
@PathVariable long inboundId,
@PathVariable long fileId) {
requirePlatform(user);
requirePositive(tenantId, inboundId, fileId);
var image = images.thumbnail(tenantId, inboundId, fileId);
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.contentType(MediaType.IMAGE_JPEG)
.contentLength(image.content().length)
.body(image.content());
}
private long requirePlatform(AuthenticatedUser user) {
if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode())
|| user.tenantId() == null || user.tenantId() != 0L
@@ -15,11 +15,19 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.Set;
import javax.imageio.ImageIO;
@Service
public class MeasurementImageService {
@@ -81,11 +89,45 @@ public class MeasurementImageService {
return new Download(object.getOriginalName(), object.getContentType(), storage.read(object.getObjectKey()));
}
public Download thumbnail(long tenantId, long inboundId, long fileId) {
Download original = download(tenantId, inboundId, fileId);
try {
BufferedImage source = ImageIO.read(new ByteArrayInputStream(original.content()));
if (source == null) {
throw new BizException(ResultCode.VALIDATION_FAILED, "称重图片无法生成缩略图");
}
double scale = Math.min(1D, Math.min(480D / source.getWidth(), 320D / source.getHeight()));
int width = Math.max(1, (int) Math.round(source.getWidth() * scale));
int height = Math.max(1, (int) Math.round(source.getHeight() * scale));
BufferedImage target = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D graphics = target.createGraphics();
try {
graphics.setColor(Color.WHITE);
graphics.fillRect(0, 0, width, height);
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BILINEAR);
graphics.setRenderingHint(RenderingHints.KEY_RENDERING,
RenderingHints.VALUE_RENDER_QUALITY);
graphics.drawImage(source, 0, 0, width, height, null);
} finally {
graphics.dispose();
}
ByteArrayOutputStream output = new ByteArrayOutputStream();
if (!ImageIO.write(target, "jpg", output)) {
throw new BizException(ResultCode.INTERNAL_ERROR, "称重图片缩略图编码失败");
}
return new Download("thumbnail.jpg", "image/jpeg", output.toByteArray());
} catch (IOException exception) {
throw new BizException(ResultCode.INTERNAL_ERROR, "称重图片缩略图生成失败");
}
}
@Transactional
public void deleteMeasurementEvidence(long tenantId, long inboundId, long fileId, long operatorId) {
FileObjectEntity object = requireObject(tenantId, inboundId, fileId);
if (!"INBOUND_MEASUREMENT_IMAGE".equals(object.getBusinessType())) {
throw new BizException(ResultCode.CONFLICT, "仅允许清理第三方尺寸测量证据图片");
if (!Set.of("INBOUND_WEIGHT_IMAGE", "INBOUND_MEASUREMENT_IMAGE")
.contains(object.getBusinessType())) {
throw new BizException(ResultCode.CONFLICT, "仅允许清理入库测量图片");
}
object.setStatus("DELETED");
object.setUpdatedBy(operatorId);
@@ -10,6 +10,7 @@ import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@@ -71,4 +72,15 @@ class PlatformMeasurementImageControllerContractTest {
assertThatThrownBy(() -> controller.upload(malformed, 10001L, 88L, file))
.isInstanceOf(BizException.class);
}
@Test
void thumbnailUsesViewPermission() throws Exception {
Method method = PlatformMeasurementImageController.class.getDeclaredMethod("thumbnail",
AuthenticatedUser.class, long.class, long.class, long.class);
assertThat(method.getAnnotation(GetMapping.class).value())
.containsExactly("/{tenantId}/{inboundId}/{fileId}/thumbnail");
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:firstleg:inbound:measure:view')");
}
}
@@ -20,6 +20,10 @@ import jakarta.annotation.Resource;
import java.nio.file.Path;
import java.math.BigDecimal;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import javax.imageio.ImageIO;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -137,6 +141,38 @@ class MeasurementImageServiceEvidenceTest {
ordered.verify(storage).delete(object.getObjectKey());
}
@Test
void deletesWeightImageThroughTheSameOwnedFileBoundary() {
FileObjectEntity object = measurementEvidence();
object.setBusinessType("INBOUND_WEIGHT_IMAGE");
when(files.selectOne(any())).thenReturn(object);
when(files.updateById(object)).thenReturn(1);
when(files.deleteById(41L)).thenReturn(1);
service.deleteMeasurementEvidence(11L, 21L, 41L, 31L);
verify(storage).delete(object.getObjectKey());
}
@Test
void createsBoundedJpegThumbnail() throws Exception {
FileObjectEntity object = measurementEvidence();
object.setOriginalName("weight.png");
object.setContentType(MediaType.IMAGE_PNG_VALUE);
when(files.selectOne(any())).thenReturn(object);
BufferedImage source = new BufferedImage(1200, 800, BufferedImage.TYPE_INT_RGB);
ByteArrayOutputStream image = new ByteArrayOutputStream();
ImageIO.write(source, "png", image);
when(storage.read(object.getObjectKey())).thenReturn(image.toByteArray());
MeasurementImageService.Download result = service.thumbnail(11L, 21L, 41L);
BufferedImage thumbnail = ImageIO.read(new java.io.ByteArrayInputStream(result.content()));
assertThat(result.contentType()).isEqualTo(MediaType.IMAGE_JPEG_VALUE);
assertThat(thumbnail.getWidth()).isEqualTo(480);
assertThat(thumbnail.getHeight()).isEqualTo(320);
}
@Test
void doesNotDeleteStoredContentWhenLogicalDeleteLosesTheRace() {
FileObjectEntity object = measurementEvidence();
@@ -3,7 +3,7 @@ package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.Response;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.GroupResponse;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest;
import com.baiyue.firstleg.service.CustomerDimensionConfigService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
@@ -30,8 +30,8 @@ public class PlatformCustomerDimensionConfigController {
@GetMapping
@PreAuthorize("hasAuthority('firstleg:dimension-config:view')")
@Operation(summary = "查询客户尺寸计算配置")
public ApiResponse<Response> get(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId) {
public ApiResponse<GroupResponse> get(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId) {
return ApiResponse.success(service.get(FirstlegScopeGuard.platform(user, tenantId)));
}
@@ -41,9 +41,9 @@ public class PlatformCustomerDimensionConfigController {
resourceType = "DIMENSION_CONFIG", resourceId = "#tenantId",
description = "平台编辑客户尺寸计算配置")
@Operation(summary = "编辑客户尺寸计算配置")
public ApiResponse<Response> update(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@Valid @RequestBody UpdateRequest request) {
public ApiResponse<GroupResponse> update(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@Valid @RequestBody UpdateRequest request) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.update(scope, user.userId(), request));
}
@@ -21,6 +21,7 @@ import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
@@ -167,6 +168,22 @@ public class PlatformInboundMeasurementController {
tenantId, inboundId, resultId, user.userId(), user.username(), request));
}
@BusinessAudit(event = "INBOUND_WEIGHT_IMAGE_DELETE", resourceType = "INBOUND_WEIGHT_RESULT",
resourceId = "#resultId", description = "删除称重图片及重量结果")
@Operation(summary = "删除称重图片", description = "删除重量结果和文件;已确认图片删除后重新汇总实际总毛重。")
@DeleteMapping("/weight-results/{resultId}")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:edit')")
public ApiResponse<Void> deleteWeightResult(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@RequestParam int version,
@PathVariable long inboundId,
@PathVariable long resultId) {
requirePlatformTenant(user, tenantId);
service.deleteWeightResult(tenantId, inboundId, resultId, version, user.userId());
return ApiResponse.success(null);
}
private static void requirePlatformTenant(AuthenticatedUser user, long tenantId) {
FirstlegScopeGuard.platform(user, tenantId);
if (user.userId() == null || user.userId() <= 0) {
@@ -0,0 +1,46 @@
package com.baiyue.firstleg.domain;
import java.math.BigDecimal;
/** 平台固定维护的四种客户箱规,不允许扩展或改名。 */
public enum CustomerDimensionBoxType {
SMALL("小箱", "30.000", "20.000", "20.000", "标准小箱"),
MEDIUM("中箱", "40.000", "30.000", "30.000", "标准中箱"),
LARGE("大箱", "50.000", "40.000", "40.000", "标准大箱"),
EXTRA_LARGE("特大箱", "60.000", "50.000", "50.000", "特大箱");
private final String displayName;
private final BigDecimal defaultLengthCm;
private final BigDecimal defaultWidthCm;
private final BigDecimal defaultHeightCm;
private final String defaultRemark;
CustomerDimensionBoxType(String displayName, String defaultLengthCm, String defaultWidthCm,
String defaultHeightCm, String defaultRemark) {
this.displayName = displayName;
this.defaultLengthCm = new BigDecimal(defaultLengthCm);
this.defaultWidthCm = new BigDecimal(defaultWidthCm);
this.defaultHeightCm = new BigDecimal(defaultHeightCm);
this.defaultRemark = defaultRemark;
}
public String displayName() {
return displayName;
}
public BigDecimal defaultLengthCm() {
return defaultLengthCm;
}
public BigDecimal defaultWidthCm() {
return defaultWidthCm;
}
public BigDecimal defaultHeightCm() {
return defaultHeightCm;
}
public String defaultRemark() {
return defaultRemark;
}
}
@@ -1,5 +1,7 @@
package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.domain.CustomerDimensionBoxType;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Digits;
@@ -10,21 +12,40 @@ import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
public final class CustomerDimensionConfigDtos {
private CustomerDimensionConfigDtos() { }
public record UpdateRequest(
@NotNull @Size(min = 4, max = 4) List<@Valid RuleUpdateRequest> rules
) { }
public record RuleUpdateRequest(
@NotNull CustomerDimensionBoxType boxType,
@NotNull @DecimalMin("0.001") @Digits(integer = 6, fraction = 3) BigDecimal innerLengthCm,
@NotNull @DecimalMin("0.001") @Digits(integer = 6, fraction = 3) BigDecimal innerWidthCm,
@NotNull @DecimalMin("0.001") @Digits(integer = 6, fraction = 3) BigDecimal innerHeightCm,
@NotNull @DecimalMin("0.001") @DecimalMax("100.000") @Digits(integer = 3, fraction = 3)
BigDecimal edgeAdditionCm,
@Size(max = 500) String remark,
@NotNull @Min(0) Integer version
) { }
public record Response(
public record GroupResponse(Long tenantId, List<RuleResponse> rules) { }
public record RuleResponse(
Long id,
Long tenantId,
CustomerDimensionBoxType boxType,
String boxTypeName,
BigDecimal innerLengthCm,
BigDecimal innerWidthCm,
BigDecimal innerHeightCm,
BigDecimal edgeAdditionCm,
BigDecimal outerLengthCm,
BigDecimal outerWidthCm,
BigDecimal outerHeightCm,
BigDecimal volumeCbm,
String remark,
Integer version,
LocalDateTime createdAt,
@@ -4,12 +4,13 @@ import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import com.baiyue.firstleg.domain.CustomerDimensionBoxType;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
/** 客户填写真实尺寸后使用的每侧加宽配置。 */
/** 客户固定箱规的内径和每侧加宽配置。 */
@Getter
@Setter
@TableName("fl_customer_dimension_config")
@@ -17,6 +18,10 @@ public class CustomerDimensionConfigEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private CustomerDimensionBoxType boxType;
private BigDecimal innerLengthCm;
private BigDecimal innerWidthCm;
private BigDecimal innerHeightCm;
private BigDecimal edgeAdditionCm;
private String remark;
private String initializationKey;
@@ -5,33 +5,62 @@ import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.math.BigDecimal;
import java.util.List;
public interface CustomerDimensionConfigMapper extends BaseMapper<CustomerDimensionConfigEntity> {
@Insert("""
<script>
INSERT IGNORE INTO fl_customer_dimension_config
(id, tenant_id, edge_addition_cm, remark, initialization_key, created_by, created_at,
(id, tenant_id, box_type, inner_length_cm, inner_width_cm, inner_height_cm,
edge_addition_cm, remark, initialization_key, created_by, created_at,
updated_by, updated_at, deleted, version)
VALUES
(#{id}, #{tenantId}, #{edgeAdditionCm}, #{remark}, #{initializationKey}, #{operatorId}, CURRENT_TIMESTAMP(3),
#{operatorId}, CURRENT_TIMESTAMP(3), 0, 0)
<foreach collection="rows" item="row" separator=",">
(#{row.id}, #{row.tenantId}, #{row.boxType}, #{row.innerLengthCm}, #{row.innerWidthCm},
#{row.innerHeightCm}, #{row.edgeAdditionCm}, #{row.remark}, #{row.initializationKey},
#{operatorId}, CURRENT_TIMESTAMP(3), #{operatorId}, CURRENT_TIMESTAMP(3), 0, 0)
</foreach>
</script>
""")
int insertDefault(@Param("id") long id, @Param("tenantId") long tenantId,
@Param("edgeAdditionCm") BigDecimal edgeAdditionCm,
@Param("remark") String remark, @Param("initializationKey") String initializationKey,
@Param("operatorId") long operatorId);
int insertDefaults(@Param("rows") List<CustomerDimensionConfigEntity> rows,
@Param("operatorId") long operatorId);
@Select("""
SELECT id, tenant_id, box_type, inner_length_cm, inner_width_cm, inner_height_cm,
edge_addition_cm, remark, initialization_key, created_by, created_at,
updated_by, updated_at, deleted, version
FROM fl_customer_dimension_config
WHERE tenant_id = #{tenantId}
AND initialization_key = #{initializationKey}
AND deleted = 0
FOR UPDATE
""")
List<CustomerDimensionConfigEntity> selectDefaultsForCompensation(
@Param("tenantId") long tenantId, @Param("initializationKey") String initializationKey);
@Delete("""
DELETE FROM fl_customer_dimension_config
WHERE id = #{id}
AND tenant_id = #{tenantId}
WHERE tenant_id = #{tenantId}
AND initialization_key = #{initializationKey}
AND edge_addition_cm = #{edgeAdditionCm}
AND version = 0
AND deleted = 0
AND box_type IN ('SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE')
AND inner_length_cm = CASE box_type
WHEN 'SMALL' THEN 30.000 WHEN 'MEDIUM' THEN 40.000
WHEN 'LARGE' THEN 50.000 WHEN 'EXTRA_LARGE' THEN 60.000 END
AND inner_width_cm = CASE box_type
WHEN 'SMALL' THEN 20.000 WHEN 'MEDIUM' THEN 30.000
WHEN 'LARGE' THEN 40.000 WHEN 'EXTRA_LARGE' THEN 50.000 END
AND inner_height_cm = CASE box_type
WHEN 'SMALL' THEN 20.000 WHEN 'MEDIUM' THEN 30.000
WHEN 'LARGE' THEN 40.000 WHEN 'EXTRA_LARGE' THEN 50.000 END
AND edge_addition_cm = 2.000
AND remark = CASE box_type
WHEN 'SMALL' THEN '标准小箱' WHEN 'MEDIUM' THEN '标准中箱'
WHEN 'LARGE' THEN '标准大箱' WHEN 'EXTRA_LARGE' THEN '特大箱' END
""")
int deleteUnchangedDefault(@Param("tenantId") long tenantId, @Param("id") long id,
@Param("initializationKey") String initializationKey,
@Param("edgeAdditionCm") BigDecimal edgeAdditionCm);
int deleteUnchangedDefaults(@Param("tenantId") long tenantId,
@Param("initializationKey") String initializationKey);
}
@@ -6,29 +6,32 @@ import com.baiyue.firstleg.mapper.CustomerDimensionConfigMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.util.List;
@Repository
public class CustomerDimensionConfigRepository {
@Resource
private CustomerDimensionConfigMapper mapper;
public CustomerDimensionConfigEntity find(long tenantId) {
return mapper.selectOne(new LambdaQueryWrapper<CustomerDimensionConfigEntity>()
public List<CustomerDimensionConfigEntity> findAll(long tenantId) {
return mapper.selectList(new LambdaQueryWrapper<CustomerDimensionConfigEntity>()
.eq(CustomerDimensionConfigEntity::getTenantId, tenantId));
}
public boolean insertDefault(long id, long tenantId, BigDecimal edgeAdditionCm,
String remark, String initializationKey, long operatorId) {
return mapper.insertDefault(id, tenantId, edgeAdditionCm, remark, initializationKey, operatorId) == 1;
public int insertDefaults(List<CustomerDimensionConfigEntity> rows, long operatorId) {
return mapper.insertDefaults(rows, operatorId);
}
public int update(CustomerDimensionConfigEntity value) {
return mapper.updateById(value);
}
public int deleteUnchangedDefault(long tenantId, long id, String initializationKey,
BigDecimal edgeAdditionCm) {
return mapper.deleteUnchangedDefault(tenantId, id, initializationKey, edgeAdditionCm);
public List<CustomerDimensionConfigEntity> findDefaultsForCompensation(long tenantId,
String initializationKey) {
return mapper.selectDefaultsForCompensation(tenantId, initializationKey);
}
public int deleteUnchangedDefaults(long tenantId, String initializationKey) {
return mapper.deleteUnchangedDefaults(tenantId, initializationKey);
}
}
@@ -157,6 +157,14 @@ public class InboundMeasurementRepository {
.eq(InboundWeightResultEntity::getId, weight.getId())) == 1;
}
public boolean deleteWeight(long tenantId, long inboundId, long resultId, int expectedVersion) {
return weights.delete(new LambdaQueryWrapper<InboundWeightResultEntity>()
.eq(InboundWeightResultEntity::getTenantId, tenantId)
.eq(InboundWeightResultEntity::getInboundId, inboundId)
.eq(InboundWeightResultEntity::getId, resultId)
.eq(InboundWeightResultEntity::getVersion, expectedVersion)) == 1;
}
public void insertRevision(InboundWeightRevisionEntity revision) {
revisions.insert(revision);
@@ -3,8 +3,11 @@ package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.domain.CustomerDimensionBoxType;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.GroupResponse;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.InitializeResponse;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.Response;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.RuleResponse;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.RuleUpdateRequest;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest;
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository;
@@ -13,75 +16,215 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
/** 管理每个客户唯一的尺寸加边配置,不参与当前订单或计费计算。 */
/** 管理每个客户固定四种箱规的尺寸配置,不参与当前订单或计费计算。 */
@Service
public class CustomerDimensionConfigService {
public static final BigDecimal DEFAULT_EDGE_ADDITION_CM = new BigDecimal("2.000");
private static final String DEFAULT_REMARK = "默认每侧加宽 2cm";
private static final BigDecimal TWO = new BigDecimal("2");
private static final BigDecimal CUBIC_CENTIMETERS_PER_CBM = new BigDecimal("1000000");
@Resource
private CustomerDimensionConfigRepository repository;
public Response get(long tenantId) {
CustomerDimensionConfigEntity value = repository.find(tenantId);
if (value == null) {
throw new BizException(ResultCode.NOT_FOUND, "客户尺寸计算配置不存在");
}
return response(value);
public GroupResponse get(long tenantId) {
return response(tenantId, requireCompleteRules(repository.findAll(tenantId)));
}
@Transactional
public Response update(long tenantId, long operatorId, UpdateRequest request) {
CustomerDimensionConfigEntity value = repository.find(tenantId);
if (value == null) {
throw new BizException(ResultCode.NOT_FOUND, "客户尺寸计算配置不存在");
public GroupResponse update(long tenantId, long operatorId, UpdateRequest request) {
Map<CustomerDimensionBoxType, RuleUpdateRequest> requested = requireFixedRuleSet(request);
List<CustomerDimensionConfigEntity> existing = requireCompleteRules(repository.findAll(tenantId));
Map<CustomerDimensionBoxType, CustomerDimensionConfigEntity> persisted = index(existing);
for (CustomerDimensionBoxType type : CustomerDimensionBoxType.values()) {
CustomerDimensionConfigEntity value = persisted.get(type);
if (!value.getVersion().equals(requested.get(type).version())) {
throw staleConfiguration();
}
}
if (!value.getVersion().equals(request.version())) {
throw new BizException(ResultCode.CONFLICT, "尺寸计算配置已被修改,请刷新后重试");
for (CustomerDimensionBoxType type : CustomerDimensionBoxType.values()) {
CustomerDimensionConfigEntity value = persisted.get(type);
RuleUpdateRequest update = requested.get(type);
value.setInnerLengthCm(update.innerLengthCm());
value.setInnerWidthCm(update.innerWidthCm());
value.setInnerHeightCm(update.innerHeightCm());
value.setEdgeAdditionCm(update.edgeAdditionCm());
value.setRemark(normalizeRemark(update.remark()));
value.setUpdatedBy(operatorId);
if (repository.update(value) != 1) {
throw staleConfiguration();
}
}
value.setEdgeAdditionCm(request.edgeAdditionCm());
value.setRemark(normalizeRemark(request.remark()));
value.setUpdatedBy(operatorId);
if (repository.update(value) != 1) {
throw new BizException(ResultCode.CONFLICT, "尺寸计算配置已被修改,请刷新后重试");
}
return response(value);
return response(tenantId, existing);
}
@Transactional
public InitializeResponse initializeDefault(long tenantId, long operatorId, String initializationKey) {
requireTenant(tenantId);
String normalizedKey = requireInitializationKey(initializationKey);
CustomerDimensionConfigEntity existing = repository.find(tenantId);
if (existing != null) {
return new InitializeResponse(existing.getId(),
normalizedKey.equals(existing.getInitializationKey()));
List<CustomerDimensionConfigEntity> existing = repository.findAll(tenantId);
if (!existing.isEmpty()) {
List<CustomerDimensionConfigEntity> complete = requireCompleteRules(existing);
return initializationResponse(complete, normalizedKey);
}
long id = IdWorker.getId();
boolean created = repository.insertDefault(id, tenantId, DEFAULT_EDGE_ADDITION_CM,
DEFAULT_REMARK, normalizedKey, operatorId);
CustomerDimensionConfigEntity persisted = repository.find(tenantId);
if (persisted == null) {
throw new BizException(ResultCode.INTERNAL_ERROR, "客户尺寸计算配置初始化失败");
}
boolean ownedByKey = normalizedKey.equals(persisted.getInitializationKey());
return new InitializeResponse(persisted.getId(), created || ownedByKey);
repository.insertDefaults(defaultRows(tenantId, normalizedKey), operatorId);
List<CustomerDimensionConfigEntity> persisted = requireCompleteRules(repository.findAll(tenantId));
return initializationResponse(persisted, normalizedKey);
}
@Transactional
public void compensateDefault(long tenantId, String initializationKey) {
requireTenant(tenantId);
String normalizedKey = requireInitializationKey(initializationKey);
CustomerDimensionConfigEntity existing = repository.find(tenantId);
if (existing == null || !normalizedKey.equals(existing.getInitializationKey())) return;
repository.deleteUnchangedDefault(tenantId, existing.getId(), normalizedKey, DEFAULT_EDGE_ADDITION_CM);
List<CustomerDimensionConfigEntity> locked =
repository.findDefaultsForCompensation(tenantId, normalizedKey);
if (!isUnchangedDefaultGroup(locked, normalizedKey)) return;
if (repository.deleteUnchangedDefaults(tenantId, normalizedKey) != CustomerDimensionBoxType.values().length) {
throw new BizException(ResultCode.INTERNAL_ERROR, "客户箱规默认配置补偿删除失败");
}
}
private static Response response(CustomerDimensionConfigEntity value) {
return new Response(value.getId(), value.getTenantId(), value.getEdgeAdditionCm(), value.getRemark(),
value.getVersion(), value.getCreatedAt(), value.getUpdatedAt());
private static Map<CustomerDimensionBoxType, RuleUpdateRequest> requireFixedRuleSet(UpdateRequest request) {
if (request == null || request.rules() == null || request.rules().size() != CustomerDimensionBoxType.values().length) {
throw new BizException(ResultCode.BAD_REQUEST, "必须完整提交固定四种箱规");
}
Map<CustomerDimensionBoxType, RuleUpdateRequest> values = new EnumMap<>(CustomerDimensionBoxType.class);
for (RuleUpdateRequest rule : request.rules()) {
if (rule == null || rule.boxType() == null || values.put(rule.boxType(), rule) != null) {
throw new BizException(ResultCode.BAD_REQUEST, "箱规类型重复或无效");
}
requirePositive(rule.innerLengthCm(), "箱规内长");
requirePositive(rule.innerWidthCm(), "箱规内宽");
requirePositive(rule.innerHeightCm(), "箱规内高");
requirePositive(rule.edgeAdditionCm(), "箱规单侧加宽");
if (rule.version() == null || rule.version() < 0) {
throw new BizException(ResultCode.BAD_REQUEST, "箱规版本号无效");
}
}
if (values.size() != CustomerDimensionBoxType.values().length) {
throw new BizException(ResultCode.BAD_REQUEST, "必须完整提交固定四种箱规");
}
return values;
}
private static List<CustomerDimensionConfigEntity> requireCompleteRules(
List<CustomerDimensionConfigEntity> values) {
if (values == null || values.isEmpty()) {
throw new BizException(ResultCode.NOT_FOUND, "客户箱规尺寸配置不存在");
}
Map<CustomerDimensionBoxType, CustomerDimensionConfigEntity> indexed = index(values);
if (values.size() != CustomerDimensionBoxType.values().length
|| indexed.size() != CustomerDimensionBoxType.values().length) {
throw new BizException(ResultCode.INTERNAL_ERROR, "客户箱规尺寸配置不完整");
}
List<CustomerDimensionConfigEntity> ordered = new ArrayList<>(CustomerDimensionBoxType.values().length);
for (CustomerDimensionBoxType type : CustomerDimensionBoxType.values()) {
CustomerDimensionConfigEntity value = indexed.get(type);
if (value == null) {
throw new BizException(ResultCode.INTERNAL_ERROR, "客户箱规尺寸配置不完整");
}
ordered.add(value);
}
return ordered;
}
private static Map<CustomerDimensionBoxType, CustomerDimensionConfigEntity> index(
List<CustomerDimensionConfigEntity> values) {
Map<CustomerDimensionBoxType, CustomerDimensionConfigEntity> indexed =
new EnumMap<>(CustomerDimensionBoxType.class);
for (CustomerDimensionConfigEntity value : values) {
if (value == null || value.getBoxType() == null || indexed.put(value.getBoxType(), value) != null) {
throw new BizException(ResultCode.INTERNAL_ERROR, "客户箱规尺寸配置不完整");
}
}
return indexed;
}
private static List<CustomerDimensionConfigEntity> defaultRows(long tenantId, String initializationKey) {
List<CustomerDimensionConfigEntity> rows = new ArrayList<>(CustomerDimensionBoxType.values().length);
for (CustomerDimensionBoxType type : CustomerDimensionBoxType.values()) {
CustomerDimensionConfigEntity value = new CustomerDimensionConfigEntity();
value.setId(IdWorker.getId());
value.setTenantId(tenantId);
value.setBoxType(type);
value.setInnerLengthCm(type.defaultLengthCm());
value.setInnerWidthCm(type.defaultWidthCm());
value.setInnerHeightCm(type.defaultHeightCm());
value.setEdgeAdditionCm(DEFAULT_EDGE_ADDITION_CM);
value.setRemark(type.defaultRemark());
value.setInitializationKey(initializationKey);
rows.add(value);
}
return rows;
}
private static boolean isUnchangedDefaultGroup(List<CustomerDimensionConfigEntity> values,
String initializationKey) {
if (values == null || values.size() != CustomerDimensionBoxType.values().length) return false;
Map<CustomerDimensionBoxType, CustomerDimensionConfigEntity> indexed;
try {
indexed = index(values);
} catch (BizException ignored) {
return false;
}
for (CustomerDimensionBoxType type : CustomerDimensionBoxType.values()) {
CustomerDimensionConfigEntity value = indexed.get(type);
if (value == null || !initializationKey.equals(value.getInitializationKey())
|| value.getVersion() == null || value.getVersion() != 0
|| value.getInnerLengthCm() == null || value.getInnerWidthCm() == null
|| value.getInnerHeightCm() == null || value.getEdgeAdditionCm() == null
|| value.getInnerLengthCm().compareTo(type.defaultLengthCm()) != 0
|| value.getInnerWidthCm().compareTo(type.defaultWidthCm()) != 0
|| value.getInnerHeightCm().compareTo(type.defaultHeightCm()) != 0
|| value.getEdgeAdditionCm().compareTo(DEFAULT_EDGE_ADDITION_CM) != 0
|| !type.defaultRemark().equals(value.getRemark())) {
return false;
}
}
return true;
}
private static InitializeResponse initializationResponse(List<CustomerDimensionConfigEntity> values,
String initializationKey) {
boolean ownedByKey = values.stream()
.allMatch(value -> initializationKey.equals(value.getInitializationKey()));
return new InitializeResponse(values.get(0).getId(), ownedByKey);
}
private static GroupResponse response(long tenantId, List<CustomerDimensionConfigEntity> values) {
return new GroupResponse(tenantId, values.stream().map(CustomerDimensionConfigService::ruleResponse).toList());
}
private static RuleResponse ruleResponse(CustomerDimensionConfigEntity value) {
BigDecimal edgePair = value.getEdgeAdditionCm().multiply(TWO);
BigDecimal outerLength = value.getInnerLengthCm().add(edgePair);
BigDecimal outerWidth = value.getInnerWidthCm().add(edgePair);
BigDecimal outerHeight = value.getInnerHeightCm().add(edgePair);
BigDecimal volume = outerLength.multiply(outerWidth).multiply(outerHeight)
.divide(CUBIC_CENTIMETERS_PER_CBM)
.setScale(3, RoundingMode.HALF_UP);
return new RuleResponse(value.getId(), value.getBoxType(), value.getBoxType().displayName(),
value.getInnerLengthCm(), value.getInnerWidthCm(), value.getInnerHeightCm(),
value.getEdgeAdditionCm(), outerLength, outerWidth, outerHeight, volume,
value.getRemark(), value.getVersion(), value.getCreatedAt(), value.getUpdatedAt());
}
private static void requirePositive(BigDecimal value, String fieldName) {
if (value == null || value.compareTo(new BigDecimal("0.001")) < 0) {
throw new BizException(ResultCode.BAD_REQUEST, fieldName + "必须大于0");
}
}
private static BizException staleConfiguration() {
return new BizException(ResultCode.CONFLICT, "箱规尺寸配置已被修改,请刷新后重试");
}
private static String normalizeRemark(String value) {
@@ -3,7 +3,9 @@ package com.baiyue.firstleg.service;
import jakarta.annotation.Resource;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.MeasurementImageClient;
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
import com.baiyue.firstleg.dto.MeasurementBatchCommand;
@@ -17,6 +19,8 @@ import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import feign.FeignException;
import java.math.BigDecimal;
import java.math.RoundingMode;
@@ -32,6 +36,8 @@ public class InboundMeasurementService {
private static final String WEIGHT_CONFIRMED = "CONFIRMED";
private static final String WEIGHT_PENDING_CONFIRM = "PENDING_CONFIRM";
private static final String WEIGHT_NEEDS_MANUAL = "NEEDS_MANUAL";
private static final Set<String> WEIGHT_EDITABLE_STATUSES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
/** 测量完成后的默认普通托盘类型;实际拆托和库位占用由提交编排服务执行。 */
private static final String RECOMMENDED_PALLET_TYPE = "STANDARD";
private static final BigDecimal LB_TO_KG = new BigDecimal("0.45359237");
@@ -40,6 +46,10 @@ public class InboundMeasurementService {
private InboundMeasurementRepository repository;
@Resource
private MeasurementBatchService batchService;
@Resource
private MeasurementImageClient imageClient;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
public InboundMeasurementResponses.Detail detail(long tenantId, long inboundId) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
@@ -231,13 +241,45 @@ public class InboundMeasurementService {
return toWeight(weight);
}
@Transactional
public void deleteWeightResult(long tenantId, long inboundId, long resultId,
int version, long operatorId) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
if (!WEIGHT_EDITABLE_STATUSES.contains(inbound.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT, "当前入库状态不允许删除称重图片");
}
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
requireVersion(version, weight.getVersion());
if (!repository.deleteWeight(tenantId, inboundId, resultId, version)) {
throw concurrentModification();
}
if (WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) {
recalculateConfirmedWeight(inbound, operatorId);
}
try {
ApiResponse<Void> response = imageClient.delete(
internalToken, operatorId, tenantId, inboundId, weight.getFileId());
if (response == null || !response.successful()) {
String message = response == null || !StringUtils.hasText(response.message())
? "称重图片删除失败" : response.message();
throw new BizException(response == null ? ResultCode.SERVICE_UNAVAILABLE
: ResultCode.of(response.code()), message);
}
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用,称重图片未删除");
}
}
private void recalculateConfirmedWeight(InboundOrderEntity inbound, long operatorId) {
BigDecimal total = repository.listWeights(inbound.getTenantId(), inbound.getId()).stream()
List<InboundWeightResultEntity> confirmedWeights = repository
.listWeights(inbound.getTenantId(), inbound.getId()).stream()
.filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()))
.toList();
BigDecimal total = confirmedWeights.stream()
.map(InboundWeightResultEntity::getFinalWeightKg)
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(3, RoundingMode.HALF_UP);
inbound.setActualGrossWeightKg(total);
inbound.setActualGrossWeightKg(confirmedWeights.isEmpty() ? null : total);
if ("WAIT_MEASURE".equals(inbound.getProcessStatus())) {
inbound.setProcessStatus("MEASURING");
}
@@ -1,10 +1,14 @@
SET NAMES utf8mb4;
USE baiyue_firstleg;
-- 每个客户唯一的尺寸计算配置;当前只保存每侧加宽值,不接入订单或计费。
-- 每个客户固定四条箱规尺寸配置;当前只维护配置事实,不接入订单或计费。
CREATE TABLE IF NOT EXISTS fl_customer_dimension_config (
id BIGINT UNSIGNED NOT NULL COMMENT '配置ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
box_type VARCHAR(24) NOT NULL COMMENT '固定箱规类型:SMALL/MEDIUM/LARGE/EXTRA_LARGE',
inner_length_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内长(厘米)',
inner_width_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内宽(厘米)',
inner_height_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内高(厘米)',
edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '每个尺寸方向的单侧加宽厘米数',
remark VARCHAR(500) NULL COMMENT '备注',
initialization_key VARCHAR(128) NOT NULL COMMENT '创建流程幂等键及补偿归属',
@@ -15,7 +19,12 @@ CREATE TABLE IF NOT EXISTS fl_customer_dimension_config (
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_customer_dimension_config_tenant (tenant_id),
UNIQUE KEY uk_customer_dimension_config_tenant_box (tenant_id, box_type),
KEY idx_customer_dimension_config_initialization (tenant_id, initialization_key),
CONSTRAINT chk_customer_dimension_box_type
CHECK (box_type IN ('SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE')),
CONSTRAINT chk_customer_dimension_inner_size
CHECK (inner_length_cm >= 0.001 AND inner_width_cm >= 0.001 AND inner_height_cm >= 0.001),
CONSTRAINT chk_customer_dimension_edge_addition
CHECK (edge_addition_cm >= 0.001 AND edge_addition_cm <= 100.000)
) ENGINE=InnoDB COMMENT='客户尺寸计算配置';
) ENGINE=InnoDB COMMENT='客户固定四箱规尺寸配置';
@@ -48,3 +48,30 @@ CREATE TABLE IF NOT EXISTS fl_consumed_event (
consumed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次成功消费时间',
PRIMARY KEY (event_id)
) ENGINE=InnoDB COMMENT='头程消息消费幂等表:记录已处理事件并按事件ID去重,防止RocketMQ重复投递造成重复建单;不是消息发布表';
CREATE TABLE IF NOT EXISTS fl_customer_dimension_config (
id BIGINT UNSIGNED NOT NULL COMMENT '配置ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
box_type VARCHAR(24) NOT NULL COMMENT '固定箱规类型:SMALL/MEDIUM/LARGE/EXTRA_LARGE',
inner_length_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内长(厘米)',
inner_width_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内宽(厘米)',
inner_height_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内高(厘米)',
edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '每个尺寸方向的单侧加宽厘米数',
remark VARCHAR(500) NULL COMMENT '备注',
initialization_key VARCHAR(128) NOT NULL COMMENT '创建流程幂等键及补偿归属',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_customer_dimension_config_tenant_box (tenant_id, box_type),
KEY idx_customer_dimension_config_initialization (tenant_id, initialization_key),
CONSTRAINT chk_customer_dimension_box_type
CHECK (box_type IN ('SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE')),
CONSTRAINT chk_customer_dimension_inner_size
CHECK (inner_length_cm >= 0.001 AND inner_width_cm >= 0.001 AND inner_height_cm >= 0.001),
CONSTRAINT chk_customer_dimension_edge_addition
CHECK (edge_addition_cm >= 0.001 AND edge_addition_cm <= 100.000)
) ENGINE=InnoDB COMMENT='客户固定四箱规尺寸配置';
@@ -733,6 +733,10 @@ CREATE TABLE IF NOT EXISTS fl_inbound_idempotency (
CREATE TABLE IF NOT EXISTS fl_customer_dimension_config (
id BIGINT UNSIGNED NOT NULL COMMENT '配置ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
box_type VARCHAR(24) NOT NULL COMMENT '固定箱规类型:SMALL/MEDIUM/LARGE/EXTRA_LARGE',
inner_length_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内长(厘米)',
inner_width_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内宽(厘米)',
inner_height_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内高(厘米)',
edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '每个尺寸方向的单侧加宽厘米数',
remark VARCHAR(500) NULL COMMENT '备注',
initialization_key VARCHAR(128) NOT NULL COMMENT '创建流程幂等键及补偿归属',
@@ -743,7 +747,12 @@ CREATE TABLE IF NOT EXISTS fl_customer_dimension_config (
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_customer_dimension_config_tenant (tenant_id),
UNIQUE KEY uk_customer_dimension_config_tenant_box (tenant_id, box_type),
KEY idx_customer_dimension_config_initialization (tenant_id, initialization_key),
CONSTRAINT chk_customer_dimension_box_type
CHECK (box_type IN ('SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE')),
CONSTRAINT chk_customer_dimension_inner_size
CHECK (inner_length_cm >= 0.001 AND inner_width_cm >= 0.001 AND inner_height_cm >= 0.001),
CONSTRAINT chk_customer_dimension_edge_addition
CHECK (edge_addition_cm >= 0.001 AND edge_addition_cm <= 100.000)
) ENGINE=InnoDB COMMENT='客户尺寸计算配置';
) ENGINE=InnoDB COMMENT='客户固定四箱规尺寸配置';
@@ -8,8 +8,8 @@ security:
paths:
/internal/firstleg/v1/customer-dimension-configs/defaults:
post:
summary: 幂等初始化客户尺寸计算默认配置
description: 仅供 IAM 在客户创建或注册成功编排中调用;同一客户和 initializationKey 重试稳定返回 createdByThisInitialization=true。
summary: 幂等初始化客户四箱规默认配置
description: 原子创建 SMALL、MEDIUM、LARGE、EXTRA_LARGE 四行;同一客户和 initializationKey 重试稳定返回 createdByThisInitialization=true,configId 为 SMALL 行 ID。
security:
- internalServiceToken: []
requestBody:
@@ -20,8 +20,8 @@ paths:
responses:
'200': {description: 成功或业务失败,data 返回 configId 和 createdByThisInitialization}
delete:
summary: 补偿删除本次新建且尚未修改的默认配置
description: 仅供 IAM 创建客户事务补偿使用;同时匹配 initializationKey、默认2cm和version=0才物理删除。
summary: 补偿删除本次新建且尚未修改的四箱规默认配置
description: 仅当四行均匹配 initializationKey、默认尺寸、默认2cm和version=0时整组物理删除,不会部分删除。
security:
- internalServiceToken: []
parameters:
@@ -31,16 +31,16 @@ paths:
'200': {description: 幂等补偿完成}
/api/platform/v1/first-leg/customer-dimension-config:
get:
summary: 查询客户尺寸计算配置
description: 需要 firstleg:dimension-config:view 权限;配置只描述真实尺寸的每侧加宽值,当前不参与订单或计费。
summary: 查询客户固定四箱规尺寸配置
description: 需要 firstleg:dimension-config:view 权限;按 SMALL、MEDIUM、LARGE、EXTRA_LARGE 固定顺序返回,当前不参与订单或计费。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
'200': {description: 成功时 data 为 CustomerDimensionConfigResponse}
'200': {description: 成功时 data 为 CustomerDimensionConfigGroupResponse}
'403': {description: 无查询权限}
put:
summary: 编辑客户尺寸计算配置
description: 需要 firstleg:dimension-config:edit 权限;使用 version 执行乐观锁更新。
summary: 整组编辑客户固定四箱规尺寸配置
description: 需要 firstleg:dimension-config:edit 权限;请求必须恰好包含四种固定 boxType,四行逐行乐观锁并在同一事务原子保存。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
requestBody:
@@ -49,7 +49,7 @@ paths:
application/json:
schema: {$ref: '#/components/schemas/CustomerDimensionConfigUpdateRequest'}
responses:
'200': {description: 成功时 data 为更新后的 CustomerDimensionConfigResponse}
'200': {description: 成功时 data 为更新后的 CustomerDimensionConfigGroupResponse}
'403': {description: 无编辑权限}
/internal/firstleg/v1/inbounds/order-approved:
post:
@@ -288,6 +288,20 @@ paths:
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 无确认权限,或修改重量时无编辑权限}
/api/platform/v1/first-leg/inbounds/{inboundId}/weight-results/{resultId}:
delete:
summary: 删除称重图片及重量结果
description: >-
需要 platform:firstleg:inbound:weight:edit 权限;仅允许在可编辑的测量状态删除。
删除已确认图片后重新汇总剩余已确认图片的实际总毛重,删除最后一张时总毛重恢复为空。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: version, in: query, required: true, schema: {type: integer, format: int32, minimum: 0}}
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: resultId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 无重量编辑权限}
/api/platform/v1/inbounds/{inboundId}/measurements/approve:
post:
summary: 审核通过测量
@@ -405,24 +419,56 @@ components:
schemas:
CustomerDimensionConfigUpdateRequest:
type: object
required: [edgeAdditionCm, version]
required: [rules]
properties:
rules:
type: array
minItems: 4
maxItems: 4
description: 必须恰好包含 SMALL、MEDIUM、LARGE、EXTRA_LARGE 各一行
items: {$ref: '#/components/schemas/CustomerDimensionConfigRuleUpdateRequest'}
CustomerDimensionConfigRuleUpdateRequest:
type: object
required: [boxType, innerLengthCm, innerWidthCm, innerHeightCm, edgeAdditionCm, version]
properties:
boxType: {type: string, enum: [SMALL, MEDIUM, LARGE, EXTRA_LARGE]}
innerLengthCm: {type: number, minimum: 0.001, multipleOf: 0.001}
innerWidthCm: {type: number, minimum: 0.001, multipleOf: 0.001}
innerHeightCm: {type: number, minimum: 0.001, multipleOf: 0.001}
edgeAdditionCm:
type: number
minimum: 0.001
maximum: 100
multipleOf: 0.001
description: 真实长、宽、高三个方向分别使用的每侧加宽厘米数
description: 箱规长、宽、高三个方向分别使用的每侧加宽厘米数
example: 2.000
remark: {type: string, maxLength: 500, nullable: true}
version: {type: integer, minimum: 0, description: 乐观锁版本号}
CustomerDimensionConfigResponse:
CustomerDimensionConfigGroupResponse:
type: object
required: [id, tenantId, edgeAdditionCm, version]
required: [tenantId, rules]
properties:
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
rules:
type: array
minItems: 4
maxItems: 4
items: {$ref: '#/components/schemas/CustomerDimensionConfigRuleResponse'}
CustomerDimensionConfigRuleResponse:
type: object
required: [id, boxType, boxTypeName, innerLengthCm, innerWidthCm, innerHeightCm, edgeAdditionCm, outerLengthCm, outerWidthCm, outerHeightCm, volumeCbm, version]
properties:
id: {type: string, pattern: '^[1-9][0-9]*$'}
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
boxType: {type: string, enum: [SMALL, MEDIUM, LARGE, EXTRA_LARGE]}
boxTypeName: {type: string, enum: [小箱, 中箱, 大箱, 特大箱]}
innerLengthCm: {type: number, minimum: 0.001, multipleOf: 0.001}
innerWidthCm: {type: number, minimum: 0.001, multipleOf: 0.001}
innerHeightCm: {type: number, minimum: 0.001, multipleOf: 0.001}
edgeAdditionCm: {type: number, minimum: 0.001, maximum: 100, example: 2.000}
outerLengthCm: {type: number, minimum: 0.001, description: innerLengthCm + edgeAdditionCm * 2}
outerWidthCm: {type: number, minimum: 0.001, description: innerWidthCm + edgeAdditionCm * 2}
outerHeightCm: {type: number, minimum: 0.001, description: innerHeightCm + edgeAdditionCm * 2}
volumeCbm: {type: number, multipleOf: 0.001, description: 使用未舍入外径完整计算后四舍五入保留3位}
remark: {type: string, nullable: true}
version: {type: integer, minimum: 0}
createdAt: {type: string, format: date-time, nullable: true}
@@ -39,6 +39,8 @@ class PlatformInboundMeasurementControllerContractTest {
+ " and (!#request.hasAdjustment() or hasAuthority('platform:firstleg:inbound:weight:edit'))",
AuthenticatedUser.class, long.class, long.class, long.class,
InboundMeasurementRequests.ConfirmWeightResultRequest.class);
assertAuthority("deleteWeightResult", "hasAuthority('platform:firstleg:inbound:weight:edit')",
AuthenticatedUser.class, long.class, int.class, long.class, long.class);
}
private void assertAuthority(String methodName, String expected, Class<?>... parameterTypes)
@@ -1,65 +1,142 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.Test;
import com.baiyue.firstleg.domain.CustomerDimensionBoxType;
import com.baiyue.firstleg.mapper.CustomerDimensionConfigMapper;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class CustomerDimensionConfigSqlContractTest {
@Test
void moduleMigrationDefinesUniqueCustomerConfigAndDefault() throws IOException {
String sql = new ClassPathResource("db/customer-dimension-config.sql")
.getContentAsString(StandardCharsets.UTF_8);
void allFourDatabaseEntrypointsDefineTheSameFourRowStructure() throws Exception {
List<String> sqlEntrypoints = List.of(
resource("db/customer-dimension-config.sql"),
resource("db/firstleg.sql"),
resource("db/phase2-inbound-schema.sql"),
Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8)
);
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fl_customer_dimension_config")
.contains("edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000")
.contains("initialization_key VARCHAR(128) NOT NULL")
.contains("UNIQUE KEY uk_customer_dimension_config_tenant (tenant_id)");
for (String sql : sqlEntrypoints) {
assertThat(sql)
.contains("fl_customer_dimension_config")
.contains("box_type VARCHAR(24) NOT NULL")
.contains("inner_length_cm DECIMAL(9,3) NOT NULL")
.contains("inner_width_cm DECIMAL(9,3) NOT NULL")
.contains("inner_height_cm DECIMAL(9,3) NOT NULL")
.contains("edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000")
.contains("initialization_key VARCHAR(128) NOT NULL")
.contains("created_by BIGINT UNSIGNED NOT NULL")
.contains("updated_by BIGINT UNSIGNED NOT NULL")
.contains("version INT NOT NULL DEFAULT 0")
.contains("UNIQUE KEY uk_customer_dimension_config_tenant_box (tenant_id, box_type)")
.contains("CHECK (box_type IN ('SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE'))")
.doesNotContain("UNIQUE KEY uk_customer_dimension_config_tenant (tenant_id)");
}
}
@Test
void unifiedMigrationIncludesConfigAndFinalAssertion() throws IOException {
String sql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
void fixedDefaultsAndBatchInitializationCoverExactlyFourBoxTypes() throws Exception {
assertThat(CustomerDimensionBoxType.values()).extracting(CustomerDimensionBoxType::name)
.containsExactly("SMALL", "MEDIUM", "LARGE", "EXTRA_LARGE");
assertThat(List.of(CustomerDimensionBoxType.values()))
.extracting(CustomerDimensionBoxType::defaultLengthCm)
.containsExactly(new java.math.BigDecimal("30.000"), new java.math.BigDecimal("40.000"),
new java.math.BigDecimal("50.000"), new java.math.BigDecimal("60.000"));
Insert annotation = CustomerDimensionConfigMapper.class
.getDeclaredMethod("insertDefaults", List.class, long.class)
.getAnnotation(Insert.class);
String sql = String.join(" ", annotation.value());
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS baiyue_firstleg.fl_customer_dimension_config")
.contains("客户尺寸计算配置表未创建")
.contains("uk_customer_dimension_config_tenant")
.contains("CREATE PROCEDURE unified_guard_dimension_config_permissions()")
.contains("id = 5102111 AND COALESCE(permission_code, '') <> 'firstleg:dimension-config:view'")
.contains("permission_code = 'firstleg:dimension-config:view' AND id <> 5102111")
.contains("id = 5102112 AND COALESCE(permission_code, '') <> 'firstleg:dimension-config:edit'")
.contains("permission_code = 'firstleg:dimension-config:edit' AND id <> 5102112")
.contains("(5102111, 0, 'PLATFORM', 'BUTTON', 5100010")
.contains("(5102112, 0, 'PLATFORM', 'BUTTON', 5100010")
.contains("r.role_code = 'PLATFORM_ADMIN'")
.contains("r.role_code = 'ORDER_OPERATOR'")
.contains("客户尺寸计算配置权限父节点企业客户不存在")
.contains("客户尺寸计算配置权限码、固定ID或父节点不正确")
.contains("平台管理员未获得客户尺寸计算配置读写权限")
.contains("订单运营角色的客户尺寸计算配置权限不是只读");
.contains("INSERT IGNORE INTO fl_customer_dimension_config")
.contains("box_type, inner_length_cm, inner_width_cm, inner_height_cm")
.contains("<foreach collection=\"rows\"");
}
@Test
void compensationDeleteRequiresExactKeyDefaultValueAndUnmodifiedVersion() throws Exception {
Delete annotation = com.baiyue.firstleg.mapper.CustomerDimensionConfigMapper.class
.getDeclaredMethod("deleteUnchangedDefault", long.class, long.class,
String.class, java.math.BigDecimal.class)
void compensationLocksThenDeletesOnlyExactDefaultRows() throws Exception {
Select select = CustomerDimensionConfigMapper.class
.getDeclaredMethod("selectDefaultsForCompensation", long.class, String.class)
.getAnnotation(Select.class);
assertThat(String.join(" ", select.value()))
.contains("tenant_id = #{tenantId}")
.contains("initialization_key = #{initializationKey}")
.contains("FOR UPDATE");
Delete annotation = CustomerDimensionConfigMapper.class
.getDeclaredMethod("deleteUnchangedDefaults", long.class, String.class)
.getAnnotation(Delete.class);
String sql = String.join(" ", annotation.value());
assertThat(sql)
.contains("tenant_id = #{tenantId}")
.contains("initialization_key = #{initializationKey}")
.contains("edge_addition_cm = #{edgeAdditionCm}")
.contains("version = 0");
.contains("version = 0")
.contains("box_type IN ('SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE')")
.contains("inner_length_cm = CASE box_type")
.contains("edge_addition_cm = 2.000")
.contains("remark = CASE box_type");
}
@Test
void unifiedMigrationKeepsPermissionGuardsAndAssertsFinalFourRowSchema() throws Exception {
String sql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
assertThat(sql)
.contains("CREATE PROCEDURE unified_guard_dimension_config_permissions()")
.contains("(5102111, 0, 'PLATFORM', 'BUTTON', 5100010")
.contains("r.role_code = 'PLATFORM_ADMIN'")
.contains("r.role_code = 'ORDER_OPERATOR'")
.contains("index_name = 'uk_customer_dimension_config_tenant_box'")
.contains("客户四箱规尺寸配置字段不完整")
.contains("客户尺寸计算配置权限码、固定ID或父节点不正确");
}
@Test
void unifiedMigrationUpgradesOnlyRecognizedLegacyTableAndBackfillsIdempotently() throws Exception {
String sql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
int upgrade = sql.indexOf("CREATE PROCEDURE unified_upgrade_legacy_customer_dimension_config()");
int create = sql.indexOf("CREATE TABLE IF NOT EXISTS baiyue_firstleg.fl_customer_dimension_config");
int backfill = sql.indexOf("INSERT INTO baiyue_firstleg.fl_customer_dimension_config (", create);
assertThat(upgrade).isGreaterThanOrEqualTo(0);
assertThat(create).isGreaterThan(upgrade);
assertThat(backfill).isGreaterThan(create);
assertThat(sql)
.contains("AND column_name = 'box_type') = 0")
.contains("AND column_name IN ('id', 'tenant_id', 'edge_addition_cm', 'remark', 'initialization_key'")
.contains("AND index_name = 'uk_customer_dimension_config_tenant'")
.contains("DROP TABLE baiyue_firstleg.fl_customer_dimension_config")
.contains("客户尺寸配置表不是可识别的旧单行结构")
.contains("SELECT id AS tenant_id, created_by")
.contains("FROM baiyue_iam.iam_tenant")
.contains("WHERE tenant_type = 'MERCHANT' AND deleted = 0 AND id > 0")
.contains("CONCAT('unified-four-box-default-v1:', tenant.tenant_id)")
.contains("ON DUPLICATE KEY UPDATE id = id")
.contains("现有企业客户未完整持有固定四箱规尺寸配置");
String repeatSafeClause = sql.substring(backfill,
sql.indexOf("DROP PROCEDURE IF EXISTS unified_assert_final_extensions", backfill));
assertThat(repeatSafeClause)
.doesNotContain("edge_addition_cm = VALUES")
.doesNotContain("inner_length_cm = VALUES")
.doesNotContain("remark = VALUES")
.doesNotContain("version = VALUES");
}
private static String resource(String path) throws Exception {
return new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
}
}
@@ -1,22 +1,30 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.domain.CustomerDimensionBoxType;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.GroupResponse;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.InitializeResponse;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.RuleUpdateRequest;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest;
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
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.anyLong;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -28,82 +36,167 @@ class CustomerDimensionConfigServiceTest {
private CustomerDimensionConfigService service;
@Test
void initializesTwoCentimeterDefaultExactlyOnce() {
CustomerDimensionConfigEntity persisted = config(91L, 8L, "2.000", 0, "registration-1");
when(repository.find(8L)).thenReturn(null, persisted);
when(repository.insertDefault(anyLong(), eq(8L), eq(new BigDecimal("2.000")),
eq("默认每侧加宽 2cm"), eq("registration-1"), eq(100L))).thenReturn(true);
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1");
assertThat(result.createdByThisInitialization()).isTrue();
assertThat(result.configId()).isEqualTo(91L);
}
@Test
void retryAfterLostResponseKeepsCreationOwnershipForSameKey() {
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.000", 0, "registration-1"));
void initializesFourDefaultsAtomicallyInFixedOrder() {
List<CustomerDimensionConfigEntity> persisted = defaultConfigs("registration-1");
when(repository.findAll(8L)).thenReturn(List.of(), persisted);
when(repository.insertDefaults(any(), eq(100L))).thenReturn(4);
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1");
assertThat(result).isEqualTo(new InitializeResponse(91L, true));
verify(repository, never()).insertDefault(anyLong(), anyLong(),
eq(new BigDecimal("2.000")), eq("默认每侧加宽 2cm"),
eq("registration-1"), anyLong());
@SuppressWarnings("unchecked")
ArgumentCaptor<List<CustomerDimensionConfigEntity>> captor = ArgumentCaptor.forClass(List.class);
verify(repository).insertDefaults(captor.capture(), eq(100L));
assertThat(captor.getValue()).extracting(CustomerDimensionConfigEntity::getBoxType)
.containsExactly(CustomerDimensionBoxType.values());
assertThat(captor.getValue()).extracting(CustomerDimensionConfigEntity::getInnerLengthCm)
.containsExactly(new BigDecimal("30.000"), new BigDecimal("40.000"),
new BigDecimal("50.000"), new BigDecimal("60.000"));
assertThat(captor.getValue()).extracting(CustomerDimensionConfigEntity::getInnerWidthCm)
.containsExactly(new BigDecimal("20.000"), new BigDecimal("30.000"),
new BigDecimal("40.000"), new BigDecimal("50.000"));
assertThat(captor.getValue()).extracting(CustomerDimensionConfigEntity::getInnerHeightCm)
.containsExactly(new BigDecimal("20.000"), new BigDecimal("30.000"),
new BigDecimal("40.000"), new BigDecimal("50.000"));
assertThat(captor.getValue()).extracting(CustomerDimensionConfigEntity::getEdgeAdditionCm)
.containsOnly(new BigDecimal("2.000"));
assertThat(captor.getValue()).extracting(CustomerDimensionConfigEntity::getRemark)
.containsExactly("标准小箱", "标准中箱", "标准大箱", "特大箱");
}
@Test
void retryAfterLostResponseKeepsCreationOwnershipForSameKey() {
when(repository.findAll(8L)).thenReturn(defaultConfigs("registration-1"));
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1");
assertThat(result).isEqualTo(new InitializeResponse(91L, true));
verify(repository, never()).insertDefaults(any(), eq(100L));
}
@Test
void differentInitializationKeyDoesNotTakeOwnershipOrOverwrite() {
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.000", 0, "registration-1"));
when(repository.findAll(8L)).thenReturn(defaultConfigs("registration-1"));
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-2");
assertThat(result).isEqualTo(new InitializeResponse(91L, false));
verify(repository, never()).insertDefault(anyLong(), anyLong(),
eq(new BigDecimal("2.000")), eq("默认每侧加宽 2cm"),
eq("registration-2"), anyLong());
verify(repository, never()).insertDefaults(any(), eq(100L));
}
@Test
void rejectsStaleUpdateVersion() {
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.000", 3, "registration-1"));
void returnsFixedOrderCalculatedOuterDimensionsAndThreeDecimalVolume() {
List<CustomerDimensionConfigEntity> shuffled = new ArrayList<>(defaultConfigs("registration-1"));
Collections.reverse(shuffled);
when(repository.findAll(8L)).thenReturn(shuffled);
assertThatThrownBy(() -> service.update(8L, 100L,
new UpdateRequest(new BigDecimal("2.500"), "新配置", 2)))
GroupResponse result = service.get(8L);
assertThat(result.rules()).extracting(rule -> rule.boxType())
.containsExactly(CustomerDimensionBoxType.values());
assertThat(result.rules().get(0).boxTypeName()).isEqualTo("小箱");
assertThat(result.rules().get(0).outerLengthCm()).isEqualByComparingTo("34.000");
assertThat(result.rules().get(0).outerWidthCm()).isEqualByComparingTo("24.000");
assertThat(result.rules().get(0).outerHeightCm()).isEqualByComparingTo("24.000");
assertThat(result.rules().get(0).volumeCbm()).isEqualByComparingTo("0.020");
}
@Test
void rejectsMissingOrDuplicateBoxTypes() {
List<RuleUpdateRequest> rules = new ArrayList<>(updateRequest(0).rules());
rules.set(3, rules.get(0));
assertThatThrownBy(() -> service.update(8L, 100L, new UpdateRequest(rules)))
.isInstanceOf(BizException.class)
.hasMessageContaining("重复");
verify(repository, never()).findAll(8L);
}
@Test
void rejectsAnyStaleVersionBeforeWritingRows() {
List<CustomerDimensionConfigEntity> persisted = defaultConfigs("registration-1");
persisted.forEach(value -> value.setVersion(3));
when(repository.findAll(8L)).thenReturn(persisted);
UpdateRequest request = updateRequest(3);
List<RuleUpdateRequest> rules = new ArrayList<>(request.rules());
RuleUpdateRequest stale = rules.get(2);
rules.set(2, new RuleUpdateRequest(stale.boxType(), stale.innerLengthCm(), stale.innerWidthCm(),
stale.innerHeightCm(), stale.edgeAdditionCm(), stale.remark(), 2));
assertThatThrownBy(() -> service.update(8L, 100L, new UpdateRequest(rules)))
.isInstanceOf(BizException.class)
.hasMessageContaining("刷新后重试");
verify(repository, never()).update(org.mockito.ArgumentMatchers.any());
verify(repository, never()).update(any());
}
@Test
void compensationUsesTenantKeyDefaultValueAndUnmodifiedVersion() {
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.000", 0, "registration-1"));
void abortsWholeTransactionalUpdateWhenAnyOptimisticWriteFails() {
when(repository.findAll(8L)).thenReturn(defaultConfigs("registration-1"));
when(repository.update(any())).thenReturn(1, 0);
service.compensateDefault(8L, "registration-1");
verify(repository).deleteUnchangedDefault(
8L, 91L, "registration-1", new BigDecimal("2.000"));
assertThatThrownBy(() -> service.update(8L, 100L, updateRequest(0)))
.isInstanceOf(BizException.class)
.hasMessageContaining("刷新后重试");
verify(repository, times(2)).update(any());
}
@Test
void editedConfigIsNotCompensatedByDelayedRollback() {
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.500", 1, "registration-1"));
void compensationLocksAndDeletesExactlyFourUnchangedDefaults() {
when(repository.findDefaultsForCompensation(8L, "registration-1"))
.thenReturn(defaultConfigs("registration-1"));
when(repository.deleteUnchangedDefaults(8L, "registration-1")).thenReturn(4);
service.compensateDefault(8L, "registration-1");
verify(repository).deleteUnchangedDefault(
8L, 91L, "registration-1", new BigDecimal("2.000"));
verify(repository).deleteUnchangedDefaults(8L, "registration-1");
}
private static CustomerDimensionConfigEntity config(long id, long tenantId,
String edgeAddition, int version,
String initializationKey) {
CustomerDimensionConfigEntity value = new CustomerDimensionConfigEntity();
value.setId(id);
value.setTenantId(tenantId);
value.setEdgeAdditionCm(new BigDecimal(edgeAddition));
value.setVersion(version);
value.setInitializationKey(initializationKey);
return value;
@Test
void compensationDoesNotDeleteAnEditedGroup() {
List<CustomerDimensionConfigEntity> values = defaultConfigs("registration-1");
values.get(2).setVersion(1);
when(repository.findDefaultsForCompensation(8L, "registration-1")).thenReturn(values);
service.compensateDefault(8L, "registration-1");
verify(repository, never()).deleteUnchangedDefaults(8L, "registration-1");
}
@Test
void compensationRollsBackInsteadOfAcceptingPartialDelete() {
when(repository.findDefaultsForCompensation(8L, "registration-1"))
.thenReturn(defaultConfigs("registration-1"));
when(repository.deleteUnchangedDefaults(8L, "registration-1")).thenReturn(3);
assertThatThrownBy(() -> service.compensateDefault(8L, "registration-1"))
.isInstanceOf(BizException.class)
.hasMessageContaining("补偿删除失败");
}
private static UpdateRequest updateRequest(int version) {
return new UpdateRequest(List.of(CustomerDimensionBoxType.values()).stream()
.map(type -> new RuleUpdateRequest(type, type.defaultLengthCm(), type.defaultWidthCm(),
type.defaultHeightCm(), new BigDecimal("2.000"), type.defaultRemark(), version))
.toList());
}
private static List<CustomerDimensionConfigEntity> defaultConfigs(String initializationKey) {
List<CustomerDimensionConfigEntity> values = new ArrayList<>();
long id = 91L;
for (CustomerDimensionBoxType type : CustomerDimensionBoxType.values()) {
CustomerDimensionConfigEntity value = new CustomerDimensionConfigEntity();
value.setId(id++);
value.setTenantId(8L);
value.setBoxType(type);
value.setInnerLengthCm(type.defaultLengthCm());
value.setInnerWidthCm(type.defaultWidthCm());
value.setInnerHeightCm(type.defaultHeightCm());
value.setEdgeAdditionCm(new BigDecimal("2.000"));
value.setRemark(type.defaultRemark());
value.setVersion(0);
value.setInitializationKey(initializationKey);
values.add(value);
}
return values;
}
}
@@ -1,6 +1,8 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.client.MeasurementImageClient;
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
@@ -18,6 +20,7 @@ import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.test.context.TestPropertySource;
import java.math.BigDecimal;
import java.util.List;
@@ -27,17 +30,21 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.tuple;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@DisplayName("平台入库测量应用服务")
@SpringJUnitConfig(InboundMeasurementService.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
class InboundMeasurementServiceTest {
@MockBean private InboundMeasurementRepository repository;
@MockBean private MeasurementBatchService batchService;
@MockBean private MeasurementImageClient imageClient;
@Resource private InboundMeasurementService service;
@Test
@@ -357,6 +364,58 @@ class InboundMeasurementServiceTest {
verify(repository, never()).updateInbound(any());
}
@Test
void deletingPendingWeightRemovesResultAndOwnedImage() {
InboundOrderEntity inbound = inbound("MEASURING");
InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "PENDING_CONFIRM", 1);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
when(repository.deleteWeight(1L, 10L, 20L, 1)).thenReturn(true);
when(imageClient.delete("internal-token", 9L, 1L, 10L, 120L))
.thenReturn(ApiResponse.success(null));
service.deleteWeightResult(1L, 10L, 20L, 1, 9L);
verify(repository).deleteWeight(1L, 10L, 20L, 1);
verify(imageClient).delete("internal-token", 9L, 1L, 10L, 120L);
verify(repository, never()).updateInbound(any());
}
@Test
void deletingLastConfirmedWeightClearsInboundTotal() {
InboundOrderEntity inbound = inbound("MEASURING");
inbound.setActualGrossWeightKg(new BigDecimal("285.500"));
InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "CONFIRMED", 2);
weight.setFinalWeightKg(new BigDecimal("285.500"));
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
when(repository.deleteWeight(1L, 10L, 20L, 2)).thenReturn(true);
when(repository.listWeights(1L, 10L)).thenReturn(List.of());
when(repository.updateInbound(inbound)).thenReturn(true);
when(imageClient.delete("internal-token", 9L, 1L, 10L, 120L))
.thenReturn(ApiResponse.success(null));
service.deleteWeightResult(1L, 10L, 20L, 2, 9L);
assertThat(inbound.getActualGrossWeightKg()).isNull();
verify(repository).updateInbound(inbound);
}
@Test
void deletingWeightRejectsStaleVersionBeforeCallingFileService() {
InboundOrderEntity inbound = inbound("MEASURING");
InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "PENDING_CONFIRM", 2);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
assertThatThrownBy(() -> service.deleteWeightResult(1L, 10L, 20L, 1, 9L))
.isInstanceOf(BizException.class)
.hasMessageContaining("版本已变化");
verify(repository, never()).deleteWeight(anyLong(), anyLong(), anyLong(), anyInt());
verifyNoInteractions(imageClient);
}
@Test
void submitAdvancesOnlyToMeasurePendingAfterAllWeightsConfirmed() {
InboundOrderEntity inbound = inbound("MEASURING");
@@ -8,7 +8,7 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
/** Firstleg internal contract for provisioning one customer dimension rule. */
/** Firstleg internal contract for atomically provisioning four default box-size dimension records. */
@FeignClient(name = "firstleg-service", contextId = "customerDimensionRuleClient",
url = "${baiyue.clients.firstleg.url:}",
path = "/internal/firstleg/v1/customer-dimension-configs")
@@ -25,5 +25,6 @@ public interface CustomerDimensionRuleClient {
@RequestParam String initializationKey);
record InitializationRequest(long tenantId, long operatorId, String initializationKey) { }
/** configId is the first record ID retained only for contract compatibility. */
record InitializationResponse(Long configId, boolean createdByThisInitialization) { }
}
@@ -12,7 +12,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
/** Provisions the customer dimension rule and compensates it when local customer creation rolls back. */
/** Provisions four default box-size dimensions and compensates them when customer creation rolls back. */
@Service
public class CustomerDimensionRuleInitializationService {
private static final Logger log = LoggerFactory.getLogger(CustomerDimensionRuleInitializationService.class);
@@ -40,13 +40,13 @@ public class CustomerDimensionRuleInitializationService {
return result;
}
lastFailure = unavailable(response);
log.warn("客户尺寸规则初始化失败,tenantId={}, attempt={}, code={}, requestId={}, traceId={}",
log.warn("客户4条默认箱规尺寸初始化失败,tenantId={}, attempt={}, code={}, requestId={}, traceId={}",
tenantId, attempt, response == null ? null : response.code(),
response == null ? null : response.requestId(),
response == null ? null : response.traceId());
} catch (RuntimeException failure) {
lastFailure = failure;
log.warn("客户尺寸规则初始化调用异常,tenantId={}, attempt={}", tenantId, attempt, failure);
log.warn("客户4条默认箱规尺寸初始化调用异常,tenantId={}, attempt={}", tenantId, attempt, failure);
}
}
compensateAfterUncertainInitialization(tenantId, initializationKey);
@@ -67,7 +67,7 @@ public class CustomerDimensionRuleInitializationService {
try {
compensateRequired(tenantId, result.initializationKey());
} catch (RuntimeException compensationFailure) {
log.error("客户创建事务回滚后尺寸规则补偿失败,tenantId={}, initializationKey={}",
log.error("客户创建事务回滚后4条默认箱规尺寸补偿失败,tenantId={}, initializationKey={}",
tenantId, result.initializationKey(), compensationFailure);
}
}
@@ -78,7 +78,7 @@ public class CustomerDimensionRuleInitializationService {
try {
compensateRequired(tenantId, initializationKey);
} catch (RuntimeException compensationFailure) {
log.error("客户尺寸规则初始化结果不确定且补偿失败,tenantId={}, initializationKey={}",
log.error("客户4条默认箱规尺寸初始化结果不确定且补偿失败,tenantId={}, initializationKey={}",
tenantId, initializationKey, compensationFailure);
}
}
@@ -95,20 +95,20 @@ public class CustomerDimensionRuleInitializationService {
}
}
throw new BizException(ResultCode.SERVICE_UNAVAILABLE,
"客户尺寸规则补偿失败,请根据客户ID修复数据一致性: " + tenantId);
"客户4条默认箱规尺寸补偿失败,请根据客户ID修复数据一致性: " + tenantId);
}
private static BizException unavailable(ApiResponse<?> response) {
String detail = response == null || response.message() == null || response.message().isBlank()
? "Firstleg 服务未返回有效响应" : response.message();
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "默认尺寸规则初始化失败: " + detail);
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "4条默认箱规尺寸初始化失败: " + detail);
}
private static BizException unavailable(RuntimeException cause) {
String detail = cause == null || cause.getMessage() == null || cause.getMessage().isBlank()
? "Firstleg 服务暂时不可用" : cause.getMessage();
return new BizException(ResultCode.SERVICE_UNAVAILABLE,
"默认尺寸规则初始化失败,已重试 3 次: " + detail);
"4条默认箱规尺寸初始化失败,已重试 3 次: " + detail);
}
private static String initializationKey(long tenantId) {
@@ -9,7 +9,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class CustomerDimensionRuleClientContractTest {
@Test
void usesTheFirstlegInternalDimensionConfigContract() throws Exception {
void usesOneAtomicCallForTheFourDefaultBoxDimensionRecords() throws Exception {
FeignClient feign = CustomerDimensionRuleClient.class.getAnnotation(FeignClient.class);
PostMapping initialize = CustomerDimensionRuleClient.class
.getMethod("initialize", String.class,
@@ -23,5 +23,8 @@ class CustomerDimensionRuleClientContractTest {
assertThat(feign.path()).isEqualTo("/internal/firstleg/v1/customer-dimension-configs");
assertThat(initialize.value()).containsExactly("/defaults");
assertThat(compensate.value()).containsExactly("/defaults");
assertThat(CustomerDimensionRuleClient.InitializationResponse.class.getRecordComponents())
.extracting(java.lang.reflect.RecordComponent::getName)
.containsExactly("configId", "createdByThisInitialization");
}
}
@@ -42,7 +42,7 @@ class CustomerDimensionRuleInitializationServiceTest {
}
@Test
void retriesWithOneStableKeyAndReturnsTheIdempotentResult() {
void retriesOneAtomicFourBoxDefaultsCallWithAStableKey() {
when(client.initialize(anyString(), any()))
.thenThrow(new RuntimeException("timeout"))
.thenReturn(ApiResponse.failure(ResultCode.SERVICE_UNAVAILABLE,
@@ -66,7 +66,7 @@ class CustomerDimensionRuleInitializationServiceTest {
}
@Test
void reportsServiceUnavailableAfterThreeFailedAttemptsAndTriesCompensation() {
void reportsServiceUnavailableWhenFourBoxDefaultsFailThreeTimesAndTriesCompensation() {
when(client.initialize(anyString(), any())).thenThrow(new RuntimeException("timeout"));
when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null));
@@ -78,7 +78,7 @@ class CustomerDimensionRuleInitializationServiceTest {
}
@Test
void localRollbackCompensatesOnlyAConfigurationCreatedByThisRegistration() {
void localRollbackCompensatesFourBoxDefaultsCreatedByThisRegistration() {
when(client.initialize(anyString(), any()))
.thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, true)));
when(client.compensate(anyString(), anyLong(), anyString())).thenReturn(ApiResponse.success(null));
@@ -95,7 +95,7 @@ class CustomerDimensionRuleInitializationServiceTest {
}
@Test
void localRollbackDoesNotDeleteAConfigurationThatAlreadyExisted() {
void localRollbackKeepsFourBoxDefaultsThatAlreadyExisted() {
when(client.initialize(anyString(), any()))
.thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, false)));
TransactionSynchronizationManager.initSynchronization();
@@ -110,7 +110,7 @@ class CustomerDimensionRuleInitializationServiceTest {
}
@Test
void responseTimeoutThenIdempotentSuccessStillCompensatesOnLocalRollback() {
void responseTimeoutThenIdempotentFourBoxSuccessStillCompensatesOnLocalRollback() {
when(client.initialize(anyString(), any()))
.thenThrow(new RuntimeException("response timeout"))
.thenReturn(ApiResponse.success(new CustomerDimensionRuleClient.InitializationResponse(91L, true)));
@@ -115,7 +115,7 @@ class MerchantOnboardingServiceTest {
}
@Test
void clientRegistrationFailsWhenDefaultDimensionRuleCannotBeInitialized() {
void clientRegistrationFailsWhenFourDefaultBoxDimensionsCannotBeInitialized() {
IamRole ownerRole = new IamRole();
ownerRole.setId(4000100L);
ownerRole.setTenantId(0L);
@@ -183,7 +183,7 @@ class TenantServiceTest {
}
@Test
void platformRegistrationFailsWhenDefaultDimensionRuleCannotBeInitialized() {
void platformRegistrationFailsWhenFourDefaultBoxDimensionsCannotBeInitialized() {
doThrow(new BizException(ResultCode.SERVICE_UNAVAILABLE, "初始化失败"))
.when(dimensionRuleInitialization).initialize(any(Long.class), any(Long.class));
@@ -60,6 +60,43 @@ class OrderApplicationServiceStateTest {
&& order.getInboundCode() == null));
}
@Test
void creatingDraftPreservesProductImageDimensionsAndRemark() {
OrderRepository repository = mock(OrderRepository.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
var cargo = new OrderRequests.Cargo(
"测试蓝牙音箱", "8518210000", "CARTON", 2,
new BigDecimal("24.000"), new BigDecimal("0.1200"),
new BigDecimal("50.00"), new BigDecimal("40.00"), new BigDecimal("30.00"),
new BigDecimal("300.00"), "USD", false, "自动化联调测试,请勿实际出货",
"CODEX-E2E-01", "012345678905", "Bluetooth Speaker", "8518210000", 10, 20,
new BigDecimal("22.000"), new BigDecimal("15.00"), "塑料 / Plastic",
"音频播放 / Audio playback", "CC-E2E-01", "NO LOGO",
new BigDecimal("18.00"), new BigDecimal("8.00"), new BigDecimal("7.00"),
"FBA-E2E-01", "CODEX-E2E-01", 2085686000000000001L);
OrderRequests.SaveRequest source = request();
OrderRequests.SaveRequest request = new OrderRequests.SaveRequest(
source.customerId(), source.customerName(), source.transportMode(), source.tradeTerm(),
source.destinationCountryCode(), source.destinationWarehouseId(), source.destinationWarehouseName(),
source.expectedShipDate(), source.customerRemark(), source.sender(), source.receiver(), List.of(cargo),
source.pickupMethod(), source.receivingWarehouseId(), source.receivingWarehouseName(),
source.channelCode(), source.channelName(), source.carrierId(), source.carrierName(), source.taxMode(),
source.pricingTaxMode(), source.containerMode(), source.customsInspection(), source.destinationType(),
source.amazonWarehouseCode(), source.shipmentId(), source.referenceId());
when(masterData.validate(10001L, request)).thenReturn(snapshots());
when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1);
service(repository, mock(OrderEligibilityGuard.class), masterData)
.createDraft(10001L, 2L, request, "preserve-product-fields");
verify(repository).insertCargo(argThat(saved ->
Long.valueOf(2085686000000000001L).equals(saved.getProductImageFileId())
&& new BigDecimal("18.00").equals(saved.getProductLengthCm())
&& new BigDecimal("8.00").equals(saved.getProductWidthCm())
&& new BigDecimal("7.00").equals(saved.getProductHeightCm())
&& "自动化联调测试,请勿实际出货".equals(saved.getCargoRemark())));
}
@Test
void creatingDraftValidatesCurrentPriceBookChannelBeforePersistence() {
OrderRepository repository = mock(OrderRepository.class);