Files
baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java
T
2026-08-20 18:02:20 +08:00

473 lines
25 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.InboundFeeDetailDtos.SaveRequest;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundFeeItemEntity;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.List;
/** 入库单人工费用明细应用服务。 */
@Service
public class InboundFeeDetailService {
private static final String FREIGHT_FEE_CODE = "FBA_HEAD_FREIGHT";
@Resource
private FeeItemRepository repository;
@Resource
private FbaPricingRepository pricingRepository;
@Resource
private FinanceFeeSourceOutboxService financeOutbox;
@Resource
private WarehouseSupplierSnapshotService suppliers;
@Resource
private FeePaymentSnapshotValidator paymentSnapshots;
@Transactional
public List<InboundFeeDetailEntity> list(long tenantId, long inboundId, boolean includeHistory) {
requiredInbound(tenantId, inboundId);
materializeLatestFreightIfMissing(tenantId, inboundId);
return repository.listDetails(tenantId, inboundId, includeHistory);
}
private void materializeLatestFreightIfMissing(long tenantId, long inboundId) {
InboundFeeEntity latest = pricingRepository.findLatestFee(tenantId, inboundId);
if (latest == null || repository.findBySourceFee(
tenantId, "FBA_PRICING", latest.getId()) != null) return;
LocalDateTime occurredAt = latest.getSentAt() != null ? latest.getSentAt()
: latest.getCalculatedAt();
if ("SENT".equals(latest.getFeeStatus())) {
publishFreight(tenantId, inboundId, latest.getId(), 0L, "SYSTEM", occurredAt);
} else if ("CALCULATED".equals(latest.getFeeStatus())) {
materializeCalculatedFreight(tenantId, inboundId, latest.getId(), 0L, occurredAt);
}
}
public InboundFeeDetailEntity publishedFreight(long tenantId, long inboundId) {
requiredInbound(tenantId, inboundId);
InboundFeeDetailEntity detail = repository.findPublishedFreight(tenantId, inboundId);
if (detail == null) throw new BizException(ResultCode.NOT_FOUND, "入库运费明细尚未发布");
return detail;
}
/** 将计费快照发布为后台和客户共同读取的入库应收运费明细。 */
@Transactional
public InboundFeeDetailEntity publishFreight(long tenantId, long inboundId, long feeId,
long operatorId, String operatorName,
LocalDateTime publishedAt) {
InboundFeeDetailEntity existing = repository.findBySourceFee(tenantId, "FBA_PRICING", feeId);
if (existing != null && "PUBLISHED".equals(existing.getPublicationStatus())) return existing;
InboundOrderEntity inbound = requiredInbound(tenantId, inboundId);
InboundFeeEntity fee = pricingRepository.findFee(tenantId, feeId);
if (fee == null || !Long.valueOf(inboundId).equals(fee.getInboundId())) {
throw new BizException(ResultCode.NOT_FOUND, "待发布的入库计费快照不存在");
}
if (!"SENT".equals(fee.getFeeStatus())) {
throw conflict("只有已发送状态的计费快照才能生成客户运费明细");
}
LocalDateTime now = publishedAt == null ? LocalDateTime.now() : publishedAt;
if (existing != null) {
repository.supersedeFreight(tenantId, inboundId, feeId, operatorId, now);
if (repository.publishCalculatedFreight(
tenantId, feeId, operatorId, operatorName, now) != 1) {
throw conflict("初算费用明细状态已变化,请刷新后重试");
}
return repository.findBySourceFee(tenantId, "FBA_PRICING", feeId);
}
FeeItemEntity feeItem = requiredFreightItem();
InboundFeeItemEntity baseItem = pricingRepository.findBaseItem(tenantId, feeId);
repository.supersedePublishedFreight(tenantId, inboundId, feeId, operatorId, now);
InboundFeeDetailEntity detail = new InboundFeeDetailEntity();
detail.setTenantId(tenantId);
detail.setInboundId(inboundId);
detail.setFeeItemId(feeItem.getId());
detail.setSourceType("FBA_PRICING");
detail.setSourceFeeId(feeId);
detail.setSourceFeeVersion(fee.getFeeVersion());
detail.setSourceFeeNo(fee.getFeeNo());
detail.setInboundCode(inbound.getInboundCode());
detail.setDirection("RECEIVABLE");
detail.setBusinessDate(now.toLocalDate());
detail.setCustomerId(inbound.getCustomerId());
detail.setCustomerName(inbound.getCustomerName());
detail.setInvoiceTitle(inbound.getCompanyName());
detail.setFeeCategorySnapshot(feeItem.getCategory());
detail.setTradeScopeSnapshot(feeItem.getTradeScope());
detail.setFeeTypeNameSnapshot(feeItem.getFeeTypeName());
detail.setChannelCode(inbound.getChannelCode());
detail.setChannelName(inbound.getChannelName());
detail.setDestinationCode(inbound.getDestinationCode());
detail.setDestinationName(inbound.getDestinationName());
detail.setPackageCount(inbound.getTotalPackageCount());
detail.setActualGrossWeightKg(fee.getActualWeightKg());
detail.setActualVolumeCbm(fee.getActualVolumeCbm());
detail.setVolumeWeightKg(inbound.getVolumeWeightKg());
detail.setChargeableWeightKg("KG".equals(fee.getChargeUnit())
? fee.getChargeableQuantity() : inbound.getChargeableWeightKg());
detail.setChargeableVolumeCbm("CBM".equals(fee.getChargeUnit())
? fee.getChargeableQuantity() : inbound.getChargeableVolumeCbm());
detail.setChargeableQuantity(fee.getChargeableQuantity());
detail.setChargeUnit(fee.getChargeUnit());
detail.setUnitPrice(effectiveUnitPrice(fee));
detail.setCurrency(fee.getCurrency());
detail.setAmount(fee.getTotalAmount().setScale(2, RoundingMode.HALF_UP));
detail.setRemark(baseItem == null ? "来源费用单:" + fee.getFeeNo()
: "来源费用单:" + fee.getFeeNo() + ";基础单价:" + baseItem.getUnitPrice());
detail.setReviewStatus("APPROVED");
detail.setReviewerId(operatorId);
detail.setReviewerName(operatorName);
detail.setReviewedAt(now);
detail.setPublicationStatus("PUBLISHED");
detail.setPublishedBy(operatorId);
detail.setPublishedByName(operatorName);
detail.setPublishedAt(now);
detail.setInboundedAt(inbound.getInboundedAt());
detail.setCreatedBy(operatorId);
detail.setUpdatedBy(operatorId);
try {
repository.insertDetail(detail);
return detail;
} catch (DuplicateKeyException exception) {
InboundFeeDetailEntity concurrent = repository.findBySourceFee(
tenantId, "FBA_PRICING", feeId);
if (concurrent != null) return concurrent;
throw exception;
}
}
/** 初算成功后生成后台可见的草稿运费明细,不发布 Finance。 */
@Transactional
public InboundFeeDetailEntity materializeCalculatedFreight(long tenantId, long inboundId, long feeId,
long operatorId, LocalDateTime calculatedAt) {
InboundFeeDetailEntity existing = repository.findBySourceFee(tenantId, "FBA_PRICING", feeId);
if (existing != null) return existing;
InboundOrderEntity inbound = requiredInbound(tenantId, inboundId);
InboundFeeEntity fee = pricingRepository.findFee(tenantId, feeId);
if (fee == null || !Long.valueOf(inboundId).equals(fee.getInboundId())) {
throw new BizException(ResultCode.NOT_FOUND, "待展示的入库计费快照不存在");
}
if (!"CALCULATED".equals(fee.getFeeStatus())) {
throw conflict("只有初算状态的计费快照可生成草稿运费明细");
}
LocalDateTime now = calculatedAt == null ? LocalDateTime.now() : calculatedAt;
repository.supersedeFreight(tenantId, inboundId, feeId, operatorId, now);
InboundFeeDetailEntity detail = freightDetail(
tenantId, inboundId, operatorId, null, now, inbound, fee, requiredFreightItem(), false);
try {
repository.insertDetail(detail);
return detail;
} catch (DuplicateKeyException exception) {
InboundFeeDetailEntity concurrent = repository.findBySourceFee(
tenantId, "FBA_PRICING", feeId);
if (concurrent != null) return concurrent;
throw exception;
}
}
private InboundFeeDetailEntity freightDetail(long tenantId, long inboundId, long operatorId,
String operatorName, LocalDateTime now,
InboundOrderEntity inbound, InboundFeeEntity fee,
FeeItemEntity feeItem, boolean published) {
InboundFeeItemEntity baseItem = pricingRepository.findBaseItem(tenantId, fee.getId());
InboundFeeDetailEntity detail = new InboundFeeDetailEntity();
detail.setTenantId(tenantId); detail.setInboundId(inboundId); detail.setFeeItemId(feeItem.getId());
detail.setSourceType("FBA_PRICING"); detail.setSourceFeeId(fee.getId());
detail.setSourceFeeVersion(fee.getFeeVersion()); detail.setSourceFeeNo(fee.getFeeNo());
detail.setInboundCode(inbound.getInboundCode()); detail.setDirection("RECEIVABLE");
detail.setBusinessDate(now.toLocalDate()); detail.setCustomerId(inbound.getCustomerId());
detail.setCustomerName(inbound.getCustomerName()); detail.setInvoiceTitle(inbound.getCompanyName());
detail.setFeeCategorySnapshot(feeItem.getCategory()); detail.setTradeScopeSnapshot(feeItem.getTradeScope());
detail.setFeeTypeNameSnapshot(feeItem.getFeeTypeName()); detail.setChannelCode(inbound.getChannelCode());
detail.setChannelName(inbound.getChannelName()); detail.setDestinationCode(inbound.getDestinationCode());
detail.setDestinationName(inbound.getDestinationName()); detail.setPackageCount(inbound.getTotalPackageCount());
detail.setActualGrossWeightKg(fee.getActualWeightKg()); detail.setActualVolumeCbm(fee.getActualVolumeCbm());
detail.setVolumeWeightKg(inbound.getVolumeWeightKg());
detail.setChargeableWeightKg("KG".equals(fee.getChargeUnit()) ? fee.getChargeableQuantity() : inbound.getChargeableWeightKg());
detail.setChargeableVolumeCbm("CBM".equals(fee.getChargeUnit()) ? fee.getChargeableQuantity() : inbound.getChargeableVolumeCbm());
detail.setChargeableQuantity(fee.getChargeableQuantity()); detail.setChargeUnit(fee.getChargeUnit());
detail.setUnitPrice(baseItem == null ? effectiveUnitPrice(fee) : baseItem.getUnitPrice());
detail.setCurrency(fee.getCurrency()); detail.setAmount(fee.getTotalAmount().setScale(2, RoundingMode.HALF_UP));
detail.setRemark("来源费用单:" + fee.getFeeNo());
detail.setReviewStatus(published ? "APPROVED" : "PENDING");
detail.setPublicationStatus(published ? "PUBLISHED" : "DRAFT");
detail.setCreatedBy(operatorId); detail.setUpdatedBy(operatorId);
if (published) {
detail.setReviewerId(operatorId); detail.setReviewerName(operatorName); detail.setReviewedAt(now);
detail.setPublishedBy(operatorId); detail.setPublishedByName(operatorName); detail.setPublishedAt(now);
}
return detail;
}
@Transactional
public InboundFeeDetailEntity create(long tenantId, long inboundId, long operatorId,
SaveRequest request) {
InboundOrderEntity inbound = requiredInbound(tenantId, inboundId);
FeeItemEntity item = requiredActiveItem(request.feeItemId());
InboundFeeDetailEntity value = new InboundFeeDetailEntity();
value.setTenantId(tenantId);
value.setInboundId(inboundId);
value.setCreatedBy(operatorId);
apply(value, inbound, item, request, operatorId);
value.setReviewStatus("PENDING");
value.setSourceType("MANUAL");
value.setSourceFeeVersion(1);
value.setPublicationStatus("DRAFT");
repository.insertDetail(value);
return value;
}
@Transactional
public InboundFeeDetailEntity update(long tenantId, long inboundId, long detailId,
long operatorId, SaveRequest request) {
InboundFeeDetailEntity value = requiredEditable(tenantId, inboundId, detailId);
InboundOrderEntity inbound = requiredInbound(tenantId, inboundId);
FeeItemEntity item = requiredActiveItem(request.feeItemId());
apply(value, inbound, item, request, operatorId);
value.setReviewStatus("PENDING");
value.setReviewerId(null);
value.setReviewerName(null);
value.setReviewedAt(null);
value.setReviewReason(null);
if (repository.updateDetail(value) != 1) throw conflict("费用明细已被其他人修改,请刷新后重试");
return required(tenantId, inboundId, detailId);
}
@Transactional
public void delete(long tenantId, long inboundId, long detailId) {
requiredEditable(tenantId, inboundId, detailId);
if (repository.deleteDetail(tenantId, inboundId, detailId) != 1) {
throw conflict("费用明细删除冲突");
}
}
@Transactional
public void approve(long tenantId, long inboundId, long detailId,
long reviewerId, String reviewerName) {
InboundFeeDetailEntity pending = requiredManual(tenantId, inboundId, detailId);
FeeItemEntity item = requiredActiveItem(pending.getFeeItemId());
revalidatePaymentSnapshot(pending);
refreshSupplierSnapshot(pending, item, reviewerId);
if (repository.review(tenantId, inboundId, detailId, "APPROVED",
reviewerId, reviewerName, null) != 1) {
throw conflict("只有待审核费用明细可以审核通过");
}
InboundFeeDetailEntity approved = required(tenantId, inboundId, detailId);
publishFinanceSource(approved, "AVAILABLE", LocalDateTime.now());
}
@Transactional
public InboundFeeDetailEntity adjust(long tenantId, long inboundId, long detailId,
int expectedSourceVersion, long operatorId,
SaveRequest request) {
InboundFeeDetailEntity value = requiredManual(tenantId, inboundId, detailId);
if (!"APPROVED".equals(value.getReviewStatus()) || "VOIDED".equals(value.getPublicationStatus())) {
throw conflict("只有审核通过且未作废的费用明细可以调整");
}
if (value.getSourceFeeVersion() == null || value.getSourceFeeVersion() != expectedSourceVersion) {
throw conflict("费用来源版本已变化,请刷新后重试");
}
InboundOrderEntity inbound = requiredInbound(tenantId, inboundId);
FeeItemEntity item = requiredActiveItem(request.feeItemId());
LocalDateTime now = LocalDateTime.now();
if (repository.supersedeApprovedManual(tenantId, inboundId, detailId,
expectedSourceVersion, operatorId, now) != 1) {
throw conflict("费用明细已被其他人调整,请刷新后重试");
}
InboundFeeDetailEntity next = new InboundFeeDetailEntity();
next.setTenantId(tenantId);
next.setInboundId(inboundId);
next.setSourceType("MANUAL");
next.setSourceFeeId(value.getSourceFeeId() == null ? value.getId() : value.getSourceFeeId());
next.setSourceFeeVersion(expectedSourceVersion + 1);
next.setSourceFeeNo(value.getSourceFeeNo());
next.setCreatedBy(operatorId);
apply(next, inbound, item, request, operatorId);
next.setReviewStatus("PENDING");
next.setPublicationStatus("DRAFT");
repository.insertDetail(next);
return next;
}
@Transactional
public void voidApproved(long tenantId, long inboundId, long detailId,
int expectedSourceVersion, long operatorId) {
InboundFeeDetailEntity current = requiredManual(tenantId, inboundId, detailId);
if (repository.voidApproved(tenantId, inboundId, detailId, expectedSourceVersion, operatorId) != 1) {
throw conflict("只有当前版本的已审核费用明细可以作废");
}
current.setPublicationStatus("VOIDED");
publishFinanceSource(current, "VOIDED", LocalDateTime.now());
}
@Transactional
public void reject(long tenantId, long inboundId, long detailId, long reviewerId,
String reviewerName, String reason) {
requiredManual(tenantId, inboundId, detailId);
if (repository.review(tenantId, inboundId, detailId, "REJECTED",
reviewerId, reviewerName, reason.trim()) != 1) {
throw conflict("只有待审核费用明细可以驳回");
}
}
private InboundOrderEntity requiredInbound(long tenantId, long inboundId) {
InboundOrderEntity value = repository.findInbound(tenantId, inboundId);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
return value;
}
private FeeItemEntity requiredActiveItem(long itemId) {
FeeItemEntity value = repository.findItem(itemId);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "费用项目不存在");
if (!"ACTIVE".equals(value.getStatus())) throw conflict("费用项目已停用,不能绑定");
return value;
}
private FeeItemEntity requiredFreightItem() {
FeeItemEntity item = repository.findItemByCode(FREIGHT_FEE_CODE);
if (item == null) throw conflict("系统费用项目FBA头程运费未初始化");
if (!"ACTIVE".equals(item.getStatus())) throw conflict("系统费用项目FBA头程运费已停用");
return item;
}
private static java.math.BigDecimal effectiveUnitPrice(InboundFeeEntity fee) {
if (fee.getChargeableQuantity() == null || fee.getChargeableQuantity().signum() <= 0) {
throw conflict("计费快照的计费量必须大于零");
}
return fee.getTotalAmount().divide(fee.getChargeableQuantity(), 4, RoundingMode.HALF_UP);
}
private static void ensureWarehouseApplicable(FeeItemEntity item, String direction) {
boolean applicable = "RECEIVABLE".equals(direction)
? Boolean.TRUE.equals(item.getWarehouseReceivable())
: Boolean.TRUE.equals(item.getWarehousePayable());
if (!applicable) {
throw conflict("所选费用项目未启用对应的仓储应收/应付,不能绑定入库单");
}
}
private InboundFeeDetailEntity required(long tenantId, long inboundId, long detailId) {
InboundFeeDetailEntity value = repository.findDetail(tenantId, inboundId, detailId);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "入库费用明细不存在");
return value;
}
private InboundFeeDetailEntity requiredEditable(long tenantId, long inboundId, long detailId) {
InboundFeeDetailEntity value = requiredManual(tenantId, inboundId, detailId);
if ("APPROVED".equals(value.getReviewStatus())) throw conflict("审核通过的费用明细不能修改或删除");
return value;
}
private InboundFeeDetailEntity requiredManual(long tenantId, long inboundId, long detailId) {
InboundFeeDetailEntity value = required(tenantId, inboundId, detailId);
if ("FBA_PRICING".equals(value.getSourceType())) {
throw conflict("自动头程运费明细为只读派生,请在头程费用中调整或发布");
}
return value;
}
private void apply(InboundFeeDetailEntity value, InboundOrderEntity inbound,
FeeItemEntity item, SaveRequest request, long operatorId) {
ensureWarehouseApplicable(item, request.direction());
value.setFeeItemId(item.getId());
value.setInboundCode(inbound.getInboundCode());
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.setFeeCategorySnapshot(item.getCategory());
value.setTradeScopeSnapshot(item.getTradeScope());
value.setFeeTypeNameSnapshot(item.getFeeTypeName());
value.setChannelCode(inbound.getChannelCode());
value.setChannelName(inbound.getChannelName());
value.setDestinationCode(inbound.getDestinationCode());
value.setDestinationName(inbound.getDestinationName());
value.setPackageCount(inbound.getTotalPackageCount());
// 财务历史字段名保留,但计费事实统一使用扣除托盘皮重后的货物净重。
value.setActualGrossWeightKg(inbound.getActualNetWeightKg());
value.setActualVolumeCbm(inbound.getActualVolumeCbm());
value.setVolumeWeightKg(inbound.getVolumeWeightKg());
value.setChargeableWeightKg(inbound.getChargeableWeightKg());
value.setChargeableVolumeCbm(inbound.getChargeableVolumeCbm());
value.setUnitPrice(request.unitPrice().setScale(4, RoundingMode.HALF_UP));
value.setCurrency(request.currency());
value.setAmount(request.amount().setScale(2, RoundingMode.HALF_UP));
value.setRemark(request.remark() == null ? null : request.remark().trim());
value.setUpdatedBy(operatorId);
}
private void applySupplierSnapshot(InboundFeeDetailEntity 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(InboundFeeDetailEntity value, FeeItemEntity item,
long operatorId) {
applySupplierSnapshot(value, item, value.getDirection(), value.getSupplierId());
value.setUpdatedBy(operatorId);
if (repository.updateDetail(value) != 1) {
throw conflict("费用明细供应商快照已变化,请刷新后重试");
}
}
private void revalidatePaymentSnapshot(InboundFeeDetailEntity value) {
applyPaymentSnapshot(value, value.getDirection(), value.getSettlementMode(), value.getPaidAt(),
value.getPaymentMethod(), value.getPaymentReferenceNo(), value.getPaymentVoucherFileIds());
}
private void applyPaymentSnapshot(InboundFeeDetailEntity 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 void publishFinanceSource(InboundFeeDetailEntity detail, String status,
LocalDateTime occurredAt) {
if ("FBA_PRICING".equals(detail.getSourceType())) return;
int version = detail.getSourceFeeVersion() == null ? 1 : detail.getSourceFeeVersion();
String eventType = "VOIDED".equals(status) ? FinanceFeeSourceOutboxService.VOIDED
: version > 1 ? FinanceFeeSourceOutboxService.ADJUSTED
: FinanceFeeSourceOutboxService.PUBLISHED;
long sourceId = detail.getSourceFeeId() == null ? detail.getId() : detail.getSourceFeeId();
financeOutbox.publishInbound(eventType, detail.getTenantId(), detail.getInboundId(),
sourceId, version, occurredAt);
}
}