diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index cd2e86341..d9fe0518e 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerFeeDetailController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerFeeDetailController.java index e545498d0..b0a0769bc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerFeeDetailController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerFeeDetailController.java @@ -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") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerFeeDetailDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerFeeDetailDtos.java index bdef4e629..fe055f261 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerFeeDetailDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerFeeDetailDtos.java @@ -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) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanItemMapper.java index 7bc4f80e9..f7247eba9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanItemMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPlanItemMapper.java @@ -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); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepository.java index 9a7ebb587..35474ffe2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepository.java @@ -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() - .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() + .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, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java index 095a678a6..cad44c116 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java @@ -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) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java index 939ffc29b..38852abab 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java @@ -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); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 6728b3ef2..3bb6bf678 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -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,"客户归属不匹配");} } diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 4b18eb36e..2d5f97d23 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -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) diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 6ba9fe97a..65824e9f8 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -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} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/FeeManagementContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/FeeManagementContractTest.java index 39d91aa30..6d49cccd2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/FeeManagementContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FeeManagementContractTest.java @@ -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 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepositoryTest.java new file mode 100644 index 000000000..82e9a90ab --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerFeeDetailRepositoryTest.java @@ -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"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index 341f89317..7ef0a3fb5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -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 { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java index 4227144e2..a04eaff04 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerFeeDetailServiceTest.java @@ -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 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", diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index b716d02c2..91e0a7bf0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -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 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));