This commit is contained in:
2026-08-18 17:40:32 +08:00
parent d70c08a378
commit 688e5649b0
15 changed files with 425 additions and 73 deletions
@@ -5645,7 +5645,44 @@ BEGIN
IF current_plan_status = 'LOADING' AND EXISTS (
SELECT 1 FROM fl_loading_pick_item
WHERE loading_plan_item_id = OLD.id AND picked_package_count > 0) THEN
IF NOT EXISTS (
IF NOT (NEW.warehouse_remark <=> OLD.warehouse_remark) THEN
IF NEW.package_count <> OLD.package_count OR NEW.deleted <> OLD.deleted
OR NOT (NEW.customer_name <=> OLD.customer_name)
OR NOT (NEW.inbound_id <=> OLD.inbound_id)
OR NOT (NEW.inbound_detail_id <=> OLD.inbound_detail_id)
OR NOT (NEW.order_id <=> OLD.order_id)
OR NOT (NEW.order_no <=> OLD.order_no)
OR NOT (NEW.inbound_code <=> OLD.inbound_code)
OR NOT (NEW.destination_warehouse_id <=> OLD.destination_warehouse_id)
OR NOT (NEW.destination_warehouse_code <=> OLD.destination_warehouse_code)
OR NOT (NEW.destination_warehouse_name <=> OLD.destination_warehouse_name)
OR NOT (NEW.sku_code <=> OLD.sku_code)
OR NOT (NEW.product_name_zh <=> OLD.product_name_zh)
OR NOT (NEW.product_name_en <=> OLD.product_name_en)
OR NOT (NEW.product_image_file_id <=> OLD.product_image_file_id)
OR NOT (NEW.product_image_url <=> OLD.product_image_url)
OR NOT (NEW.product_quantity <=> OLD.product_quantity)
OR NOT (NEW.units_per_carton <=> OLD.units_per_carton)
OR NOT (NEW.pallet_count <=> OLD.pallet_count)
OR NOT (NEW.gross_weight_kg <=> OLD.gross_weight_kg)
OR NOT (NEW.net_weight_kg <=> OLD.net_weight_kg)
OR NOT (NEW.dimensional_weight_kg <=> OLD.dimensional_weight_kg)
OR NOT (NEW.volume_cbm <=> OLD.volume_cbm)
OR NOT (NEW.fba_tracking_no <=> OLD.fba_tracking_no)
OR NOT (NEW.shipment_no <=> OLD.shipment_no)
OR NOT (NEW.shipping_mark <=> OLD.shipping_mark)
OR NOT (NEW.load_sequence <=> OLD.load_sequence)
OR NOT (NEW.position_snapshot <=> OLD.position_snapshot)
OR NOT (NEW.internal_remark <=> OLD.internal_remark)
OR NOT (NEW.customer_remark <=> OLD.customer_remark)
OR NOT (NEW.frozen_at <=> OLD.frozen_at)
OR NOT (NEW.frozen_by <=> OLD.frozen_by)
OR NOT (NEW.removed_by <=> OLD.removed_by)
OR NOT (NEW.removed_by_name <=> OLD.removed_by_name)
OR NOT (NEW.removed_at <=> OLD.removed_at) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细修改仓库备注时不得变更其他业务字段';
END IF;
ELSEIF NOT EXISTS (
SELECT 1 FROM fl_loading_pick_item
WHERE loading_plan_item_id = OLD.id
AND item_status = 'PARTIAL'
@@ -5680,7 +5717,6 @@ BEGIN
OR NOT (NEW.position_snapshot <=> OLD.position_snapshot)
OR NOT (NEW.internal_remark <=> OLD.internal_remark)
OR NOT (NEW.customer_remark <=> OLD.customer_remark)
OR NOT (NEW.warehouse_remark <=> OLD.warehouse_remark)
OR NOT (NEW.frozen_at <=> OLD.frozen_at)
OR NOT (NEW.frozen_by <=> OLD.frozen_by)
OR NOT (NEW.removed_by <=> OLD.removed_by)
@@ -76,7 +76,7 @@ public class PlatformContainerFeeDetailController {
@PathVariable long loadingPlanId, @PathVariable long feeId,
@Valid @RequestBody AdjustRequest request) {
return ApiResponse.success(service.adjust(FirstlegScopeGuard.platform(user, accountId),
loadingPlanId, feeId, request.expectedSourceVersion(), user.userId(), request.values()));
loadingPlanId, feeId, user.userId(), request));
}
@PostMapping("/{feeId}/void")
@@ -29,20 +29,9 @@ public final class ContainerFeeDetailDtos {
public record AdjustRequest(
@NotNull @Min(1) Integer expectedSourceVersion,
@NotNull @Min(1) Long feeItemId,
@NotBlank @Pattern(regexp = "RECEIVABLE|PAYABLE") String direction,
@NotNull LocalDate businessDate,
@NotNull @DecimalMin("0.00") BigDecimal amount,
@NotBlank @Pattern(regexp = "CNY") String currency,
@NotNull Boolean settlementIncluded,
Long partnerId,
@Size(max = 200) String partnerName,
@Size(max = 500) String remark
@NotNull @DecimalMin("0.00") BigDecimal amount
) {
public SaveRequest values() {
return new SaveRequest(feeItemId, direction, businessDate, amount, currency,
settlementIncluded, partnerId, partnerName, remark);
}
}
public record VersionRequest(@NotNull @Min(1) Integer expectedSourceVersion) {
@@ -10,17 +10,19 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstl
""")
int removeEditing(@Param("planId") long planId,@Param("itemId") long itemId,@Param("expectedVersion") int expectedVersion,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id
UPDATE fl_loading_plan_item i
SET i.warehouse_remark=#{remark}, i.updated_by=#{operatorId}, i.updated_by_name=#{operatorName},
i.updated_at=CURRENT_TIMESTAMP(3), i.version=i.version+1,
p.version=p.version+1, p.updated_by=#{operatorId}, p.updated_by_name=#{operatorName},
p.updated_at=CURRENT_TIMESTAMP(3)
WHERE i.id=#{itemId} AND i.loading_plan_id=#{planId} AND i.deleted=0
AND i.version=#{expectedItemVersion} AND p.version=#{expectedPlanVersion}
AND p.plan_status IN ('EDITING','LOADING')
i.updated_at=CURRENT_TIMESTAMP(3), i.version=i.version+1
WHERE i.id=#{itemId} AND i.loading_plan_id=#{planId} AND i.deleted=0 AND i.frozen_at IS NULL
AND i.version=#{expectedItemVersion}
AND EXISTS (
SELECT 1 FROM fl_loading_plan p
WHERE p.id=#{planId} AND p.version=#{touchedPlanVersion}
AND p.plan_status IN ('EDITING','LOADING')
)
""")
int updateWarehouseRemark(@Param("planId") long planId, @Param("itemId") long itemId,
@Param("expectedPlanVersion") int expectedPlanVersion,
@Param("touchedPlanVersion") int touchedPlanVersion,
@Param("expectedItemVersion") int expectedItemVersion,
@Param("remark") String remark, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@@ -7,6 +7,8 @@ import com.baiyue.firstleg.mapper.ContainerFeeDetailMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@@ -56,14 +58,26 @@ public class ContainerFeeDetailRepository {
.set(ContainerFeeDetailEntity::getUpdatedBy, reviewerId));
}
public int adjust(ContainerFeeDetailEntity value, int expectedVersion) {
return mapper.update(value, new LambdaUpdateWrapper<ContainerFeeDetailEntity>()
.eq(ContainerFeeDetailEntity::getTenantId, value.getTenantId())
.eq(ContainerFeeDetailEntity::getLoadingPlanId, value.getLoadingPlanId())
.eq(ContainerFeeDetailEntity::getId, value.getId())
public int adjust(long tenantId, long loadingPlanId, long feeId, int expectedVersion,
LocalDate businessDate, BigDecimal amount, long operatorId,
LocalDateTime updatedAt) {
return mapper.update(null, new LambdaUpdateWrapper<ContainerFeeDetailEntity>()
.eq(ContainerFeeDetailEntity::getTenantId, tenantId)
.eq(ContainerFeeDetailEntity::getLoadingPlanId, loadingPlanId)
.eq(ContainerFeeDetailEntity::getId, feeId)
.eq(ContainerFeeDetailEntity::getReviewStatus, "APPROVED")
.eq(ContainerFeeDetailEntity::getSourceStatus, "ACTIVE")
.eq(ContainerFeeDetailEntity::getSourceVersion, expectedVersion));
.eq(ContainerFeeDetailEntity::getSourceVersion, expectedVersion)
.set(ContainerFeeDetailEntity::getBusinessDate, businessDate)
.set(ContainerFeeDetailEntity::getAmount, amount)
.set(ContainerFeeDetailEntity::getSourceVersion, expectedVersion + 1)
.set(ContainerFeeDetailEntity::getReviewStatus, "PENDING")
.set(ContainerFeeDetailEntity::getReviewerId, null)
.set(ContainerFeeDetailEntity::getReviewerName, null)
.set(ContainerFeeDetailEntity::getReviewedAt, null)
.set(ContainerFeeDetailEntity::getReviewReason, null)
.set(ContainerFeeDetailEntity::getUpdatedBy, operatorId)
.set(ContainerFeeDetailEntity::getUpdatedAt, updatedAt));
}
public int voidApproved(long tenantId, long loadingPlanId, long feeId,
@@ -69,9 +69,9 @@ public class SmartLoadingRepository {
public int remove(long planId, long itemId, int version, long operatorId, String operatorName) {
return items.removeEditing(planId, itemId, version, operatorId, operatorName);
}
public int updateWarehouseRemark(long planId, long itemId, int expectedPlanVersion, int expectedItemVersion,
public int updateWarehouseRemark(long planId, long itemId, int touchedPlanVersion, int expectedItemVersion,
String remark, long operatorId, String operatorName) {
return items.updateWarehouseRemark(planId, itemId, expectedPlanVersion, expectedItemVersion,
return items.updateWarehouseRemark(planId, itemId, touchedPlanVersion, expectedItemVersion,
remark, operatorId, operatorName);
}
public int push(long planId, String key, int version, long operatorId, String operatorName) {
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AdjustRequest;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.SaveRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
@@ -81,19 +82,23 @@ public class ContainerFeeDetailService {
@Transactional
public ContainerFeeDetailEntity adjust(long tenantId, long loadingPlanId, long feeId,
int expectedVersion, long operatorId,
SaveRequest request) {
long operatorId, AdjustRequest request) {
requireOwnedPlan(tenantId, loadingPlanId);
ContainerFeeDetailEntity value = required(tenantId, loadingPlanId, feeId);
FeeItemEntity item = requireItem(request.feeItemId(), request.direction());
apply(value, item, request, operatorId);
value.setSourceVersion(expectedVersion + 1);
value.setReviewStatus("PENDING");
value.setReviewerId(null);
value.setReviewerName(null);
value.setReviewedAt(null);
value.setReviewReason(null);
if (repository.adjust(value, expectedVersion) != 1) {
int expectedVersion = request.expectedSourceVersion();
var amount = request.amount().setScale(2, RoundingMode.HALF_UP);
if (value.getSourceVersion() != null && value.getSourceVersion() == expectedVersion + 1
&& "ACTIVE".equals(value.getSourceStatus()) && "PENDING".equals(value.getReviewStatus())
&& request.businessDate().equals(value.getBusinessDate())
&& amount.compareTo(value.getAmount()) == 0) {
return value;
}
if (!"ACTIVE".equals(value.getSourceStatus()) || !"APPROVED".equals(value.getReviewStatus())
|| value.getSourceVersion() == null || value.getSourceVersion() != expectedVersion) {
throw conflict("只有当前版本的已审核柜次费用可以调整");
}
if (repository.adjust(tenantId, loadingPlanId, feeId, expectedVersion,
request.businessDate(), amount, operatorId, LocalDateTime.now()) != 1) {
throw conflict("只有当前版本的已审核柜次费用可以调整");
}
return required(tenantId, loadingPlanId, feeId);
@@ -11,6 +11,7 @@ import com.baiyue.firstleg.repository.SmartLoadingRepository;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.split.service.SplitPreloadService;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@@ -25,6 +26,7 @@ import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.sql.SQLException;
import java.time.LocalDateTime;
import java.util.*;
@@ -228,9 +230,17 @@ public class SmartLoadingService {
LoadingPlanItemEntity item = repository.item(planId, itemId);
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
String before = json(values("warehouseRemark", item.getWarehouseRemark(), "version", item.getVersion()));
if (repository.updateWarehouseRemark(planId, itemId, request.expectedPlanVersion(),
request.expectedItemVersion(), remark, operatorId, operatorName) != 1) {
throw conflict("装柜计划或明细已被其他人修改,请刷新后重试");
try {
touch(plan, operatorId, operatorName);
if (repository.updateWarehouseRemark(planId, itemId, request.expectedPlanVersion() + 1,
request.expectedItemVersion(), remark, operatorId, operatorName) != 1) {
throw conflict("装柜计划或明细已被其他人修改,请刷新后重试");
}
} catch (DataAccessException exception) {
if (hasSqlState(exception, "45000")) {
throw conflict("装柜计划或明细状态已变化,仓库备注未保存,请刷新后重试");
}
throw exception;
}
history(planId, itemId, "WAREHOUSE_REMARK", plan.getPlanStatus(), plan.getPlanStatus(), operation,
before, json(values("warehouseRemark", remark, "requestHash", requestHash)), operatorId, operatorName);
@@ -535,6 +545,15 @@ public class SmartLoadingService {
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单货物服务暂不可用");
}
private static boolean hasSqlState(Throwable throwable, String sqlState) {
for (Throwable current = throwable; current != null; current = current.getCause()) {
if (current instanceof SQLException sqlException && sqlState.equals(sqlException.getSQLState())) {
return true;
}
}
return false;
}
private static String trim(String x){return x==null?null:x.trim();} private static BigDecimal unit(BigDecimal x){return x==null?BigDecimal.ZERO:x.max(BigDecimal.ZERO);}
private static void requireAccount(long x){if(x<=0)throw new BizException(ResultCode.BAD_REQUEST,"accountId必须大于零");} private static BizException conflict(String x){return new BizException(ResultCode.CONFLICT,x);} private static BizException validation(String x){return new BizException(ResultCode.VALIDATION_FAILED,x);} private static BizException forbidden(){return new BizException(ResultCode.FORBIDDEN,"客户归属不匹配");}
}
@@ -827,7 +827,44 @@ BEGIN
IF current_plan_status = 'LOADING' AND EXISTS (
SELECT 1 FROM fl_loading_pick_item
WHERE loading_plan_item_id = OLD.id AND picked_package_count > 0) THEN
IF NOT EXISTS (
IF NOT (NEW.warehouse_remark <=> OLD.warehouse_remark) THEN
IF NEW.package_count <> OLD.package_count OR NEW.deleted <> OLD.deleted
OR NOT (NEW.customer_name <=> OLD.customer_name)
OR NOT (NEW.inbound_id <=> OLD.inbound_id)
OR NOT (NEW.inbound_detail_id <=> OLD.inbound_detail_id)
OR NOT (NEW.order_id <=> OLD.order_id)
OR NOT (NEW.order_no <=> OLD.order_no)
OR NOT (NEW.inbound_code <=> OLD.inbound_code)
OR NOT (NEW.destination_warehouse_id <=> OLD.destination_warehouse_id)
OR NOT (NEW.destination_warehouse_code <=> OLD.destination_warehouse_code)
OR NOT (NEW.destination_warehouse_name <=> OLD.destination_warehouse_name)
OR NOT (NEW.sku_code <=> OLD.sku_code)
OR NOT (NEW.product_name_zh <=> OLD.product_name_zh)
OR NOT (NEW.product_name_en <=> OLD.product_name_en)
OR NOT (NEW.product_image_file_id <=> OLD.product_image_file_id)
OR NOT (NEW.product_image_url <=> OLD.product_image_url)
OR NOT (NEW.product_quantity <=> OLD.product_quantity)
OR NOT (NEW.units_per_carton <=> OLD.units_per_carton)
OR NOT (NEW.pallet_count <=> OLD.pallet_count)
OR NOT (NEW.gross_weight_kg <=> OLD.gross_weight_kg)
OR NOT (NEW.net_weight_kg <=> OLD.net_weight_kg)
OR NOT (NEW.dimensional_weight_kg <=> OLD.dimensional_weight_kg)
OR NOT (NEW.volume_cbm <=> OLD.volume_cbm)
OR NOT (NEW.fba_tracking_no <=> OLD.fba_tracking_no)
OR NOT (NEW.shipment_no <=> OLD.shipment_no)
OR NOT (NEW.shipping_mark <=> OLD.shipping_mark)
OR NOT (NEW.load_sequence <=> OLD.load_sequence)
OR NOT (NEW.position_snapshot <=> OLD.position_snapshot)
OR NOT (NEW.internal_remark <=> OLD.internal_remark)
OR NOT (NEW.customer_remark <=> OLD.customer_remark)
OR NOT (NEW.frozen_at <=> OLD.frozen_at)
OR NOT (NEW.frozen_by <=> OLD.frozen_by)
OR NOT (NEW.removed_by <=> OLD.removed_by)
OR NOT (NEW.removed_by_name <=> OLD.removed_by_name)
OR NOT (NEW.removed_at <=> OLD.removed_at) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细修改仓库备注时不得变更其他业务字段';
END IF;
ELSEIF NOT EXISTS (
SELECT 1 FROM fl_loading_pick_item
WHERE loading_plan_item_id = OLD.id
AND item_status = 'PARTIAL'
@@ -862,7 +899,6 @@ BEGIN
OR NOT (NEW.position_snapshot <=> OLD.position_snapshot)
OR NOT (NEW.internal_remark <=> OLD.internal_remark)
OR NOT (NEW.customer_remark <=> OLD.customer_remark)
OR NOT (NEW.warehouse_remark <=> OLD.warehouse_remark)
OR NOT (NEW.frozen_at <=> OLD.frozen_at)
OR NOT (NEW.frozen_by <=> OLD.frozen_by)
OR NOT (NEW.removed_by <=> OLD.removed_by)
@@ -833,6 +833,26 @@ paths:
application/json:
schema: {$ref: '#/components/schemas/MerchantInboundBillingEnvelope'}
'403': {description: 非当前客户或无订单查看权限}
/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/{feeId}/adjust:
post:
summary: 调整已审核柜次费用的日期和金额
description: >-
仅 ACTIVE、APPROVED 且 sourceVersion 匹配的费用可调整。成功后 sourceVersion 加一并重新进入
PENDING;费用方向、费用项目、往来单位、币种、参与结算和备注保持原快照。身份或结算字段需要变化时,
必须作废原费用并新增。同一 expectedSourceVersion、businessDate、amount 的原样重试只读返回已生成的
PENDING 版本;旧版本异参、已作废或非已审核状态返回 A0409 且零写入。
parameters:
- {name: accountId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: loadingPlanId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: feeId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ContainerFeeAdjustRequest'}
responses:
'200': {description: 成功时 data 为调整后柜次费用;业务冲突通过响应体 A0409 返回}
'403': {description: 无 firstleg:inbound:fee-detail:edit 权限}
components:
securitySchemes:
bearerAuth:
@@ -1793,3 +1813,11 @@ components:
properties:
expectedVersion: {type: integer, minimum: 0}
reason: {type: string, minLength: 1, maxLength: 500}
ContainerFeeAdjustRequest:
type: object
additionalProperties: false
required: [expectedSourceVersion, businessDate, amount]
properties:
expectedSourceVersion: {type: integer, minimum: 1}
businessDate: {type: string, format: date}
amount: {type: number, minimum: 0}
@@ -41,14 +41,25 @@ class FeeManagementContractTest {
}
@Test
@DisplayName("柜次费用新增和调整请求均携带往来单位快照")
void containerFeeRequestsCarryCounterpartySnapshot() {
for (Class<?> requestType : List.of(
com.baiyue.firstleg.dto.ContainerFeeDetailDtos.SaveRequest.class,
com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AdjustRequest.class)) {
assertThat(Arrays.stream(requestType.getRecordComponents()).map(RecordComponent::getName))
.contains("partnerId", "partnerName");
}
@DisplayName("柜次费用调整只能修改版本、业务日期和金额")
void containerFeeAdjustmentCannotReplaceSourceIdentity() {
assertThat(Arrays.stream(com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AdjustRequest.class
.getRecordComponents()).map(RecordComponent::getName))
.containsExactly("expectedSourceVersion", "businessDate", "amount");
}
@Test
@DisplayName("柜次费用调整OpenAPI冻结三字段请求和快照不漂移语义")
void containerFeeAdjustmentOpenApiIsNarrowAndVersioned() throws Exception {
String source = new ClassPathResource("openapi/firstleg-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(source)
.contains("/api/platform/v1/loading-plans/{loadingPlanId}/fee-details/{feeId}/adjust:")
.contains("ContainerFeeAdjustRequest:")
.contains("required: [expectedSourceVersion, businessDate, amount]")
.contains("additionalProperties: false")
.contains("费用方向、费用项目、往来单位、币种、参与结算和备注保持原快照")
.contains("旧版本异参、已作废或非已审核状态返回 A0409 且零写入");
}
@Test
@@ -0,0 +1,27 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class ContainerFeeDetailRepositoryTest {
@Test
void adjustmentUpdateOnlyTouchesAmountDateVersionAndReviewProjection() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepository.java"),
StandardCharsets.UTF_8);
String method = source.substring(source.indexOf("public int adjust("),
source.indexOf("public int voidApproved("));
assertThat(method).contains("getBusinessDate", "getAmount", "getSourceVersion",
"getReviewStatus", "getReviewerId", "getReviewerName", "getReviewedAt",
"getReviewReason", "getUpdatedBy", "getUpdatedAt")
.doesNotContain("getFeeItemId", "getFeeItemCodeSnapshot", "getFeeItemNameSnapshot",
"getDirection", "getPartnerId", "getPartnerName", "getCurrency",
"getSettlementIncluded", "getRemark");
}
}
@@ -75,6 +75,22 @@ class SmartLoadingSqlContractTest {
.contains("version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'");
}
@Test
@DisplayName("仓库备注使用计划先行与明细单表CAS并允许已拣明细仅改备注")
void shouldUpdateWarehouseRemarkWithoutMultiTableTriggerConflict() throws Exception {
String mapper = Files.readString(Path.of(
"src/main/java/com/baiyue/firstleg/mapper/LoadingPlanItemMapper.java"), StandardCharsets.UTF_8);
assertThat(mapper)
.contains("UPDATE fl_loading_plan_item i")
.contains("p.version=#{touchedPlanVersion}")
.contains("p.plan_status IN ('EDITING','LOADING')")
.contains("i.frozen_at IS NULL")
.doesNotContain("UPDATE fl_loading_plan_item i JOIN fl_loading_plan p")
.doesNotContain("p.version=p.version+1");
assertWarehouseRemarkTriggerContract(migration());
assertWarehouseRemarkTriggerContract(unifiedMigration());
}
private static void assertContainerLifecycleSchema(String sql) {
assertThat(sql)
.contains("source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION'")
@@ -89,6 +105,16 @@ class SmartLoadingSqlContractTest {
.contains("column_name='merged_into_container_booking_id'");
}
private static void assertWarehouseRemarkTriggerContract(String sql) {
assertThat(sql)
.contains("IF NOT (NEW.warehouse_remark <=> OLD.warehouse_remark) THEN")
.contains("IF NEW.package_count <> OLD.package_count OR NEW.deleted <> OLD.deleted")
.contains("已有PDA拣货记录的装柜明细修改仓库备注时不得变更其他业务字段")
.contains("ELSEIF NOT EXISTS (")
.contains("IF OLD.frozen_at IS NOT NULL THEN")
.contains("装柜完成后的明细永久冻结");
}
@Test
@DisplayName("清关与报关采用柜次级文件、一键完成和真实文件哈希")
void shouldProvideSimpleCustomsDocuments() throws Exception {
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AdjustRequest;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
@@ -20,6 +21,8 @@ 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.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -69,24 +72,95 @@ class ContainerFeeDetailServiceTest {
@Test
void adjustmentIncrementsSourceVersionAndReturnsToReview() {
ownedPlan();
FeeItemEntity item = item();
when(feeItems.findItem(11L)).thenReturn(item);
ContainerFeeDetailEntity current = new ContainerFeeDetailEntity();
current.setId(51L);
current.setTenantId(10001L);
current.setLoadingPlanId(41L);
current.setSourceVersion(1);
ContainerFeeDetailEntity current = approvedFee(1);
ContainerFeeDetailEntity adjusted = approvedFee(2);
adjusted.setBusinessDate(LocalDate.of(2026, 8, 12));
adjusted.setAmount(new BigDecimal("325.00"));
adjusted.setReviewStatus("PENDING");
when(repository.find(10001L, 41L, 51L)).thenReturn(current);
when(repository.adjust(any(), eq(1))).thenReturn(1);
when(repository.adjust(eq(10001L), eq(41L), eq(51L), eq(1),
eq(LocalDate.of(2026, 8, 12)), eq(new BigDecimal("325.00")), eq(9L), any()))
.thenReturn(1);
when(repository.find(10001L, 41L, 51L)).thenReturn(current, adjusted);
service.adjust(10001L, 41L, 51L, 1, 9L, request(true));
ContainerFeeDetailEntity result = service.adjust(10001L, 41L, 51L, 9L,
adjustment(1, "325.00"));
ArgumentCaptor<ContainerFeeDetailEntity> captor = ArgumentCaptor.forClass(ContainerFeeDetailEntity.class);
verify(repository).adjust(captor.capture(), eq(1));
assertThat(captor.getValue().getSourceVersion()).isEqualTo(2);
assertThat(captor.getValue().getReviewStatus()).isEqualTo("PENDING");
assertThat(captor.getValue().getPartnerId()).isEqualTo(71L);
assertThat(captor.getValue().getPartnerName()).isEqualTo("现有往来单位");
assertThat(result.getSourceVersion()).isEqualTo(2);
assertThat(result.getReviewStatus()).isEqualTo("PENDING");
assertThat(result.getFeeItemId()).isEqualTo(11L);
assertThat(result.getDirection()).isEqualTo("PAYABLE");
assertThat(result.getPartnerId()).isEqualTo(71L);
assertThat(result.getCurrency()).isEqualTo("CNY");
assertThat(result.getSettlementIncluded()).isTrue();
assertThat(result.getRemark()).isEqualTo("原费用备注");
verify(feeItems, never()).findItem(anyLong());
}
@Test
void exactAdjustmentReplayReturnsCurrentPendingVersionWithoutWriting() {
ownedPlan();
ContainerFeeDetailEntity replay = approvedFee(2);
replay.setReviewStatus("PENDING");
replay.setBusinessDate(LocalDate.of(2026, 8, 12));
replay.setAmount(new BigDecimal("325.00"));
when(repository.find(10001L, 41L, 51L)).thenReturn(replay);
assertThat(service.adjust(10001L, 41L, 51L, 9L, adjustment(1, "325.0")))
.isSameAs(replay);
verify(repository, never()).adjust(anyLong(), anyLong(), anyLong(),
anyInt(), any(), any(), anyLong(), any());
}
@Test
void staleOrDifferentReplayIsRejectedWithoutWriting() {
ownedPlan();
ContainerFeeDetailEntity current = approvedFee(2);
current.setReviewStatus("PENDING");
current.setBusinessDate(LocalDate.of(2026, 8, 12));
current.setAmount(new BigDecimal("325.00"));
when(repository.find(10001L, 41L, 51L)).thenReturn(current);
assertThatThrownBy(() -> service.adjust(10001L, 41L, 51L, 9L,
adjustment(1, "326.00"))).isInstanceOf(BizException.class)
.hasMessageContaining("当前版本的已审核");
verify(repository, never()).adjust(anyLong(), anyLong(), anyLong(),
anyInt(), any(), any(), anyLong(), any());
}
@Test
void concurrentVersionChangeReturnsConflictWithoutPublishing() {
ownedPlan();
when(repository.find(10001L, 41L, 51L)).thenReturn(approvedFee(1));
when(repository.adjust(eq(10001L), eq(41L), eq(51L), eq(1), any(), any(), eq(9L), any()))
.thenReturn(0);
assertThatThrownBy(() -> service.adjust(10001L, 41L, 51L, 9L,
adjustment(1, "325.00"))).isInstanceOf(BizException.class)
.hasMessageContaining("当前版本的已审核");
verify(outbox, never()).publishContainer(any(), anyLong(), anyLong(), anyLong(),
anyInt(), any());
}
@Test
void voidedOrUnapprovedFeeCannotBeAdjusted() {
ownedPlan();
ContainerFeeDetailEntity voided = approvedFee(1);
voided.setSourceStatus("VOIDED");
when(repository.find(10001L, 41L, 51L)).thenReturn(voided);
assertThatThrownBy(() -> service.adjust(10001L, 41L, 51L, 9L,
adjustment(1, "325.00"))).isInstanceOf(BizException.class);
ContainerFeeDetailEntity pending = approvedFee(1);
pending.setReviewStatus("PENDING");
when(repository.find(10001L, 41L, 51L)).thenReturn(pending);
assertThatThrownBy(() -> service.adjust(10001L, 41L, 51L, 9L,
adjustment(1, "325.00"))).isInstanceOf(BizException.class);
verify(repository, never()).adjust(anyLong(), anyLong(), anyLong(),
anyInt(), any(), any(), anyLong(), any());
}
@Test
@@ -139,6 +213,32 @@ class ContainerFeeDetailServiceTest {
return value;
}
private static ContainerFeeDetailEntity approvedFee(int version) {
ContainerFeeDetailEntity value = new ContainerFeeDetailEntity();
value.setId(51L);
value.setTenantId(10001L);
value.setLoadingPlanId(41L);
value.setFeeItemId(11L);
value.setFeeItemCodeSnapshot("TRUCKING");
value.setFeeItemNameSnapshot("拖车费");
value.setDirection("PAYABLE");
value.setPartnerId(71L);
value.setPartnerName("现有往来单位");
value.setCurrency("CNY");
value.setSettlementIncluded(true);
value.setRemark("原费用备注");
value.setBusinessDate(LocalDate.of(2026, 8, 11));
value.setAmount(new BigDecimal("300.00"));
value.setSourceStatus("ACTIVE");
value.setReviewStatus("APPROVED");
value.setSourceVersion(version);
return value;
}
private static AdjustRequest adjustment(int version, String amount) {
return new AdjustRequest(version, LocalDate.of(2026, 8, 12), new BigDecimal(amount));
}
private static ContainerFeeDetailDtos.SaveRequest request(boolean settlementIncluded) {
return new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
@@ -29,11 +29,13 @@ import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.sql.SQLException;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
@@ -391,35 +393,92 @@ class SmartLoadingServiceTest {
when(repository.item(1L, 8L)).thenReturn(line);
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of(line));
when(repository.updateWarehouseRemark(1L, 8L, 2, 0, null, 7L, "u")).thenReturn(1);
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
when(repository.updateWarehouseRemark(1L, 8L, 3, 0, null, 7L, "u")).thenReturn(1);
Plan result = service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest(" ", 2, 0, "warehouse-clear"), 7L, "u");
assertThat(result.status()).isEqualTo("LOADING");
verify(loadingPickService).lockMutable(1L);
verify(repository).updateWarehouseRemark(1L, 8L, 2, 0, null, 7L, "u");
var order = inOrder(loadingPickService, repository);
order.verify(loadingPickService).lockMutable(1L);
order.verify(repository).touch(1L, 2, 7L, "u");
order.verify(repository).updateWarehouseRemark(1L, 8L, 3, 0, null, 7L, "u");
ArgumentCaptor<LoadingPlanHistoryEntity> history = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class);
verify(repository).insert(history.capture());
assertThat(history.getValue().getActionType()).isEqualTo("WAREHOUSE_REMARK");
assertThat(history.getValue().getBeforeSnapshot()).contains("旧仓库备注");
}
@Test
void updatesWarehouseRemarkWhilePlanIsEditing() {
LoadingPlanEntity plan = plan("EDITING", 1);
LoadingPlanItemEntity line = item(8, 9, 20, 1);
when(repository.plan(1L)).thenReturn(plan, plan("EDITING", 2));
when(repository.item(1L, 8L)).thenReturn(line);
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of(line));
when(repository.touch(1L, 1, 7L, "u")).thenReturn(1);
when(repository.updateWarehouseRemark(1L, 8L, 2, 0, "待核对外箱", 7L, "u")).thenReturn(1);
Plan result = service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest("待核对外箱", 1, 0, "warehouse-editing"), 7L, "u");
assertThat(result.status()).isEqualTo("EDITING");
assertThat(result.version()).isEqualTo(2);
verify(repository).updateWarehouseRemark(1L, 8L, 2, 0, "待核对外箱", 7L, "u");
}
@Test
void warehouseRemarkConflictProducesNoHistory() {
LoadingPlanEntity plan = plan("LOADING", 2);
LoadingPlanItemEntity line = item(8, 9, 20, 1);
when(repository.plan(1L)).thenReturn(plan);
when(repository.item(1L, 8L)).thenReturn(line);
when(repository.updateWarehouseRemark(1L, 8L, 2, 0, "新备注", 7L, "u")).thenReturn(0);
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
when(repository.updateWarehouseRemark(1L, 8L, 3, 0, "新备注", 7L, "u")).thenReturn(0);
BizException error = catchThrowableOfType(() -> service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest("新备注", 2, 0, "warehouse-conflict"), 7L, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
verify(repository).touch(1L, 2, 7L, "u");
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
}
@Test
void warehouseRemarkMapsTriggerSqlStateToConflict() {
LoadingPlanEntity plan = plan("LOADING", 2);
LoadingPlanItemEntity line = item(8, 9, 20, 1);
when(repository.plan(1L)).thenReturn(plan);
when(repository.item(1L, 8L)).thenReturn(line);
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
when(repository.updateWarehouseRemark(1L, 8L, 3, 0, "新备注", 7L, "u"))
.thenThrow(new DataIntegrityViolationException("trigger",
new SQLException("状态冲突", "45000")));
BizException error = catchThrowableOfType(() -> service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest("新备注", 2, 0, "warehouse-trigger"), 7L, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
assertThat(error.getMessage()).contains("仓库备注未保存").contains("刷新后重试");
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
}
@Test
void stalePlanVersionRejectsWarehouseRemarkBeforeItemWrite() {
when(repository.plan(1L)).thenReturn(plan("LOADING", 3));
BizException error = catchThrowableOfType(() -> service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest("新备注", 2, 0, "warehouse-stale-plan"), 7L, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString());
verify(repository, never()).updateWarehouseRemark(anyLong(), anyLong(), anyInt(), anyInt(),
any(), anyLong(), anyString());
}
@Test
void completedPlanRejectsWarehouseRemarkBeforeAnyWrite() {
when(repository.plan(1L)).thenReturn(plan("COMPLETED", 4));