This commit is contained in:
2026-08-20 18:02:20 +08:00
parent 1e8577dc4e
commit b1655a1841
39 changed files with 626 additions and 38 deletions
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
@FeignClient(name = "file-service", contextId = "firstlegFeePaymentVoucherFileClient",
url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/business-files")
public interface FeePaymentVoucherFileClient {
@GetMapping("/{tenantId}/FINANCE_VOUCHER/{customerId}/{fileId}")
ApiResponse<Metadata> metadata(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId,
@PathVariable long customerId,
@PathVariable long fileId);
record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String sha256, String contentType, Long sizeBytes) {}
}
@@ -9,6 +9,8 @@ import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
public final class ContainerFeeDetailDtos {
private ContainerFeeDetailDtos() {
@@ -21,8 +23,18 @@ public final class ContainerFeeDetailDtos {
@NotNull @DecimalMin("0.00") BigDecimal amount,
@NotBlank @Pattern(regexp = "CNY") String currency,
Long supplierId,
@Pattern(regexp = "ON_ACCOUNT|DIRECT_PAID") String settlementMode,
LocalDateTime paidAt,
@Pattern(regexp = "ONLINE|BANK_TRANSFER|CASH|OTHER") String paymentMethod,
@Size(max = 128) String paymentReferenceNo,
@Size(max = 20) List<@Min(1) Long> paymentVoucherFileIds,
@Size(max = 500) String remark
) {
public SaveRequest(Long feeItemId, String direction, LocalDate businessDate,
BigDecimal amount, String currency, Long supplierId, String remark) {
this(feeItemId, direction, businessDate, amount, currency, supplierId,
null, null, null, null, null, remark);
}
}
public record AdjustRequest(
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** Finance 回查的 Firstleg 费用来源只读快照。 */
public record FinanceFeeSourceSnapshot(
@@ -21,6 +22,11 @@ public record FinanceFeeSourceSnapshot(
Long supplierId,
String supplierName,
String supplierCategory,
String settlementMode,
LocalDateTime paidAt,
String paymentMethod,
String paymentReferenceNo,
List<Long> paymentVoucherFileIds,
Long feeItemId,
String feeItemCode,
String feeItemName,
@@ -8,6 +8,8 @@ import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** 入库单费用明细请求模型。 */
public final class InboundFeeDetailDtos {
@@ -22,12 +24,18 @@ public final class InboundFeeDetailDtos {
@NotBlank @Pattern(regexp = "[A-Z]{3}") String currency,
@NotNull @DecimalMin("0.00") BigDecimal amount,
Long supplierId,
@Pattern(regexp = "ON_ACCOUNT|DIRECT_PAID") String settlementMode,
LocalDateTime paidAt,
@Pattern(regexp = "ONLINE|BANK_TRANSFER|CASH|OTHER") String paymentMethod,
@Size(max = 128) String paymentReferenceNo,
@Size(max = 20) List<@jakarta.validation.constraints.Min(1) Long> paymentVoucherFileIds,
@Size(max = 500) String remark
) {
public SaveRequest(Long feeItemId, String direction, LocalDate businessDate,
BigDecimal unitPrice, String currency, BigDecimal amount,
String remark) {
this(feeItemId, direction, businessDate, unitPrice, currency, amount, null, remark);
this(feeItemId, direction, businessDate, unitPrice, currency, amount,
null, null, null, null, null, null, remark);
}
}
@@ -3,17 +3,20 @@ package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** 柜次级人工应收/应付费用来源。 */
@Getter
@Setter
@TableName("fl_container_fee_detail")
@TableName(value = "fl_container_fee_detail", autoResultMap = true)
public class ContainerFeeDetailEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
@@ -28,6 +31,12 @@ public class ContainerFeeDetailEntity {
private Long supplierId;
private String supplierName;
private String supplierCategory;
private String settlementMode;
private LocalDateTime paidAt;
private String paymentMethod;
private String paymentReferenceNo;
@TableField(typeHandler = JacksonTypeHandler.class)
private List<Long> paymentVoucherFileIds;
private LocalDate businessDate;
private BigDecimal amount;
private String currency;
@@ -3,17 +3,20 @@ package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** 柜次费用审核发布时固化的不可变版本快照。 */
@Getter
@Setter
@TableName("fl_container_fee_source_snapshot")
@TableName(value = "fl_container_fee_source_snapshot", autoResultMap = true)
public class ContainerFeeSourceSnapshotEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
@@ -30,6 +33,12 @@ public class ContainerFeeSourceSnapshotEntity {
private Long supplierId;
private String supplierName;
private String supplierCategory;
private String settlementMode;
private LocalDateTime paidAt;
private String paymentMethod;
private String paymentReferenceNo;
@TableField(typeHandler = JacksonTypeHandler.class)
private List<Long> paymentVoucherFileIds;
private LocalDate businessDate;
private BigDecimal amount;
private String currency;
@@ -3,6 +3,8 @@ package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.baiyue.common.core.model.BaseEntity;
import lombok.Getter;
import lombok.Setter;
@@ -10,11 +12,12 @@ import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** 入库单人工费用明细及业务快照。 */
@Getter
@Setter
@TableName("fl_inbound_fee_detail")
@TableName(value = "fl_inbound_fee_detail", autoResultMap = true)
public class InboundFeeDetailEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
@@ -30,6 +33,12 @@ public class InboundFeeDetailEntity extends BaseEntity {
private Long supplierId;
private String supplierName;
private String supplierCategory;
private String settlementMode;
private LocalDateTime paidAt;
private String paymentMethod;
private String paymentReferenceNo;
@TableField(typeHandler = JacksonTypeHandler.class)
private List<Long> paymentVoucherFileIds;
private LocalDate businessDate;
private Long customerId;
private String customerName;
@@ -29,6 +29,11 @@ public class ContainerFeeSourceSnapshotRepository {
value.setSupplierId(source.getSupplierId());
value.setSupplierName(source.getSupplierName());
value.setSupplierCategory(source.getSupplierCategory());
value.setSettlementMode(source.getSettlementMode());
value.setPaidAt(source.getPaidAt());
value.setPaymentMethod(source.getPaymentMethod());
value.setPaymentReferenceNo(source.getPaymentReferenceNo());
value.setPaymentVoucherFileIds(source.getPaymentVoucherFileIds());
value.setBusinessDate(source.getBusinessDate());
value.setAmount(source.getAmount());
value.setCurrency(source.getCurrency());
@@ -29,6 +29,7 @@ public class ContainerFeeDetailService {
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);
@@ -60,6 +61,7 @@ public class ContainerFeeDetailService {
long reviewerId, String reviewerName) {
ContainerFeeDetailEntity pending = required(tenantId, loadingPlanId, feeId);
FeeItemEntity item = requireItem(pending.getFeeItemId(), pending.getDirection());
revalidatePaymentSnapshot(pending);
refreshSupplierSnapshot(pending, item, reviewerId);
if (repository.review(tenantId, loadingPlanId, feeId, "APPROVED",
reviewerId, reviewerName, null) != 1) {
@@ -152,6 +154,8 @@ public class ContainerFeeDetailService {
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());
@@ -188,6 +192,25 @@ public class ContainerFeeDetailService {
}
}
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);
}
@@ -0,0 +1,101 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.FeePaymentVoucherFileClient;
import feign.FeignException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
@Service
public class FeePaymentSnapshotValidator {
private static final Set<String> MODES = Set.of("ON_ACCOUNT", "DIRECT_PAID");
private static final Set<String> METHODS = Set.of("ONLINE", "BANK_TRANSFER", "CASH", "OTHER");
private static final String VOUCHER_TYPE = "FINANCE_VOUCHER";
private final FeePaymentVoucherFileClient files;
private final String internalToken;
public FeePaymentSnapshotValidator(FeePaymentVoucherFileClient files,
@Value("${baiyue.security.internal-service-token}") String internalToken) {
this.files = files;
this.internalToken = internalToken;
}
public Snapshot validate(long customerId, String direction, String settlementMode,
LocalDateTime paidAt, String paymentMethod,
String paymentReferenceNo, List<Long> paymentVoucherFileIds) {
String mode = trim(settlementMode);
String method = trim(paymentMethod);
String reference = trim(paymentReferenceNo);
List<Long> vouchers = normalizeVouchers(paymentVoucherFileIds);
if ("RECEIVABLE".equals(direction)) {
if (mode != null || paidAt != null || method != null || reference != null || !vouchers.isEmpty()) {
throw bad("应收费用不得携带结算方式或已付信息");
}
return Snapshot.empty();
}
if (!"PAYABLE".equals(direction) || !MODES.contains(mode)) {
throw bad("应付费用必须选择ON_ACCOUNT或DIRECT_PAID");
}
if ("ON_ACCOUNT".equals(mode)) {
if (paidAt != null || method != null || reference != null || !vouchers.isEmpty()) {
throw bad("账期应付不得携带已付时间、方式、流水号或凭证");
}
return new Snapshot(mode, null, null, null, List.of());
}
if (paidAt == null || !METHODS.contains(method)) {
throw bad("直接已付必须填写已付时间和有效付款方式");
}
if (reference == null && vouchers.isEmpty()) {
throw bad("直接已付必须填写付款流水号或至少一个付款凭证");
}
vouchers.forEach(fileId -> validateVoucher(customerId, fileId));
return new Snapshot(mode, paidAt, method, reference, vouchers);
}
private void validateVoucher(long customerId, long fileId) {
try {
var response = files.metadata(internalToken, customerId, customerId, fileId);
var metadata = response == null ? null : response.data();
if (response == null || !response.successful() || metadata == null
|| !Long.valueOf(fileId).equals(metadata.fileId())
|| !Long.valueOf(customerId).equals(metadata.tenantId())
|| !VOUCHER_TYPE.equals(metadata.businessType())
|| !String.valueOf(customerId).equals(metadata.businessId())) {
throw bad("付款凭证不存在或不属于当前客户");
}
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用,无法校验付款凭证");
}
}
private static List<Long> normalizeVouchers(List<Long> values) {
if (values == null || values.isEmpty()) return List.of();
if (values.size() > 20 || values.stream().anyMatch(value -> value == null || value <= 0)) {
throw bad("付款凭证文件ID无效或超过20个");
}
return List.copyOf(new LinkedHashSet<>(values));
}
private static String trim(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
private static BizException bad(String message) {
return new BizException(ResultCode.BAD_REQUEST, message);
}
public record Snapshot(String settlementMode, LocalDateTime paidAt, String paymentMethod,
String paymentReferenceNo, List<Long> paymentVoucherFileIds) {
static Snapshot empty() {
return new Snapshot(null, null, null, null, List.of());
}
}
}
@@ -40,12 +40,17 @@ public class FinanceFeeSourceSnapshotService {
String.valueOf(inbound.getOrderId()), nullSafe(item.getFeeCode()),
nullSafe(detail.getDirection()), nullSafe(detail.getSupplierId()),
nullSafe(detail.getSupplierName()), nullSafe(detail.getSupplierCategory()),
nullSafe(detail.getSettlementMode()), nullSafe(detail.getPaidAt()),
nullSafe(detail.getPaymentMethod()), nullSafe(detail.getPaymentReferenceNo()),
nullSafe(detail.getPaymentVoucherFileIds()),
detail.getAmount().toPlainString(),
nullSafe(detail.getCurrency()));
return new FinanceFeeSourceSnapshot(detail.getTenantId(), "INBOUND_FEE", sourceFeeId,
version, inboundId, inbound.getInboundCode(), inbound.getOrderId(), null, null, null,
detail.getTenantId(), inbound.getCustomerName(), detail.getSupplierId(),
detail.getSupplierName(), detail.getSupplierCategory(), item.getId(), item.getFeeCode(),
detail.getSupplierName(), detail.getSupplierCategory(), detail.getSettlementMode(),
detail.getPaidAt(), detail.getPaymentMethod(), detail.getPaymentReferenceNo(),
detail.getPaymentVoucherFileIds(), item.getId(), item.getFeeCode(),
detail.getFeeTypeNameSnapshot(), detail.getDirection(), detail.getBusinessDate(),
detail.getCurrency(), detail.getAmount(), detail.getReviewStatus(),
detail.getPublicationStatus(),
@@ -62,12 +67,17 @@ public class FinanceFeeSourceSnapshotService {
nullSafe(detail.getFeeItemCode()), nullSafe(detail.getDirection()),
nullSafe(detail.getSupplierId()), nullSafe(detail.getSupplierName()),
nullSafe(detail.getSupplierCategory()),
nullSafe(detail.getSettlementMode()), nullSafe(detail.getPaidAt()),
nullSafe(detail.getPaymentMethod()), nullSafe(detail.getPaymentReferenceNo()),
nullSafe(detail.getPaymentVoucherFileIds()),
detail.getAmount().toPlainString(), nullSafe(detail.getCurrency()),
nullSafe(detail.getReviewStatus()));
return new FinanceFeeSourceSnapshot(detail.getTenantId(), "CONTAINER_FEE", feeId,
version, null, null, null, containerId, detail.getContainerBookingId(),
detail.getContainerNo(), detail.getTenantId(), null, detail.getSupplierId(),
detail.getSupplierName(), detail.getSupplierCategory(), detail.getFeeItemId(),
detail.getSupplierName(), detail.getSupplierCategory(), detail.getSettlementMode(),
detail.getPaidAt(), detail.getPaymentMethod(), detail.getPaymentReferenceNo(),
detail.getPaymentVoucherFileIds(), detail.getFeeItemId(),
detail.getFeeItemCode(), detail.getFeeItemName(), detail.getDirection(),
detail.getBusinessDate(), detail.getCurrency(), detail.getAmount(), detail.getReviewStatus(),
"PUBLISHED",
@@ -31,6 +31,8 @@ public class InboundFeeDetailService {
private FinanceFeeSourceOutboxService financeOutbox;
@Resource
private WarehouseSupplierSnapshotService suppliers;
@Resource
private FeePaymentSnapshotValidator paymentSnapshots;
@Transactional
public List<InboundFeeDetailEntity> list(long tenantId, long inboundId, boolean includeHistory) {
@@ -255,6 +257,7 @@ public class InboundFeeDetailService {
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) {
@@ -381,6 +384,8 @@ public class InboundFeeDetailService {
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());
@@ -430,6 +435,25 @@ public class InboundFeeDetailService {
}
}
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);
}