This commit is contained in:
2026-09-02 16:17:41 +08:00
parent 12fbf2aa67
commit 03d04431f2
33 changed files with 1367 additions and 22 deletions
@@ -4,6 +4,8 @@ import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AdjustRequest;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AllocationGroupAdjustRequest;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AllocationGroupRequest;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.RejectRequest;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.VersionRequest;
@@ -43,6 +45,48 @@ public class PlatformContainerFeeDetailController {
loadingPlanId, user.userId(), request));
}
@PostMapping("/allocation-groups")
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:edit')")
@BusinessAudit(event = "CONTAINER_FEE_ALLOCATION_GROUP_CREATE", resourceType = "LOADING_PLAN", resourceId = "#loadingPlanId", captureBefore = false)
public ApiResponse<List<ContainerFeeDetailEntity>> createAllocationGroup(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long loadingPlanId,
@Valid @RequestBody AllocationGroupRequest request) {
return ApiResponse.success(service.createAllocationGroup(loadingPlanId, user.userId(), request));
}
@PostMapping("/allocation-groups/{groupId}/approve")
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:review')")
public ApiResponse<List<ContainerFeeDetailEntity>> approveAllocationGroup(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long loadingPlanId,
@PathVariable long groupId) {
return ApiResponse.success(service.approveAllocationGroup(loadingPlanId, groupId, user.userId(), user.username()));
}
@PostMapping("/allocation-groups/{groupId}/reject")
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:review')")
public ApiResponse<List<ContainerFeeDetailEntity>> rejectAllocationGroup(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long loadingPlanId,
@PathVariable long groupId, @Valid @RequestBody RejectRequest request) {
return ApiResponse.success(service.rejectAllocationGroup(loadingPlanId, groupId, user.userId(), user.username(), request.reason()));
}
@PostMapping("/allocation-groups/{groupId}/adjust")
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:edit')")
public ApiResponse<List<ContainerFeeDetailEntity>> adjustAllocationGroup(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long loadingPlanId,
@PathVariable long groupId, @Valid @RequestBody AllocationGroupAdjustRequest request) {
return ApiResponse.success(service.adjustAllocationGroup(loadingPlanId, groupId, user.userId(), request));
}
@PostMapping("/allocation-groups/{groupId}/void")
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:review')")
public ApiResponse<Void> voidAllocationGroup(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long loadingPlanId,
@PathVariable long groupId, @Valid @RequestBody VersionRequest request) {
service.voidAllocationGroup(loadingPlanId, groupId, request.expectedSourceVersion(), user.userId());
return ApiResponse.success(null);
}
@PostMapping("/{feeId}/approve")
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:review')")
@BusinessAudit(event = "CONTAINER_FEE_DETAIL_APPROVE", resourceType = "CONTAINER_FEE_DETAIL", resourceId = "#feeId")
@@ -1,5 +1,6 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
@@ -44,6 +45,30 @@ public final class ContainerFeeDetailDtos {
) {
}
public record Allocation(
@NotNull @Min(1) Long customerId,
@NotNull @Min(1) Long inboundId,
@NotBlank @Size(max = 64) String inboundCode,
@NotNull @DecimalMin(value = "0.00", inclusive = false) BigDecimal amount
) { }
public record AllocationGroupRequest(
@NotNull @Valid SaveRequest fee,
@NotNull @Size(min = 1, max = 200) List<@Valid Allocation> allocations
) { }
public record AllocationAmount(
@NotNull @Min(1) Long feeId,
@NotNull @DecimalMin(value = "0.00", inclusive = false) BigDecimal amount
) { }
public record AllocationGroupAdjustRequest(
@NotNull @Min(1) Integer expectedSourceVersion,
@NotNull LocalDate businessDate,
@NotNull @DecimalMin(value = "0.00", inclusive = false) BigDecimal totalAmount,
@NotNull @Size(min = 1, max = 200) List<@Valid AllocationAmount> allocations
) { }
public record VersionRequest(@NotNull @Min(1) Integer expectedSourceVersion) {
}
@@ -21,6 +21,10 @@ public class ContainerFeeDetailEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long allocationGroupId;
private Long allocationInboundId;
private String allocationInboundCode;
private BigDecimal allocationGroupAmount;
private Long loadingPlanId;
private Long containerBookingId;
private String containerNo;
@@ -23,6 +23,10 @@ public class ContainerFeeSourceSnapshotEntity {
private Long sourceFeeId;
private Integer sourceVersion;
private Long tenantId;
private Long allocationGroupId;
private Long allocationInboundId;
private String allocationInboundCode;
private BigDecimal allocationGroupAmount;
private Long loadingPlanId;
private Long containerBookingId;
private String containerNo;
@@ -25,6 +25,14 @@ public class ContainerFeeDetailRepository {
.orderByDesc(ContainerFeeDetailEntity::getId));
}
public List<ContainerFeeDetailEntity> listGroup(long loadingPlanId, long allocationGroupId) {
return mapper.selectList(new LambdaQueryWrapper<ContainerFeeDetailEntity>()
.eq(ContainerFeeDetailEntity::getLoadingPlanId, loadingPlanId)
.eq(ContainerFeeDetailEntity::getAllocationGroupId, allocationGroupId)
.orderByAsc(ContainerFeeDetailEntity::getTenantId)
.orderByAsc(ContainerFeeDetailEntity::getAllocationInboundCode));
}
public ContainerFeeDetailEntity find(long tenantId, long loadingPlanId, long feeId) {
return mapper.selectOne(new LambdaQueryWrapper<ContainerFeeDetailEntity>()
.eq(ContainerFeeDetailEntity::getTenantId, tenantId)
@@ -96,6 +104,15 @@ public class ContainerFeeDetailRepository {
.set(ContainerFeeDetailEntity::getUpdatedAt, updatedAt));
}
public int updateAllocationGroupAmount(long loadingPlanId, long feeId, int sourceVersion,
BigDecimal groupAmount) {
return mapper.update(null, new LambdaUpdateWrapper<ContainerFeeDetailEntity>()
.eq(ContainerFeeDetailEntity::getLoadingPlanId, loadingPlanId)
.eq(ContainerFeeDetailEntity::getId, feeId)
.eq(ContainerFeeDetailEntity::getSourceVersion, sourceVersion)
.set(ContainerFeeDetailEntity::getAllocationGroupAmount, groupAmount));
}
public int voidApproved(long tenantId, long loadingPlanId, long feeId,
int expectedVersion, long operatorId) {
return mapper.update(null, new LambdaUpdateWrapper<ContainerFeeDetailEntity>()
@@ -19,6 +19,10 @@ public class ContainerFeeSourceSnapshotRepository {
value.setSourceFeeId(source.getId());
value.setSourceVersion(source.getSourceVersion());
value.setTenantId(source.getTenantId());
value.setAllocationGroupId(source.getAllocationGroupId());
value.setAllocationInboundId(source.getAllocationInboundId());
value.setAllocationInboundCode(source.getAllocationInboundCode());
value.setAllocationGroupAmount(source.getAllocationGroupAmount());
value.setLoadingPlanId(source.getLoadingPlanId());
value.setContainerBookingId(source.getContainerBookingId());
value.setContainerNo(source.getContainerNo());
@@ -3,6 +3,10 @@ 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.Allocation;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AllocationAmount;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AllocationGroupAdjustRequest;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AllocationGroupRequest;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.SaveRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
@@ -17,8 +21,14 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.RoundingMode;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
@Service
@RequiredArgsConstructor
@@ -40,9 +50,17 @@ public class ContainerFeeDetailService {
public ContainerFeeDetailEntity create(long tenantId, long loadingPlanId, long operatorId,
SaveRequest request) {
PlanContext plan = requireOwnedPlan(tenantId, loadingPlanId);
List<InboundAllocationKey> owned = loading.items(loadingPlanId).stream()
.filter(item -> Long.valueOf(tenantId).equals(item.getAccountId()))
.map(item -> new InboundAllocationKey(item.getInboundId(), item.getInboundCode()))
.distinct().toList();
if (owned.size() != 1) throw conflict("柜次包含多个入仓码,必须使用柜级费用分摊录入");
FeeItemEntity item = requireItem(request.feeItemId(), request.direction());
ContainerFeeDetailEntity value = new ContainerFeeDetailEntity();
value.setTenantId(tenantId);
value.setAllocationInboundId(owned.get(0).inboundId());
value.setAllocationInboundCode(owned.get(0).inboundCode());
value.setAllocationGroupAmount(request.amount().setScale(2, RoundingMode.HALF_UP));
value.setLoadingPlanId(loadingPlanId);
value.setContainerBookingId(plan.plan().getContainerBookingId());
value.setContainerNo(plan.booking() == null ? null : plan.booking().getContainerNo());
@@ -56,10 +74,112 @@ public class ContainerFeeDetailService {
return value;
}
@Transactional
public List<ContainerFeeDetailEntity> createAllocationGroup(long loadingPlanId, long operatorId,
AllocationGroupRequest request) {
PlanContext plan = requirePlan(loadingPlanId);
SaveRequest common = request.fee();
FeeItemEntity item = requireItem(common.feeItemId(), common.direction());
BigDecimal total = common.amount().setScale(2, RoundingMode.HALF_UP);
BigDecimal allocated = request.allocations().stream().map(Allocation::amount)
.reduce(BigDecimal.ZERO, BigDecimal::add).setScale(2, RoundingMode.HALF_UP);
if (allocated.compareTo(total) != 0) throw conflict("柜级费用分摊合计必须等于费用总额");
Map<InboundAllocationKey, Long> ownership = new HashMap<>();
loading.items(loadingPlanId).forEach(value -> ownership.put(
new InboundAllocationKey(value.getInboundId(), value.getInboundCode()), value.getAccountId()));
Set<InboundAllocationKey> unique = new HashSet<>();
long groupId = IdWorker.getId();
List<ContainerFeeDetailEntity> rows = new java.util.ArrayList<>();
for (Allocation allocation : request.allocations()) {
InboundAllocationKey key = new InboundAllocationKey(allocation.inboundId(), allocation.inboundCode().trim());
if (!unique.add(key)) throw conflict("同一入仓码不得重复分摊");
if (!allocation.customerId().equals(ownership.get(key)))
throw conflict("分摊客户、入仓码与柜内真实归属不一致");
ContainerFeeDetailEntity value = new ContainerFeeDetailEntity();
value.setId(IdWorker.getId()); value.setTenantId(allocation.customerId());
value.setAllocationGroupId(groupId); value.setAllocationInboundId(allocation.inboundId());
value.setAllocationInboundCode(allocation.inboundCode().trim()); value.setAllocationGroupAmount(total);
value.setLoadingPlanId(loadingPlanId); value.setContainerBookingId(plan.plan().getContainerBookingId());
value.setContainerNo(plan.booking() == null ? null : plan.booking().getContainerNo());
value.setSourceVersion(1); value.setSourceStatus("ACTIVE"); value.setReviewStatus("PENDING");
value.setCreatedBy(operatorId); value.setCreatedAt(LocalDateTime.now());
SaveRequest allocationRequest = new SaveRequest(common.feeItemId(), common.direction(),
common.businessDate(), allocation.amount(), common.currency(), common.supplierId(),
common.settlementMode(), common.paidAt(), common.paymentMethod(), common.paymentReferenceNo(),
common.paymentVoucherFileIds(), common.remark());
apply(value, item, allocationRequest, operatorId); repository.insert(value); rows.add(value);
}
return rows;
}
@Transactional
public List<ContainerFeeDetailEntity> approveAllocationGroup(long loadingPlanId, long groupId,
long reviewerId, String reviewerName) {
requirePlan(loadingPlanId);
List<ContainerFeeDetailEntity> rows = requiredGroup(loadingPlanId, groupId);
for (ContainerFeeDetailEntity row : rows) approveRow(row, reviewerId, reviewerName);
return requiredGroup(loadingPlanId, groupId);
}
@Transactional
public List<ContainerFeeDetailEntity> rejectAllocationGroup(long loadingPlanId, long groupId,
long reviewerId, String reviewerName, String reason) {
requirePlan(loadingPlanId);
List<ContainerFeeDetailEntity> rows = requiredGroup(loadingPlanId, groupId);
for (ContainerFeeDetailEntity row : rows) {
if (repository.review(row.getTenantId(), loadingPlanId, row.getId(), "REJECTED",
reviewerId, reviewerName, reason.trim()) != 1) throw conflict("分摊组已变化,请刷新后重试");
}
return requiredGroup(loadingPlanId, groupId);
}
@Transactional
public List<ContainerFeeDetailEntity> adjustAllocationGroup(long loadingPlanId, long groupId,
long operatorId, AllocationGroupAdjustRequest request) {
requirePlan(loadingPlanId);
List<ContainerFeeDetailEntity> rows = requiredGroup(loadingPlanId, groupId);
if (rows.stream().anyMatch(row -> row.getSourceVersion() != request.expectedSourceVersion()
|| !"APPROVED".equals(row.getReviewStatus()) || !"ACTIVE".equals(row.getSourceStatus())))
throw conflict("只有完整的当前已审核分摊组可以调整");
Map<Long, BigDecimal> amounts = new HashMap<>();
for (AllocationAmount allocation : request.allocations()) {
if (amounts.put(allocation.feeId(), allocation.amount().setScale(2, RoundingMode.HALF_UP)) != null)
throw conflict("分摊费用行不得重复");
}
if (!amounts.keySet().equals(rows.stream().map(ContainerFeeDetailEntity::getId).collect(java.util.stream.Collectors.toSet())))
throw conflict("调整必须包含分摊组全部费用行");
BigDecimal sum = amounts.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add).setScale(2, RoundingMode.HALF_UP);
if (sum.compareTo(request.totalAmount().setScale(2, RoundingMode.HALF_UP)) != 0)
throw conflict("调整后的分摊合计必须等于费用总额");
for (ContainerFeeDetailEntity row : rows) {
if (repository.adjust(row.getTenantId(), loadingPlanId, row.getId(), request.expectedSourceVersion(),
request.businessDate(), amounts.get(row.getId()), operatorId, LocalDateTime.now()) != 1)
throw conflict("柜级费用分摊组已变化,请刷新后重试");
repository.updateAllocationGroupAmount(loadingPlanId, row.getId(), request.expectedSourceVersion() + 1,
request.totalAmount().setScale(2, RoundingMode.HALF_UP));
}
return requiredGroup(loadingPlanId, groupId);
}
@Transactional
public void voidAllocationGroup(long loadingPlanId, long groupId, int expectedVersion, long operatorId) {
requirePlan(loadingPlanId);
List<ContainerFeeDetailEntity> rows = requiredGroup(loadingPlanId, groupId);
for (ContainerFeeDetailEntity row : rows) voidRow(row, expectedVersion, operatorId);
}
@Transactional
public void approve(long tenantId, long loadingPlanId, long feeId,
long reviewerId, String reviewerName) {
ContainerFeeDetailEntity pending = required(tenantId, loadingPlanId, feeId);
if (pending.getAllocationGroupId() != null) throw conflict("分摊费用必须整组审核");
approveRow(pending, reviewerId, reviewerName);
}
private void approveRow(ContainerFeeDetailEntity pending, long reviewerId, String reviewerName) {
long tenantId = pending.getTenantId();
long loadingPlanId = pending.getLoadingPlanId();
long feeId = pending.getId();
FeeItemEntity item = requireItem(pending.getFeeItemId(), pending.getDirection());
revalidatePaymentSnapshot(pending);
refreshSupplierSnapshot(pending, item, reviewerId);
@@ -79,6 +199,8 @@ public class ContainerFeeDetailService {
public void reject(long tenantId, long loadingPlanId, long feeId, long reviewerId,
String reviewerName, String reason) {
required(tenantId, loadingPlanId, feeId);
if (required(tenantId, loadingPlanId, feeId).getAllocationGroupId() != null)
throw conflict("分摊费用必须整组驳回");
if (repository.review(tenantId, loadingPlanId, feeId, "REJECTED", reviewerId,
reviewerName, reason.trim()) != 1) {
throw conflict("只有待审核柜次费用可以驳回");
@@ -90,6 +212,7 @@ public class ContainerFeeDetailService {
long operatorId, AdjustRequest request) {
requireOwnedPlan(tenantId, loadingPlanId);
ContainerFeeDetailEntity value = required(tenantId, loadingPlanId, feeId);
if (value.getAllocationGroupId() != null) throw conflict("分摊费用必须整组调整");
int expectedVersion = request.expectedSourceVersion();
var amount = request.amount().setScale(2, RoundingMode.HALF_UP);
if (value.getSourceVersion() != null && value.getSourceVersion() == expectedVersion + 1
@@ -113,6 +236,14 @@ public class ContainerFeeDetailService {
public void voidApproved(long tenantId, long loadingPlanId, long feeId,
int expectedVersion, long operatorId) {
ContainerFeeDetailEntity current = required(tenantId, loadingPlanId, feeId);
if (current.getAllocationGroupId() != null) throw conflict("分摊费用必须整组作废");
voidRow(current, expectedVersion, operatorId);
}
private void voidRow(ContainerFeeDetailEntity current, int expectedVersion, long operatorId) {
long tenantId = current.getTenantId();
long loadingPlanId = current.getLoadingPlanId();
long feeId = current.getId();
if (repository.voidApproved(tenantId, loadingPlanId, feeId, expectedVersion, operatorId) != 1) {
throw conflict("只有当前版本的已审核柜次费用可以作废");
}
@@ -121,13 +252,24 @@ public class ContainerFeeDetailService {
}
private PlanContext requireOwnedPlan(long tenantId, long loadingPlanId) {
LoadingPlanEntity plan = loading.plan(loadingPlanId);
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次不存在");
PlanContext context = requirePlan(loadingPlanId);
LoadingPlanEntity plan = context.plan();
boolean owned = loading.items(loadingPlanId).stream()
.anyMatch(item -> Long.valueOf(tenantId).equals(item.getAccountId()));
if (!owned) throw new BizException(ResultCode.NOT_FOUND, "柜次不属于该客户");
return new PlanContext(plan, plan.getContainerBookingId() == null
? null : loading.booking(plan.getContainerBookingId()));
return context;
}
private PlanContext requirePlan(long loadingPlanId) {
LoadingPlanEntity plan = loading.plan(loadingPlanId);
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次不存在");
return new PlanContext(plan, plan.getContainerBookingId() == null ? null : loading.booking(plan.getContainerBookingId()));
}
private List<ContainerFeeDetailEntity> requiredGroup(long loadingPlanId, long groupId) {
List<ContainerFeeDetailEntity> rows = repository.listGroup(loadingPlanId, groupId);
if (rows.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "柜级费用分摊组不存在");
return rows;
}
private FeeItemEntity requireItem(long feeItemId, String direction) {
@@ -217,4 +359,5 @@ public class ContainerFeeDetailService {
private record PlanContext(LoadingPlanEntity plan, ContainerBookingEntity booking) {
}
private record InboundAllocationKey(Long inboundId, String inboundCode) { }
}
@@ -70,10 +70,12 @@ public class FinanceFeeSourceSnapshotService {
nullSafe(detail.getSettlementMode()), nullSafe(detail.getPaidAt()),
nullSafe(detail.getPaymentMethod()), nullSafe(detail.getPaymentReferenceNo()),
nullSafe(detail.getPaymentVoucherFileIds()),
nullSafe(detail.getAllocationGroupId()), nullSafe(detail.getAllocationInboundId()),
nullSafe(detail.getAllocationInboundCode()), nullSafe(detail.getAllocationGroupAmount()),
detail.getAmount().toPlainString(), nullSafe(detail.getCurrency()),
nullSafe(detail.getReviewStatus()));
return new FinanceFeeSourceSnapshot(detail.getTenantId(), "CONTAINER_FEE", feeId,
version, null, null, null, containerId, detail.getContainerBookingId(),
version, detail.getAllocationInboundId(), detail.getAllocationInboundCode(), null, containerId, detail.getContainerBookingId(),
detail.getContainerNo(), detail.getTenantId(), null, detail.getSupplierId(),
detail.getSupplierName(), detail.getSupplierCategory(), detail.getSettlementMode(),
detail.getPaidAt(), detail.getPaymentMethod(), detail.getPaymentReferenceNo(),