Files
baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerFeeDetailService.java
T

382 lines
22 KiB
Java

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;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
import com.baiyue.firstleg.repository.ContainerFeeSourceSnapshotRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import lombok.RequiredArgsConstructor;
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
public class ContainerFeeDetailService {
private final ContainerFeeDetailRepository repository;
private final SmartLoadingRepository loading;
private final FeeItemRepository feeItems;
private final FinanceFeeSourceOutboxService financeOutbox;
private final WarehouseSupplierSnapshotService suppliers;
private final ContainerFeeSourceSnapshotRepository sourceSnapshots;
private final FeePaymentSnapshotValidator paymentSnapshots;
public List<ContainerFeeDetailEntity> list(long tenantId, long loadingPlanId) {
requireOwnedPlan(tenantId, loadingPlanId);
return repository.list(tenantId, loadingPlanId);
}
@Transactional
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.getOrderId(), text(item.getOrderNo()),
item.getInboundId(), text(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.setAllocationOrderId(owned.get(0).orderId());
value.setAllocationOrderNo(owned.get(0).orderNo());
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());
value.setSourceVersion(1);
value.setSourceStatus("ACTIVE");
value.setReviewStatus("PENDING");
value.setCreatedBy(operatorId);
value.setCreatedAt(LocalDateTime.now());
apply(value, item, request, operatorId);
repository.insert(value);
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<>();
Map<OrderAllocationKey, Long> orderOwnership = new HashMap<>();
loading.items(loadingPlanId).forEach(value -> {
ownership.put(new InboundAllocationKey(value.getOrderId(), text(value.getOrderNo()), value.getInboundId(),
text(value.getInboundCode())), value.getAccountId());
orderOwnership.put(new OrderAllocationKey(value.getOrderId(), text(value.getOrderNo())), value.getAccountId());
});
Set<InboundAllocationKey> unique = new HashSet<>();
long groupId = IdWorker.getId();
List<ContainerFeeDetailEntity> rows = new java.util.ArrayList<>();
for (Allocation allocation : request.allocations()) {
String inboundCode = text(allocation.inboundCode());
if ((allocation.inboundId() == null) != (inboundCode == null))
throw new BizException(ResultCode.VALIDATION_FAILED, "入仓ID与入仓码必须同时填写或同时留空");
InboundAllocationKey key = new InboundAllocationKey(allocation.orderId(), allocation.orderNo().trim(),
allocation.inboundId(), inboundCode);
if (!unique.add(key)) throw conflict("同一业务归属不得重复分摊");
Long owner = allocation.inboundId() == null
? orderOwnership.get(new OrderAllocationKey(allocation.orderId(), allocation.orderNo().trim()))
: ownership.get(key);
if (!allocation.customerId().equals(owner))
throw conflict("分摊客户、订单及入仓信息与柜内真实归属不一致");
ContainerFeeDetailEntity value = new ContainerFeeDetailEntity();
value.setId(IdWorker.getId()); value.setTenantId(allocation.customerId());
value.setAllocationGroupId(groupId); value.setAllocationOrderId(allocation.orderId());
value.setAllocationOrderNo(allocation.orderNo().trim()); value.setAllocationInboundId(allocation.inboundId());
value.setAllocationInboundCode(inboundCode); 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);
if (repository.review(tenantId, loadingPlanId, feeId, "APPROVED",
reviewerId, reviewerName, null) != 1) {
throw conflict("只有待审核柜次费用可以审核通过");
}
ContainerFeeDetailEntity approved = required(tenantId, loadingPlanId, feeId);
sourceSnapshots.saveApproved(approved);
String eventType = approved.getSourceVersion() > 1
? FinanceFeeSourceOutboxService.ADJUSTED : FinanceFeeSourceOutboxService.PUBLISHED;
financeOutbox.publishContainer(eventType, tenantId, loadingPlanId, feeId,
approved.getSourceVersion(), LocalDateTime.now());
}
@Transactional
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("只有待审核柜次费用可以驳回");
}
}
@Transactional
public ContainerFeeDetailEntity adjust(long tenantId, long loadingPlanId, long feeId,
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
&& "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);
}
@Transactional
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("只有当前版本的已审核柜次费用可以作废");
}
financeOutbox.publishContainer(FinanceFeeSourceOutboxService.VOIDED, tenantId,
loadingPlanId, feeId, expectedVersion, LocalDateTime.now());
}
private PlanContext requireOwnedPlan(long tenantId, long loadingPlanId) {
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 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) {
FeeItemEntity item = feeItems.findItem(feeItemId);
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "费用项目不存在");
if (!"ACTIVE".equals(item.getStatus())) throw conflict("费用项目已停用");
boolean applicable = "RECEIVABLE".equals(direction)
? Boolean.TRUE.equals(item.getPickupReceivable()) || Boolean.TRUE.equals(item.getWarehouseReceivable())
: Boolean.TRUE.equals(item.getPickupPayable()) || Boolean.TRUE.equals(item.getWarehousePayable());
if (!applicable) throw conflict("费用项目未启用对应应收/应付方向");
return item;
}
private ContainerFeeDetailEntity required(long tenantId, long loadingPlanId, long feeId) {
ContainerFeeDetailEntity value = repository.find(tenantId, loadingPlanId, feeId);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "柜次费用明细不存在");
return value;
}
private void apply(ContainerFeeDetailEntity value, FeeItemEntity item,
SaveRequest request, long operatorId) {
value.setFeeItemId(item.getId());
value.setFeeItemCodeSnapshot(item.getFeeCode());
value.setFeeItemNameSnapshot(item.getFeeTypeName());
value.setDirection(request.direction());
applySupplierSnapshot(value, item, request.direction(), request.supplierId());
applyPaymentSnapshot(value, request.direction(), request.settlementMode(), request.paidAt(),
request.paymentMethod(), request.paymentReferenceNo(), request.paymentVoucherFileIds());
value.setBusinessDate(request.businessDate());
value.setAmount(request.amount().setScale(2, RoundingMode.HALF_UP));
value.setCurrency(request.currency());
value.setRemark(request.remark() == null ? null : request.remark().trim());
value.setUpdatedBy(operatorId);
value.setUpdatedAt(LocalDateTime.now());
}
private void applySupplierSnapshot(ContainerFeeDetailEntity value, FeeItemEntity item,
String direction, Long supplierId) {
if (!"PAYABLE".equals(direction)) {
value.setSupplierId(null);
value.setSupplierName(null);
value.setSupplierCategory(null);
return;
}
if (supplierId == null || supplierId <= 0) {
throw new BizException(ResultCode.VALIDATION_FAILED, "应付柜次费用必须选择有效供应商");
}
String category = FeeItemSupplierCategory.normalize(item.getSupplierCategory());
var snapshot = suppliers.fee(supplierId, category);
value.setSupplierId(snapshot.supplierId());
value.setSupplierName(snapshot.name());
value.setSupplierCategory(category);
}
private void refreshSupplierSnapshot(ContainerFeeDetailEntity value, FeeItemEntity item,
long operatorId) {
applySupplierSnapshot(value, item, value.getDirection(), value.getSupplierId());
if (repository.updateSupplierSnapshot(value.getTenantId(), value.getLoadingPlanId(),
value.getId(), value.getSupplierId(), value.getSupplierName(),
value.getSupplierCategory(), operatorId) != 1) {
throw conflict("柜次费用供应商快照已变化,请刷新后重试");
}
}
private void revalidatePaymentSnapshot(ContainerFeeDetailEntity value) {
applyPaymentSnapshot(value, value.getDirection(), value.getSettlementMode(), value.getPaidAt(),
value.getPaymentMethod(), value.getPaymentReferenceNo(), value.getPaymentVoucherFileIds());
}
private void applyPaymentSnapshot(ContainerFeeDetailEntity value, String direction,
String settlementMode, LocalDateTime paidAt,
String paymentMethod, String paymentReferenceNo,
List<Long> paymentVoucherFileIds) {
var snapshot = paymentSnapshots.validate(value.getTenantId(), direction, settlementMode,
paidAt, paymentMethod, paymentReferenceNo, paymentVoucherFileIds);
value.setSettlementMode(snapshot.settlementMode());
value.setPaidAt(snapshot.paidAt());
value.setPaymentMethod(snapshot.paymentMethod());
value.setPaymentReferenceNo(snapshot.paymentReferenceNo());
value.setPaymentVoucherFileIds(snapshot.paymentVoucherFileIds().isEmpty()
? null : snapshot.paymentVoucherFileIds());
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
private record PlanContext(LoadingPlanEntity plan, ContainerBookingEntity booking) {
}
private static String text(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private record InboundAllocationKey(Long orderId, String orderNo, Long inboundId, String inboundCode) { }
private record OrderAllocationKey(Long orderId, String orderNo) { }
}