From 2980308f4d2b608ac8255010a4ec3d75e872cae2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 15:27:10 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/BusinessFeeSourceChangedV1.java | 9 +++ .../PlatformFinanceVoucherController.java | 65 +++++++++++++++++++ .../BusinessFeeSourceEventService.java | 11 +++- .../BusinessFeeSourceEventServiceTest.java | 6 +- .../PlatformFinanceVoucherControllerTest.java | 51 +++++++++++++++ .../financefee/dto/OverseasFeeSourceDtos.java | 9 ++- .../financefee/entity/OverseasFeeSource.java | 9 +++ .../service/OverseasFeeCurrencyConverter.java | 47 ++++++++++++++ .../service/OverseasFeeOutboxService.java | 5 +- .../service/OverseasFeeSourceService.java | 18 ++++- .../service/DestuffingPalletCostService.java | 4 ++ .../src/main/resources/db/lastleg.sql | 11 +++- .../openapi/overseas-finance-fees-v1.yaml | 9 ++- ...OverseasContainerFeeSourceServiceTest.java | 8 ++- .../service/OverseasFeeBusinessGuardTest.java | 6 +- .../OverseasFeeCurrencyConverterTest.java | 37 +++++++++++ .../service/OverseasFeeOutboxServiceTest.java | 4 ++ 17 files changed, 292 insertions(+), 17 deletions(-) create mode 100644 finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceVoucherController.java create mode 100644 finance/src/test/java/com/baiyue/finance/PlatformFinanceVoucherControllerTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverter.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverterTest.java diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java index 8ae7a67e5..012d99fc3 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java @@ -44,6 +44,15 @@ public record BusinessFeeSourceChangedV1( LocalDate businessDate, String currency, BigDecimal totalAmount, + String originalCurrency, + BigDecimal originalAmount, + BigDecimal exchangeRate, + String exchangeRateMonth, + BigDecimal chargeableQuantity, + String quantityUnit, + BigDecimal unitPrice, + LocalDate periodStart, + LocalDate periodEnd, String reviewStatus, String publicationStatus, String sourceStatus, diff --git a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceVoucherController.java b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceVoucherController.java new file mode 100644 index 000000000..2ad9c3bac --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceVoucherController.java @@ -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 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 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; + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java index a1f421fe9..946bfce8e 100644 --- a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java @@ -115,6 +115,13 @@ public class BusinessFeeSourceEventService { || blank(value.feeItemName()) || !DIRECTIONS.contains(value.direction()) || value.businessDate() == null || !"CNY".equals(value.currency()) || 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())) { throw error("FIN-EVT-012", "跨区费用来源快照字段不完整"); } @@ -148,7 +155,9 @@ public class BusinessFeeSourceEventService { value.warehouseId(), value.feeItemCode(), value.feeItemName(), value.direction(), value.supplierId(), value.supplierName(), value.supplierCategory(), value.settlementMode(), value.paidAt(), value.paymentMethod(), 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))); if (!expected.equals(value.sourceHash())) throw error("FIN-EVT-004", "费用来源快照哈希不一致"); } diff --git a/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java index a997a3a92..5392b1a73 100644 --- a/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java @@ -69,7 +69,8 @@ class BusinessFeeSourceEventServiceTest { "OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null, null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null, 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( EventPayloadCanonicalizer.canonicalize(json.valueToTree(unsigned))); BusinessFeeSourceChangedV1 payload = new BusinessFeeSourceChangedV1("PUBLISHED", "LASTLEG", @@ -77,7 +78,8 @@ class BusinessFeeSourceEventServiceTest { "OVERSEAS_PROCUREMENT", "801", "PO-US-001", null, null, null, null, null, null, "91", "PROCUREMENT_SERVICE", "采购服务费", "RECEIVABLE", null, null, 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); String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174701", diff --git a/finance/src/test/java/com/baiyue/finance/PlatformFinanceVoucherControllerTest.java b/finance/src/test/java/com/baiyue/finance/PlatformFinanceVoucherControllerTest.java new file mode 100644 index 000000000..62a70024d --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/PlatformFinanceVoucherControllerTest.java @@ -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')"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java index af3d859bf..0c4a4b379 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java @@ -43,8 +43,13 @@ public final class OverseasFeeSourceDtos { @Size(max = 128) String paymentReferenceNo, @Size(max = 20) List<@NotBlank String> paymentVoucherFileIds, @NotNull LocalDate businessDate, - @NotBlank @Pattern(regexp = "CNY") String currency, - @NotNull @DecimalMin(value = "0.00", inclusive = false) BigDecimal amount + @NotBlank @Pattern(regexp = "CNY|USD") String currency, + @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) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java index 59507799f..860dd5991 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java @@ -46,6 +46,15 @@ public class OverseasFeeSource { private LocalDate businessDate; private String currency; 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 publicationStatus; private String sourceStatus; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverter.java new file mode 100644 index 000000000..69263374b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverter.java @@ -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) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java index ed5410c7e..349dab9e1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java @@ -66,7 +66,10 @@ public class OverseasFeeOutboxService { text(value.getSupplierId()), value.getSupplierName(), value.getSupplierCategory(), value.getSettlementMode(), instant(value.getPaidAt()), value.getPaymentMethod(), 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); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java index 8318c2813..123f3f3c5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java @@ -19,7 +19,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; -import java.math.RoundingMode; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -40,6 +39,7 @@ public class OverseasFeeSourceService { private final OverseasFeeCommandMapper commands; private final OverseasFeeBusinessGuard businessGuard; private final OverseasFeeOutboxService outbox; + private final OverseasFeeCurrencyConverter currencyConverter; private final ObjectMapper objectMapper; public List 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.setSupplierCategory(text(request.supplierCategory())); value.setSettlementMode(text(request.settlementMode())); 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.setAmount(request.amount().setScale(2, RoundingMode.HALF_UP)); value.setReviewStatus("DRAFT"); + value.setPaymentVoucherFileIds(request.paymentVoucherFileIds()); value.setBusinessDate(request.businessDate()); + 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.setCreatedAt(now()); value.setUpdatedAt(now()); return value; @@ -172,6 +178,12 @@ public class OverseasFeeSourceService { if ("OVERSEAS_CONTAINER".equals(value.businessType()) && !OVERSEAS_CONTAINER_FEE_ITEMS.contains(value.feeItemCode().trim())) 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 files = value.paymentVoucherFileIds() != null && !value.paymentVoucherFileIds().isEmpty(); if (!payable) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostService.java index 2ad342942..946ded305 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostService.java @@ -83,6 +83,10 @@ public class DestuffingPalletCostService { 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.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.setReviewedAt(now); v.setCreatedBy(operator); v.setUpdatedBy(operator); v.setCreatedAt(now); v.setUpdatedAt(now); return v; } diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 5480c02c4..9e97b8de2 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -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业务已付', 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快照', - 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', + business_date DATE NOT NULL COMMENT '费用业务日期', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,固定CNY', + 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', 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', @@ -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_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_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_review CHECK(review_status IN('DRAFT','PENDING','APPROVED','REJECTED')), CONSTRAINT chk_ll_finance_fee_publication CHECK(publication_status IN('UNPUBLISHED','PUBLISHED')), diff --git a/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml index 6c2072bf1..13c19aa0d 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml @@ -85,6 +85,11 @@ components: feeItemName: { type: string } direction: { type: string, enum: [RECEIVABLE, PAYABLE] } businessDate: { type: string, format: date } - currency: { type: string, enum: [CNY] } - amount: { type: number, format: decimal, minimum: 0, exclusiveMinimum: true } + currency: { type: string, enum: [CNY, USD], description: 业务原币;USD由服务端按业务月份有效汇率折算为CNY入池 } + 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 } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java index a0a883740..59c3f2e4c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java @@ -4,6 +4,7 @@ import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos.SaveRequest; import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; import com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper; import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper; +import com.baiyue.lastleg.masterdata.exchange.mapper.MonthlyExchangeRateMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -22,7 +23,9 @@ class OverseasContainerFeeSourceServiceTest { private final OverseasFeeBusinessGuard guard = mock(OverseasFeeBusinessGuard.class); private final OverseasFeeOutboxService outbox = mock(OverseasFeeOutboxService.class); 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 void createsContainerFeeUsingTheRealContainerAsItsStableBusinessReference() { @@ -50,7 +53,8 @@ class OverseasContainerFeeSourceServiceTest { source.warehouseId(), "CUSTOM_FEE", "自定义费", source.direction(), source.supplierId(), source.supplierName(), source.supplierCategory(), source.settlementMode(), source.paidAt(), 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); assertThatThrownBy(() -> service.create("operator-a", "bad-container-fee", request)) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuardTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuardTest.java index a3ae4605a..d91032261 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuardTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuardTest.java @@ -57,7 +57,8 @@ class OverseasFeeBusinessGuardTest { source.warehouseId(), source.feeItemCode(), source.feeItemName(), source.direction(), source.supplierId(), source.supplierName(), source.supplierCategory(), source.settlementMode(), source.paidAt(), 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(); assertThatThrownBy(() -> new OverseasFeeBusinessGuard(jdbc).requireOwned(inconsistent)) @@ -83,7 +84,8 @@ class OverseasFeeBusinessGuardTest { return new SaveRequest(customerId, "客户A", "OVERSEAS_CONTAINER_FEE", "OVERSEAS_CONTAINER", bookingId, containerNo, null, null, null, null, bookingId, containerNo, 91L, "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 { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverterTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverterTest.java new file mode 100644 index 000000000..40358bd1f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverterTest.java @@ -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汇率"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxServiceTest.java index e76b49736..6f9a6acdf 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxServiceTest.java @@ -38,6 +38,8 @@ class OverseasFeeOutboxServiceTest { source.setFeeItemCode("PROCUREMENT_SERVICE"); source.setFeeItemName("采购服务费"); source.setDirection("RECEIVABLE"); source.setBusinessDate(LocalDate.of(2026, 9, 2)); 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.setSourceStatus("ACTIVE"); source.setPaymentVoucherFileIds(List.of()); @@ -52,5 +54,7 @@ class OverseasFeeOutboxServiceTest { assertThat(payload.inboundId()).isNull(); assertThat(payload.inboundCode()).isNull(); assertThat(payload.sourceHash()).startsWith("sha256:").hasSize(71); + assertThat(payload.originalCurrency()).isEqualTo("USD"); + assertThat(payload.exchangeRateMonth()).isEqualTo("2026-09"); } }