382 lines
22 KiB
Java
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) { }
|
|
}
|