This commit is contained in:
2026-08-07 20:07:30 +08:00
parent c698290106
commit c4b0972dc9
31 changed files with 1089 additions and 204 deletions
@@ -3,7 +3,7 @@ package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.Response;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.GroupResponse;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest;
import com.baiyue.firstleg.service.CustomerDimensionConfigService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
@@ -30,8 +30,8 @@ public class PlatformCustomerDimensionConfigController {
@GetMapping
@PreAuthorize("hasAuthority('firstleg:dimension-config:view')")
@Operation(summary = "查询客户尺寸计算配置")
public ApiResponse<Response> get(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId) {
public ApiResponse<GroupResponse> get(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId) {
return ApiResponse.success(service.get(FirstlegScopeGuard.platform(user, tenantId)));
}
@@ -41,9 +41,9 @@ public class PlatformCustomerDimensionConfigController {
resourceType = "DIMENSION_CONFIG", resourceId = "#tenantId",
description = "平台编辑客户尺寸计算配置")
@Operation(summary = "编辑客户尺寸计算配置")
public ApiResponse<Response> update(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@Valid @RequestBody UpdateRequest request) {
public ApiResponse<GroupResponse> update(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@Valid @RequestBody UpdateRequest request) {
long scope = FirstlegScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.update(scope, user.userId(), request));
}
@@ -21,6 +21,7 @@ import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
@@ -167,6 +168,22 @@ public class PlatformInboundMeasurementController {
tenantId, inboundId, resultId, user.userId(), user.username(), request));
}
@BusinessAudit(event = "INBOUND_WEIGHT_IMAGE_DELETE", resourceType = "INBOUND_WEIGHT_RESULT",
resourceId = "#resultId", description = "删除称重图片及重量结果")
@Operation(summary = "删除称重图片", description = "删除重量结果和文件;已确认图片删除后重新汇总实际总毛重。")
@DeleteMapping("/weight-results/{resultId}")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:weight:edit')")
public ApiResponse<Void> deleteWeightResult(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@RequestParam int version,
@PathVariable long inboundId,
@PathVariable long resultId) {
requirePlatformTenant(user, tenantId);
service.deleteWeightResult(tenantId, inboundId, resultId, version, user.userId());
return ApiResponse.success(null);
}
private static void requirePlatformTenant(AuthenticatedUser user, long tenantId) {
FirstlegScopeGuard.platform(user, tenantId);
if (user.userId() == null || user.userId() <= 0) {
@@ -0,0 +1,46 @@
package com.baiyue.firstleg.domain;
import java.math.BigDecimal;
/** 平台固定维护的四种客户箱规,不允许扩展或改名。 */
public enum CustomerDimensionBoxType {
SMALL("小箱", "30.000", "20.000", "20.000", "标准小箱"),
MEDIUM("中箱", "40.000", "30.000", "30.000", "标准中箱"),
LARGE("大箱", "50.000", "40.000", "40.000", "标准大箱"),
EXTRA_LARGE("特大箱", "60.000", "50.000", "50.000", "特大箱");
private final String displayName;
private final BigDecimal defaultLengthCm;
private final BigDecimal defaultWidthCm;
private final BigDecimal defaultHeightCm;
private final String defaultRemark;
CustomerDimensionBoxType(String displayName, String defaultLengthCm, String defaultWidthCm,
String defaultHeightCm, String defaultRemark) {
this.displayName = displayName;
this.defaultLengthCm = new BigDecimal(defaultLengthCm);
this.defaultWidthCm = new BigDecimal(defaultWidthCm);
this.defaultHeightCm = new BigDecimal(defaultHeightCm);
this.defaultRemark = defaultRemark;
}
public String displayName() {
return displayName;
}
public BigDecimal defaultLengthCm() {
return defaultLengthCm;
}
public BigDecimal defaultWidthCm() {
return defaultWidthCm;
}
public BigDecimal defaultHeightCm() {
return defaultHeightCm;
}
public String defaultRemark() {
return defaultRemark;
}
}
@@ -1,5 +1,7 @@
package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.domain.CustomerDimensionBoxType;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Digits;
@@ -10,21 +12,40 @@ import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
public final class CustomerDimensionConfigDtos {
private CustomerDimensionConfigDtos() { }
public record UpdateRequest(
@NotNull @Size(min = 4, max = 4) List<@Valid RuleUpdateRequest> rules
) { }
public record RuleUpdateRequest(
@NotNull CustomerDimensionBoxType boxType,
@NotNull @DecimalMin("0.001") @Digits(integer = 6, fraction = 3) BigDecimal innerLengthCm,
@NotNull @DecimalMin("0.001") @Digits(integer = 6, fraction = 3) BigDecimal innerWidthCm,
@NotNull @DecimalMin("0.001") @Digits(integer = 6, fraction = 3) BigDecimal innerHeightCm,
@NotNull @DecimalMin("0.001") @DecimalMax("100.000") @Digits(integer = 3, fraction = 3)
BigDecimal edgeAdditionCm,
@Size(max = 500) String remark,
@NotNull @Min(0) Integer version
) { }
public record Response(
public record GroupResponse(Long tenantId, List<RuleResponse> rules) { }
public record RuleResponse(
Long id,
Long tenantId,
CustomerDimensionBoxType boxType,
String boxTypeName,
BigDecimal innerLengthCm,
BigDecimal innerWidthCm,
BigDecimal innerHeightCm,
BigDecimal edgeAdditionCm,
BigDecimal outerLengthCm,
BigDecimal outerWidthCm,
BigDecimal outerHeightCm,
BigDecimal volumeCbm,
String remark,
Integer version,
LocalDateTime createdAt,
@@ -4,12 +4,13 @@ import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
import com.baiyue.firstleg.domain.CustomerDimensionBoxType;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
/** 客户填写真实尺寸后使用的每侧加宽配置。 */
/** 客户固定箱规的内径和每侧加宽配置。 */
@Getter
@Setter
@TableName("fl_customer_dimension_config")
@@ -17,6 +18,10 @@ public class CustomerDimensionConfigEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private CustomerDimensionBoxType boxType;
private BigDecimal innerLengthCm;
private BigDecimal innerWidthCm;
private BigDecimal innerHeightCm;
private BigDecimal edgeAdditionCm;
private String remark;
private String initializationKey;
@@ -5,33 +5,62 @@ import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.math.BigDecimal;
import java.util.List;
public interface CustomerDimensionConfigMapper extends BaseMapper<CustomerDimensionConfigEntity> {
@Insert("""
<script>
INSERT IGNORE INTO fl_customer_dimension_config
(id, tenant_id, edge_addition_cm, remark, initialization_key, created_by, created_at,
(id, tenant_id, box_type, inner_length_cm, inner_width_cm, inner_height_cm,
edge_addition_cm, remark, initialization_key, created_by, created_at,
updated_by, updated_at, deleted, version)
VALUES
(#{id}, #{tenantId}, #{edgeAdditionCm}, #{remark}, #{initializationKey}, #{operatorId}, CURRENT_TIMESTAMP(3),
#{operatorId}, CURRENT_TIMESTAMP(3), 0, 0)
<foreach collection="rows" item="row" separator=",">
(#{row.id}, #{row.tenantId}, #{row.boxType}, #{row.innerLengthCm}, #{row.innerWidthCm},
#{row.innerHeightCm}, #{row.edgeAdditionCm}, #{row.remark}, #{row.initializationKey},
#{operatorId}, CURRENT_TIMESTAMP(3), #{operatorId}, CURRENT_TIMESTAMP(3), 0, 0)
</foreach>
</script>
""")
int insertDefault(@Param("id") long id, @Param("tenantId") long tenantId,
@Param("edgeAdditionCm") BigDecimal edgeAdditionCm,
@Param("remark") String remark, @Param("initializationKey") String initializationKey,
@Param("operatorId") long operatorId);
int insertDefaults(@Param("rows") List<CustomerDimensionConfigEntity> rows,
@Param("operatorId") long operatorId);
@Select("""
SELECT id, tenant_id, box_type, inner_length_cm, inner_width_cm, inner_height_cm,
edge_addition_cm, remark, initialization_key, created_by, created_at,
updated_by, updated_at, deleted, version
FROM fl_customer_dimension_config
WHERE tenant_id = #{tenantId}
AND initialization_key = #{initializationKey}
AND deleted = 0
FOR UPDATE
""")
List<CustomerDimensionConfigEntity> selectDefaultsForCompensation(
@Param("tenantId") long tenantId, @Param("initializationKey") String initializationKey);
@Delete("""
DELETE FROM fl_customer_dimension_config
WHERE id = #{id}
AND tenant_id = #{tenantId}
WHERE tenant_id = #{tenantId}
AND initialization_key = #{initializationKey}
AND edge_addition_cm = #{edgeAdditionCm}
AND version = 0
AND deleted = 0
AND box_type IN ('SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE')
AND inner_length_cm = CASE box_type
WHEN 'SMALL' THEN 30.000 WHEN 'MEDIUM' THEN 40.000
WHEN 'LARGE' THEN 50.000 WHEN 'EXTRA_LARGE' THEN 60.000 END
AND inner_width_cm = CASE box_type
WHEN 'SMALL' THEN 20.000 WHEN 'MEDIUM' THEN 30.000
WHEN 'LARGE' THEN 40.000 WHEN 'EXTRA_LARGE' THEN 50.000 END
AND inner_height_cm = CASE box_type
WHEN 'SMALL' THEN 20.000 WHEN 'MEDIUM' THEN 30.000
WHEN 'LARGE' THEN 40.000 WHEN 'EXTRA_LARGE' THEN 50.000 END
AND edge_addition_cm = 2.000
AND remark = CASE box_type
WHEN 'SMALL' THEN '标准小箱' WHEN 'MEDIUM' THEN '标准中箱'
WHEN 'LARGE' THEN '标准大箱' WHEN 'EXTRA_LARGE' THEN '特大箱' END
""")
int deleteUnchangedDefault(@Param("tenantId") long tenantId, @Param("id") long id,
@Param("initializationKey") String initializationKey,
@Param("edgeAdditionCm") BigDecimal edgeAdditionCm);
int deleteUnchangedDefaults(@Param("tenantId") long tenantId,
@Param("initializationKey") String initializationKey);
}
@@ -6,29 +6,32 @@ import com.baiyue.firstleg.mapper.CustomerDimensionConfigMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.util.List;
@Repository
public class CustomerDimensionConfigRepository {
@Resource
private CustomerDimensionConfigMapper mapper;
public CustomerDimensionConfigEntity find(long tenantId) {
return mapper.selectOne(new LambdaQueryWrapper<CustomerDimensionConfigEntity>()
public List<CustomerDimensionConfigEntity> findAll(long tenantId) {
return mapper.selectList(new LambdaQueryWrapper<CustomerDimensionConfigEntity>()
.eq(CustomerDimensionConfigEntity::getTenantId, tenantId));
}
public boolean insertDefault(long id, long tenantId, BigDecimal edgeAdditionCm,
String remark, String initializationKey, long operatorId) {
return mapper.insertDefault(id, tenantId, edgeAdditionCm, remark, initializationKey, operatorId) == 1;
public int insertDefaults(List<CustomerDimensionConfigEntity> rows, long operatorId) {
return mapper.insertDefaults(rows, operatorId);
}
public int update(CustomerDimensionConfigEntity value) {
return mapper.updateById(value);
}
public int deleteUnchangedDefault(long tenantId, long id, String initializationKey,
BigDecimal edgeAdditionCm) {
return mapper.deleteUnchangedDefault(tenantId, id, initializationKey, edgeAdditionCm);
public List<CustomerDimensionConfigEntity> findDefaultsForCompensation(long tenantId,
String initializationKey) {
return mapper.selectDefaultsForCompensation(tenantId, initializationKey);
}
public int deleteUnchangedDefaults(long tenantId, String initializationKey) {
return mapper.deleteUnchangedDefaults(tenantId, initializationKey);
}
}
@@ -157,6 +157,14 @@ public class InboundMeasurementRepository {
.eq(InboundWeightResultEntity::getId, weight.getId())) == 1;
}
public boolean deleteWeight(long tenantId, long inboundId, long resultId, int expectedVersion) {
return weights.delete(new LambdaQueryWrapper<InboundWeightResultEntity>()
.eq(InboundWeightResultEntity::getTenantId, tenantId)
.eq(InboundWeightResultEntity::getInboundId, inboundId)
.eq(InboundWeightResultEntity::getId, resultId)
.eq(InboundWeightResultEntity::getVersion, expectedVersion)) == 1;
}
public void insertRevision(InboundWeightRevisionEntity revision) {
revisions.insert(revision);
@@ -3,8 +3,11 @@ package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.domain.CustomerDimensionBoxType;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.GroupResponse;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.InitializeResponse;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.Response;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.RuleResponse;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.RuleUpdateRequest;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest;
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository;
@@ -13,75 +16,215 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
/** 管理每个客户唯一的尺寸加边配置,不参与当前订单或计费计算。 */
/** 管理每个客户固定四种箱规的尺寸配置,不参与当前订单或计费计算。 */
@Service
public class CustomerDimensionConfigService {
public static final BigDecimal DEFAULT_EDGE_ADDITION_CM = new BigDecimal("2.000");
private static final String DEFAULT_REMARK = "默认每侧加宽 2cm";
private static final BigDecimal TWO = new BigDecimal("2");
private static final BigDecimal CUBIC_CENTIMETERS_PER_CBM = new BigDecimal("1000000");
@Resource
private CustomerDimensionConfigRepository repository;
public Response get(long tenantId) {
CustomerDimensionConfigEntity value = repository.find(tenantId);
if (value == null) {
throw new BizException(ResultCode.NOT_FOUND, "客户尺寸计算配置不存在");
}
return response(value);
public GroupResponse get(long tenantId) {
return response(tenantId, requireCompleteRules(repository.findAll(tenantId)));
}
@Transactional
public Response update(long tenantId, long operatorId, UpdateRequest request) {
CustomerDimensionConfigEntity value = repository.find(tenantId);
if (value == null) {
throw new BizException(ResultCode.NOT_FOUND, "客户尺寸计算配置不存在");
public GroupResponse update(long tenantId, long operatorId, UpdateRequest request) {
Map<CustomerDimensionBoxType, RuleUpdateRequest> requested = requireFixedRuleSet(request);
List<CustomerDimensionConfigEntity> existing = requireCompleteRules(repository.findAll(tenantId));
Map<CustomerDimensionBoxType, CustomerDimensionConfigEntity> persisted = index(existing);
for (CustomerDimensionBoxType type : CustomerDimensionBoxType.values()) {
CustomerDimensionConfigEntity value = persisted.get(type);
if (!value.getVersion().equals(requested.get(type).version())) {
throw staleConfiguration();
}
}
if (!value.getVersion().equals(request.version())) {
throw new BizException(ResultCode.CONFLICT, "尺寸计算配置已被修改,请刷新后重试");
for (CustomerDimensionBoxType type : CustomerDimensionBoxType.values()) {
CustomerDimensionConfigEntity value = persisted.get(type);
RuleUpdateRequest update = requested.get(type);
value.setInnerLengthCm(update.innerLengthCm());
value.setInnerWidthCm(update.innerWidthCm());
value.setInnerHeightCm(update.innerHeightCm());
value.setEdgeAdditionCm(update.edgeAdditionCm());
value.setRemark(normalizeRemark(update.remark()));
value.setUpdatedBy(operatorId);
if (repository.update(value) != 1) {
throw staleConfiguration();
}
}
value.setEdgeAdditionCm(request.edgeAdditionCm());
value.setRemark(normalizeRemark(request.remark()));
value.setUpdatedBy(operatorId);
if (repository.update(value) != 1) {
throw new BizException(ResultCode.CONFLICT, "尺寸计算配置已被修改,请刷新后重试");
}
return response(value);
return response(tenantId, existing);
}
@Transactional
public InitializeResponse initializeDefault(long tenantId, long operatorId, String initializationKey) {
requireTenant(tenantId);
String normalizedKey = requireInitializationKey(initializationKey);
CustomerDimensionConfigEntity existing = repository.find(tenantId);
if (existing != null) {
return new InitializeResponse(existing.getId(),
normalizedKey.equals(existing.getInitializationKey()));
List<CustomerDimensionConfigEntity> existing = repository.findAll(tenantId);
if (!existing.isEmpty()) {
List<CustomerDimensionConfigEntity> complete = requireCompleteRules(existing);
return initializationResponse(complete, normalizedKey);
}
long id = IdWorker.getId();
boolean created = repository.insertDefault(id, tenantId, DEFAULT_EDGE_ADDITION_CM,
DEFAULT_REMARK, normalizedKey, operatorId);
CustomerDimensionConfigEntity persisted = repository.find(tenantId);
if (persisted == null) {
throw new BizException(ResultCode.INTERNAL_ERROR, "客户尺寸计算配置初始化失败");
}
boolean ownedByKey = normalizedKey.equals(persisted.getInitializationKey());
return new InitializeResponse(persisted.getId(), created || ownedByKey);
repository.insertDefaults(defaultRows(tenantId, normalizedKey), operatorId);
List<CustomerDimensionConfigEntity> persisted = requireCompleteRules(repository.findAll(tenantId));
return initializationResponse(persisted, normalizedKey);
}
@Transactional
public void compensateDefault(long tenantId, String initializationKey) {
requireTenant(tenantId);
String normalizedKey = requireInitializationKey(initializationKey);
CustomerDimensionConfigEntity existing = repository.find(tenantId);
if (existing == null || !normalizedKey.equals(existing.getInitializationKey())) return;
repository.deleteUnchangedDefault(tenantId, existing.getId(), normalizedKey, DEFAULT_EDGE_ADDITION_CM);
List<CustomerDimensionConfigEntity> locked =
repository.findDefaultsForCompensation(tenantId, normalizedKey);
if (!isUnchangedDefaultGroup(locked, normalizedKey)) return;
if (repository.deleteUnchangedDefaults(tenantId, normalizedKey) != CustomerDimensionBoxType.values().length) {
throw new BizException(ResultCode.INTERNAL_ERROR, "客户箱规默认配置补偿删除失败");
}
}
private static Response response(CustomerDimensionConfigEntity value) {
return new Response(value.getId(), value.getTenantId(), value.getEdgeAdditionCm(), value.getRemark(),
value.getVersion(), value.getCreatedAt(), value.getUpdatedAt());
private static Map<CustomerDimensionBoxType, RuleUpdateRequest> requireFixedRuleSet(UpdateRequest request) {
if (request == null || request.rules() == null || request.rules().size() != CustomerDimensionBoxType.values().length) {
throw new BizException(ResultCode.BAD_REQUEST, "必须完整提交固定四种箱规");
}
Map<CustomerDimensionBoxType, RuleUpdateRequest> values = new EnumMap<>(CustomerDimensionBoxType.class);
for (RuleUpdateRequest rule : request.rules()) {
if (rule == null || rule.boxType() == null || values.put(rule.boxType(), rule) != null) {
throw new BizException(ResultCode.BAD_REQUEST, "箱规类型重复或无效");
}
requirePositive(rule.innerLengthCm(), "箱规内长");
requirePositive(rule.innerWidthCm(), "箱规内宽");
requirePositive(rule.innerHeightCm(), "箱规内高");
requirePositive(rule.edgeAdditionCm(), "箱规单侧加宽");
if (rule.version() == null || rule.version() < 0) {
throw new BizException(ResultCode.BAD_REQUEST, "箱规版本号无效");
}
}
if (values.size() != CustomerDimensionBoxType.values().length) {
throw new BizException(ResultCode.BAD_REQUEST, "必须完整提交固定四种箱规");
}
return values;
}
private static List<CustomerDimensionConfigEntity> requireCompleteRules(
List<CustomerDimensionConfigEntity> values) {
if (values == null || values.isEmpty()) {
throw new BizException(ResultCode.NOT_FOUND, "客户箱规尺寸配置不存在");
}
Map<CustomerDimensionBoxType, CustomerDimensionConfigEntity> indexed = index(values);
if (values.size() != CustomerDimensionBoxType.values().length
|| indexed.size() != CustomerDimensionBoxType.values().length) {
throw new BizException(ResultCode.INTERNAL_ERROR, "客户箱规尺寸配置不完整");
}
List<CustomerDimensionConfigEntity> ordered = new ArrayList<>(CustomerDimensionBoxType.values().length);
for (CustomerDimensionBoxType type : CustomerDimensionBoxType.values()) {
CustomerDimensionConfigEntity value = indexed.get(type);
if (value == null) {
throw new BizException(ResultCode.INTERNAL_ERROR, "客户箱规尺寸配置不完整");
}
ordered.add(value);
}
return ordered;
}
private static Map<CustomerDimensionBoxType, CustomerDimensionConfigEntity> index(
List<CustomerDimensionConfigEntity> values) {
Map<CustomerDimensionBoxType, CustomerDimensionConfigEntity> indexed =
new EnumMap<>(CustomerDimensionBoxType.class);
for (CustomerDimensionConfigEntity value : values) {
if (value == null || value.getBoxType() == null || indexed.put(value.getBoxType(), value) != null) {
throw new BizException(ResultCode.INTERNAL_ERROR, "客户箱规尺寸配置不完整");
}
}
return indexed;
}
private static List<CustomerDimensionConfigEntity> defaultRows(long tenantId, String initializationKey) {
List<CustomerDimensionConfigEntity> rows = new ArrayList<>(CustomerDimensionBoxType.values().length);
for (CustomerDimensionBoxType type : CustomerDimensionBoxType.values()) {
CustomerDimensionConfigEntity value = new CustomerDimensionConfigEntity();
value.setId(IdWorker.getId());
value.setTenantId(tenantId);
value.setBoxType(type);
value.setInnerLengthCm(type.defaultLengthCm());
value.setInnerWidthCm(type.defaultWidthCm());
value.setInnerHeightCm(type.defaultHeightCm());
value.setEdgeAdditionCm(DEFAULT_EDGE_ADDITION_CM);
value.setRemark(type.defaultRemark());
value.setInitializationKey(initializationKey);
rows.add(value);
}
return rows;
}
private static boolean isUnchangedDefaultGroup(List<CustomerDimensionConfigEntity> values,
String initializationKey) {
if (values == null || values.size() != CustomerDimensionBoxType.values().length) return false;
Map<CustomerDimensionBoxType, CustomerDimensionConfigEntity> indexed;
try {
indexed = index(values);
} catch (BizException ignored) {
return false;
}
for (CustomerDimensionBoxType type : CustomerDimensionBoxType.values()) {
CustomerDimensionConfigEntity value = indexed.get(type);
if (value == null || !initializationKey.equals(value.getInitializationKey())
|| value.getVersion() == null || value.getVersion() != 0
|| value.getInnerLengthCm() == null || value.getInnerWidthCm() == null
|| value.getInnerHeightCm() == null || value.getEdgeAdditionCm() == null
|| value.getInnerLengthCm().compareTo(type.defaultLengthCm()) != 0
|| value.getInnerWidthCm().compareTo(type.defaultWidthCm()) != 0
|| value.getInnerHeightCm().compareTo(type.defaultHeightCm()) != 0
|| value.getEdgeAdditionCm().compareTo(DEFAULT_EDGE_ADDITION_CM) != 0
|| !type.defaultRemark().equals(value.getRemark())) {
return false;
}
}
return true;
}
private static InitializeResponse initializationResponse(List<CustomerDimensionConfigEntity> values,
String initializationKey) {
boolean ownedByKey = values.stream()
.allMatch(value -> initializationKey.equals(value.getInitializationKey()));
return new InitializeResponse(values.get(0).getId(), ownedByKey);
}
private static GroupResponse response(long tenantId, List<CustomerDimensionConfigEntity> values) {
return new GroupResponse(tenantId, values.stream().map(CustomerDimensionConfigService::ruleResponse).toList());
}
private static RuleResponse ruleResponse(CustomerDimensionConfigEntity value) {
BigDecimal edgePair = value.getEdgeAdditionCm().multiply(TWO);
BigDecimal outerLength = value.getInnerLengthCm().add(edgePair);
BigDecimal outerWidth = value.getInnerWidthCm().add(edgePair);
BigDecimal outerHeight = value.getInnerHeightCm().add(edgePair);
BigDecimal volume = outerLength.multiply(outerWidth).multiply(outerHeight)
.divide(CUBIC_CENTIMETERS_PER_CBM)
.setScale(3, RoundingMode.HALF_UP);
return new RuleResponse(value.getId(), value.getBoxType(), value.getBoxType().displayName(),
value.getInnerLengthCm(), value.getInnerWidthCm(), value.getInnerHeightCm(),
value.getEdgeAdditionCm(), outerLength, outerWidth, outerHeight, volume,
value.getRemark(), value.getVersion(), value.getCreatedAt(), value.getUpdatedAt());
}
private static void requirePositive(BigDecimal value, String fieldName) {
if (value == null || value.compareTo(new BigDecimal("0.001")) < 0) {
throw new BizException(ResultCode.BAD_REQUEST, fieldName + "必须大于0");
}
}
private static BizException staleConfiguration() {
return new BizException(ResultCode.CONFLICT, "箱规尺寸配置已被修改,请刷新后重试");
}
private static String normalizeRemark(String value) {
@@ -3,7 +3,9 @@ package com.baiyue.firstleg.service;
import jakarta.annotation.Resource;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.MeasurementImageClient;
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
import com.baiyue.firstleg.dto.MeasurementBatchCommand;
@@ -17,6 +19,8 @@ import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import feign.FeignException;
import java.math.BigDecimal;
import java.math.RoundingMode;
@@ -32,6 +36,8 @@ public class InboundMeasurementService {
private static final String WEIGHT_CONFIRMED = "CONFIRMED";
private static final String WEIGHT_PENDING_CONFIRM = "PENDING_CONFIRM";
private static final String WEIGHT_NEEDS_MANUAL = "NEEDS_MANUAL";
private static final Set<String> WEIGHT_EDITABLE_STATUSES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
/** 测量完成后的默认普通托盘类型;实际拆托和库位占用由提交编排服务执行。 */
private static final String RECOMMENDED_PALLET_TYPE = "STANDARD";
private static final BigDecimal LB_TO_KG = new BigDecimal("0.45359237");
@@ -40,6 +46,10 @@ public class InboundMeasurementService {
private InboundMeasurementRepository repository;
@Resource
private MeasurementBatchService batchService;
@Resource
private MeasurementImageClient imageClient;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
public InboundMeasurementResponses.Detail detail(long tenantId, long inboundId) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
@@ -231,13 +241,45 @@ public class InboundMeasurementService {
return toWeight(weight);
}
@Transactional
public void deleteWeightResult(long tenantId, long inboundId, long resultId,
int version, long operatorId) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
if (!WEIGHT_EDITABLE_STATUSES.contains(inbound.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT, "当前入库状态不允许删除称重图片");
}
InboundWeightResultEntity weight = requireWeight(tenantId, inboundId, resultId);
requireVersion(version, weight.getVersion());
if (!repository.deleteWeight(tenantId, inboundId, resultId, version)) {
throw concurrentModification();
}
if (WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) {
recalculateConfirmedWeight(inbound, operatorId);
}
try {
ApiResponse<Void> response = imageClient.delete(
internalToken, operatorId, tenantId, inboundId, weight.getFileId());
if (response == null || !response.successful()) {
String message = response == null || !StringUtils.hasText(response.message())
? "称重图片删除失败" : response.message();
throw new BizException(response == null ? ResultCode.SERVICE_UNAVAILABLE
: ResultCode.of(response.code()), message);
}
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用,称重图片未删除");
}
}
private void recalculateConfirmedWeight(InboundOrderEntity inbound, long operatorId) {
BigDecimal total = repository.listWeights(inbound.getTenantId(), inbound.getId()).stream()
List<InboundWeightResultEntity> confirmedWeights = repository
.listWeights(inbound.getTenantId(), inbound.getId()).stream()
.filter(weight -> WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus()))
.toList();
BigDecimal total = confirmedWeights.stream()
.map(InboundWeightResultEntity::getFinalWeightKg)
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(3, RoundingMode.HALF_UP);
inbound.setActualGrossWeightKg(total);
inbound.setActualGrossWeightKg(confirmedWeights.isEmpty() ? null : total);
if ("WAIT_MEASURE".equals(inbound.getProcessStatus())) {
inbound.setProcessStatus("MEASURING");
}
@@ -1,10 +1,14 @@
SET NAMES utf8mb4;
USE baiyue_firstleg;
-- 每个客户唯一的尺寸计算配置;当前只保存每侧加宽值,不接入订单或计费。
-- 每个客户固定四条箱规尺寸配置;当前只维护配置事实,不接入订单或计费。
CREATE TABLE IF NOT EXISTS fl_customer_dimension_config (
id BIGINT UNSIGNED NOT NULL COMMENT '配置ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
box_type VARCHAR(24) NOT NULL COMMENT '固定箱规类型:SMALL/MEDIUM/LARGE/EXTRA_LARGE',
inner_length_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内长(厘米)',
inner_width_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内宽(厘米)',
inner_height_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内高(厘米)',
edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '每个尺寸方向的单侧加宽厘米数',
remark VARCHAR(500) NULL COMMENT '备注',
initialization_key VARCHAR(128) NOT NULL COMMENT '创建流程幂等键及补偿归属',
@@ -15,7 +19,12 @@ CREATE TABLE IF NOT EXISTS fl_customer_dimension_config (
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_customer_dimension_config_tenant (tenant_id),
UNIQUE KEY uk_customer_dimension_config_tenant_box (tenant_id, box_type),
KEY idx_customer_dimension_config_initialization (tenant_id, initialization_key),
CONSTRAINT chk_customer_dimension_box_type
CHECK (box_type IN ('SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE')),
CONSTRAINT chk_customer_dimension_inner_size
CHECK (inner_length_cm >= 0.001 AND inner_width_cm >= 0.001 AND inner_height_cm >= 0.001),
CONSTRAINT chk_customer_dimension_edge_addition
CHECK (edge_addition_cm >= 0.001 AND edge_addition_cm <= 100.000)
) ENGINE=InnoDB COMMENT='客户尺寸计算配置';
) ENGINE=InnoDB COMMENT='客户固定四箱规尺寸配置';
@@ -48,3 +48,30 @@ CREATE TABLE IF NOT EXISTS fl_consumed_event (
consumed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次成功消费时间',
PRIMARY KEY (event_id)
) ENGINE=InnoDB COMMENT='头程消息消费幂等表:记录已处理事件并按事件ID去重,防止RocketMQ重复投递造成重复建单;不是消息发布表';
CREATE TABLE IF NOT EXISTS fl_customer_dimension_config (
id BIGINT UNSIGNED NOT NULL COMMENT '配置ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
box_type VARCHAR(24) NOT NULL COMMENT '固定箱规类型:SMALL/MEDIUM/LARGE/EXTRA_LARGE',
inner_length_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内长(厘米)',
inner_width_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内宽(厘米)',
inner_height_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内高(厘米)',
edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '每个尺寸方向的单侧加宽厘米数',
remark VARCHAR(500) NULL COMMENT '备注',
initialization_key VARCHAR(128) NOT NULL COMMENT '创建流程幂等键及补偿归属',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_customer_dimension_config_tenant_box (tenant_id, box_type),
KEY idx_customer_dimension_config_initialization (tenant_id, initialization_key),
CONSTRAINT chk_customer_dimension_box_type
CHECK (box_type IN ('SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE')),
CONSTRAINT chk_customer_dimension_inner_size
CHECK (inner_length_cm >= 0.001 AND inner_width_cm >= 0.001 AND inner_height_cm >= 0.001),
CONSTRAINT chk_customer_dimension_edge_addition
CHECK (edge_addition_cm >= 0.001 AND edge_addition_cm <= 100.000)
) ENGINE=InnoDB COMMENT='客户固定四箱规尺寸配置';
@@ -733,6 +733,10 @@ CREATE TABLE IF NOT EXISTS fl_inbound_idempotency (
CREATE TABLE IF NOT EXISTS fl_customer_dimension_config (
id BIGINT UNSIGNED NOT NULL COMMENT '配置ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
box_type VARCHAR(24) NOT NULL COMMENT '固定箱规类型:SMALL/MEDIUM/LARGE/EXTRA_LARGE',
inner_length_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内长(厘米)',
inner_width_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内宽(厘米)',
inner_height_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内高(厘米)',
edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000 COMMENT '每个尺寸方向的单侧加宽厘米数',
remark VARCHAR(500) NULL COMMENT '备注',
initialization_key VARCHAR(128) NOT NULL COMMENT '创建流程幂等键及补偿归属',
@@ -743,7 +747,12 @@ CREATE TABLE IF NOT EXISTS fl_customer_dimension_config (
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_customer_dimension_config_tenant (tenant_id),
UNIQUE KEY uk_customer_dimension_config_tenant_box (tenant_id, box_type),
KEY idx_customer_dimension_config_initialization (tenant_id, initialization_key),
CONSTRAINT chk_customer_dimension_box_type
CHECK (box_type IN ('SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE')),
CONSTRAINT chk_customer_dimension_inner_size
CHECK (inner_length_cm >= 0.001 AND inner_width_cm >= 0.001 AND inner_height_cm >= 0.001),
CONSTRAINT chk_customer_dimension_edge_addition
CHECK (edge_addition_cm >= 0.001 AND edge_addition_cm <= 100.000)
) ENGINE=InnoDB COMMENT='客户尺寸计算配置';
) ENGINE=InnoDB COMMENT='客户固定四箱规尺寸配置';
@@ -8,8 +8,8 @@ security:
paths:
/internal/firstleg/v1/customer-dimension-configs/defaults:
post:
summary: 幂等初始化客户尺寸计算默认配置
description: 仅供 IAM 在客户创建或注册成功编排中调用;同一客户和 initializationKey 重试稳定返回 createdByThisInitialization=true。
summary: 幂等初始化客户四箱规默认配置
description: 原子创建 SMALL、MEDIUM、LARGE、EXTRA_LARGE 四行;同一客户和 initializationKey 重试稳定返回 createdByThisInitialization=true,configId 为 SMALL 行 ID。
security:
- internalServiceToken: []
requestBody:
@@ -20,8 +20,8 @@ paths:
responses:
'200': {description: 成功或业务失败,data 返回 configId 和 createdByThisInitialization}
delete:
summary: 补偿删除本次新建且尚未修改的默认配置
description: 仅供 IAM 创建客户事务补偿使用;同时匹配 initializationKey、默认2cm和version=0才物理删除。
summary: 补偿删除本次新建且尚未修改的四箱规默认配置
description: 仅当四行均匹配 initializationKey、默认尺寸、默认2cm和version=0时整组物理删除,不会部分删除。
security:
- internalServiceToken: []
parameters:
@@ -31,16 +31,16 @@ paths:
'200': {description: 幂等补偿完成}
/api/platform/v1/first-leg/customer-dimension-config:
get:
summary: 查询客户尺寸计算配置
description: 需要 firstleg:dimension-config:view 权限;配置只描述真实尺寸的每侧加宽值,当前不参与订单或计费。
summary: 查询客户固定四箱规尺寸配置
description: 需要 firstleg:dimension-config:view 权限;按 SMALL、MEDIUM、LARGE、EXTRA_LARGE 固定顺序返回,当前不参与订单或计费。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
'200': {description: 成功时 data 为 CustomerDimensionConfigResponse}
'200': {description: 成功时 data 为 CustomerDimensionConfigGroupResponse}
'403': {description: 无查询权限}
put:
summary: 编辑客户尺寸计算配置
description: 需要 firstleg:dimension-config:edit 权限;使用 version 执行乐观锁更新。
summary: 整组编辑客户固定四箱规尺寸配置
description: 需要 firstleg:dimension-config:edit 权限;请求必须恰好包含四种固定 boxType,四行逐行乐观锁并在同一事务原子保存。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
requestBody:
@@ -49,7 +49,7 @@ paths:
application/json:
schema: {$ref: '#/components/schemas/CustomerDimensionConfigUpdateRequest'}
responses:
'200': {description: 成功时 data 为更新后的 CustomerDimensionConfigResponse}
'200': {description: 成功时 data 为更新后的 CustomerDimensionConfigGroupResponse}
'403': {description: 无编辑权限}
/internal/firstleg/v1/inbounds/order-approved:
post:
@@ -288,6 +288,20 @@ paths:
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 无确认权限,或修改重量时无编辑权限}
/api/platform/v1/first-leg/inbounds/{inboundId}/weight-results/{resultId}:
delete:
summary: 删除称重图片及重量结果
description: >-
需要 platform:firstleg:inbound:weight:edit 权限;仅允许在可编辑的测量状态删除。
删除已确认图片后重新汇总剩余已确认图片的实际总毛重,删除最后一张时总毛重恢复为空。
parameters:
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: version, in: query, required: true, schema: {type: integer, format: int32, minimum: 0}}
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: resultId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 无重量编辑权限}
/api/platform/v1/inbounds/{inboundId}/measurements/approve:
post:
summary: 审核通过测量
@@ -405,24 +419,56 @@ components:
schemas:
CustomerDimensionConfigUpdateRequest:
type: object
required: [edgeAdditionCm, version]
required: [rules]
properties:
rules:
type: array
minItems: 4
maxItems: 4
description: 必须恰好包含 SMALL、MEDIUM、LARGE、EXTRA_LARGE 各一行
items: {$ref: '#/components/schemas/CustomerDimensionConfigRuleUpdateRequest'}
CustomerDimensionConfigRuleUpdateRequest:
type: object
required: [boxType, innerLengthCm, innerWidthCm, innerHeightCm, edgeAdditionCm, version]
properties:
boxType: {type: string, enum: [SMALL, MEDIUM, LARGE, EXTRA_LARGE]}
innerLengthCm: {type: number, minimum: 0.001, multipleOf: 0.001}
innerWidthCm: {type: number, minimum: 0.001, multipleOf: 0.001}
innerHeightCm: {type: number, minimum: 0.001, multipleOf: 0.001}
edgeAdditionCm:
type: number
minimum: 0.001
maximum: 100
multipleOf: 0.001
description: 真实长、宽、高三个方向分别使用的每侧加宽厘米数
description: 箱规长、宽、高三个方向分别使用的每侧加宽厘米数
example: 2.000
remark: {type: string, maxLength: 500, nullable: true}
version: {type: integer, minimum: 0, description: 乐观锁版本号}
CustomerDimensionConfigResponse:
CustomerDimensionConfigGroupResponse:
type: object
required: [id, tenantId, edgeAdditionCm, version]
required: [tenantId, rules]
properties:
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
rules:
type: array
minItems: 4
maxItems: 4
items: {$ref: '#/components/schemas/CustomerDimensionConfigRuleResponse'}
CustomerDimensionConfigRuleResponse:
type: object
required: [id, boxType, boxTypeName, innerLengthCm, innerWidthCm, innerHeightCm, edgeAdditionCm, outerLengthCm, outerWidthCm, outerHeightCm, volumeCbm, version]
properties:
id: {type: string, pattern: '^[1-9][0-9]*$'}
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
boxType: {type: string, enum: [SMALL, MEDIUM, LARGE, EXTRA_LARGE]}
boxTypeName: {type: string, enum: [小箱, 中箱, 大箱, 特大箱]}
innerLengthCm: {type: number, minimum: 0.001, multipleOf: 0.001}
innerWidthCm: {type: number, minimum: 0.001, multipleOf: 0.001}
innerHeightCm: {type: number, minimum: 0.001, multipleOf: 0.001}
edgeAdditionCm: {type: number, minimum: 0.001, maximum: 100, example: 2.000}
outerLengthCm: {type: number, minimum: 0.001, description: innerLengthCm + edgeAdditionCm * 2}
outerWidthCm: {type: number, minimum: 0.001, description: innerWidthCm + edgeAdditionCm * 2}
outerHeightCm: {type: number, minimum: 0.001, description: innerHeightCm + edgeAdditionCm * 2}
volumeCbm: {type: number, multipleOf: 0.001, description: 使用未舍入外径完整计算后四舍五入保留3位}
remark: {type: string, nullable: true}
version: {type: integer, minimum: 0}
createdAt: {type: string, format: date-time, nullable: true}
@@ -39,6 +39,8 @@ class PlatformInboundMeasurementControllerContractTest {
+ " and (!#request.hasAdjustment() or hasAuthority('platform:firstleg:inbound:weight:edit'))",
AuthenticatedUser.class, long.class, long.class, long.class,
InboundMeasurementRequests.ConfirmWeightResultRequest.class);
assertAuthority("deleteWeightResult", "hasAuthority('platform:firstleg:inbound:weight:edit')",
AuthenticatedUser.class, long.class, int.class, long.class, long.class);
}
private void assertAuthority(String methodName, String expected, Class<?>... parameterTypes)
@@ -1,65 +1,142 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.Test;
import com.baiyue.firstleg.domain.CustomerDimensionBoxType;
import com.baiyue.firstleg.mapper.CustomerDimensionConfigMapper;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class CustomerDimensionConfigSqlContractTest {
@Test
void moduleMigrationDefinesUniqueCustomerConfigAndDefault() throws IOException {
String sql = new ClassPathResource("db/customer-dimension-config.sql")
.getContentAsString(StandardCharsets.UTF_8);
void allFourDatabaseEntrypointsDefineTheSameFourRowStructure() throws Exception {
List<String> sqlEntrypoints = List.of(
resource("db/customer-dimension-config.sql"),
resource("db/firstleg.sql"),
resource("db/phase2-inbound-schema.sql"),
Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8)
);
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fl_customer_dimension_config")
.contains("edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000")
.contains("initialization_key VARCHAR(128) NOT NULL")
.contains("UNIQUE KEY uk_customer_dimension_config_tenant (tenant_id)");
for (String sql : sqlEntrypoints) {
assertThat(sql)
.contains("fl_customer_dimension_config")
.contains("box_type VARCHAR(24) NOT NULL")
.contains("inner_length_cm DECIMAL(9,3) NOT NULL")
.contains("inner_width_cm DECIMAL(9,3) NOT NULL")
.contains("inner_height_cm DECIMAL(9,3) NOT NULL")
.contains("edge_addition_cm DECIMAL(6,3) NOT NULL DEFAULT 2.000")
.contains("initialization_key VARCHAR(128) NOT NULL")
.contains("created_by BIGINT UNSIGNED NOT NULL")
.contains("updated_by BIGINT UNSIGNED NOT NULL")
.contains("version INT NOT NULL DEFAULT 0")
.contains("UNIQUE KEY uk_customer_dimension_config_tenant_box (tenant_id, box_type)")
.contains("CHECK (box_type IN ('SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE'))")
.doesNotContain("UNIQUE KEY uk_customer_dimension_config_tenant (tenant_id)");
}
}
@Test
void unifiedMigrationIncludesConfigAndFinalAssertion() throws IOException {
String sql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
void fixedDefaultsAndBatchInitializationCoverExactlyFourBoxTypes() throws Exception {
assertThat(CustomerDimensionBoxType.values()).extracting(CustomerDimensionBoxType::name)
.containsExactly("SMALL", "MEDIUM", "LARGE", "EXTRA_LARGE");
assertThat(List.of(CustomerDimensionBoxType.values()))
.extracting(CustomerDimensionBoxType::defaultLengthCm)
.containsExactly(new java.math.BigDecimal("30.000"), new java.math.BigDecimal("40.000"),
new java.math.BigDecimal("50.000"), new java.math.BigDecimal("60.000"));
Insert annotation = CustomerDimensionConfigMapper.class
.getDeclaredMethod("insertDefaults", List.class, long.class)
.getAnnotation(Insert.class);
String sql = String.join(" ", annotation.value());
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS baiyue_firstleg.fl_customer_dimension_config")
.contains("客户尺寸计算配置表未创建")
.contains("uk_customer_dimension_config_tenant")
.contains("CREATE PROCEDURE unified_guard_dimension_config_permissions()")
.contains("id = 5102111 AND COALESCE(permission_code, '') <> 'firstleg:dimension-config:view'")
.contains("permission_code = 'firstleg:dimension-config:view' AND id <> 5102111")
.contains("id = 5102112 AND COALESCE(permission_code, '') <> 'firstleg:dimension-config:edit'")
.contains("permission_code = 'firstleg:dimension-config:edit' AND id <> 5102112")
.contains("(5102111, 0, 'PLATFORM', 'BUTTON', 5100010")
.contains("(5102112, 0, 'PLATFORM', 'BUTTON', 5100010")
.contains("r.role_code = 'PLATFORM_ADMIN'")
.contains("r.role_code = 'ORDER_OPERATOR'")
.contains("客户尺寸计算配置权限父节点企业客户不存在")
.contains("客户尺寸计算配置权限码、固定ID或父节点不正确")
.contains("平台管理员未获得客户尺寸计算配置读写权限")
.contains("订单运营角色的客户尺寸计算配置权限不是只读");
.contains("INSERT IGNORE INTO fl_customer_dimension_config")
.contains("box_type, inner_length_cm, inner_width_cm, inner_height_cm")
.contains("<foreach collection=\"rows\"");
}
@Test
void compensationDeleteRequiresExactKeyDefaultValueAndUnmodifiedVersion() throws Exception {
Delete annotation = com.baiyue.firstleg.mapper.CustomerDimensionConfigMapper.class
.getDeclaredMethod("deleteUnchangedDefault", long.class, long.class,
String.class, java.math.BigDecimal.class)
void compensationLocksThenDeletesOnlyExactDefaultRows() throws Exception {
Select select = CustomerDimensionConfigMapper.class
.getDeclaredMethod("selectDefaultsForCompensation", long.class, String.class)
.getAnnotation(Select.class);
assertThat(String.join(" ", select.value()))
.contains("tenant_id = #{tenantId}")
.contains("initialization_key = #{initializationKey}")
.contains("FOR UPDATE");
Delete annotation = CustomerDimensionConfigMapper.class
.getDeclaredMethod("deleteUnchangedDefaults", long.class, String.class)
.getAnnotation(Delete.class);
String sql = String.join(" ", annotation.value());
assertThat(sql)
.contains("tenant_id = #{tenantId}")
.contains("initialization_key = #{initializationKey}")
.contains("edge_addition_cm = #{edgeAdditionCm}")
.contains("version = 0");
.contains("version = 0")
.contains("box_type IN ('SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE')")
.contains("inner_length_cm = CASE box_type")
.contains("edge_addition_cm = 2.000")
.contains("remark = CASE box_type");
}
@Test
void unifiedMigrationKeepsPermissionGuardsAndAssertsFinalFourRowSchema() throws Exception {
String sql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
assertThat(sql)
.contains("CREATE PROCEDURE unified_guard_dimension_config_permissions()")
.contains("(5102111, 0, 'PLATFORM', 'BUTTON', 5100010")
.contains("r.role_code = 'PLATFORM_ADMIN'")
.contains("r.role_code = 'ORDER_OPERATOR'")
.contains("index_name = 'uk_customer_dimension_config_tenant_box'")
.contains("客户四箱规尺寸配置字段不完整")
.contains("客户尺寸计算配置权限码、固定ID或父节点不正确");
}
@Test
void unifiedMigrationUpgradesOnlyRecognizedLegacyTableAndBackfillsIdempotently() throws Exception {
String sql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
int upgrade = sql.indexOf("CREATE PROCEDURE unified_upgrade_legacy_customer_dimension_config()");
int create = sql.indexOf("CREATE TABLE IF NOT EXISTS baiyue_firstleg.fl_customer_dimension_config");
int backfill = sql.indexOf("INSERT INTO baiyue_firstleg.fl_customer_dimension_config (", create);
assertThat(upgrade).isGreaterThanOrEqualTo(0);
assertThat(create).isGreaterThan(upgrade);
assertThat(backfill).isGreaterThan(create);
assertThat(sql)
.contains("AND column_name = 'box_type') = 0")
.contains("AND column_name IN ('id', 'tenant_id', 'edge_addition_cm', 'remark', 'initialization_key'")
.contains("AND index_name = 'uk_customer_dimension_config_tenant'")
.contains("DROP TABLE baiyue_firstleg.fl_customer_dimension_config")
.contains("客户尺寸配置表不是可识别的旧单行结构")
.contains("SELECT id AS tenant_id, created_by")
.contains("FROM baiyue_iam.iam_tenant")
.contains("WHERE tenant_type = 'MERCHANT' AND deleted = 0 AND id > 0")
.contains("CONCAT('unified-four-box-default-v1:', tenant.tenant_id)")
.contains("ON DUPLICATE KEY UPDATE id = id")
.contains("现有企业客户未完整持有固定四箱规尺寸配置");
String repeatSafeClause = sql.substring(backfill,
sql.indexOf("DROP PROCEDURE IF EXISTS unified_assert_final_extensions", backfill));
assertThat(repeatSafeClause)
.doesNotContain("edge_addition_cm = VALUES")
.doesNotContain("inner_length_cm = VALUES")
.doesNotContain("remark = VALUES")
.doesNotContain("version = VALUES");
}
private static String resource(String path) throws Exception {
return new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
}
}
@@ -1,22 +1,30 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.domain.CustomerDimensionBoxType;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.GroupResponse;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.InitializeResponse;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.RuleUpdateRequest;
import com.baiyue.firstleg.dto.CustomerDimensionConfigDtos.UpdateRequest;
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -28,82 +36,167 @@ class CustomerDimensionConfigServiceTest {
private CustomerDimensionConfigService service;
@Test
void initializesTwoCentimeterDefaultExactlyOnce() {
CustomerDimensionConfigEntity persisted = config(91L, 8L, "2.000", 0, "registration-1");
when(repository.find(8L)).thenReturn(null, persisted);
when(repository.insertDefault(anyLong(), eq(8L), eq(new BigDecimal("2.000")),
eq("默认每侧加宽 2cm"), eq("registration-1"), eq(100L))).thenReturn(true);
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1");
assertThat(result.createdByThisInitialization()).isTrue();
assertThat(result.configId()).isEqualTo(91L);
}
@Test
void retryAfterLostResponseKeepsCreationOwnershipForSameKey() {
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.000", 0, "registration-1"));
void initializesFourDefaultsAtomicallyInFixedOrder() {
List<CustomerDimensionConfigEntity> persisted = defaultConfigs("registration-1");
when(repository.findAll(8L)).thenReturn(List.of(), persisted);
when(repository.insertDefaults(any(), eq(100L))).thenReturn(4);
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1");
assertThat(result).isEqualTo(new InitializeResponse(91L, true));
verify(repository, never()).insertDefault(anyLong(), anyLong(),
eq(new BigDecimal("2.000")), eq("默认每侧加宽 2cm"),
eq("registration-1"), anyLong());
@SuppressWarnings("unchecked")
ArgumentCaptor<List<CustomerDimensionConfigEntity>> captor = ArgumentCaptor.forClass(List.class);
verify(repository).insertDefaults(captor.capture(), eq(100L));
assertThat(captor.getValue()).extracting(CustomerDimensionConfigEntity::getBoxType)
.containsExactly(CustomerDimensionBoxType.values());
assertThat(captor.getValue()).extracting(CustomerDimensionConfigEntity::getInnerLengthCm)
.containsExactly(new BigDecimal("30.000"), new BigDecimal("40.000"),
new BigDecimal("50.000"), new BigDecimal("60.000"));
assertThat(captor.getValue()).extracting(CustomerDimensionConfigEntity::getInnerWidthCm)
.containsExactly(new BigDecimal("20.000"), new BigDecimal("30.000"),
new BigDecimal("40.000"), new BigDecimal("50.000"));
assertThat(captor.getValue()).extracting(CustomerDimensionConfigEntity::getInnerHeightCm)
.containsExactly(new BigDecimal("20.000"), new BigDecimal("30.000"),
new BigDecimal("40.000"), new BigDecimal("50.000"));
assertThat(captor.getValue()).extracting(CustomerDimensionConfigEntity::getEdgeAdditionCm)
.containsOnly(new BigDecimal("2.000"));
assertThat(captor.getValue()).extracting(CustomerDimensionConfigEntity::getRemark)
.containsExactly("标准小箱", "标准中箱", "标准大箱", "特大箱");
}
@Test
void retryAfterLostResponseKeepsCreationOwnershipForSameKey() {
when(repository.findAll(8L)).thenReturn(defaultConfigs("registration-1"));
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-1");
assertThat(result).isEqualTo(new InitializeResponse(91L, true));
verify(repository, never()).insertDefaults(any(), eq(100L));
}
@Test
void differentInitializationKeyDoesNotTakeOwnershipOrOverwrite() {
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.000", 0, "registration-1"));
when(repository.findAll(8L)).thenReturn(defaultConfigs("registration-1"));
InitializeResponse result = service.initializeDefault(8L, 100L, "registration-2");
assertThat(result).isEqualTo(new InitializeResponse(91L, false));
verify(repository, never()).insertDefault(anyLong(), anyLong(),
eq(new BigDecimal("2.000")), eq("默认每侧加宽 2cm"),
eq("registration-2"), anyLong());
verify(repository, never()).insertDefaults(any(), eq(100L));
}
@Test
void rejectsStaleUpdateVersion() {
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.000", 3, "registration-1"));
void returnsFixedOrderCalculatedOuterDimensionsAndThreeDecimalVolume() {
List<CustomerDimensionConfigEntity> shuffled = new ArrayList<>(defaultConfigs("registration-1"));
Collections.reverse(shuffled);
when(repository.findAll(8L)).thenReturn(shuffled);
assertThatThrownBy(() -> service.update(8L, 100L,
new UpdateRequest(new BigDecimal("2.500"), "新配置", 2)))
GroupResponse result = service.get(8L);
assertThat(result.rules()).extracting(rule -> rule.boxType())
.containsExactly(CustomerDimensionBoxType.values());
assertThat(result.rules().get(0).boxTypeName()).isEqualTo("小箱");
assertThat(result.rules().get(0).outerLengthCm()).isEqualByComparingTo("34.000");
assertThat(result.rules().get(0).outerWidthCm()).isEqualByComparingTo("24.000");
assertThat(result.rules().get(0).outerHeightCm()).isEqualByComparingTo("24.000");
assertThat(result.rules().get(0).volumeCbm()).isEqualByComparingTo("0.020");
}
@Test
void rejectsMissingOrDuplicateBoxTypes() {
List<RuleUpdateRequest> rules = new ArrayList<>(updateRequest(0).rules());
rules.set(3, rules.get(0));
assertThatThrownBy(() -> service.update(8L, 100L, new UpdateRequest(rules)))
.isInstanceOf(BizException.class)
.hasMessageContaining("重复");
verify(repository, never()).findAll(8L);
}
@Test
void rejectsAnyStaleVersionBeforeWritingRows() {
List<CustomerDimensionConfigEntity> persisted = defaultConfigs("registration-1");
persisted.forEach(value -> value.setVersion(3));
when(repository.findAll(8L)).thenReturn(persisted);
UpdateRequest request = updateRequest(3);
List<RuleUpdateRequest> rules = new ArrayList<>(request.rules());
RuleUpdateRequest stale = rules.get(2);
rules.set(2, new RuleUpdateRequest(stale.boxType(), stale.innerLengthCm(), stale.innerWidthCm(),
stale.innerHeightCm(), stale.edgeAdditionCm(), stale.remark(), 2));
assertThatThrownBy(() -> service.update(8L, 100L, new UpdateRequest(rules)))
.isInstanceOf(BizException.class)
.hasMessageContaining("刷新后重试");
verify(repository, never()).update(org.mockito.ArgumentMatchers.any());
verify(repository, never()).update(any());
}
@Test
void compensationUsesTenantKeyDefaultValueAndUnmodifiedVersion() {
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.000", 0, "registration-1"));
void abortsWholeTransactionalUpdateWhenAnyOptimisticWriteFails() {
when(repository.findAll(8L)).thenReturn(defaultConfigs("registration-1"));
when(repository.update(any())).thenReturn(1, 0);
service.compensateDefault(8L, "registration-1");
verify(repository).deleteUnchangedDefault(
8L, 91L, "registration-1", new BigDecimal("2.000"));
assertThatThrownBy(() -> service.update(8L, 100L, updateRequest(0)))
.isInstanceOf(BizException.class)
.hasMessageContaining("刷新后重试");
verify(repository, times(2)).update(any());
}
@Test
void editedConfigIsNotCompensatedByDelayedRollback() {
when(repository.find(8L)).thenReturn(config(91L, 8L, "2.500", 1, "registration-1"));
void compensationLocksAndDeletesExactlyFourUnchangedDefaults() {
when(repository.findDefaultsForCompensation(8L, "registration-1"))
.thenReturn(defaultConfigs("registration-1"));
when(repository.deleteUnchangedDefaults(8L, "registration-1")).thenReturn(4);
service.compensateDefault(8L, "registration-1");
verify(repository).deleteUnchangedDefault(
8L, 91L, "registration-1", new BigDecimal("2.000"));
verify(repository).deleteUnchangedDefaults(8L, "registration-1");
}
private static CustomerDimensionConfigEntity config(long id, long tenantId,
String edgeAddition, int version,
String initializationKey) {
CustomerDimensionConfigEntity value = new CustomerDimensionConfigEntity();
value.setId(id);
value.setTenantId(tenantId);
value.setEdgeAdditionCm(new BigDecimal(edgeAddition));
value.setVersion(version);
value.setInitializationKey(initializationKey);
return value;
@Test
void compensationDoesNotDeleteAnEditedGroup() {
List<CustomerDimensionConfigEntity> values = defaultConfigs("registration-1");
values.get(2).setVersion(1);
when(repository.findDefaultsForCompensation(8L, "registration-1")).thenReturn(values);
service.compensateDefault(8L, "registration-1");
verify(repository, never()).deleteUnchangedDefaults(8L, "registration-1");
}
@Test
void compensationRollsBackInsteadOfAcceptingPartialDelete() {
when(repository.findDefaultsForCompensation(8L, "registration-1"))
.thenReturn(defaultConfigs("registration-1"));
when(repository.deleteUnchangedDefaults(8L, "registration-1")).thenReturn(3);
assertThatThrownBy(() -> service.compensateDefault(8L, "registration-1"))
.isInstanceOf(BizException.class)
.hasMessageContaining("补偿删除失败");
}
private static UpdateRequest updateRequest(int version) {
return new UpdateRequest(List.of(CustomerDimensionBoxType.values()).stream()
.map(type -> new RuleUpdateRequest(type, type.defaultLengthCm(), type.defaultWidthCm(),
type.defaultHeightCm(), new BigDecimal("2.000"), type.defaultRemark(), version))
.toList());
}
private static List<CustomerDimensionConfigEntity> defaultConfigs(String initializationKey) {
List<CustomerDimensionConfigEntity> values = new ArrayList<>();
long id = 91L;
for (CustomerDimensionBoxType type : CustomerDimensionBoxType.values()) {
CustomerDimensionConfigEntity value = new CustomerDimensionConfigEntity();
value.setId(id++);
value.setTenantId(8L);
value.setBoxType(type);
value.setInnerLengthCm(type.defaultLengthCm());
value.setInnerWidthCm(type.defaultWidthCm());
value.setInnerHeightCm(type.defaultHeightCm());
value.setEdgeAdditionCm(new BigDecimal("2.000"));
value.setRemark(type.defaultRemark());
value.setVersion(0);
value.setInitializationKey(initializationKey);
values.add(value);
}
return values;
}
}
@@ -1,6 +1,8 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.client.MeasurementImageClient;
import com.baiyue.firstleg.dto.InboundMeasurementRequests;
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
@@ -18,6 +20,7 @@ import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.test.context.TestPropertySource;
import java.math.BigDecimal;
import java.util.List;
@@ -27,17 +30,21 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.tuple;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@DisplayName("平台入库测量应用服务")
@SpringJUnitConfig(InboundMeasurementService.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
class InboundMeasurementServiceTest {
@MockBean private InboundMeasurementRepository repository;
@MockBean private MeasurementBatchService batchService;
@MockBean private MeasurementImageClient imageClient;
@Resource private InboundMeasurementService service;
@Test
@@ -357,6 +364,58 @@ class InboundMeasurementServiceTest {
verify(repository, never()).updateInbound(any());
}
@Test
void deletingPendingWeightRemovesResultAndOwnedImage() {
InboundOrderEntity inbound = inbound("MEASURING");
InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "PENDING_CONFIRM", 1);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
when(repository.deleteWeight(1L, 10L, 20L, 1)).thenReturn(true);
when(imageClient.delete("internal-token", 9L, 1L, 10L, 120L))
.thenReturn(ApiResponse.success(null));
service.deleteWeightResult(1L, 10L, 20L, 1, 9L);
verify(repository).deleteWeight(1L, 10L, 20L, 1);
verify(imageClient).delete("internal-token", 9L, 1L, 10L, 120L);
verify(repository, never()).updateInbound(any());
}
@Test
void deletingLastConfirmedWeightClearsInboundTotal() {
InboundOrderEntity inbound = inbound("MEASURING");
inbound.setActualGrossWeightKg(new BigDecimal("285.500"));
InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "CONFIRMED", 2);
weight.setFinalWeightKg(new BigDecimal("285.500"));
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
when(repository.deleteWeight(1L, 10L, 20L, 2)).thenReturn(true);
when(repository.listWeights(1L, 10L)).thenReturn(List.of());
when(repository.updateInbound(inbound)).thenReturn(true);
when(imageClient.delete("internal-token", 9L, 1L, 10L, 120L))
.thenReturn(ApiResponse.success(null));
service.deleteWeightResult(1L, 10L, 20L, 2, 9L);
assertThat(inbound.getActualGrossWeightKg()).isNull();
verify(repository).updateInbound(inbound);
}
@Test
void deletingWeightRejectsStaleVersionBeforeCallingFileService() {
InboundOrderEntity inbound = inbound("MEASURING");
InboundWeightResultEntity weight = weight(20L, "285.500", "KG", "PENDING_CONFIRM", 2);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
assertThatThrownBy(() -> service.deleteWeightResult(1L, 10L, 20L, 1, 9L))
.isInstanceOf(BizException.class)
.hasMessageContaining("版本已变化");
verify(repository, never()).deleteWeight(anyLong(), anyLong(), anyLong(), anyInt());
verifyNoInteractions(imageClient);
}
@Test
void submitAdvancesOnlyToMeasurePendingAfterAllWeightsConfirmed() {
InboundOrderEntity inbound = inbound("MEASURING");