From c4b0972dc929e208243ebaad5f89cc3e737a8abb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 7 Aug 2026 20:07:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 110 ++++++++- file/AGENTS.md | 4 +- .../PlatformMeasurementImageController.java | 18 ++ .../file/service/MeasurementImageService.java | 46 +++- ...easurementImageControllerContractTest.java | 12 + .../MeasurementImageServiceEvidenceTest.java | 36 +++ ...formCustomerDimensionConfigController.java | 12 +- .../PlatformInboundMeasurementController.java | 17 ++ .../domain/CustomerDimensionBoxType.java | 46 ++++ .../dto/CustomerDimensionConfigDtos.java | 25 +- .../entity/CustomerDimensionConfigEntity.java | 7 +- .../mapper/CustomerDimensionConfigMapper.java | 57 +++-- .../CustomerDimensionConfigRepository.java | 21 +- .../InboundMeasurementRepository.java | 8 + .../CustomerDimensionConfigService.java | 225 ++++++++++++++---- .../service/InboundMeasurementService.java | 46 +++- .../db/customer-dimension-config.sql | 15 +- firstleg/src/main/resources/db/firstleg.sql | 27 +++ .../resources/db/phase2-inbound-schema.sql | 13 +- .../main/resources/openapi/firstleg-v1.yaml | 76 ++++-- ...oundMeasurementControllerContractTest.java | 2 + ...ustomerDimensionConfigSqlContractTest.java | 147 +++++++++--- .../CustomerDimensionConfigServiceTest.java | 189 +++++++++++---- .../InboundMeasurementServiceTest.java | 59 +++++ .../client/CustomerDimensionRuleClient.java | 3 +- ...merDimensionRuleInitializationService.java | 16 +- ...stomerDimensionRuleClientContractTest.java | 5 +- ...imensionRuleInitializationServiceTest.java | 10 +- .../MerchantOnboardingServiceTest.java | 2 +- .../baiyue/iam/service/TenantServiceTest.java | 2 +- .../OrderApplicationServiceStateTest.java | 37 +++ 31 files changed, 1089 insertions(+), 204 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/domain/CustomerDimensionBoxType.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 00602650d..5310fb0ce 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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' diff --git a/file/AGENTS.md b/file/AGENTS.md index e8ec8f827..dcf8113b5 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -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`。 diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformMeasurementImageController.java b/file/src/main/java/com/baiyue/file/controller/PlatformMeasurementImageController.java index 88f283971..fd52b336a 100644 --- a/file/src/main/java/com/baiyue/file/controller/PlatformMeasurementImageController.java +++ b/file/src/main/java/com/baiyue/file/controller/PlatformMeasurementImageController.java @@ -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 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 diff --git a/file/src/main/java/com/baiyue/file/service/MeasurementImageService.java b/file/src/main/java/com/baiyue/file/service/MeasurementImageService.java index 82db4c2b3..1a2c1f350 100644 --- a/file/src/main/java/com/baiyue/file/service/MeasurementImageService.java +++ b/file/src/main/java/com/baiyue/file/service/MeasurementImageService.java @@ -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); diff --git a/file/src/test/java/com/baiyue/file/controller/PlatformMeasurementImageControllerContractTest.java b/file/src/test/java/com/baiyue/file/controller/PlatformMeasurementImageControllerContractTest.java index fd0d78555..681256990 100644 --- a/file/src/test/java/com/baiyue/file/controller/PlatformMeasurementImageControllerContractTest.java +++ b/file/src/test/java/com/baiyue/file/controller/PlatformMeasurementImageControllerContractTest.java @@ -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')"); + } } diff --git a/file/src/test/java/com/baiyue/file/service/MeasurementImageServiceEvidenceTest.java b/file/src/test/java/com/baiyue/file/service/MeasurementImageServiceEvidenceTest.java index e579cedd5..7ac7999b9 100644 --- a/file/src/test/java/com/baiyue/file/service/MeasurementImageServiceEvidenceTest.java +++ b/file/src/test/java/com/baiyue/file/service/MeasurementImageServiceEvidenceTest.java @@ -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(); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformCustomerDimensionConfigController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformCustomerDimensionConfigController.java index 3a9992596..934413b7e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformCustomerDimensionConfigController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformCustomerDimensionConfigController.java @@ -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 get(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam long tenantId) { + public ApiResponse 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 update(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam long tenantId, - @Valid @RequestBody UpdateRequest request) { + public ApiResponse 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)); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java index dc2423f2d..6b57ef921 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java @@ -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 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) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/CustomerDimensionBoxType.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/CustomerDimensionBoxType.java new file mode 100644 index 000000000..e808e3322 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/CustomerDimensionBoxType.java @@ -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; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerDimensionConfigDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerDimensionConfigDtos.java index fc4d3f934..a75c4b6b3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerDimensionConfigDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerDimensionConfigDtos.java @@ -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 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, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerDimensionConfigEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerDimensionConfigEntity.java index 0fe4f75ca..e19926031 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerDimensionConfigEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/CustomerDimensionConfigEntity.java @@ -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; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/CustomerDimensionConfigMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/CustomerDimensionConfigMapper.java index cc2116713..dced299bf 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/CustomerDimensionConfigMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/CustomerDimensionConfigMapper.java @@ -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 { @Insert(""" + """) - 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 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 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); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/CustomerDimensionConfigRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/CustomerDimensionConfigRepository.java index 45c8aea1d..2bebaf7fd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/CustomerDimensionConfigRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/CustomerDimensionConfigRepository.java @@ -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() + public List findAll(long tenantId) { + return mapper.selectList(new LambdaQueryWrapper() .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 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 findDefaultsForCompensation(long tenantId, + String initializationKey) { + return mapper.selectDefaultsForCompensation(tenantId, initializationKey); + } + + public int deleteUnchangedDefaults(long tenantId, String initializationKey) { + return mapper.deleteUnchangedDefaults(tenantId, initializationKey); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java index 41d987814..3bb963ec9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundMeasurementRepository.java @@ -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() + .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); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionConfigService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionConfigService.java index f86f8e9d8..93a9b00c1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionConfigService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/CustomerDimensionConfigService.java @@ -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 requested = requireFixedRuleSet(request); + List existing = requireCompleteRules(repository.findAll(tenantId)); + Map 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 existing = repository.findAll(tenantId); + if (!existing.isEmpty()) { + List 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 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 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 requireFixedRuleSet(UpdateRequest request) { + if (request == null || request.rules() == null || request.rules().size() != CustomerDimensionBoxType.values().length) { + throw new BizException(ResultCode.BAD_REQUEST, "必须完整提交固定四种箱规"); + } + Map 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 requireCompleteRules( + List values) { + if (values == null || values.isEmpty()) { + throw new BizException(ResultCode.NOT_FOUND, "客户箱规尺寸配置不存在"); + } + Map indexed = index(values); + if (values.size() != CustomerDimensionBoxType.values().length + || indexed.size() != CustomerDimensionBoxType.values().length) { + throw new BizException(ResultCode.INTERNAL_ERROR, "客户箱规尺寸配置不完整"); + } + List 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 index( + List values) { + Map 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 defaultRows(long tenantId, String initializationKey) { + List 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 values, + String initializationKey) { + if (values == null || values.size() != CustomerDimensionBoxType.values().length) return false; + Map 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 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 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) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index 75f16ace6..f176ddd62 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -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 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 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 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"); } diff --git a/firstleg/src/main/resources/db/customer-dimension-config.sql b/firstleg/src/main/resources/db/customer-dimension-config.sql index 1fe38ac7c..e41481190 100644 --- a/firstleg/src/main/resources/db/customer-dimension-config.sql +++ b/firstleg/src/main/resources/db/customer-dimension-config.sql @@ -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='客户固定四箱规尺寸配置'; diff --git a/firstleg/src/main/resources/db/firstleg.sql b/firstleg/src/main/resources/db/firstleg.sql index 3952303c4..52b5677b0 100644 --- a/firstleg/src/main/resources/db/firstleg.sql +++ b/firstleg/src/main/resources/db/firstleg.sql @@ -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='客户固定四箱规尺寸配置'; diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 833babad3..0d6b610ab 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -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='客户固定四箱规尺寸配置'; diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index a3a9cd913..1e481abe2 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -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} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java index a720e8ee5..09b719000 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementControllerContractTest.java @@ -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) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerDimensionConfigSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerDimensionConfigSqlContractTest.java index 1b9784e60..9dbb945cd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerDimensionConfigSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/CustomerDimensionConfigSqlContractTest.java @@ -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 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(" 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); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java index f8d28d1c8..8f0ee2fec 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionConfigServiceTest.java @@ -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 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> 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 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 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 persisted = defaultConfigs("registration-1"); + persisted.forEach(value -> value.setVersion(3)); + when(repository.findAll(8L)).thenReturn(persisted); + UpdateRequest request = updateRequest(3); + List 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 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 defaultConfigs(String initializationKey) { + List 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; } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java index 6a788f274..87420e244 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -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"); diff --git a/iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java b/iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java index ec6a148f9..f02b8f982 100644 --- a/iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java +++ b/iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java @@ -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) { } } diff --git a/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java index 050ae2305..32bd7217c 100644 --- a/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java +++ b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java @@ -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) { diff --git a/iam/src/test/java/com/baiyue/iam/client/CustomerDimensionRuleClientContractTest.java b/iam/src/test/java/com/baiyue/iam/client/CustomerDimensionRuleClientContractTest.java index f6a0a355e..2375311f0 100644 --- a/iam/src/test/java/com/baiyue/iam/client/CustomerDimensionRuleClientContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/client/CustomerDimensionRuleClientContractTest.java @@ -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"); } } diff --git a/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationServiceTest.java index cdf4d9164..459a72fab 100644 --- a/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationServiceTest.java @@ -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))); diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java index 7b2071e9d..b89e5fc57 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java @@ -115,7 +115,7 @@ class MerchantOnboardingServiceTest { } @Test - void clientRegistrationFailsWhenDefaultDimensionRuleCannotBeInitialized() { + void clientRegistrationFailsWhenFourDefaultBoxDimensionsCannotBeInitialized() { IamRole ownerRole = new IamRole(); ownerRole.setId(4000100L); ownerRole.setTenantId(0L); diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index cd244dca3..ed6814832 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -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)); diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index 2550e1d95..249141a8a 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -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);