This commit is contained in:
2026-09-09 15:27:10 +08:00
parent 5f671e164f
commit 2980308f4d
17 changed files with 292 additions and 17 deletions
@@ -44,6 +44,15 @@ public record BusinessFeeSourceChangedV1(
LocalDate businessDate, LocalDate businessDate,
String currency, String currency,
BigDecimal totalAmount, BigDecimal totalAmount,
String originalCurrency,
BigDecimal originalAmount,
BigDecimal exchangeRate,
String exchangeRateMonth,
BigDecimal chargeableQuantity,
String quantityUnit,
BigDecimal unitPrice,
LocalDate periodStart,
LocalDate periodEnd,
String reviewStatus, String reviewStatus,
String publicationStatus, String publicationStatus,
String sourceStatus, String sourceStatus,
@@ -0,0 +1,65 @@
package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.finance.client.FinanceFileClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/api/platform/finance/v1/vouchers")
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
public class PlatformFinanceVoucherController {
private final FinanceFileClient files;
private final String token;
public PlatformFinanceVoucherController(FinanceFileClient files,
@Value("${baiyue.security.internal-service-token:}") String token) {
this.files = files;
this.token = token;
}
public record Voucher(String fileId, String fileName, String contentType, Long sizeBytes) {}
@GetMapping("/{fileId}")
public ApiResponse<Voucher> metadata(@PathVariable long fileId, @RequestParam long customerId) {
var file = scopedFile(customerId, fileId);
return FinanceResponses.success(new Voucher(String.valueOf(fileId), file.fileName(), file.contentType(), file.sizeBytes()));
}
@GetMapping("/{fileId}/content")
public ResponseEntity<byte[]> content(@PathVariable long fileId, @RequestParam long customerId) {
var file = scopedFile(customerId, fileId);
var response = files.download(token, customerId, customerId, fileId);
if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "付款凭证读取失败,请稍后重试");
}
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.header("X-Content-Type-Options", "nosniff")
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(file.fileName() == null ? "付款凭证" : file.fileName(), StandardCharsets.UTF_8).build().toString())
.contentType(MediaType.APPLICATION_OCTET_STREAM).body(response.getBody());
}
private FinanceFileClient.Metadata scopedFile(long customerId, long fileId) {
if (customerId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "客户或付款凭证无效");
var response = files.metadata(token, customerId, customerId, fileId);
var file = response == null ? null : response.data();
if (file == null || !Long.valueOf(fileId).equals(file.fileId())
|| !Long.valueOf(customerId).equals(file.tenantId())
|| !"FINANCE_VOUCHER".equals(file.businessType())
|| !String.valueOf(customerId).equals(file.businessId())) {
throw new BizException(ResultCode.BAD_REQUEST, "付款凭证不存在或不属于当前客户");
}
return file;
}
}
@@ -115,6 +115,13 @@ public class BusinessFeeSourceEventService {
|| blank(value.feeItemName()) || !DIRECTIONS.contains(value.direction()) || blank(value.feeItemName()) || !DIRECTIONS.contains(value.direction())
|| value.businessDate() == null || !"CNY".equals(value.currency()) || value.businessDate() == null || !"CNY".equals(value.currency())
|| value.totalAmount() == null || value.totalAmount().signum() <= 0 || value.totalAmount() == null || value.totalAmount().signum() <= 0
|| !Set.of("CNY", "USD").contains(value.originalCurrency())
|| value.originalAmount() == null || value.originalAmount().signum() <= 0
|| value.exchangeRate() == null || value.exchangeRate().signum() <= 0
|| blank(value.exchangeRateMonth())
|| (value.periodStart() == null) != (value.periodEnd() == null)
|| value.periodStart() != null && value.periodStart().isAfter(value.periodEnd())
|| (value.chargeableQuantity() == null) != blank(value.quantityUnit())
|| !"APPROVED".equals(value.reviewStatus()) || blank(value.sourceHash())) { || !"APPROVED".equals(value.reviewStatus()) || blank(value.sourceHash())) {
throw error("FIN-EVT-012", "跨区费用来源快照字段不完整"); throw error("FIN-EVT-012", "跨区费用来源快照字段不完整");
} }
@@ -148,7 +155,9 @@ public class BusinessFeeSourceEventService {
value.warehouseId(), value.feeItemCode(), value.feeItemName(), value.direction(), value.supplierId(), value.warehouseId(), value.feeItemCode(), value.feeItemName(), value.direction(), value.supplierId(),
value.supplierName(), value.supplierCategory(), value.settlementMode(), value.paidAt(), value.paymentMethod(), value.supplierName(), value.supplierCategory(), value.settlementMode(), value.paidAt(), value.paymentMethod(),
value.paymentReferenceNo(), value.paymentVoucherFileIds(), value.businessDate(), value.currency(), value.paymentReferenceNo(), value.paymentVoucherFileIds(), value.businessDate(), value.currency(),
value.totalAmount(), value.reviewStatus(), "PUBLISHED", "ACTIVE", null); value.totalAmount(), value.originalCurrency(), value.originalAmount(), value.exchangeRate(),
value.exchangeRateMonth(), value.chargeableQuantity(), value.quantityUnit(), value.unitPrice(),
value.periodStart(), value.periodEnd(), value.reviewStatus(), "PUBLISHED", "ACTIVE", null);
String expected = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(unsigned))); String expected = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(objectMapper.valueToTree(unsigned)));
if (!expected.equals(value.sourceHash())) throw error("FIN-EVT-004", "费用来源快照哈希不一致"); if (!expected.equals(value.sourceHash())) throw error("FIN-EVT-004", "费用来源快照哈希不一致");
} }
@@ -69,7 +69,8 @@ class BusinessFeeSourceEventServiceTest {
"OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null, "OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null,
null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null, null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null,
null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY", null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY",
new BigDecimal("120.00"), "APPROVED", "PUBLISHED", "ACTIVE", null); new BigDecimal("120.00"), "CNY", new BigDecimal("120.00"), BigDecimal.ONE,
"2026-09", null, null, null, null, null, "APPROVED", "PUBLISHED", "ACTIVE", null);
String sourceHash = EventPayloadHash.sha256( String sourceHash = EventPayloadHash.sha256(
EventPayloadCanonicalizer.canonicalize(json.valueToTree(unsigned))); EventPayloadCanonicalizer.canonicalize(json.valueToTree(unsigned)));
BusinessFeeSourceChangedV1 payload = new BusinessFeeSourceChangedV1("PUBLISHED", "LASTLEG", BusinessFeeSourceChangedV1 payload = new BusinessFeeSourceChangedV1("PUBLISHED", "LASTLEG",
@@ -77,7 +78,8 @@ class BusinessFeeSourceEventServiceTest {
"OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null, "OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null,
null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null, null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null,
null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY", null, null, null, null, null, List.of(), LocalDate.of(2026, 9, 2), "CNY",
new BigDecimal("120.00"), "APPROVED", "PUBLISHED", "ACTIVE", sourceHash); new BigDecimal("120.00"), "CNY", new BigDecimal("120.00"), BigDecimal.ONE,
"2026-09", null, null, null, null, null, "APPROVED", "PUBLISHED", "ACTIVE", sourceHash);
JsonNode node = json.valueToTree(payload); JsonNode node = json.valueToTree(payload);
String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
return new EventEnvelope("123e4567-e89b-12d3-a456-426614174701", return new EventEnvelope("123e4567-e89b-12d3-a456-426614174701",
@@ -0,0 +1,51 @@
package com.baiyue.finance;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.finance.controller.PlatformFinanceVoucherController;
import org.junit.jupiter.api.Test;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class PlatformFinanceVoucherControllerTest {
private final FinanceFileClient files = mock(FinanceFileClient.class);
private final PlatformFinanceVoucherController controller = new PlatformFinanceVoucherController(files, "test-token");
private FinanceFileClient.Metadata file(long customerId, String type, String businessId) {
return new FinanceFileClient.Metadata(91L, customerId, type, businessId, "付款.png", "sha", "image/png", 3L);
}
@Test void returnsFilenameAndDownloadsWithinCustomerScope() {
when(files.metadata("test-token", 11, 11, 91)).thenReturn(ApiResponse.success(file(11, "FINANCE_VOUCHER", "11"), "test", "test"));
when(files.download("test-token", 11, 11, 91)).thenReturn(ResponseEntity.ok(new byte[]{1, 2, 3}));
assertThat(controller.metadata(91, 11).data().fileName()).isEqualTo("付款.png");
var content = controller.content(91, 11);
assertThat(content.getBody()).containsExactly(1, 2, 3);
assertThat(content.getHeaders().getCacheControl()).isEqualTo("no-store");
assertThat(content.getHeaders().getFirst("Content-Disposition")).startsWith("attachment;");
}
@Test void rejectsWrongCustomerBusinessTypeAndBusinessIdBeforeDownload() {
for (var invalid : new FinanceFileClient.Metadata[]{file(12, "FINANCE_VOUCHER", "11"), file(11, "OTHER", "11"), file(11, "FINANCE_VOUCHER", "12")}) {
when(files.metadata("test-token", 11, 11, 91)).thenReturn(ApiResponse.success(invalid, "test", "test"));
assertThatThrownBy(() -> controller.content(91, 11)).isInstanceOf(BizException.class);
}
verify(files, never()).download(anyString(), anyLong(), anyLong(), anyLong());
}
@Test void rejectsMissingMetadataAndFailedContent() {
assertThatThrownBy(() -> controller.content(91, 11)).isInstanceOf(BizException.class);
when(files.metadata("test-token", 11, 11, 91)).thenReturn(ApiResponse.success(file(11, "FINANCE_VOUCHER", "11"), "test", "test"));
when(files.download("test-token", 11, 11, 91)).thenReturn(ResponseEntity.notFound().build());
assertThatThrownBy(() -> controller.content(91, 11)).isInstanceOf(BizException.class);
}
@Test void requiresPlatformListPermission() {
assertThat(PlatformFinanceVoucherController.class.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:finance:customer-arap:list')");
}
}
@@ -43,8 +43,13 @@ public final class OverseasFeeSourceDtos {
@Size(max = 128) String paymentReferenceNo, @Size(max = 128) String paymentReferenceNo,
@Size(max = 20) List<@NotBlank String> paymentVoucherFileIds, @Size(max = 20) List<@NotBlank String> paymentVoucherFileIds,
@NotNull LocalDate businessDate, @NotNull LocalDate businessDate,
@NotBlank @Pattern(regexp = "CNY") String currency, @NotBlank @Pattern(regexp = "CNY|USD") String currency,
@NotNull @DecimalMin(value = "0.00", inclusive = false) BigDecimal amount @NotNull @DecimalMin(value = "0.00", inclusive = false) BigDecimal amount,
@DecimalMin(value = "0.00", inclusive = false) BigDecimal chargeableQuantity,
@Size(max = 32) String quantityUnit,
@DecimalMin(value = "0.00", inclusive = false) BigDecimal unitPrice,
LocalDate periodStart,
LocalDate periodEnd
) { } ) { }
public record ActionRequest(@NotBlank @Size(max = 128) String idempotencyKey) { } public record ActionRequest(@NotBlank @Size(max = 128) String idempotencyKey) { }
@@ -46,6 +46,15 @@ public class OverseasFeeSource {
private LocalDate businessDate; private LocalDate businessDate;
private String currency; private String currency;
private BigDecimal amount; private BigDecimal amount;
private String originalCurrency;
private BigDecimal originalAmount;
private BigDecimal exchangeRate;
private String exchangeRateMonth;
private BigDecimal chargeableQuantity;
private String quantityUnit;
private BigDecimal unitPrice;
private LocalDate periodStart;
private LocalDate periodEnd;
private String reviewStatus; private String reviewStatus;
private String publicationStatus; private String publicationStatus;
private String sourceStatus; private String sourceStatus;
@@ -0,0 +1,47 @@
package com.baiyue.lastleg.financefee.service;
import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRate;
import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
@Component
@RequiredArgsConstructor
public class OverseasFeeCurrencyConverter {
private static final DateTimeFormatter MONTH = DateTimeFormatter.ofPattern("yyyy-MM");
private final MonthlyExchangeRateMapper rates;
public Conversion convert(String currency, BigDecimal amount, LocalDate businessDate) {
String originalCurrency = currency == null ? null : currency.trim().toUpperCase();
BigDecimal originalAmount = amount == null ? null : amount.setScale(2, RoundingMode.HALF_UP);
if ("CNY".equals(originalCurrency)) {
return new Conversion(originalCurrency, originalAmount, BigDecimal.ONE.setScale(6),
businessDate.format(MONTH), originalAmount);
}
if (!"USD".equals(originalCurrency)) throw bad("海外费用原币只支持CNY或USD");
String month = businessDate.format(MONTH);
MonthlyExchangeRate rate = rates.findMonth(month);
if (rate == null || !"ACTIVE".equals(rate.getStatus()) || rate.getUsdToCnyAverage() == null
|| rate.getUsdToCnyAverage().signum() <= 0) {
throw bad("业务月份缺少有效USD兑CNY汇率,不能发布财务费用");
}
BigDecimal exchangeRate = rate.getUsdToCnyAverage().setScale(6, RoundingMode.HALF_UP);
return new Conversion(originalCurrency, originalAmount, exchangeRate, month,
originalAmount.multiply(exchangeRate).setScale(2, RoundingMode.HALF_UP));
}
private static ResponseStatusException bad(String message) {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}
public record Conversion(String originalCurrency, BigDecimal originalAmount,
BigDecimal exchangeRate, String exchangeRateMonth,
BigDecimal settlementAmount) { }
}
@@ -66,7 +66,10 @@ public class OverseasFeeOutboxService {
text(value.getSupplierId()), value.getSupplierName(), value.getSupplierCategory(), text(value.getSupplierId()), value.getSupplierName(), value.getSupplierCategory(),
value.getSettlementMode(), instant(value.getPaidAt()), value.getPaymentMethod(), value.getSettlementMode(), instant(value.getPaidAt()), value.getPaymentMethod(),
value.getPaymentReferenceNo(), value.getPaymentVoucherFileIds() == null ? List.of() : value.getPaymentVoucherFileIds(), value.getPaymentReferenceNo(), value.getPaymentVoucherFileIds() == null ? List.of() : value.getPaymentVoucherFileIds(),
value.getBusinessDate(), value.getCurrency(), value.getAmount(), value.getReviewStatus(), value.getBusinessDate(), value.getCurrency(), value.getAmount(),
value.getOriginalCurrency(), value.getOriginalAmount(), value.getExchangeRate(), value.getExchangeRateMonth(),
value.getChargeableQuantity(), value.getQuantityUnit(), value.getUnitPrice(), value.getPeriodStart(), value.getPeriodEnd(),
value.getReviewStatus(),
"PUBLISHED", action == null && hash == null ? "ACTIVE" : value.getSourceStatus(), hash); "PUBLISHED", action == null && hash == null ? "ACTIVE" : value.getSourceStatus(), hash);
} }
@@ -19,7 +19,6 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
@@ -40,6 +39,7 @@ public class OverseasFeeSourceService {
private final OverseasFeeCommandMapper commands; private final OverseasFeeCommandMapper commands;
private final OverseasFeeBusinessGuard businessGuard; private final OverseasFeeBusinessGuard businessGuard;
private final OverseasFeeOutboxService outbox; private final OverseasFeeOutboxService outbox;
private final OverseasFeeCurrencyConverter currencyConverter;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
public List<OverseasFeeSource> list(String customerId, String businessType, String businessNo, public List<OverseasFeeSource> list(String customerId, String businessType, String businessNo,
@@ -151,8 +151,14 @@ public class OverseasFeeSourceService {
value.setDirection(request.direction()); value.setSupplierId(request.supplierId()); value.setSupplierName(text(request.supplierName())); value.setDirection(request.direction()); value.setSupplierId(request.supplierId()); value.setSupplierName(text(request.supplierName()));
value.setSupplierCategory(text(request.supplierCategory())); value.setSettlementMode(text(request.settlementMode())); value.setSupplierCategory(text(request.supplierCategory())); value.setSettlementMode(text(request.settlementMode()));
value.setPaidAt(request.paidAt()); value.setPaymentMethod(text(request.paymentMethod())); value.setPaymentReferenceNo(text(request.paymentReferenceNo())); value.setPaidAt(request.paidAt()); value.setPaymentMethod(text(request.paymentMethod())); value.setPaymentReferenceNo(text(request.paymentReferenceNo()));
value.setPaymentVoucherFileIds(request.paymentVoucherFileIds()); value.setBusinessDate(request.businessDate()); value.setCurrency(request.currency()); value.setPaymentVoucherFileIds(request.paymentVoucherFileIds()); value.setBusinessDate(request.businessDate());
value.setAmount(request.amount().setScale(2, RoundingMode.HALF_UP)); value.setReviewStatus("DRAFT"); OverseasFeeCurrencyConverter.Conversion conversion = currencyConverter.convert(request.currency(), request.amount(), request.businessDate());
value.setCurrency("CNY"); value.setAmount(conversion.settlementAmount());
value.setOriginalCurrency(conversion.originalCurrency()); value.setOriginalAmount(conversion.originalAmount());
value.setExchangeRate(conversion.exchangeRate()); value.setExchangeRateMonth(conversion.exchangeRateMonth());
value.setChargeableQuantity(request.chargeableQuantity()); value.setQuantityUnit(text(request.quantityUnit()));
value.setUnitPrice(request.unitPrice()); value.setPeriodStart(request.periodStart()); value.setPeriodEnd(request.periodEnd());
value.setReviewStatus("DRAFT");
value.setPublicationStatus("UNPUBLISHED"); value.setSourceStatus("ACTIVE"); value.setCreatedBy(operatorId); value.setUpdatedBy(operatorId); value.setPublicationStatus("UNPUBLISHED"); value.setSourceStatus("ACTIVE"); value.setCreatedBy(operatorId); value.setUpdatedBy(operatorId);
value.setCreatedAt(now()); value.setUpdatedAt(now()); value.setCreatedAt(now()); value.setUpdatedAt(now());
return value; return value;
@@ -172,6 +178,12 @@ public class OverseasFeeSourceService {
if ("OVERSEAS_CONTAINER".equals(value.businessType()) if ("OVERSEAS_CONTAINER".equals(value.businessType())
&& !OVERSEAS_CONTAINER_FEE_ITEMS.contains(value.feeItemCode().trim())) && !OVERSEAS_CONTAINER_FEE_ITEMS.contains(value.feeItemCode().trim()))
throw bad("海外货柜费用项目不受支持"); throw bad("海外货柜费用项目不受支持");
if ((value.periodStart() == null) != (value.periodEnd() == null)
|| value.periodStart() != null && value.periodStart().isAfter(value.periodEnd()))
throw bad("计费期间必须同时填写且开始日期不能晚于结束日期");
if ((value.chargeableQuantity() == null) != (text(value.quantityUnit()) == null)
|| value.unitPrice() != null && value.chargeableQuantity() == null)
throw bad("计费数量、单位和单价信息不完整");
boolean payable = "PAYABLE".equals(value.direction()); boolean payable = "PAYABLE".equals(value.direction());
boolean files = value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty(); boolean files = value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty();
if (!payable) { if (!payable) {
@@ -83,6 +83,10 @@ public class DestuffingPalletCostService {
v.setBusinessId(costId); v.setBusinessNo("DESTUFF-"+bookingId); v.setContainerBookingId(parseLong(bookingId)); v.setContainerNo(containerNo); v.setBusinessId(costId); v.setBusinessNo("DESTUFF-"+bookingId); v.setContainerBookingId(parseLong(bookingId)); v.setContainerNo(containerNo);
v.setWarehouseId(warehouseId); v.setFeeItemCode("DESTUFF_PALLET"); v.setFeeItemName("拆柜打托费"); v.setDirection("RECEIVABLE"); v.setWarehouseId(warehouseId); v.setFeeItemCode("DESTUFF_PALLET"); v.setFeeItemName("拆柜打托费"); v.setDirection("RECEIVABLE");
v.setPaymentVoucherFileIds(List.of()); v.setBusinessDate(LocalDate.now()); v.setCurrency("CNY"); v.setAmount(amount); v.setPaymentVoucherFileIds(List.of()); v.setBusinessDate(LocalDate.now()); v.setCurrency("CNY"); v.setAmount(amount);
v.setOriginalCurrency("CNY"); v.setOriginalAmount(amount); v.setExchangeRate(BigDecimal.ONE.setScale(6));
v.setExchangeRateMonth(LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM")));
v.setChargeableQuantity(volume.volume()); v.setQuantityUnit("CBM");
v.setUnitPrice(volume.volume().signum() > 0 ? amount.divide(volume.volume(), 6, RoundingMode.HALF_UP) : null);
v.setReviewStatus("APPROVED"); v.setPublicationStatus("PUBLISHED"); v.setSourceStatus("ACTIVE"); v.setReviewerId(operator); v.setReviewStatus("APPROVED"); v.setPublicationStatus("PUBLISHED"); v.setSourceStatus("ACTIVE"); v.setReviewerId(operator);
v.setReviewedAt(now); v.setCreatedBy(operator); v.setUpdatedBy(operator); v.setCreatedAt(now); v.setUpdatedAt(now); return v; v.setReviewedAt(now); v.setCreatedBy(operator); v.setUpdatedBy(operator); v.setCreatedAt(now); v.setUpdatedAt(now); return v;
} }
@@ -832,8 +832,13 @@ CREATE TABLE IF NOT EXISTS ll_finance_fee_source (
supplier_category VARCHAR(32) NULL COMMENT '应付供应商类别约束', settlement_mode VARCHAR(16) NULL COMMENT 'ON_ACCOUNT账期应付或DIRECT_PAID业务已付', supplier_category VARCHAR(32) NULL COMMENT '应付供应商类别约束', settlement_mode VARCHAR(16) NULL COMMENT 'ON_ACCOUNT账期应付或DIRECT_PAID业务已付',
paid_at DATETIME(3) NULL COMMENT '直接已付时间', payment_method VARCHAR(32) NULL COMMENT '直接已付方式', paid_at DATETIME(3) NULL COMMENT '直接已付时间', payment_method VARCHAR(32) NULL COMMENT '直接已付方式',
payment_reference_no VARCHAR(128) NULL COMMENT '直接已付流水号', payment_voucher_file_ids JSON NULL COMMENT '直接已付凭证文件ID快照', payment_reference_no VARCHAR(128) NULL COMMENT '直接已付流水号', payment_voucher_file_ids JSON NULL COMMENT '直接已付凭证文件ID快照',
business_date DATE NOT NULL COMMENT '费用业务日期', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,当前固定CNY', business_date DATE NOT NULL COMMENT '费用业务日期', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,固定CNY',
amount DECIMAL(20,6) NOT NULL COMMENT '费用金额', review_status VARCHAR(16) NOT NULL COMMENT 'DRAFT、PENDING、APPROVED或REJECTED', amount DECIMAL(20,6) NOT NULL COMMENT '人民币结算金额', original_currency CHAR(3) NOT NULL COMMENT '业务原币种,支持CNY或USD',
original_amount DECIMAL(20,6) NOT NULL COMMENT '业务原币金额', exchange_rate DECIMAL(20,6) NOT NULL COMMENT '原币折算人民币汇率,人民币固定为1',
exchange_rate_month CHAR(7) NOT NULL COMMENT '汇率月份yyyy-MM', chargeable_quantity DECIMAL(20,6) NULL COMMENT '计费数量,如箱数、托数、体积或天数',
quantity_unit VARCHAR(32) NULL COMMENT '计费数量单位', unit_price DECIMAL(20,6) NULL COMMENT '原币计费单价',
period_start DATE NULL COMMENT '仓租等期间费用开始日期', period_end DATE NULL COMMENT '仓租等期间费用结束日期',
review_status VARCHAR(16) NOT NULL COMMENT 'DRAFT、PENDING、APPROVED或REJECTED',
publication_status VARCHAR(16) NOT NULL COMMENT 'UNPUBLISHED或PUBLISHED', source_status VARCHAR(16) NOT NULL COMMENT 'ACTIVE、SUPERSEDED或VOIDED', publication_status VARCHAR(16) NOT NULL COMMENT 'UNPUBLISHED或PUBLISHED', source_status VARCHAR(16) NOT NULL COMMENT 'ACTIVE、SUPERSEDED或VOIDED',
review_reason VARCHAR(500) NULL COMMENT '审核或作废原因', reviewer_id VARCHAR(128) NULL COMMENT '审核人ID', reviewed_at DATETIME(3) NULL COMMENT '审核时间', review_reason VARCHAR(500) NULL COMMENT '审核或作废原因', reviewer_id VARCHAR(128) NULL COMMENT '审核人ID', reviewed_at DATETIME(3) NULL COMMENT '审核时间',
created_by VARCHAR(128) NOT NULL COMMENT '创建人ID', updated_by VARCHAR(128) NOT NULL COMMENT '最后更新人ID', created_by VARCHAR(128) NOT NULL COMMENT '创建人ID', updated_by VARCHAR(128) NOT NULL COMMENT '最后更新人ID',
@@ -842,6 +847,8 @@ CREATE TABLE IF NOT EXISTS ll_finance_fee_source (
KEY idx_ll_finance_fee_customer(customer_id,source_status,created_at), KEY idx_ll_finance_fee_business(business_type,business_no,created_at), KEY idx_ll_finance_fee_customer(customer_id,source_status,created_at), KEY idx_ll_finance_fee_business(business_type,business_no,created_at),
KEY idx_ll_finance_fee_container_customer(container_booking_id,customer_id,source_status,created_at), KEY idx_ll_finance_fee_container_customer(container_booking_id,customer_id,source_status,created_at),
CONSTRAINT chk_ll_finance_fee_amount CHECK(amount>0), CONSTRAINT chk_ll_finance_fee_currency CHECK(currency='CNY'), CONSTRAINT chk_ll_finance_fee_amount CHECK(amount>0), CONSTRAINT chk_ll_finance_fee_currency CHECK(currency='CNY'),
CONSTRAINT chk_ll_finance_fee_original_currency CHECK(original_currency IN('CNY','USD')),
CONSTRAINT chk_ll_finance_fee_period CHECK((period_start IS NULL AND period_end IS NULL) OR (period_start IS NOT NULL AND period_end IS NOT NULL AND period_start<=period_end)),
CONSTRAINT chk_ll_finance_fee_direction CHECK(direction IN('RECEIVABLE','PAYABLE')), CONSTRAINT chk_ll_finance_fee_direction CHECK(direction IN('RECEIVABLE','PAYABLE')),
CONSTRAINT chk_ll_finance_fee_review CHECK(review_status IN('DRAFT','PENDING','APPROVED','REJECTED')), CONSTRAINT chk_ll_finance_fee_review CHECK(review_status IN('DRAFT','PENDING','APPROVED','REJECTED')),
CONSTRAINT chk_ll_finance_fee_publication CHECK(publication_status IN('UNPUBLISHED','PUBLISHED')), CONSTRAINT chk_ll_finance_fee_publication CHECK(publication_status IN('UNPUBLISHED','PUBLISHED')),
@@ -85,6 +85,11 @@ components:
feeItemName: { type: string } feeItemName: { type: string }
direction: { type: string, enum: [RECEIVABLE, PAYABLE] } direction: { type: string, enum: [RECEIVABLE, PAYABLE] }
businessDate: { type: string, format: date } businessDate: { type: string, format: date }
currency: { type: string, enum: [CNY] } currency: { type: string, enum: [CNY, USD], description: 业务原币;USD由服务端按业务月份有效汇率折算为CNY入池 }
amount: { type: number, format: decimal, minimum: 0, exclusiveMinimum: true } amount: { type: number, format: decimal, minimum: 0, exclusiveMinimum: true, description: 业务原币金额 }
chargeableQuantity: { type: number, format: decimal, minimum: 0, exclusiveMinimum: true, nullable: true }
quantityUnit: { type: string, maxLength: 32, nullable: true }
unitPrice: { type: number, format: decimal, minimum: 0, exclusiveMinimum: true, nullable: true }
periodStart: { type: string, format: date, nullable: true }
periodEnd: { type: string, format: date, nullable: true }
securitySchemes: { bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } } securitySchemes: { bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } }
@@ -4,6 +4,7 @@ import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest;
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper; import com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper;
import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper; import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper;
import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
@@ -22,7 +23,9 @@ class OverseasContainerFeeSourceServiceTest {
private final OverseasFeeBusinessGuard guard = mock(OverseasFeeBusinessGuard.class); private final OverseasFeeBusinessGuard guard = mock(OverseasFeeBusinessGuard.class);
private final OverseasFeeOutboxService outbox = mock(OverseasFeeOutboxService.class); private final OverseasFeeOutboxService outbox = mock(OverseasFeeOutboxService.class);
private final OverseasFeeSourceService service = new OverseasFeeSourceService( private final OverseasFeeSourceService service = new OverseasFeeSourceService(
mapper, commands, guard, outbox, new ObjectMapper().findAndRegisterModules()); mapper, commands, guard, outbox,
new OverseasFeeCurrencyConverter(mock(MonthlyExchangeRateMapper.class)),
new ObjectMapper().findAndRegisterModules());
@Test @Test
void createsContainerFeeUsingTheRealContainerAsItsStableBusinessReference() { void createsContainerFeeUsingTheRealContainerAsItsStableBusinessReference() {
@@ -50,7 +53,8 @@ class OverseasContainerFeeSourceServiceTest {
source.warehouseId(), "CUSTOM_FEE", "自定义费", source.direction(), source.supplierId(), source.warehouseId(), "CUSTOM_FEE", "自定义费", source.direction(), source.supplierId(),
source.supplierName(), source.supplierCategory(), source.settlementMode(), source.paidAt(), source.supplierName(), source.supplierCategory(), source.settlementMode(), source.paidAt(),
source.paymentMethod(), source.paymentReferenceNo(), source.paymentVoucherFileIds(), source.paymentMethod(), source.paymentReferenceNo(), source.paymentVoucherFileIds(),
source.businessDate(), source.currency(), source.amount()); source.businessDate(), source.currency(), source.amount(), source.chargeableQuantity(),
source.quantityUnit(), source.unitPrice(), source.periodStart(), source.periodEnd());
when(commands.find("operator-a", "CREATE", "bad-container-fee")).thenReturn(null); when(commands.find("operator-a", "CREATE", "bad-container-fee")).thenReturn(null);
assertThatThrownBy(() -> service.create("operator-a", "bad-container-fee", request)) assertThatThrownBy(() -> service.create("operator-a", "bad-container-fee", request))
@@ -57,7 +57,8 @@ class OverseasFeeBusinessGuardTest {
source.warehouseId(), source.feeItemCode(), source.feeItemName(), source.direction(), source.supplierId(), source.warehouseId(), source.feeItemCode(), source.feeItemName(), source.direction(), source.supplierId(),
source.supplierName(), source.supplierCategory(), source.settlementMode(), source.paidAt(), source.supplierName(), source.supplierCategory(), source.settlementMode(), source.paidAt(),
source.paymentMethod(), source.paymentReferenceNo(), source.paymentVoucherFileIds(), source.paymentMethod(), source.paymentReferenceNo(), source.paymentVoucherFileIds(),
source.businessDate(), source.currency(), source.amount()); source.businessDate(), source.currency(), source.amount(), source.chargeableQuantity(),
source.quantityUnit(), source.unitPrice(), source.periodStart(), source.periodEnd());
StubJdbc jdbc = new StubJdbc(); StubJdbc jdbc = new StubJdbc();
assertThatThrownBy(() -> new OverseasFeeBusinessGuard(jdbc).requireOwned(inconsistent)) assertThatThrownBy(() -> new OverseasFeeBusinessGuard(jdbc).requireOwned(inconsistent))
@@ -83,7 +84,8 @@ class OverseasFeeBusinessGuardTest {
return new SaveRequest(customerId, "客户A", "OVERSEAS_CONTAINER_FEE", "OVERSEAS_CONTAINER", return new SaveRequest(customerId, "客户A", "OVERSEAS_CONTAINER_FEE", "OVERSEAS_CONTAINER",
bookingId, containerNo, null, null, null, null, bookingId, containerNo, 91L, bookingId, containerNo, null, null, null, null, bookingId, containerNo, 91L,
"PORT_FEE", "港口费", "RECEIVABLE", null, null, null, null, null, null, null, "PORT_FEE", "港口费", "RECEIVABLE", null, null, null, null, null, null, null,
List.of(), LocalDate.of(2026, 9, 8), "CNY", new BigDecimal("88.00")); List.of(), LocalDate.of(2026, 9, 8), "CNY", new BigDecimal("88.00"),
null, null, null, null, null);
} }
private static final class StubJdbc extends JdbcTemplate { private static final class StubJdbc extends JdbcTemplate {
@@ -0,0 +1,37 @@
package com.baiyue.lastleg.financefee.service;
import com.baiyue.lastleg.masterdata.exchange.entity.MonthlyExchangeRate;
import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDate;
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.when;
class OverseasFeeCurrencyConverterTest {
private final MonthlyExchangeRateMapper rates = mock(MonthlyExchangeRateMapper.class);
private final OverseasFeeCurrencyConverter converter = new OverseasFeeCurrencyConverter(rates);
@Test
void convertsUsdUsingTheActiveRateForTheBusinessMonth() {
MonthlyExchangeRate rate = new MonthlyExchangeRate();
rate.setStatus("ACTIVE"); rate.setUsdToCnyAverage(new BigDecimal("7.120000"));
when(rates.findMonth("2026-09")).thenReturn(rate);
var result = converter.convert("USD", new BigDecimal("12.34"), LocalDate.of(2026, 9, 9));
assertThat(result.originalAmount()).isEqualByComparingTo("12.34");
assertThat(result.exchangeRate()).isEqualByComparingTo("7.120000");
assertThat(result.settlementAmount()).isEqualByComparingTo("87.86");
}
@Test
void refusesUsdWhenTheBusinessMonthHasNoActiveRate() {
assertThatThrownBy(() -> converter.convert("USD", BigDecimal.TEN, LocalDate.of(2026, 9, 9)))
.hasMessageContaining("缺少有效USD兑CNY汇率");
}
}
@@ -38,6 +38,8 @@ class OverseasFeeOutboxServiceTest {
source.setFeeItemCode("PROCUREMENT_SERVICE"); source.setFeeItemName("采购服务费"); source.setFeeItemCode("PROCUREMENT_SERVICE"); source.setFeeItemName("采购服务费");
source.setDirection("RECEIVABLE"); source.setBusinessDate(LocalDate.of(2026, 9, 2)); source.setDirection("RECEIVABLE"); source.setBusinessDate(LocalDate.of(2026, 9, 2));
source.setCurrency("CNY"); source.setAmount(new BigDecimal("120.00")); source.setCurrency("CNY"); source.setAmount(new BigDecimal("120.00"));
source.setOriginalCurrency("USD"); source.setOriginalAmount(new BigDecimal("16.85"));
source.setExchangeRate(new BigDecimal("7.122000")); source.setExchangeRateMonth("2026-09");
source.setReviewStatus("APPROVED"); source.setPublicationStatus("PUBLISHED"); source.setReviewStatus("APPROVED"); source.setPublicationStatus("PUBLISHED");
source.setSourceStatus("ACTIVE"); source.setPaymentVoucherFileIds(List.of()); source.setSourceStatus("ACTIVE"); source.setPaymentVoucherFileIds(List.of());
@@ -52,5 +54,7 @@ class OverseasFeeOutboxServiceTest {
assertThat(payload.inboundId()).isNull(); assertThat(payload.inboundId()).isNull();
assertThat(payload.inboundCode()).isNull(); assertThat(payload.inboundCode()).isNull();
assertThat(payload.sourceHash()).startsWith("sha256:").hasSize(71); assertThat(payload.sourceHash()).startsWith("sha256:").hasSize(71);
assertThat(payload.originalCurrency()).isEqualTo("USD");
assertThat(payload.exchangeRateMonth()).isEqualTo("2026-09");
} }
} }