diff --git a/finance/src/main/java/com/baiyue/finance/client/FirstlegCostSnapshotClient.java b/finance/src/main/java/com/baiyue/finance/client/FirstlegCostSnapshotClient.java new file mode 100644 index 000000000..3d2b6c1ac --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/client/FirstlegCostSnapshotClient.java @@ -0,0 +1,17 @@ +package com.baiyue.finance.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.util.List; + +@FeignClient(name="firstleg-service", contextId="financeFirstlegCostSnapshotClient", url="${baiyue.clients.firstleg.url:}", path="/internal/firstleg/v1/finance-cost-snapshots") +public interface FirstlegCostSnapshotClient { + @GetMapping("/customers/{customerId}/containers/{containerId}") + ApiResponse container(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long customerId, @PathVariable long containerId); + record ContainerSnapshot(long containerId, long customerId, List orders) {} + record OrderVolume(long orderId, Long inboundId, BigDecimal volumeCbm) {} +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java index dc9f6d704..b253ff4e2 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java @@ -5,6 +5,7 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.finance.service.FinanceService; import com.baiyue.finance.client.FinanceFileClient; +import com.baiyue.finance.client.FirstlegCostSnapshotClient; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -22,11 +23,12 @@ import org.springframework.web.multipart.MultipartFile; public class PlatformFinanceController { private final FinanceService service; private final FinanceFileClient fileClient; + private final FirstlegCostSnapshotClient costSnapshotClient; @Value("${baiyue.security.internal-service-token:}") private String fileToken; - public PlatformFinanceController(FinanceService service, FinanceFileClient fileClient) { this.service = service; this.fileClient = fileClient; } + public PlatformFinanceController(FinanceService service, FinanceFileClient fileClient, FirstlegCostSnapshotClient costSnapshotClient) { this.service = service; this.fileClient = fileClient; this.costSnapshotClient = costSnapshotClient; } @PostMapping(value = "/vouchers", consumes = "multipart/form-data") - @PreAuthorize("hasAuthority('platform:finance:receipt:create')") + @PreAuthorize("hasAnyAuthority('platform:finance:receipt:create','platform:finance:cost:create')") public ApiResponse uploadVoucher(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long customerId, @RequestPart("file") MultipartFile file) { @@ -71,6 +73,12 @@ public class PlatformFinanceController { @PreAuthorize("hasAuthority('platform:finance:bill:view')") public ApiResponse> bill(@PathVariable long billId) { return ApiResponse.success(service.billById(billId)); } + @GetMapping("/bills/{billId}/revisions") + @PreAuthorize("hasAuthority('platform:finance:bill:view')") + public ApiResponse revisions(@PathVariable long billId, @RequestParam long customerId) { + return ApiResponse.success(service.billRevisions(customerId, billId)); + } + @GetMapping("/receipts") @PreAuthorize("hasAuthority('platform:finance:receipt:view')") public ApiResponse receipts(@RequestParam(required = false) Long customerId) { return ApiResponse.success(service.listReceipts(customerId)); } @@ -100,7 +108,17 @@ public class PlatformFinanceController { public ApiResponse> cost(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CostRequest request) { if (request.currency() != null && !"CNY".equals(request.currency())) throw new IllegalArgumentException("首期仅支持CNY"); var internal = new FinanceService.CostRequest(null, request.costType(), request.orderId(), null, request.containerId(), null, null, "MANUAL", request.expenseName(), request.amount(), request.voucherFileIds(), request.remark()); - return ApiResponse.success(service.createCost(request.customerId(), user.userId(), internal, key)); + java.util.List volumes = java.util.List.of(); + if ("CONTAINER_SHARED".equals(request.costType())) { + if (request.containerId() == null) throw new IllegalArgumentException("整柜成本必须指定柜次"); + var response = costSnapshotClient.container(fileToken, request.customerId(), request.containerId()); + var snapshot = response == null ? null : response.data(); + if (snapshot == null || snapshot.customerId() != request.customerId() || snapshot.containerId() != request.containerId() || snapshot.orders() == null || snapshot.orders().isEmpty()) { + throw new IllegalArgumentException("Firstleg未返回有效柜内订单体积快照"); + } + volumes = snapshot.orders().stream().map(v -> new FinanceService.ContainerOrderVolume(v.orderId(), v.inboundId(), snapshot.containerId(), snapshot.customerId(), v.volumeCbm())).toList(); + } + return ApiResponse.success(service.createCost(request.customerId(), user.userId(), internal, volumes, key)); } @PutMapping("/bills/{billId}") diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java index 7c5010576..3955c88a8 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java @@ -74,6 +74,13 @@ public class FinanceService { return bill(tenantId, billId); } + public List> billRevisions(long customerId, long billId) { + requireCustomer(customerId); + Number count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill WHERE id=? AND tenant_id=?", Number.class, billId, customerId); + if (count == null || count.intValue() != 1) throw bad("账单不存在或不属于客户"); + return jdbc.queryForList("SELECT id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by,created_at FROM fin_bill_revision WHERE tenant_id=? AND bill_id=? ORDER BY version DESC,id DESC", customerId, billId); + } + @Transactional public Map issueBillById(long operatorId, long billId, int expectedVersion, String idempotencyKey) { Long tenantId = jdbc.queryForObject("SELECT tenant_id FROM fin_bill WHERE id=?", Long.class, billId); @@ -215,12 +222,14 @@ public class FinanceService { @Transactional public Map createCost(long tenantId, long operatorId, CostRequest request, List containerOrders, String idempotencyKey) { - requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "COST_CREATE", idempotencyKey, request); + requireCustomer(tenantId); Long replay = reserve(tenantId, operatorId, "COST_CREATE", idempotencyKey, Map.of("cost", request, "containerOrders", containerOrders == null ? List.of() : containerOrders)); if (replay != null) return jdbc.queryForMap("SELECT * FROM fin_cost WHERE id=? AND tenant_id=?", replay, tenantId); if (!"SINGLE_ORDER".equals(request.costType()) && !"CONTAINER_SHARED".equals(request.costType())) throw bad("成本类型只能是SINGLE_ORDER或CONTAINER_SHARED"); if (request.amount() == null || request.amount().signum() <= 0) throw bad("成本金额必须大于0"); + validateVoucherFiles(tenantId, request.voucherFileIds()); if ("SINGLE_ORDER".equals(request.costType()) && (request.orderId() == null || request.containerId() != null)) throw bad("单票成本必须指定订单且不能指定柜次"); if ("CONTAINER_SHARED".equals(request.costType()) && (request.containerId() == null || containerOrders == null || containerOrders.isEmpty())) throw bad("整柜成本必须指定柜次及柜内有效订单体积"); + if ("SINGLE_ORDER".equals(request.costType())) validateSingleOrder(tenantId, request.orderId()); validateContainerOrders(tenantId, request.containerId(), containerOrders); KeyHolder holder = new GeneratedKeyHolder(); jdbc.update(connection -> { PreparedStatement ps = connection.prepareStatement("INSERT INTO fin_cost(tenant_id,cost_no,cost_type,order_id,inbound_id,container_id,supplier_id,supplier_name_snapshot,fee_item_code,fee_item_name,cost_amount,voucher_file_ids,remark,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", Statement.RETURN_GENERATED_KEYS); @@ -242,9 +251,16 @@ public class FinanceService { || row.volumeCbm() == null || row.volumeCbm().signum() <= 0 || !seen.add(row.orderId())) { throw bad("柜次、订单、客户归属或体积无效"); } + Number exists = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source WHERE customer_id=? AND order_id=? AND status='AVAILABLE'", Number.class, tenantId, row.orderId()); + if (exists != null && exists.intValue() == 0) throw bad("柜内订单不存在或不属于客户"); } } + private void validateSingleOrder(long tenantId, long orderId) { + Number exists = jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source WHERE customer_id=? AND order_id=? AND status='AVAILABLE'", Number.class, tenantId, orderId); + if (exists != null && exists.intValue() == 0) throw bad("单票订单不存在或不属于客户"); + } + private void persistContainerAllocations(long tenantId, long operatorId, long costId, BigDecimal amount, List rows) { for (CostAllocation allocation : allocateContainerCost(amount, rows)) { @@ -271,10 +287,11 @@ public class FinanceService { ContainerOrderVolume row = rows.get(i); boolean tail = i == rows.size() - 1; BigDecimal rate = row.volumeCbm().divide(totalVolume, 10, java.math.RoundingMode.HALF_UP); - BigDecimal share = tail ? amount.subtract(allocated).setScale(6, java.math.RoundingMode.HALF_UP) - : amount.multiply(rate).setScale(6, java.math.RoundingMode.HALF_UP); + BigDecimal regularShare = amount.multiply(rate).setScale(6, java.math.RoundingMode.HALF_UP); + BigDecimal share = tail ? amount.subtract(allocated).setScale(6, java.math.RoundingMode.HALF_UP) : regularShare; + boolean roundingAdjustment = tail && share.compareTo(regularShare) != 0; allocated = allocated.add(share); - result.add(new CostAllocation(row, totalVolume, rate, share, tail)); + result.add(new CostAllocation(row, totalVolume, rate, share, roundingAdjustment)); } return List.copyOf(result); } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java index 9810c5bf0..16393dc53 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java @@ -24,6 +24,10 @@ class FinanceContractTest { assertTrue(service.contains("status='PROCESSING'") && service.contains("stableNo")); assertTrue(service.contains("request_hash") && service.contains("result_resource_id") && service.contains("Idempotency-Key已用于其他请求")); assertTrue(service.contains("status IN ('ISSUED','PARTIALLY_PAID','PAID')")); + String controller = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java")); + assertTrue(controller.contains("/vouchers") && controller.contains("MultipartFile")); + assertTrue(controller.contains("costSnapshotClient.container") && controller.contains("createCost(request.customerId(), user.userId(), internal, volumes, key)")); + assertTrue(service.contains("validateVoucherFiles(tenantId, request.voucherFileIds())")); String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); assertTrue(unified.contains("CREATE DATABASE IF NOT EXISTS baiyue_finance")); assertTrue(unified.contains("assert_finance_permissions")); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java b/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java index 25c5493e0..acfc6717b 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceServiceValidationTest.java @@ -1,6 +1,8 @@ package com.baiyue.finance; import com.baiyue.finance.service.FinanceService; +import com.baiyue.finance.client.FinanceFileClient; +import com.baiyue.common.core.api.ApiResponse; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.JdbcTemplate; @@ -14,6 +16,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; +import org.springframework.test.util.ReflectionTestUtils; class FinanceServiceValidationTest { @Test @@ -28,7 +31,12 @@ class FinanceServiceValidationTest { assertEquals(new BigDecimal("50.000000"), allocations.get(2).amount()); assertEquals(new BigDecimal("100.000000"), allocations.stream().map(FinanceService.CostAllocation::amount) .reduce(BigDecimal.ZERO, BigDecimal::add)); - assertEquals(true, allocations.get(2).roundingAdjustment()); + assertEquals(false, allocations.get(2).roundingAdjustment()); + var tail = FinanceService.allocateContainerCost(new BigDecimal("100.00"), List.of( + new FinanceService.ContainerOrderVolume(21L, 201L, 7L, 9L, BigDecimal.ONE), + new FinanceService.ContainerOrderVolume(22L, 202L, 7L, 9L, BigDecimal.ONE), + new FinanceService.ContainerOrderVolume(23L, 203L, 7L, 9L, BigDecimal.ONE))); + assertEquals(true, tail.get(2).roundingAdjustment()); } @Test @@ -62,4 +70,49 @@ class FinanceServiceValidationTest { List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE), new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE))); assertThrows(RuntimeException.class, () -> service.createReceipt(1L, 2L, request, "k3")); } + + @Test + void rejectsVoucherFromAnotherCustomer() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + FinanceFileClient files = mock(FinanceFileClient.class); + when(files.metadata(anyString(), eq(9L), eq(9L), eq(77L))).thenReturn(ApiResponse.success( + new FinanceFileClient.Metadata(77L, 10L, "FINANCE_VOUCHER", "9", "v.jpg", "sha", "image/jpeg", 10L))); + FinanceService service = new FinanceService(jdbc, new ObjectMapper()); + ReflectionTestUtils.setField(service, "fileClient", files); + var request = new FinanceService.ReceiptRequest("R-3", 1L, null, "BANK_TRANSFER", null, List.of("77"), null, + List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE))); + assertThrows(RuntimeException.class, () -> service.createReceipt(9L, 2L, request, "k4")); + } + + @Test + void rejectsSingleOrderWhenOrderNotOwnedByCustomer() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source"), eq(Number.class), any(), any())).thenReturn(0); + FinanceService service = new FinanceService(jdbc, new ObjectMapper()); + var request = new FinanceService.CostRequest("C-2", "SINGLE_ORDER", 99L, null, null, null, null, "MANUAL", "货拉拉", BigDecimal.ONE, List.of("1"), null); + assertThrows(RuntimeException.class, () -> service.createCost(9L, 2L, request, "k5")); + } + + @Test + void returnsBillRevisionHistoryOnlyForOwningCustomer() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_bill"), eq(Number.class), eq(50L), eq(9L))) + .thenReturn(1); + var rows = List.>of(java.util.Map.of("version", 2)); + when(jdbc.queryForList(startsWith("SELECT id,bill_id,version"), eq(9L), eq(50L))).thenReturn(rows); + FinanceService service = new FinanceService(jdbc, new ObjectMapper()); + + assertEquals(rows, service.billRevisions(9L, 50L)); + } + + @Test + void rejectsBillRevisionHistoryFromAnotherCustomer() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_bill"), eq(Number.class), eq(50L), eq(9L))) + .thenReturn(0); + FinanceService service = new FinanceService(jdbc, new ObjectMapper()); + + assertThrows(RuntimeException.class, () -> service.billRevisions(9L, 50L)); + verify(jdbc, never()).queryForList(startsWith("SELECT id,bill_id,version"), (Object[]) any()); + } } diff --git a/finance/src/test/java/com/baiyue/finance/PlatformFinanceControllerBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/PlatformFinanceControllerBehaviorTest.java new file mode 100644 index 000000000..d1725df6e --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/PlatformFinanceControllerBehaviorTest.java @@ -0,0 +1,74 @@ +package com.baiyue.finance; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.finance.client.FinanceFileClient; +import com.baiyue.finance.client.FirstlegCostSnapshotClient; +import com.baiyue.finance.controller.PlatformFinanceController; +import com.baiyue.finance.service.FinanceService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PlatformFinanceControllerBehaviorTest { + @Mock FinanceService service; + @Mock FinanceFileClient fileClient; + @Mock FirstlegCostSnapshotClient snapshotClient; + private PlatformFinanceController controller; + private final AuthenticatedUser user = new AuthenticatedUser( + 7L, null, null, null, "finance", "ADMIN", "WEB", Set.of()); + + @BeforeEach + void setUp() { + controller = new PlatformFinanceController(service, fileClient, snapshotClient); + ReflectionTestUtils.setField(controller, "fileToken", "internal-token"); + } + + @Test + void containerCostUsesFirstlegSnapshotInsteadOfBrowserData() { + var request = new PlatformFinanceController.CostRequest( + 10L, "CONTAINER_SHARED", null, 88L, "清关费", new BigDecimal("300.00"), + "CNY", List.of("91"), "整柜成本"); + var snapshot = new FirstlegCostSnapshotClient.ContainerSnapshot(88L, 10L, List.of( + new FirstlegCostSnapshotClient.OrderVolume(101L, 1001L, new BigDecimal("1.25")), + new FirstlegCostSnapshotClient.OrderVolume(102L, 1002L, new BigDecimal("2.75")))); + when(snapshotClient.container("internal-token", 10L, 88L)).thenReturn(ApiResponse.success(snapshot)); + when(service.createCost(eq(10L), eq(7L), any(), any(), eq("cost-key"))).thenReturn(Map.of("id", 1L)); + + controller.cost(user, "cost-key", request); + + verify(snapshotClient).container("internal-token", 10L, 88L); + verify(service).createCost(eq(10L), eq(7L), any(), eq(List.of( + new FinanceService.ContainerOrderVolume(101L, 1001L, 88L, 10L, new BigDecimal("1.25")), + new FinanceService.ContainerOrderVolume(102L, 1002L, 88L, 10L, new BigDecimal("2.75")))), eq("cost-key")); + } + + @Test + void containerCostRejectsEmptyFirstlegSnapshot() { + var request = new PlatformFinanceController.CostRequest( + 10L, "CONTAINER_SHARED", null, 88L, "拖车费", BigDecimal.TEN, + "CNY", List.of("91"), null); + when(snapshotClient.container("internal-token", 10L, 88L)).thenReturn(ApiResponse.success( + new FirstlegCostSnapshotClient.ContainerSnapshot(88L, 10L, List.of()))); + + assertThatThrownBy(() -> controller.cost(user, "cost-key", request)) + .hasMessageContaining("未返回有效柜内订单体积快照"); + verify(service, never()).createCost(any(Long.class), any(Long.class), any(), any(), any()); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/InternalFinanceCostSnapshotController.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/InternalFinanceCostSnapshotController.java new file mode 100644 index 000000000..de9d8ab37 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/InternalFinanceCostSnapshotController.java @@ -0,0 +1,29 @@ +package com.baiyue.firstleg.loading.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.firstleg.loading.service.FinanceCostSnapshotService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.*; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@RestController +@RequestMapping("/internal/firstleg/v1/finance-cost-snapshots") +public class InternalFinanceCostSnapshotController { + private final FinanceCostSnapshotService service; + @Value("${baiyue.security.internal-service-token}") private String expectedToken; + public InternalFinanceCostSnapshotController(FinanceCostSnapshotService service) { this.service = service; } + + @GetMapping("/customers/{customerId}/containers/{containerId}") + public ApiResponse container( + @RequestHeader(value="X-Internal-Service-Token", required=false) String token, + @PathVariable long customerId, @PathVariable long containerId) { + if (token == null || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), expectedToken.getBytes(StandardCharsets.UTF_8))) { + throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效"); + } + return ApiResponse.success(service.container(customerId, containerId)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/FinanceCostSnapshotService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/FinanceCostSnapshotService.java new file mode 100644 index 000000000..862a0d4b3 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/FinanceCostSnapshotService.java @@ -0,0 +1,32 @@ +package com.baiyue.firstleg.loading.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.repository.SmartLoadingRepository; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.List; + +@Service +public class FinanceCostSnapshotService { + private final SmartLoadingRepository repository; + public FinanceCostSnapshotService(SmartLoadingRepository repository) { this.repository = repository; } + + public ContainerSnapshot container(long customerId, long loadingPlanId) { + if (customerId <= 0 || loadingPlanId <= 0 || repository.plan(loadingPlanId) == null) { + throw new BizException(ResultCode.NOT_FOUND, "柜次不存在"); + } + List orders = repository.items(loadingPlanId).stream() + .filter(item -> item.getAccountId() != null && item.getAccountId() == customerId) + .filter(item -> item.getOrderId() != null && item.getOrderId() > 0) + .filter(item -> item.getVolumeCbm() != null && item.getVolumeCbm().signum() > 0) + .map(item -> new OrderVolume(item.getOrderId(), item.getInboundId(), item.getVolumeCbm())) + .toList(); + if (orders.isEmpty()) throw new BizException(ResultCode.BAD_REQUEST, "柜次没有该客户的有效订单体积"); + return new ContainerSnapshot(loadingPlanId, customerId, orders); + } + + public record ContainerSnapshot(long containerId, long customerId, List orders) {} + public record OrderVolume(long orderId, Long inboundId, BigDecimal volumeCbm) {} +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/FinanceCostSnapshotServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/FinanceCostSnapshotServiceTest.java new file mode 100644 index 000000000..dc6f432c9 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/FinanceCostSnapshotServiceTest.java @@ -0,0 +1,68 @@ +package com.baiyue.firstleg.loading.service; + +import com.baiyue.firstleg.entity.LoadingPlanEntity; +import com.baiyue.firstleg.entity.LoadingPlanItemEntity; +import com.baiyue.firstleg.repository.SmartLoadingRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +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.when; + +@ExtendWith(MockitoExtension.class) +class FinanceCostSnapshotServiceTest { + @Mock SmartLoadingRepository repository; + @InjectMocks FinanceCostSnapshotService service; + + @Test + void returnsOnlyValidVolumesOwnedByCustomer() { + when(repository.plan(88L)).thenReturn(mock(LoadingPlanEntity.class)); + when(repository.items(88L)).thenReturn(List.of( + item(10L, 101L, 1001L, "1.25"), + item(11L, 102L, 1002L, "2.00"), + item(10L, 103L, 1003L, "0"))); + + var snapshot = service.container(10L, 88L); + + assertThat(snapshot.customerId()).isEqualTo(10L); + assertThat(snapshot.containerId()).isEqualTo(88L); + assertThat(snapshot.orders()).containsExactly( + new FinanceCostSnapshotService.OrderVolume(101L, 1001L, new BigDecimal("1.25"))); + } + + @Test + void rejectsContainerWithoutValidCustomerVolume() { + when(repository.plan(88L)).thenReturn(mock(LoadingPlanEntity.class)); + when(repository.items(88L)).thenReturn(List.of( + item(11L, 102L, 1002L, "2.00"), + item(10L, 103L, 1003L, "0"))); + + assertThatThrownBy(() -> service.container(10L, 88L)) + .hasMessageContaining("没有该客户的有效订单体积"); + } + + @Test + void rejectsUnknownContainer() { + when(repository.plan(88L)).thenReturn(null); + + assertThatThrownBy(() -> service.container(10L, 88L)) + .hasMessageContaining("柜次不存在"); + } + + private LoadingPlanItemEntity item(long customerId, long orderId, long inboundId, String volume) { + var item = new LoadingPlanItemEntity(); + item.setAccountId(customerId); + item.setOrderId(orderId); + item.setInboundId(inboundId); + item.setVolumeCbm(new BigDecimal(volume)); + return item; + } +}