Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev
# Conflicts: # scripts/admin-test-env-modules/orders.mjs # scripts/admin-test-env-modules/phase3-container-loading.mjs
This commit is contained in:
@@ -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;
|
||||
|
||||
+10
-1
@@ -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;
|
||||
|
||||
@@ -11,7 +11,7 @@ import org.apache.ibatis.annotations.Update;
|
||||
|
||||
@Mapper
|
||||
public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntity> {
|
||||
@Update("UPDATE fl_container_booking SET container_no=#{containerNo},ocean_booking_id=#{oceanBookingId},ocean_booking_requirement_id=#{requirementId},record_status='ACTIVE',bill_of_lading_no=#{billOfLadingNo},vessel_name=#{vesselName},voyage_no=#{voyageNo},shipping_company_id=#{shippingCompanyId},shipping_company_name=#{shippingCompanyName},departure_port_code=#{departurePortCode},departure_port_name=#{departurePortName},destination_port_code=#{destinationPortCode},destination_port_name=#{destinationPortName},channel_code=#{channelCode},channel_name=#{channelName},customer_id=#{customerId},customer_name=#{customerName},estimated_sailing_at=#{estimatedSailingAt},estimated_port_arrival_at=#{estimatedArrivalAt},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=NOW(),version=version+1 WHERE id=#{id} AND version=#{version} AND source_type='SIMULATION' AND record_status='ACTIVE' AND ocean_booking_id IS NULL")
|
||||
@Update("UPDATE fl_container_booking SET container_no=#{containerNo},ocean_booking_id=#{oceanBookingId},ocean_booking_requirement_id=#{requirementId},record_status='ACTIVE',bill_of_lading_no=#{billOfLadingNo},vessel_name=#{vesselName},voyage_no=#{voyageNo},shipping_company_id=#{shippingCompanyId},shipping_company_name=#{shippingCompanyName},departure_port_code=#{departurePortCode},departure_port_name=#{departurePortName},destination_port_code=#{destinationPortCode},destination_port_name=#{destinationPortName},channel_code=#{channelCode},channel_name=#{channelName},customer_id=#{customerId},customer_name=#{customerName},estimated_sailing_at=#{estimatedSailingAt},estimated_port_arrival_at=#{estimatedPortArrivalAt},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=NOW(),version=version+1 WHERE id=#{id} AND version=#{version} AND source_type='SIMULATION' AND record_status='ACTIVE' AND ocean_booking_id IS NULL")
|
||||
int linkReleasedBooking(@Param("id") long id, @Param("oceanBookingId") long oceanBookingId, @Param("requirementId") long requirementId, @Param("containerNo") String containerNo, @Param("billOfLadingNo") String billOfLadingNo, @Param("vesselName") String vesselName, @Param("voyageNo") String voyageNo, @Param("shippingCompanyId") Long shippingCompanyId, @Param("shippingCompanyName") String shippingCompanyName, @Param("departurePortCode") String departurePortCode, @Param("departurePortName") String departurePortName, @Param("destinationPortCode") String destinationPortCode, @Param("destinationPortName") String destinationPortName, @Param("channelCode") String channelCode, @Param("channelName") String channelName, @Param("customerId") Long customerId, @Param("customerName") String customerName, @Param("estimatedSailingAt") java.time.LocalDateTime estimatedSailingAt, @Param("estimatedPortArrivalAt") java.time.LocalDateTime estimatedPortArrivalAt, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
@Select("""
|
||||
<script>
|
||||
|
||||
+5
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-2
@@ -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);
|
||||
}
|
||||
|
||||
@@ -74,6 +74,11 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_detail (
|
||||
supplier_id BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空',
|
||||
supplier_name VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空',
|
||||
supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商',
|
||||
settlement_mode VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空',
|
||||
paid_at DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间',
|
||||
payment_method VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式',
|
||||
payment_reference_no VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号',
|
||||
payment_voucher_file_ids JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组',
|
||||
business_date DATE NOT NULL COMMENT '费用业务日期',
|
||||
amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额',
|
||||
currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY',
|
||||
@@ -96,6 +101,15 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_detail (
|
||||
CONSTRAINT chk_container_fee_currency CHECK (currency = 'CNY'),
|
||||
CONSTRAINT chk_container_fee_amount CHECK (amount >= 0),
|
||||
CONSTRAINT chk_container_fee_review CHECK (review_status IN ('PENDING', 'APPROVED', 'REJECTED')),
|
||||
CONSTRAINT chk_container_fee_settlement CHECK (
|
||||
(direction='RECEIVABLE' AND settlement_mode IS NULL AND paid_at IS NULL AND payment_method IS NULL
|
||||
AND payment_reference_no IS NULL AND payment_voucher_file_ids IS NULL)
|
||||
OR (direction='PAYABLE' AND settlement_mode='ON_ACCOUNT' AND paid_at IS NULL AND payment_method IS NULL
|
||||
AND payment_reference_no IS NULL AND payment_voucher_file_ids IS NULL)
|
||||
OR (direction='PAYABLE' AND settlement_mode='DIRECT_PAID' AND paid_at IS NOT NULL
|
||||
AND payment_method IN ('ONLINE','BANK_TRANSFER','CASH','OTHER')
|
||||
AND (payment_reference_no IS NOT NULL OR JSON_LENGTH(payment_voucher_file_ids)>0))
|
||||
),
|
||||
CONSTRAINT chk_container_fee_source CHECK (source_status IN ('ACTIVE', 'VOIDED'))
|
||||
) ENGINE=InnoDB COMMENT='柜次级人工应收应付费用来源;审核通过后自动同步Finance';
|
||||
|
||||
@@ -114,6 +128,11 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot (
|
||||
supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse供应商ID快照',
|
||||
supplier_name VARCHAR(200) NULL COMMENT 'Warehouse供应商名称快照',
|
||||
supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商',
|
||||
settlement_mode VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空',
|
||||
paid_at DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间',
|
||||
payment_method VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式',
|
||||
payment_reference_no VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号',
|
||||
payment_voucher_file_ids JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组',
|
||||
business_date DATE NOT NULL COMMENT '费用业务日期',
|
||||
amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额',
|
||||
currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY',
|
||||
@@ -157,12 +176,42 @@ CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_name',
|
||||
"VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id");
|
||||
CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_category',
|
||||
"VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商' AFTER supplier_name");
|
||||
CALL firstleg_finance_add_column('fl_container_fee_detail', 'settlement_mode',
|
||||
"VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT或DIRECT_PAID' AFTER supplier_category");
|
||||
CALL firstleg_finance_add_column('fl_container_fee_detail', 'paid_at',
|
||||
"DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间' AFTER settlement_mode");
|
||||
CALL firstleg_finance_add_column('fl_container_fee_detail', 'payment_method',
|
||||
"VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式' AFTER paid_at");
|
||||
CALL firstleg_finance_add_column('fl_container_fee_detail', 'payment_reference_no',
|
||||
"VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号' AFTER payment_method");
|
||||
CALL firstleg_finance_add_column('fl_container_fee_detail', 'payment_voucher_file_ids',
|
||||
"JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no");
|
||||
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'settlement_mode',
|
||||
"VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT或DIRECT_PAID' AFTER supplier_category");
|
||||
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'paid_at',
|
||||
"DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间' AFTER settlement_mode");
|
||||
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'payment_method',
|
||||
"VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式' AFTER paid_at");
|
||||
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'payment_reference_no',
|
||||
"VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号' AFTER payment_method");
|
||||
CALL firstleg_finance_add_column('fl_container_fee_source_snapshot', 'payment_voucher_file_ids',
|
||||
"JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no");
|
||||
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id',
|
||||
"BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction");
|
||||
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_name',
|
||||
"VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id");
|
||||
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_category',
|
||||
"VARCHAR(16) NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商' AFTER supplier_name");
|
||||
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'settlement_mode',
|
||||
"VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT或DIRECT_PAID' AFTER supplier_category");
|
||||
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'paid_at',
|
||||
"DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间' AFTER settlement_mode");
|
||||
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'payment_method',
|
||||
"VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式' AFTER paid_at");
|
||||
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'payment_reference_no',
|
||||
"VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号' AFTER payment_method");
|
||||
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'payment_voucher_file_ids',
|
||||
"JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组' AFTER payment_reference_no");
|
||||
DROP PROCEDURE IF EXISTS firstleg_finance_add_column;
|
||||
|
||||
DROP PROCEDURE IF EXISTS firstleg_finance_assert_schema;
|
||||
@@ -185,12 +234,22 @@ BEGIN
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_detail'
|
||||
AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category')) <> 3 THEN
|
||||
AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category', 'settlement_mode',
|
||||
'paid_at', 'payment_method', 'payment_reference_no',
|
||||
'payment_voucher_file_ids')) <> 8 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用供应商快照字段不完整';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_source_snapshot'
|
||||
AND column_name IN ('settlement_mode', 'paid_at', 'payment_method',
|
||||
'payment_reference_no', 'payment_voucher_file_ids')) <> 5 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用已付版本快照字段不完整';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee_detail'
|
||||
AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category')) <> 3 THEN
|
||||
AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category', 'settlement_mode',
|
||||
'paid_at', 'payment_method', 'payment_reference_no',
|
||||
'payment_voucher_file_ids')) <> 8 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:入库费用供应商快照字段不完整';
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
@@ -40,6 +40,11 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee_detail (
|
||||
supplier_id BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空',
|
||||
supplier_name VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空',
|
||||
supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别快照;应收为空',
|
||||
settlement_mode VARCHAR(16) NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空',
|
||||
paid_at DATETIME(3) NULL COMMENT 'DIRECT_PAID业务已付时间',
|
||||
payment_method VARCHAR(32) NULL COMMENT 'DIRECT_PAID付款方式',
|
||||
payment_reference_no VARCHAR(128) NULL COMMENT 'DIRECT_PAID付款流水号',
|
||||
payment_voucher_file_ids JSON NULL COMMENT 'DIRECT_PAID付款凭证File ID数组',
|
||||
business_date DATE NOT NULL COMMENT '业务发生日',
|
||||
fee_category_snapshot VARCHAR(64) NOT NULL COMMENT '费用分类快照',
|
||||
trade_scope_snapshot VARCHAR(20) NOT NULL COMMENT '国内国际快照',
|
||||
@@ -73,6 +78,15 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee_detail (
|
||||
KEY idx_inbound_fee_detail (tenant_id, inbound_id, business_date, deleted),
|
||||
KEY idx_inbound_fee_review (tenant_id, review_status, business_date, deleted),
|
||||
KEY idx_inbound_fee_item (tenant_id, fee_item_id, deleted),
|
||||
CONSTRAINT chk_inbound_fee_settlement CHECK (
|
||||
(direction='RECEIVABLE' AND settlement_mode IS NULL AND paid_at IS NULL AND payment_method IS NULL
|
||||
AND payment_reference_no IS NULL AND payment_voucher_file_ids IS NULL)
|
||||
OR (direction='PAYABLE' AND settlement_mode='ON_ACCOUNT' AND paid_at IS NULL AND payment_method IS NULL
|
||||
AND payment_reference_no IS NULL AND payment_voucher_file_ids IS NULL)
|
||||
OR (direction='PAYABLE' AND settlement_mode='DIRECT_PAID' AND paid_at IS NOT NULL
|
||||
AND payment_method IN ('ONLINE','BANK_TRANSFER','CASH','OTHER')
|
||||
AND (payment_reference_no IS NOT NULL OR JSON_LENGTH(payment_voucher_file_ids)>0))
|
||||
),
|
||||
CONSTRAINT fk_inbound_fee_detail_order FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id),
|
||||
CONSTRAINT fk_inbound_fee_detail_item FOREIGN KEY (fee_item_id) REFERENCES fl_fee_item (id)
|
||||
) ENGINE=InnoDB COMMENT='入库单应收应付费用明细';
|
||||
|
||||
@@ -304,6 +304,11 @@ components:
|
||||
currency: {type: string, pattern: '^[A-Z]{3}$'}
|
||||
amount: {type: number, minimum: 0}
|
||||
supplierId: {type: string, nullable: true}
|
||||
settlementMode: {type: string, enum: [ON_ACCOUNT, DIRECT_PAID], nullable: true, description: 仅PAYABLE必填;ON_ACCOUNT账期应付,DIRECT_PAID业务已付}
|
||||
paidAt: {type: string, format: date-time, nullable: true, description: DIRECT_PAID必填;ON_ACCOUNT与RECEIVABLE为空}
|
||||
paymentMethod: {type: string, enum: [ONLINE, BANK_TRANSFER, CASH, OTHER], nullable: true, description: DIRECT_PAID必填}
|
||||
paymentReferenceNo: {type: string, maxLength: 128, nullable: true}
|
||||
paymentVoucherFileIds: {type: array, maxItems: 20, nullable: true, items: {type: string, pattern: '^[1-9][0-9]*$'}, description: DIRECT_PAID时与paymentReferenceNo至少一项;File按当前客户失败关闭校验}
|
||||
remark: {type: string, maxLength: 500, nullable: true}
|
||||
InboundFeeDetailVersionRequest:
|
||||
type: object
|
||||
@@ -331,6 +336,11 @@ components:
|
||||
supplierId: {type: string, nullable: true}
|
||||
supplierName: {type: string, nullable: true, readOnly: true}
|
||||
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: 费用项目供应商类别约束快照;为空表示仅要求供应商ACTIVE}
|
||||
settlementMode: {type: string, enum: [ON_ACCOUNT, DIRECT_PAID], nullable: true}
|
||||
paidAt: {type: string, format: date-time, nullable: true}
|
||||
paymentMethod: {type: string, enum: [ONLINE, BANK_TRANSFER, CASH, OTHER], nullable: true}
|
||||
paymentReferenceNo: {type: string, nullable: true}
|
||||
paymentVoucherFileIds: {type: array, nullable: true, items: {type: string}}
|
||||
businessDate: {type: string, format: date}
|
||||
customerId: {type: string, nullable: true}
|
||||
customerName: {type: string, nullable: true}
|
||||
|
||||
@@ -881,7 +881,7 @@ paths:
|
||||
summary: 调整已审核柜次费用的日期和金额
|
||||
description: >-
|
||||
仅 ACTIVE、APPROVED 且 sourceVersion 匹配的费用可调整。成功后 sourceVersion 加一并重新进入
|
||||
PENDING;费用方向、费用项目、供应商、币种和备注保持原快照。业务字段需要变化时,
|
||||
PENDING;费用方向、费用项目、供应商、结算方式、已付快照、币种和备注保持原快照。业务字段需要变化时,
|
||||
必须作废原费用并新增。同一 expectedSourceVersion、businessDate、amount 的原样重试只读返回已生成的
|
||||
PENDING 版本;旧版本异参、已作废或非已审核状态返回 A0409 且零写入。
|
||||
parameters:
|
||||
@@ -1887,6 +1887,11 @@ components:
|
||||
supplierId: {type: integer, format: int64, nullable: true}
|
||||
supplierName: {type: string, nullable: true}
|
||||
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: PAYABLE供应商类别约束;为空时仅要求供应商ACTIVE}
|
||||
settlementMode: {type: string, enum: [ON_ACCOUNT, DIRECT_PAID], nullable: true, description: 仅PAYABLE;ON_ACCOUNT可进入AP,DIRECT_PAID仅计成本且禁止进入AP}
|
||||
paidAt: {type: string, format: date-time, nullable: true}
|
||||
paymentMethod: {type: string, enum: [ONLINE, BANK_TRANSFER, CASH, OTHER], nullable: true}
|
||||
paymentReferenceNo: {type: string, nullable: true}
|
||||
paymentVoucherFileIds: {type: array, nullable: true, items: {type: integer, format: int64}}
|
||||
feeItemId: {type: integer, format: int64, nullable: true}
|
||||
feeItemCode: {type: string, nullable: true}
|
||||
feeItemName: {type: string}
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.firstleg.client;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class FeePaymentVoucherFileClientContractTest {
|
||||
@Test
|
||||
void validatesFinanceVoucherWithinExactCustomerScope() throws Exception {
|
||||
var method = FeePaymentVoucherFileClient.class.getMethod("metadata",
|
||||
String.class, long.class, long.class, long.class);
|
||||
|
||||
assertThat(method.getAnnotation(GetMapping.class).value())
|
||||
.containsExactly("/{tenantId}/FINANCE_VOUCHER/{customerId}/{fileId}");
|
||||
assertThat(method.getParameters()[0].getAnnotation(RequestHeader.class).value())
|
||||
.isEqualTo("X-Internal-Service-Token");
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -58,7 +58,7 @@ class FeeManagementContractTest {
|
||||
.contains("ContainerFeeAdjustRequest:")
|
||||
.contains("required: [expectedSourceVersion, businessDate, amount]")
|
||||
.contains("additionalProperties: false")
|
||||
.contains("费用方向、费用项目、供应商、币种和备注保持原快照")
|
||||
.contains("费用方向、费用项目、供应商、结算方式、已付快照、币种和备注保持原快照")
|
||||
.contains("旧版本异参、已作废或非已审核状态返回 A0409 且零写入");
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -65,6 +65,8 @@ class InternalFinanceFeeSourceControllerTest {
|
||||
.contains("/internal/firstleg/v1/containers/{containerId}/fees/{feeId}/source-snapshot:")
|
||||
.contains("{name: sourceVersion, in: query, required: true")
|
||||
.contains("supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true")
|
||||
.contains("settlementMode: {type: string, enum: [ON_ACCOUNT, DIRECT_PAID], nullable: true")
|
||||
.contains("paymentVoucherFileIds:")
|
||||
.contains("为空时仅要求供应商ACTIVE")
|
||||
.contains("/internal/firstleg/v1/fee-sources/commands/void:")
|
||||
.contains("FIRSTLEG_FEE_VOIDED Outbox")
|
||||
|
||||
+18
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -61,6 +62,23 @@ class ContainerBookingMapperContractTest {
|
||||
assertThat(sql).contains("i.account_id<>#{customerId}").doesNotContain("<>");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("关联订舱 SQL 的预计到港参数与 Mapper 参数名一致")
|
||||
void linkReleasedBookingUsesDeclaredEstimatedArrivalParameter() {
|
||||
var method = Arrays.stream(ContainerBookingMapper.class.getDeclaredMethods())
|
||||
.filter(candidate -> candidate.getName().equals("linkReleasedBooking"))
|
||||
.findFirst().orElseThrow();
|
||||
String sql = String.join("\n", method.getAnnotation(Update.class).value());
|
||||
var parameterNames = Arrays.stream(method.getParameters())
|
||||
.map(parameter -> parameter.getAnnotation(Param.class).value())
|
||||
.toList();
|
||||
|
||||
assertThat(parameterNames).contains("estimatedPortArrivalAt");
|
||||
assertThat(sql)
|
||||
.contains("estimated_port_arrival_at=#{estimatedPortArrivalAt}")
|
||||
.doesNotContain("#{estimatedArrivalAt}");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("草稿完整更新和预装运输白名单都带版本与状态门禁")
|
||||
void guardsUpdatesInSql() {
|
||||
|
||||
+9
-1
@@ -18,11 +18,15 @@ class FinanceFeeSourceMigrationContractTest {
|
||||
assertThat(baseline).contains(
|
||||
"supplier_id BIGINT UNSIGNED NULL",
|
||||
"supplier_name VARCHAR(200) NULL",
|
||||
"supplier_category VARCHAR(16) NULL");
|
||||
"supplier_category VARCHAR(16) NULL",
|
||||
"settlement_mode VARCHAR(16) NULL",
|
||||
"payment_voucher_file_ids JSON NULL");
|
||||
assertThat(migration).contains(
|
||||
"firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id'",
|
||||
"firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_name'",
|
||||
"firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_category'",
|
||||
"firstleg_finance_add_column('fl_inbound_fee_detail', 'settlement_mode'",
|
||||
"firstleg_finance_add_column('fl_inbound_fee_detail', 'payment_voucher_file_ids'",
|
||||
"Firstleg Finance迁移失败:入库费用供应商快照字段不完整");
|
||||
}
|
||||
|
||||
@@ -35,12 +39,16 @@ class FinanceFeeSourceMigrationContractTest {
|
||||
"uk_inbound_outbox_event_key", "next_retry_at", "locked_at", "locked_by",
|
||||
"idx_inbound_outbox_finance_relay", "CREATE TABLE IF NOT EXISTS fl_container_fee_detail",
|
||||
"source_version", "supplier_id", "supplier_name", "supplier_category",
|
||||
"settlement_mode", "paid_at", "payment_method", "payment_reference_no",
|
||||
"payment_voucher_file_ids",
|
||||
"CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot",
|
||||
"uk_container_fee_source_version",
|
||||
"CREATE TABLE IF NOT EXISTS fl_finance_fee_source_command",
|
||||
"uk_finance_fee_command_idem",
|
||||
"request_hash CHAR(64) NOT NULL",
|
||||
"firstleg_finance_add_column('fl_container_fee_detail', 'supplier_id'", "RECEIVABLE", "PAYABLE",
|
||||
"firstleg_finance_add_column('fl_container_fee_source_snapshot', 'settlement_mode'",
|
||||
"柜次费用已付版本快照字段不完整",
|
||||
"firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id'",
|
||||
"Firstleg Finance迁移失败:入库费用供应商快照字段不完整",
|
||||
"chk_container_fee_currency", "currency = 'CNY'", "firstleg_finance_assert_schema");
|
||||
|
||||
+35
-1
@@ -12,6 +12,7 @@ import com.baiyue.firstleg.repository.ContainerFeeSourceSnapshotRepository;
|
||||
import com.baiyue.firstleg.repository.FeeItemRepository;
|
||||
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -29,6 +30,7 @@ import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
|
||||
class ContainerFeeDetailServiceTest {
|
||||
private final ContainerFeeDetailRepository repository = mock(ContainerFeeDetailRepository.class);
|
||||
@@ -37,8 +39,18 @@ class ContainerFeeDetailServiceTest {
|
||||
private final FinanceFeeSourceOutboxService outbox = mock(FinanceFeeSourceOutboxService.class);
|
||||
private final WarehouseSupplierSnapshotService suppliers = mock(WarehouseSupplierSnapshotService.class);
|
||||
private final ContainerFeeSourceSnapshotRepository sourceSnapshots = mock(ContainerFeeSourceSnapshotRepository.class);
|
||||
private final FeePaymentSnapshotValidator paymentSnapshots = mock(FeePaymentSnapshotValidator.class);
|
||||
private final ContainerFeeDetailService service =
|
||||
new ContainerFeeDetailService(repository, loading, feeItems, outbox, suppliers, sourceSnapshots);
|
||||
new ContainerFeeDetailService(repository, loading, feeItems, outbox, suppliers, sourceSnapshots,
|
||||
paymentSnapshots);
|
||||
|
||||
@BeforeEach
|
||||
void paymentSnapshot() {
|
||||
lenient().when(paymentSnapshots.validate(anyLong(), any(), any(), any(), any(), any(), any()))
|
||||
.thenAnswer(invocation -> new FeePaymentSnapshotValidator.Snapshot(
|
||||
invocation.getArgument(2), invocation.getArgument(3), invocation.getArgument(4),
|
||||
invocation.getArgument(5), invocation.getArgument(6) == null ? List.of() : invocation.getArgument(6)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsContainerThatDoesNotContainCustomerOrders() {
|
||||
@@ -199,6 +211,28 @@ class ContainerFeeDetailServiceTest {
|
||||
assertThat(captor.getValue().getSupplierName()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void directPaidFieldsAreStoredAsBusinessSnapshot() {
|
||||
ownedPlan();
|
||||
when(feeItems.findItem(11L)).thenReturn(item());
|
||||
when(suppliers.fee(71L, null)).thenReturn(
|
||||
new com.baiyue.firstleg.client.WarehouseSupplierClient.Snapshot(
|
||||
71L, "现有供应商", "TRUCKING", "ACTIVE"));
|
||||
LocalDateTime paidAt = LocalDateTime.of(2026, 8, 20, 10, 0);
|
||||
var request = new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
|
||||
LocalDate.of(2026, 8, 20), new BigDecimal("300.00"), "CNY", 71L,
|
||||
"DIRECT_PAID", paidAt, "BANK_TRANSFER", "BANK-001", List.of(901L), "已付拖车费");
|
||||
|
||||
service.create(10001L, 41L, 9L, request);
|
||||
|
||||
ArgumentCaptor<ContainerFeeDetailEntity> captor = ArgumentCaptor.forClass(ContainerFeeDetailEntity.class);
|
||||
verify(repository).insert(captor.capture());
|
||||
assertThat(captor.getValue().getSettlementMode()).isEqualTo("DIRECT_PAID");
|
||||
assertThat(captor.getValue().getPaidAt()).isEqualTo(paidAt);
|
||||
assertThat(captor.getValue().getPaymentMethod()).isEqualTo("BANK_TRANSFER");
|
||||
assertThat(captor.getValue().getPaymentVoucherFileIds()).containsExactly(901L);
|
||||
}
|
||||
|
||||
private void ownedPlan() {
|
||||
LoadingPlanEntity plan = new LoadingPlanEntity();
|
||||
plan.setId(41L);
|
||||
|
||||
@@ -14,6 +14,7 @@ import com.baiyue.firstleg.repository.FbaPricingRepository;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
@@ -27,12 +28,14 @@ import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
|
||||
@DisplayName("费用项目和入库费用明细")
|
||||
@SpringJUnitConfig({FeeItemService.class, InboundFeeDetailService.class})
|
||||
@@ -45,11 +48,21 @@ class FeeManagementServiceTest {
|
||||
private FinanceFeeSourceOutboxService financeOutbox;
|
||||
@MockBean
|
||||
private WarehouseSupplierSnapshotService suppliers;
|
||||
@MockBean
|
||||
private FeePaymentSnapshotValidator paymentSnapshots;
|
||||
@Resource
|
||||
private FeeItemService feeItems;
|
||||
@Resource
|
||||
private InboundFeeDetailService details;
|
||||
|
||||
@BeforeEach
|
||||
void paymentSnapshot() {
|
||||
lenient().when(paymentSnapshots.validate(anyLong(), any(), any(), any(), any(), any(), any()))
|
||||
.thenAnswer(invocation -> new FeePaymentSnapshotValidator.Snapshot(
|
||||
invocation.getArgument(2), invocation.getArgument(3), invocation.getArgument(4),
|
||||
invocation.getArgument(5), invocation.getArgument(6) == null ? List.of() : invocation.getArgument(6)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("客户B不能删除客户A的费用明细")
|
||||
void rejectsCrossCustomerFeeDetailDelete() {
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.FeePaymentVoucherFileClient;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class FeePaymentSnapshotValidatorTest {
|
||||
private final FeePaymentVoucherFileClient files = mock(FeePaymentVoucherFileClient.class);
|
||||
private final FeePaymentSnapshotValidator validator =
|
||||
new FeePaymentSnapshotValidator(files, "file-token");
|
||||
|
||||
@Test
|
||||
void receivableClearsSettlementAndRejectsPaidFields() {
|
||||
assertThat(validator.validate(9L, "RECEIVABLE", null, null, null, null, null))
|
||||
.isEqualTo(new FeePaymentSnapshotValidator.Snapshot(null, null, null, null, List.of()));
|
||||
assertThatThrownBy(() -> validator.validate(9L, "RECEIVABLE", "ON_ACCOUNT",
|
||||
null, null, null, null)).isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("应收费用不得");
|
||||
}
|
||||
|
||||
@Test
|
||||
void onAccountRejectsEveryPaidField() {
|
||||
assertThat(validator.validate(9L, "PAYABLE", "ON_ACCOUNT",
|
||||
null, null, null, null).settlementMode()).isEqualTo("ON_ACCOUNT");
|
||||
assertThatThrownBy(() -> validator.validate(9L, "PAYABLE", "ON_ACCOUNT",
|
||||
LocalDateTime.now(), "CASH", "CASH-1", List.of(1L)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("账期应付不得");
|
||||
}
|
||||
|
||||
@Test
|
||||
void directPaidRequiresTimeMethodAndReferenceOrVoucher() {
|
||||
LocalDateTime paidAt = LocalDateTime.of(2026, 8, 20, 10, 0);
|
||||
assertThatThrownBy(() -> validator.validate(9L, "PAYABLE", "DIRECT_PAID",
|
||||
paidAt, "BANK_TRANSFER", null, null))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("流水号或至少一个付款凭证");
|
||||
|
||||
assertThat(validator.validate(9L, "PAYABLE", "DIRECT_PAID",
|
||||
paidAt, "BANK_TRANSFER", "BANK-001", null))
|
||||
.extracting(FeePaymentSnapshotValidator.Snapshot::paymentReferenceNo)
|
||||
.isEqualTo("BANK-001");
|
||||
}
|
||||
|
||||
@Test
|
||||
void directPaidVoucherIsValidatedAgainstCurrentCustomerAndFileType() {
|
||||
LocalDateTime paidAt = LocalDateTime.of(2026, 8, 20, 10, 0);
|
||||
when(files.metadata("file-token", 9L, 9L, 901L)).thenReturn(ApiResponse.success(
|
||||
new FeePaymentVoucherFileClient.Metadata(901L, 9L, "FINANCE_VOUCHER", "9",
|
||||
"voucher.pdf", "a".repeat(64), "application/pdf", 100L)));
|
||||
|
||||
var result = validator.validate(9L, "PAYABLE", "DIRECT_PAID",
|
||||
paidAt, "ONLINE", null, List.of(901L, 901L));
|
||||
|
||||
assertThat(result.paymentVoucherFileIds()).containsExactly(901L);
|
||||
verify(files).metadata("file-token", 9L, 9L, 901L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fileMismatchFailsClosed() {
|
||||
when(files.metadata("file-token", 9L, 9L, 901L)).thenReturn(ApiResponse.success(
|
||||
new FeePaymentVoucherFileClient.Metadata(901L, 10L, "FINANCE_VOUCHER", "10",
|
||||
"voucher.pdf", "a".repeat(64), "application/pdf", 100L)));
|
||||
|
||||
assertThatThrownBy(() -> validator.validate(9L, "PAYABLE", "DIRECT_PAID",
|
||||
LocalDateTime.now(), "CASH", null, List.of(901L)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不属于当前客户");
|
||||
}
|
||||
}
|
||||
+3
@@ -84,6 +84,7 @@ class FinanceFeeSourceSnapshotServiceTest {
|
||||
detail.setSupplierId(71L);
|
||||
detail.setSupplierName("现有供应商");
|
||||
detail.setSupplierCategory("TRUCKING");
|
||||
detail.setSettlementMode("ON_ACCOUNT");
|
||||
detail.setBusinessDate(java.time.LocalDate.of(2026, 8, 12));
|
||||
detail.setAmount(new BigDecimal("300.00"));
|
||||
detail.setCurrency("CNY");
|
||||
@@ -96,6 +97,8 @@ class FinanceFeeSourceSnapshotServiceTest {
|
||||
assertThat(snapshot.supplierId()).isEqualTo(71L);
|
||||
assertThat(snapshot.supplierName()).isEqualTo("现有供应商");
|
||||
assertThat(snapshot.supplierCategory()).isEqualTo("TRUCKING");
|
||||
assertThat(snapshot.settlementMode()).isEqualTo("ON_ACCOUNT");
|
||||
assertThat(snapshot.paidAt()).isNull();
|
||||
assertThat(snapshot.sourceHash()).matches("[0-9a-f]{64}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user