From 3fe825bd20e27b567219f9295188b727eb97b0c6 Mon Sep 17 00:00:00 2001 From: sh-ad Date: Wed, 17 Dec 2025 18:07:02 +0300 Subject: [PATCH 1/2] init feature-1 --- .../com/example/app/config/AppConfig.java | 13 +- .../java/com/example/common/ErrorCodes.java | 1 + .../java/com/example/common/model/Item.java | 4 +- .../example/common/model/ModelSmokeTest.java | 2 +- .../client/TimeoutDeliveryClient.java | 39 +++++ .../orderapi/model/OrderPriceRequest.java | 10 ++ .../orderapi/model/OrderPriceResponse.java | 12 ++ .../orderapi/service/PriceService.java | 136 +++++++++++++++--- .../orderapi/service/UserDiscountPolicy.java | 33 +++++ .../example/orderapi/OrderServiceTest.java | 8 +- .../example/orderapi/PriceServiceTest.java | 101 ++++++++++--- .../client/TimeoutDeliveryClientTest.java | 59 ++++++++ specs/feature-1.md | 34 +++-- 13 files changed, 386 insertions(+), 66 deletions(-) create mode 100644 services/order-api/src/main/java/com/example/orderapi/client/TimeoutDeliveryClient.java create mode 100644 services/order-api/src/main/java/com/example/orderapi/service/UserDiscountPolicy.java create mode 100644 services/order-api/src/test/java/com/example/orderapi/client/TimeoutDeliveryClientTest.java diff --git a/app/src/main/java/com/example/app/config/AppConfig.java b/app/src/main/java/com/example/app/config/AppConfig.java index 90300d9..4150a26 100644 --- a/app/src/main/java/com/example/app/config/AppConfig.java +++ b/app/src/main/java/com/example/app/config/AppConfig.java @@ -7,12 +7,13 @@ import com.example.common.db.UserRepository; import com.example.common.db.UserStore; import com.example.orderapi.client.DeliveryClient; -import com.example.orderapi.service.NoDiscountPolicy; -import com.example.orderapi.service.OrderService; -import com.example.orderapi.service.PriceService; import com.example.loyalty.service.BatchRunner; import com.example.loyalty.service.LoyaltyCalculator; +import com.example.orderapi.client.TimeoutDeliveryClient; +import com.example.orderapi.service.OrderService; +import com.example.orderapi.service.PriceService; import java.math.BigDecimal; +import java.time.Duration; import javax.sql.DataSource; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -37,12 +38,12 @@ public OrderStore orderStore(DataSource dataSource) { @Bean public DeliveryClient deliveryClient() { - return new StubDeliveryClient(); + return new TimeoutDeliveryClient(new StubDeliveryClient(), Duration.ofMillis(500)); } @Bean - public PriceService priceService(DeliveryClient deliveryClient) { - return new PriceService(deliveryClient, new NoDiscountPolicy(), "USD"); + public PriceService priceService(DeliveryClient deliveryClient, DiscountStore discountStore) { + return new PriceService(deliveryClient, discountStore, "USD"); } @Bean diff --git a/libs/common/src/main/java/com/example/common/ErrorCodes.java b/libs/common/src/main/java/com/example/common/ErrorCodes.java index e72e76d..caa5512 100644 --- a/libs/common/src/main/java/com/example/common/ErrorCodes.java +++ b/libs/common/src/main/java/com/example/common/ErrorCodes.java @@ -3,6 +3,7 @@ public final class ErrorCodes { public static final String DELIVERY_UNAVAILABLE = "DELIVERY_UNAVAILABLE"; public static final String INVALID_REQUEST = "INVALID_REQUEST"; + public static final String ORDER_TOO_SMALL = "ORDER_TOO_SMALL"; public static final String INTERNAL_ERROR = "INTERNAL_ERROR"; public static final String LOYALTY_INVALID_INPUT = "LOYALTY_INVALID_INPUT"; diff --git a/libs/common/src/main/java/com/example/common/model/Item.java b/libs/common/src/main/java/com/example/common/model/Item.java index b6c5720..5a4fc44 100644 --- a/libs/common/src/main/java/com/example/common/model/Item.java +++ b/libs/common/src/main/java/com/example/common/model/Item.java @@ -15,6 +15,9 @@ public Item(String sku, int quantity, BigDecimal unitPrice) { } this.quantity = quantity; this.unitPrice = Objects.requireNonNull(unitPrice, "unitPrice"); + if (unitPrice.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("unitPrice must be non-negative"); + } } public String getSku() { @@ -29,4 +32,3 @@ public BigDecimal getUnitPrice() { return unitPrice; } } - diff --git a/libs/common/src/test/java/com/example/common/model/ModelSmokeTest.java b/libs/common/src/test/java/com/example/common/model/ModelSmokeTest.java index b96d567..e34d0d4 100644 --- a/libs/common/src/test/java/com/example/common/model/ModelSmokeTest.java +++ b/libs/common/src/test/java/com/example/common/model/ModelSmokeTest.java @@ -18,6 +18,7 @@ void itemValidatesQuantityAndNulls() { assertThrows(NullPointerException.class, () -> new Item(null, 1, new BigDecimal("1.00"))); assertThrows(NullPointerException.class, () -> new Item("sku-1", 1, null)); assertThrows(IllegalArgumentException.class, () -> new Item("sku-1", 0, new BigDecimal("1.00"))); + assertThrows(IllegalArgumentException.class, () -> new Item("sku-1", 1, new BigDecimal("-1.00"))); Item item = new Item("sku-1", 2, new BigDecimal("10.00")); assertEquals("sku-1", item.getSku()); @@ -25,4 +26,3 @@ void itemValidatesQuantityAndNulls() { assertEquals(new BigDecimal("10.00"), item.getUnitPrice()); } } - diff --git a/services/order-api/src/main/java/com/example/orderapi/client/TimeoutDeliveryClient.java b/services/order-api/src/main/java/com/example/orderapi/client/TimeoutDeliveryClient.java new file mode 100644 index 0000000..e8790e3 --- /dev/null +++ b/services/order-api/src/main/java/com/example/orderapi/client/TimeoutDeliveryClient.java @@ -0,0 +1,39 @@ +package com.example.orderapi.client; + +import com.example.common.model.Address; +import com.example.common.model.Item; +import com.example.orderapi.model.DeliveryQuote; +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +/** + * Оборачивает DeliveryClient и гарантирует таймаут + безопасное падение в Optional.empty(). + */ +public class TimeoutDeliveryClient implements DeliveryClient { + + private final DeliveryClient delegate; + private final Duration timeout; + + public TimeoutDeliveryClient(DeliveryClient delegate, Duration timeout) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + this.timeout = Objects.requireNonNull(timeout, "timeout"); + } + + @Override + public Optional getQuote(Address address, List items) { + try { + return CompletableFuture + .supplyAsync(() -> delegate.getQuote(address, items)) + .orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS) + .exceptionally(ex -> Optional.empty()) + .get(); + } catch (Exception e) { + return Optional.empty(); + } + } +} + diff --git a/services/order-api/src/main/java/com/example/orderapi/model/OrderPriceRequest.java b/services/order-api/src/main/java/com/example/orderapi/model/OrderPriceRequest.java index c41e918..b8f765d 100644 --- a/services/order-api/src/main/java/com/example/orderapi/model/OrderPriceRequest.java +++ b/services/order-api/src/main/java/com/example/orderapi/model/OrderPriceRequest.java @@ -9,11 +9,17 @@ public class OrderPriceRequest { private final List items; private final Address address; private final String userId; + private final String currency; public OrderPriceRequest(List items, Address address, String userId) { + this(items, address, userId, "USD"); + } + + public OrderPriceRequest(List items, Address address, String userId, String currency) { this.items = Objects.requireNonNull(items, "items"); this.address = Objects.requireNonNull(address, "address"); this.userId = Objects.requireNonNull(userId, "userId"); + this.currency = Objects.requireNonNull(currency, "currency"); } public List getItems() { @@ -27,4 +33,8 @@ public Address getAddress() { public String getUserId() { return userId; } + + public String getCurrency() { + return currency; + } } diff --git a/services/order-api/src/main/java/com/example/orderapi/model/OrderPriceResponse.java b/services/order-api/src/main/java/com/example/orderapi/model/OrderPriceResponse.java index c293c63..253ef0e 100644 --- a/services/order-api/src/main/java/com/example/orderapi/model/OrderPriceResponse.java +++ b/services/order-api/src/main/java/com/example/orderapi/model/OrderPriceResponse.java @@ -1,6 +1,7 @@ package com.example.orderapi.model; import java.math.BigDecimal; +import java.util.List; import java.util.Optional; public class OrderPriceResponse { @@ -10,15 +11,22 @@ public class OrderPriceResponse { private final BigDecimal total; private final String currency; private final Error error; + private final List warnings; public OrderPriceResponse(BigDecimal itemsTotal, BigDecimal deliveryFee, BigDecimal discount, BigDecimal total, String currency, Error error) { + this(itemsTotal, deliveryFee, discount, total, currency, error, List.of()); + } + + public OrderPriceResponse(BigDecimal itemsTotal, BigDecimal deliveryFee, BigDecimal discount, + BigDecimal total, String currency, Error error, List warnings) { this.itemsTotal = itemsTotal; this.deliveryFee = deliveryFee; this.discount = discount; this.total = total; this.currency = currency; this.error = error; + this.warnings = warnings == null ? List.of() : List.copyOf(warnings); } public BigDecimal getItemsTotal() { @@ -45,5 +53,9 @@ public Optional getError() { return Optional.ofNullable(error); } + public List getWarnings() { + return warnings; + } + public record Error(String code, String message) {} } diff --git a/services/order-api/src/main/java/com/example/orderapi/service/PriceService.java b/services/order-api/src/main/java/com/example/orderapi/service/PriceService.java index 506911b..2df22ec 100644 --- a/services/order-api/src/main/java/com/example/orderapi/service/PriceService.java +++ b/services/order-api/src/main/java/com/example/orderapi/service/PriceService.java @@ -2,63 +2,153 @@ import com.example.common.ErrorCodes; import com.example.common.MoneyUtils; -import com.example.orderapi.client.DeliveryClient; +import com.example.common.db.DiscountStore; import com.example.common.model.Address; -import com.example.orderapi.model.DeliveryQuote; import com.example.common.model.Item; +import com.example.orderapi.client.DeliveryClient; +import com.example.orderapi.model.DeliveryQuote; import com.example.orderapi.model.OrderPriceRequest; import com.example.orderapi.model.OrderPriceResponse; import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; public class PriceService { private final DeliveryClient deliveryClient; - private final DiscountPolicy discountPolicy; - private final String currency; + private final DiscountStore discountStore; + private final String defaultCurrency; - public PriceService(DeliveryClient deliveryClient, DiscountPolicy discountPolicy, String currency) { + private static final BigDecimal MAX_DISCOUNT_RATE = new BigDecimal("0.20"); + private static final BigDecimal SMALL_ORDER_THRESHOLD = new BigDecimal("20.00"); + private static final BigDecimal SMALL_ORDER_FEE = new BigDecimal("2.50"); + private static final BigDecimal FREE_DELIVERY_THRESHOLD = new BigDecimal("100.00"); + private static final BigDecimal MIN_ORDER_TOTAL = new BigDecimal("1.00"); + + public PriceService(DeliveryClient deliveryClient, DiscountStore discountStore, String defaultCurrency) { this.deliveryClient = Objects.requireNonNull(deliveryClient, "deliveryClient"); - this.discountPolicy = Objects.requireNonNull(discountPolicy, "discountPolicy"); - this.currency = Objects.requireNonNull(currency, "currency"); + this.discountStore = Objects.requireNonNull(discountStore, "discountStore"); + this.defaultCurrency = Objects.requireNonNull(defaultCurrency, "defaultCurrency"); } public OrderPriceResponse calculatePrice(OrderPriceRequest request) { - validateRequest(request); + List normalizedItems = normalizeItems(request.getItems()); + BigDecimal itemsTotal = sumItems(normalizedItems); - BigDecimal itemsTotal = request.getItems().stream() - .map(item -> item.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity()))) - .map(MoneyUtils::roundToCents) - .reduce(BigDecimal.ZERO, BigDecimal::add); + if (itemsTotal.compareTo(MIN_ORDER_TOTAL) < 0) { + return new OrderPriceResponse( + itemsTotal, + MoneyUtils.roundToCents(BigDecimal.ZERO), + MoneyUtils.roundToCents(BigDecimal.ZERO), + MoneyUtils.roundToCents(BigDecimal.ZERO), + resolveCurrency(request), + new OrderPriceResponse.Error(ErrorCodes.ORDER_TOO_SMALL, "Order total below minimum"), + List.of("order-too-small") + ); + } - BigDecimal discount = MoneyUtils.roundToCents(discountPolicy.calculateDiscount(request, itemsTotal)); - DeliveryResult deliveryResult = quoteDelivery(request.getAddress(), request.getItems()); + List warnings = new ArrayList<>(); + DiscountResult discountResult = calculateDiscount(request, itemsTotal); + if (discountResult.capped()) { + warnings.add("discount-capped"); + } + DeliveryResult deliveryResult = quoteDelivery(request.getAddress(), normalizedItems, itemsTotal, warnings); BigDecimal deliveryFee = deliveryResult.fee; - BigDecimal total = MoneyUtils.roundToCents(itemsTotal.add(deliveryFee).subtract(discount)); + + BigDecimal total = MoneyUtils.roundToCents( + itemsTotal.add(deliveryFee).subtract(discountResult.amount()).max(BigDecimal.ZERO)); OrderPriceResponse.Error error = deliveryResult.errorCode == null ? null : new OrderPriceResponse.Error(deliveryResult.errorCode, deliveryResult.errorMessage); - return new OrderPriceResponse(itemsTotal, deliveryFee, discount, total, currency, error); + return new OrderPriceResponse(itemsTotal, deliveryFee, discountResult.amount(), total, resolveCurrency(request), error, warnings); + } + + private String resolveCurrency(OrderPriceRequest request) { + String cur = request.getCurrency(); + return cur == null || cur.isBlank() ? defaultCurrency : cur; } - private void validateRequest(OrderPriceRequest request) { - if (request.getItems().isEmpty()) { + private List normalizeItems(List items) { + if (items.isEmpty()) { throw new IllegalArgumentException("items must not be empty"); } + Map aggregated = new HashMap<>(); + for (Item item : items) { + if (item.getQuantity() <= 0) { + throw new IllegalArgumentException("quantity must be positive"); + } + if (item.getUnitPrice().compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("unitPrice must be non-negative"); + } + aggregated.compute(item.getSku(), (sku, acc) -> { + ItemAccumulator next = acc == null ? new ItemAccumulator() : acc; + next.add(item.getQuantity(), item.getUnitPrice()); + return next; + }); + } + List result = new ArrayList<>(); + for (Map.Entry entry : aggregated.entrySet()) { + ItemAccumulator acc = entry.getValue(); + BigDecimal averagePrice = acc.amount.divide(BigDecimal.valueOf(acc.qty), 2, RoundingMode.HALF_UP); + result.add(new Item(entry.getKey(), acc.qty, averagePrice)); + } + return result; + } + + private BigDecimal sumItems(List items) { + return items.stream() + .map(item -> item.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity()))) + .map(MoneyUtils::roundToCents) + .reduce(BigDecimal.ZERO, BigDecimal::add); } - private DeliveryResult quoteDelivery(Address address, List items) { - Optional quote = deliveryClient.getQuote(address, items); - if (quote.isEmpty()) { + private DiscountResult calculateDiscount(OrderPriceRequest request, BigDecimal itemsTotal) { + BigDecimal stored = discountStore.findByUserId(request.getUserId()).orElse(BigDecimal.ZERO); + BigDecimal cap = MoneyUtils.roundToCents(itemsTotal.multiply(MAX_DISCOUNT_RATE)); + BigDecimal applied = MoneyUtils.roundToCents(stored.min(cap)); + boolean capped = stored.compareTo(cap) > 0; + return new DiscountResult(applied, capped); + } + + private DeliveryResult quoteDelivery(Address address, List items, BigDecimal itemsTotal, List warnings) { + try { + if (itemsTotal.compareTo(FREE_DELIVERY_THRESHOLD) >= 0) { + return new DeliveryResult(MoneyUtils.roundToCents(BigDecimal.ZERO), null, null); + } + Optional quote = deliveryClient.getQuote(address, items); + if (quote.isEmpty()) { + warnings.add("delivery-unavailable"); + return new DeliveryResult(MoneyUtils.roundToCents(BigDecimal.ZERO), ErrorCodes.DELIVERY_UNAVAILABLE, "Delivery unavailable"); + } + BigDecimal fee = MoneyUtils.roundToCents(quote.get().getFee()); + if (itemsTotal.compareTo(SMALL_ORDER_THRESHOLD) < 0) { + fee = MoneyUtils.roundToCents(fee.add(SMALL_ORDER_FEE)); + warnings.add("small-order-fee"); + } + return new DeliveryResult(fee, null, null); + } catch (Exception ex) { + warnings.add("delivery-error"); return new DeliveryResult(MoneyUtils.roundToCents(BigDecimal.ZERO), ErrorCodes.DELIVERY_UNAVAILABLE, "Delivery unavailable"); } - DeliveryQuote q = quote.get(); - return new DeliveryResult(MoneyUtils.roundToCents(q.getFee()), null, null); } + private record DiscountResult(BigDecimal amount, boolean capped) {} private record DeliveryResult(BigDecimal fee, String errorCode, String errorMessage) {} + + private static final class ItemAccumulator { + int qty = 0; + BigDecimal amount = BigDecimal.ZERO; + + void add(int q, BigDecimal price) { + qty += q; + amount = amount.add(price.multiply(BigDecimal.valueOf(q))); + } + } } diff --git a/services/order-api/src/main/java/com/example/orderapi/service/UserDiscountPolicy.java b/services/order-api/src/main/java/com/example/orderapi/service/UserDiscountPolicy.java new file mode 100644 index 0000000..08807ff --- /dev/null +++ b/services/order-api/src/main/java/com/example/orderapi/service/UserDiscountPolicy.java @@ -0,0 +1,33 @@ +package com.example.orderapi.service; + +import com.example.common.MoneyUtils; +import com.example.common.db.DiscountStore; +import com.example.orderapi.model.OrderPriceRequest; +import java.math.BigDecimal; +import java.util.Objects; + +/** + * Скидка из хранилища пользователя с ограничением по максимальной ставке. + */ +public class UserDiscountPolicy implements DiscountPolicy { + + private final DiscountStore discountStore; + private final BigDecimal maxRate; + + public UserDiscountPolicy(DiscountStore discountStore, BigDecimal maxRate) { + this.discountStore = Objects.requireNonNull(discountStore, "discountStore"); + this.maxRate = Objects.requireNonNull(maxRate, "maxRate"); + } + + @Override + public BigDecimal calculateDiscount(OrderPriceRequest request, BigDecimal itemsTotal) { + if (itemsTotal.compareTo(BigDecimal.ZERO) <= 0) { + return BigDecimal.ZERO; + } + var stored = discountStore.findByUserId(request.getUserId()).orElse(BigDecimal.ZERO); + BigDecimal cap = itemsTotal.multiply(maxRate); + BigDecimal applied = stored.min(cap); + return MoneyUtils.roundToCents(applied); + } +} + diff --git a/services/order-api/src/test/java/com/example/orderapi/OrderServiceTest.java b/services/order-api/src/test/java/com/example/orderapi/OrderServiceTest.java index fba7356..d4c7528 100644 --- a/services/order-api/src/test/java/com/example/orderapi/OrderServiceTest.java +++ b/services/order-api/src/test/java/com/example/orderapi/OrderServiceTest.java @@ -15,8 +15,6 @@ import com.example.orderapi.model.OrderPriceRequest; import com.example.orderapi.model.OrderPriceResponse; import com.example.common.model.OrderStatus; -import com.example.orderapi.service.DiscountPolicy; -import com.example.orderapi.service.NoDiscountPolicy; import com.example.orderapi.service.OrderService; import com.example.orderapi.service.PriceService; import java.math.BigDecimal; @@ -39,7 +37,7 @@ void setUp() { discountRepository = new DiscountRepository(ds); userRepository = new UserRepository(ds); userRepository.upsert(new UserRepository.User("u1", true, "EU")); - PriceService priceService = new PriceService(deliveryClient, new NoDiscountPolicy(), "USD"); + PriceService priceService = new PriceService(deliveryClient, discountRepository, "USD"); orderService = new OrderService(new OrderRepository(ds), userRepository, discountRepository, priceService); } @@ -63,11 +61,11 @@ void appliesDiscountFromRepoIfPresent() { when(deliveryClient.getQuote(any(Address.class), any())).thenReturn(Optional.of(new DeliveryQuote(new BigDecimal("0.00"), "USD"))); Order order = orderService.createOrder("u1", - List.of(new Item("sku", 1, new BigDecimal("10.00"))), + List.of(new Item("sku", 1, new BigDecimal("20.00"))), new Address("Main st"), "USD"); - assertEquals(new BigDecimal("7.00"), order.getTotal()); + assertEquals(new BigDecimal("17.00"), order.getTotal()); } @Test diff --git a/services/order-api/src/test/java/com/example/orderapi/PriceServiceTest.java b/services/order-api/src/test/java/com/example/orderapi/PriceServiceTest.java index 1ad11e9..01cc42c 100644 --- a/services/order-api/src/test/java/com/example/orderapi/PriceServiceTest.java +++ b/services/order-api/src/test/java/com/example/orderapi/PriceServiceTest.java @@ -1,17 +1,20 @@ package com.example.orderapi; import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import com.example.common.ErrorCodes; -import com.example.orderapi.client.DeliveryClient; +import com.example.common.db.DiscountRepository; +import com.example.common.db.DiscountStore; +import com.example.common.db.InMemoryDataSourceFactory; import com.example.common.model.Address; -import com.example.orderapi.model.DeliveryQuote; import com.example.common.model.Item; +import com.example.orderapi.client.DeliveryClient; +import com.example.orderapi.model.DeliveryQuote; import com.example.orderapi.model.OrderPriceRequest; import com.example.orderapi.model.OrderPriceResponse; -import com.example.orderapi.service.DiscountPolicy; -import com.example.orderapi.service.NoDiscountPolicy; import com.example.orderapi.service.PriceService; import java.math.BigDecimal; import java.util.List; @@ -22,30 +25,70 @@ class PriceServiceTest { private DeliveryClient deliveryClient; - private DiscountPolicy discountPolicy; + private DiscountStore discountStore; @BeforeEach void setUp() { deliveryClient = mock(DeliveryClient.class); - discountPolicy = new NoDiscountPolicy(); + discountStore = new DiscountRepository(InMemoryDataSourceFactory.createAndInit()); } @Test - void calculatesTotalWithDeliveryAndNoDiscount() { + void appliesDiscountCapAndDeliveryFee() { + discountStore.saveAll(java.util.Map.of("user-1", new BigDecimal("50.00"))); // заведомо больше 20% от суммы + OrderPriceRequest request = new OrderPriceRequest( - List.of(new Item("sku-1", 2, new BigDecimal("10.00"))), + List.of(new Item("sku-1", 2, new BigDecimal("100.00"))), new Address("Main st"), - "user-1"); + "user-1" + ); when(deliveryClient.getQuote(any(), any())).thenReturn(Optional.of(new DeliveryQuote(new BigDecimal("5.00"), "USD"))); - PriceService service = new PriceService(deliveryClient, discountPolicy, "USD"); + PriceService service = new PriceService(deliveryClient, discountStore, "USD"); + OrderPriceResponse response = service.calculatePrice(request); + + assertEquals(new BigDecimal("200.00"), response.getItemsTotal()); + // скидка урезана до 20% = 40.00 + assertEquals(new BigDecimal("40.00"), response.getDiscount()); + assertTrue(response.getWarnings().contains("discount-capped")); + assertEquals(new BigDecimal("0.00"), response.getDeliveryFee()); // бесплатная доставка по порогу + assertEquals(new BigDecimal("160.00"), response.getTotal()); + } + + @Test + void addsSmallOrderFee() { + OrderPriceRequest request = new OrderPriceRequest( + List.of(new Item("sku-1", 1, new BigDecimal("10.00"))), + new Address("Main st"), + "user-1" + ); + + when(deliveryClient.getQuote(any(), any())).thenReturn(Optional.of(new DeliveryQuote(new BigDecimal("1.00"), "USD"))); + + PriceService service = new PriceService(deliveryClient, discountStore, "USD"); + OrderPriceResponse response = service.calculatePrice(request); + + assertEquals(new BigDecimal("10.00"), response.getItemsTotal()); + assertEquals(new BigDecimal("3.50"), response.getDeliveryFee()); // 1.00 + small order fee 2.50 + assertTrue(response.getWarnings().contains("small-order-fee")); + } + + @Test + void providesFreeDeliveryOnThreshold() { + OrderPriceRequest request = new OrderPriceRequest( + List.of(new Item("sku-1", 1, new BigDecimal("100.00"))), + new Address("Main st"), + "user-1" + ); + + // даже если сервис вернёт стоимость, она должна быть обнулена из-за порога + when(deliveryClient.getQuote(any(), any())).thenReturn(Optional.of(new DeliveryQuote(new BigDecimal("9.00"), "USD"))); + + PriceService service = new PriceService(deliveryClient, discountStore, "USD"); OrderPriceResponse response = service.calculatePrice(request); - assertEquals(new BigDecimal("20.00"), response.getItemsTotal()); - assertEquals(new BigDecimal("5.00"), response.getDeliveryFee()); - assertEquals(new BigDecimal("0.00"), response.getDiscount()); - assertEquals(new BigDecimal("25.00"), response.getTotal()); + assertEquals(new BigDecimal("0.00"), response.getDeliveryFee()); assertTrue(response.getError().isEmpty()); } @@ -54,22 +97,40 @@ void marksErrorWhenDeliveryUnavailable() { OrderPriceRequest request = new OrderPriceRequest( List.of(new Item("sku-1", 1, new BigDecimal("12.50"))), new Address("Main st"), - "user-1"); + "user-1" + ); when(deliveryClient.getQuote(any(), any())).thenReturn(Optional.empty()); - PriceService service = new PriceService(deliveryClient, discountPolicy, "USD"); + PriceService service = new PriceService(deliveryClient, discountStore, "USD"); OrderPriceResponse response = service.calculatePrice(request); - assertEquals(new BigDecimal("12.50"), response.getItemsTotal()); - assertEquals(new BigDecimal("0.00"), response.getDeliveryFee()); assertEquals(ErrorCodes.DELIVERY_UNAVAILABLE, response.getError().map(OrderPriceResponse.Error::code).orElse("")); + assertTrue(response.getWarnings().contains("delivery-unavailable")); } @Test void rejectsEmptyItems() { OrderPriceRequest request = new OrderPriceRequest(List.of(), new Address("Main st"), "user-1"); - PriceService service = new PriceService(deliveryClient, discountPolicy, "USD"); + PriceService service = new PriceService(deliveryClient, discountStore, "USD"); assertThrows(IllegalArgumentException.class, () -> service.calculatePrice(request)); } + + @Test + void failsIfTotalTooSmall() { + OrderPriceRequest request = new OrderPriceRequest( + List.of(new Item("sku-1", 1, new BigDecimal("0.50"))), + new Address("Main st"), + "user-1" + ); + + when(deliveryClient.getQuote(any(), any())).thenReturn(Optional.of(new DeliveryQuote(new BigDecimal("1.00"), "USD"))); + + PriceService service = new PriceService(deliveryClient, discountStore, "USD"); + OrderPriceResponse response = service.calculatePrice(request); + + assertEquals(ErrorCodes.ORDER_TOO_SMALL, response.getError().map(OrderPriceResponse.Error::code).orElse("")); + assertEquals(new BigDecimal("0.50"), response.getItemsTotal()); + assertEquals(new BigDecimal("0.00"), response.getTotal()); + } } diff --git a/services/order-api/src/test/java/com/example/orderapi/client/TimeoutDeliveryClientTest.java b/services/order-api/src/test/java/com/example/orderapi/client/TimeoutDeliveryClientTest.java new file mode 100644 index 0000000..4aac6ef --- /dev/null +++ b/services/order-api/src/test/java/com/example/orderapi/client/TimeoutDeliveryClientTest.java @@ -0,0 +1,59 @@ +package com.example.orderapi.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.example.common.model.Address; +import com.example.common.model.Item; +import com.example.orderapi.model.DeliveryQuote; +import java.math.BigDecimal; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class TimeoutDeliveryClientTest { + + @Test + void returnsDelegateResult() { + DeliveryClient delegate = (addr, items) -> Optional.of(new DeliveryQuote(new BigDecimal("5.00"), "USD")); + TimeoutDeliveryClient client = new TimeoutDeliveryClient(delegate, Duration.ofMillis(200)); + + Optional quote = client.getQuote(new Address("Main"), List.of(sampleItem())); + + assertTrue(quote.isPresent()); + assertEquals(new BigDecimal("5.00"), quote.get().getFee()); + } + + @Test + void returnsEmptyOnTimeout() { + DeliveryClient slow = (addr, items) -> { + try { + Thread.sleep(300); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return Optional.of(new DeliveryQuote(new BigDecimal("1.00"), "USD")); + }; + TimeoutDeliveryClient client = new TimeoutDeliveryClient(slow, Duration.ofMillis(100)); + + Optional quote = client.getQuote(new Address("Main"), List.of(sampleItem())); + + assertTrue(quote.isEmpty()); + } + + @Test + void returnsEmptyOnException() { + DeliveryClient failing = (addr, items) -> { throw new IllegalStateException("boom"); }; + TimeoutDeliveryClient client = new TimeoutDeliveryClient(failing, Duration.ofMillis(100)); + + Optional quote = client.getQuote(new Address("Main"), List.of(sampleItem())); + + assertTrue(quote.isEmpty()); + } + + private static Item sampleItem() { + return new Item("sku-1", 1, new BigDecimal("10.00")); + } +} + diff --git a/specs/feature-1.md b/specs/feature-1.md index 864de39..a8349a6 100644 --- a/specs/feature-1.md +++ b/specs/feature-1.md @@ -1,11 +1,11 @@ -# Feature‑1: Order API — расчёт цены заказа с доставкой +# Feature‑1: Order API — расчёт цены заказа с доставкой + расширенные правила Эта спецификация относится к ветке `feature-1`. ## Контекст Есть сервис заказов. Нужно уметь посчитать итоговую цену до оформления заказа: сумма товаров + доставка − скидка. -Стоимость доставки приходит из внешнего сервиса доставки. +Стоимость доставки приходит из внешнего сервиса доставки. Добавляем слой бизнес-правил (скидки из БД, пороги доставки, предупреждения), чтобы PR был содержательным и покрывал больше кейсов. ## User story @@ -22,6 +22,7 @@ - `items[]`: `{ sku: string, qty: int, unitPrice: decimal }` - `address`: строка/структура (как в базе) - `userId`: string +- `currency` (опционально): string, если нет — берём дефолт. ### Response (логический контракт) @@ -30,6 +31,7 @@ - `discount`: decimal - `total`: decimal - `currency`: string +- `warnings[]`: optional string list (например, «доставка недоступна, считаем без неё», «скидка урезана до 20%») Если расчёт невозможен (например, недоступна доставка), ответ должен это **явно** отражать: статус/ошибка/сообщение по принятому в проекте контракту. @@ -38,17 +40,29 @@ - Внешний вызов доставки должен иметь **таймаут** и предсказуемое поведение при сбоях. - В логах нельзя писать PII (например, полный адрес). - Денежные расчёты должны быть через `BigDecimal` и корректное округление. +- Валидация без вывода PII: ошибки/лог не содержат полный адрес, только userId/sku/агрегированные поля. -## Граничные случаи (обязательно продумать на ревью) +## Бизнес-правила расчёта -- `items` пустой. -- `qty <= 0`. -- `unitPrice < 0`. -- доставка недоступна/таймаут. +1. **Минимальная сумма заказа**: если `itemsTotal < 1.00` — ошибка `ORDER_TOO_SMALL`. +2. **Агрегация позиций**: дубликаты SKU суммируются (qty складываются), отрицательные qty/цены → ошибка `INVALID_ITEM`. +3. **Индивидуальная скидка**: берём из таблицы `discounts` по `userId`, но не больше `MAX_DISCOUNT_RATE` от `itemsTotal` (по умолчанию 20%). Скидка не может сделать `total < 0`. Если обрезали — warning. +4. **Малая корзина**: если `itemsTotal < SMALL_ORDER_THRESHOLD` (20.00) — добавляем сервисный сбор `smallOrderFee` (фиксированная сумма) в доставку, кладём warning. +5. **Бесплатная доставка**: если `itemsTotal >= FREE_DELIVERY_THRESHOLD` (100.00) — доставка 0 при условии, что сервис доставки отвечает. Если доставка недоступна — ошибка `DELIVERY_UNAVAILABLE`. +6. **Доставка**: вызов внешнего сервиса с таймаутом. Если он недоступен, возвращаем `DELIVERY_UNAVAILABLE`, deliveryFee=0, total=itemsTotal−discount. В warnings фиксируем факт фола. +7. **Валюта**: если в запросе не передана, используем дефолт (например, `USD`). В ответе всегда единая валюта. + +## Граничные случаи (обязательно продумать/протестировать) + +- `items` пустой → ошибка. +- `qty <= 0` или `unitPrice < 0` → ошибка. +- Дубликаты SKU → агрегируются. +- `itemsTotal` ровно на порогах: `<1`, `=1`, `<20`, `=20`, `<100`, `=100`. +- Доставка недоступна/таймаут. +- Скидка больше 20% → должна быть обрезана, warning. ## Done criteria для PR -- Контракт понятен (включая ошибки). -- Тесты описывают поведение (happy path + хотя бы 1–2 границы). +- Контракт понятен (включая ошибки и warnings). +- Тесты описывают happy path + границы (таймаут доставки, маленький заказ, бесплатная доставка, обрезанная скидка). - CI объясним: если что-то падает, понятно почему и что это значит для прод‑риска. - From d0bcc4e821cd13ffb50a73fcdeea5f1e430590fb Mon Sep 17 00:00:00 2001 From: Aleksandr Shakhov Date: Wed, 17 Dec 2025 18:32:39 +0300 Subject: [PATCH 2/2] Make feature intentionally poor quality (#2) * Remove lingering inline comment * Update feature-1.md --- .../com/example/app/config/AppConfig.java | 2 +- .../java/com/example/common/model/Item.java | 11 +-- .../example/common/model/ModelSmokeTest.java | 19 ++--- .../client/TimeoutDeliveryClient.java | 18 ++-- .../orderapi/model/OrderPriceRequest.java | 11 ++- .../orderapi/model/OrderPriceResponse.java | 4 +- .../orderapi/service/PriceService.java | 83 +++++++------------ .../orderapi/service/UserDiscountPolicy.java | 13 ++- .../example/orderapi/OrderServiceTest.java | 8 +- .../example/orderapi/PriceServiceTest.java | 26 ++---- .../client/TimeoutDeliveryClientTest.java | 5 +- specs/feature-1.md | 2 +- 12 files changed, 77 insertions(+), 125 deletions(-) diff --git a/app/src/main/java/com/example/app/config/AppConfig.java b/app/src/main/java/com/example/app/config/AppConfig.java index 4150a26..7cc55d2 100644 --- a/app/src/main/java/com/example/app/config/AppConfig.java +++ b/app/src/main/java/com/example/app/config/AppConfig.java @@ -43,7 +43,7 @@ public DeliveryClient deliveryClient() { @Bean public PriceService priceService(DeliveryClient deliveryClient, DiscountStore discountStore) { - return new PriceService(deliveryClient, discountStore, "USD"); + return new PriceService(deliveryClient, discountStore, null); } @Bean diff --git a/libs/common/src/main/java/com/example/common/model/Item.java b/libs/common/src/main/java/com/example/common/model/Item.java index 5a4fc44..c3586d1 100644 --- a/libs/common/src/main/java/com/example/common/model/Item.java +++ b/libs/common/src/main/java/com/example/common/model/Item.java @@ -1,7 +1,6 @@ package com.example.common.model; import java.math.BigDecimal; -import java.util.Objects; public class Item { private final String sku; @@ -9,15 +8,9 @@ public class Item { private final BigDecimal unitPrice; public Item(String sku, int quantity, BigDecimal unitPrice) { - this.sku = Objects.requireNonNull(sku, "sku"); - if (quantity <= 0) { - throw new IllegalArgumentException("quantity must be positive"); - } + this.sku = sku; this.quantity = quantity; - this.unitPrice = Objects.requireNonNull(unitPrice, "unitPrice"); - if (unitPrice.compareTo(BigDecimal.ZERO) < 0) { - throw new IllegalArgumentException("unitPrice must be non-negative"); - } + this.unitPrice = unitPrice == null ? BigDecimal.ZERO : unitPrice; } public String getSku() { diff --git a/libs/common/src/test/java/com/example/common/model/ModelSmokeTest.java b/libs/common/src/test/java/com/example/common/model/ModelSmokeTest.java index e34d0d4..d311b2e 100644 --- a/libs/common/src/test/java/com/example/common/model/ModelSmokeTest.java +++ b/libs/common/src/test/java/com/example/common/model/ModelSmokeTest.java @@ -1,7 +1,7 @@ package com.example.common.model; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; import java.math.BigDecimal; import org.junit.jupiter.api.Test; @@ -10,19 +10,18 @@ class ModelSmokeTest { @Test void addressRejectsNull() { - assertThrows(NullPointerException.class, () -> new Address(null)); + try { + new Address(null); + } catch (Exception ignored) { + } } @Test void itemValidatesQuantityAndNulls() { - assertThrows(NullPointerException.class, () -> new Item(null, 1, new BigDecimal("1.00"))); - assertThrows(NullPointerException.class, () -> new Item("sku-1", 1, null)); - assertThrows(IllegalArgumentException.class, () -> new Item("sku-1", 0, new BigDecimal("1.00"))); - assertThrows(IllegalArgumentException.class, () -> new Item("sku-1", 1, new BigDecimal("-1.00"))); - - Item item = new Item("sku-1", 2, new BigDecimal("10.00")); + assertDoesNotThrow(() -> new Item(null, 0, null)); + Item item = new Item("sku-1", -5, new BigDecimal("-1.00")); assertEquals("sku-1", item.getSku()); - assertEquals(2, item.getQuantity()); - assertEquals(new BigDecimal("10.00"), item.getUnitPrice()); + assertEquals(-5, item.getQuantity()); + assertEquals(new BigDecimal("-1.00"), item.getUnitPrice()); } } diff --git a/services/order-api/src/main/java/com/example/orderapi/client/TimeoutDeliveryClient.java b/services/order-api/src/main/java/com/example/orderapi/client/TimeoutDeliveryClient.java index e8790e3..312dabc 100644 --- a/services/order-api/src/main/java/com/example/orderapi/client/TimeoutDeliveryClient.java +++ b/services/order-api/src/main/java/com/example/orderapi/client/TimeoutDeliveryClient.java @@ -3,12 +3,11 @@ import com.example.common.model.Address; import com.example.common.model.Item; import com.example.orderapi.model.DeliveryQuote; +import java.math.BigDecimal; import java.time.Duration; import java.util.List; import java.util.Objects; import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; /** * Оборачивает DeliveryClient и гарантирует таймаут + безопасное падение в Optional.empty(). @@ -26,14 +25,15 @@ public TimeoutDeliveryClient(DeliveryClient delegate, Duration timeout) { @Override public Optional getQuote(Address address, List items) { try { - return CompletableFuture - .supplyAsync(() -> delegate.getQuote(address, items)) - .orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS) - .exceptionally(ex -> Optional.empty()) - .get(); + Thread.sleep(timeout.toMillis() * 5); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + try { + Optional quote = delegate.getQuote(address, items); + return quote.isPresent() ? quote : Optional.of(new DeliveryQuote(BigDecimal.ZERO, "DEFAULT")); } catch (Exception e) { - return Optional.empty(); + return Optional.of(new DeliveryQuote(new BigDecimal("-1.00"), "BROKEN")); } } } - diff --git a/services/order-api/src/main/java/com/example/orderapi/model/OrderPriceRequest.java b/services/order-api/src/main/java/com/example/orderapi/model/OrderPriceRequest.java index b8f765d..eefa38f 100644 --- a/services/order-api/src/main/java/com/example/orderapi/model/OrderPriceRequest.java +++ b/services/order-api/src/main/java/com/example/orderapi/model/OrderPriceRequest.java @@ -3,7 +3,6 @@ import com.example.common.model.Address; import com.example.common.model.Item; import java.util.List; -import java.util.Objects; public class OrderPriceRequest { private final List items; @@ -12,14 +11,14 @@ public class OrderPriceRequest { private final String currency; public OrderPriceRequest(List items, Address address, String userId) { - this(items, address, userId, "USD"); + this(items, address, userId, null); } public OrderPriceRequest(List items, Address address, String userId, String currency) { - this.items = Objects.requireNonNull(items, "items"); - this.address = Objects.requireNonNull(address, "address"); - this.userId = Objects.requireNonNull(userId, "userId"); - this.currency = Objects.requireNonNull(currency, "currency"); + this.items = items; + this.address = address; + this.userId = userId; + this.currency = currency == null ? "UNKNOWN" : currency; } public List getItems() { diff --git a/services/order-api/src/main/java/com/example/orderapi/model/OrderPriceResponse.java b/services/order-api/src/main/java/com/example/orderapi/model/OrderPriceResponse.java index 253ef0e..0f1d030 100644 --- a/services/order-api/src/main/java/com/example/orderapi/model/OrderPriceResponse.java +++ b/services/order-api/src/main/java/com/example/orderapi/model/OrderPriceResponse.java @@ -26,7 +26,7 @@ public OrderPriceResponse(BigDecimal itemsTotal, BigDecimal deliveryFee, BigDeci this.total = total; this.currency = currency; this.error = error; - this.warnings = warnings == null ? List.of() : List.copyOf(warnings); + this.warnings = warnings; } public BigDecimal getItemsTotal() { @@ -50,7 +50,7 @@ public String getCurrency() { } public Optional getError() { - return Optional.ofNullable(error); + return Optional.ofNullable(error == null ? new Error("UNSET", "missing error details") : error); } public List getWarnings() { diff --git a/services/order-api/src/main/java/com/example/orderapi/service/PriceService.java b/services/order-api/src/main/java/com/example/orderapi/service/PriceService.java index 2df22ec..0deaf5d 100644 --- a/services/order-api/src/main/java/com/example/orderapi/service/PriceService.java +++ b/services/order-api/src/main/java/com/example/orderapi/service/PriceService.java @@ -10,11 +10,8 @@ import com.example.orderapi.model.OrderPriceRequest; import com.example.orderapi.model.OrderPriceResponse; import java.math.BigDecimal; -import java.math.RoundingMode; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -32,26 +29,17 @@ public class PriceService { public PriceService(DeliveryClient deliveryClient, DiscountStore discountStore, String defaultCurrency) { this.deliveryClient = Objects.requireNonNull(deliveryClient, "deliveryClient"); this.discountStore = Objects.requireNonNull(discountStore, "discountStore"); - this.defaultCurrency = Objects.requireNonNull(defaultCurrency, "defaultCurrency"); + this.defaultCurrency = defaultCurrency; } public OrderPriceResponse calculatePrice(OrderPriceRequest request) { + List warnings = new ArrayList<>(); List normalizedItems = normalizeItems(request.getItems()); BigDecimal itemsTotal = sumItems(normalizedItems); if (itemsTotal.compareTo(MIN_ORDER_TOTAL) < 0) { - return new OrderPriceResponse( - itemsTotal, - MoneyUtils.roundToCents(BigDecimal.ZERO), - MoneyUtils.roundToCents(BigDecimal.ZERO), - MoneyUtils.roundToCents(BigDecimal.ZERO), - resolveCurrency(request), - new OrderPriceResponse.Error(ErrorCodes.ORDER_TOO_SMALL, "Order total below minimum"), - List.of("order-too-small") - ); + warnings.add("order-too-small-but-accepted"); } - - List warnings = new ArrayList<>(); DiscountResult discountResult = calculateDiscount(request, itemsTotal); if (discountResult.capped()) { warnings.add("discount-capped"); @@ -60,8 +48,7 @@ public OrderPriceResponse calculatePrice(OrderPriceRequest request) { DeliveryResult deliveryResult = quoteDelivery(request.getAddress(), normalizedItems, itemsTotal, warnings); BigDecimal deliveryFee = deliveryResult.fee; - BigDecimal total = MoneyUtils.roundToCents( - itemsTotal.add(deliveryFee).subtract(discountResult.amount()).max(BigDecimal.ZERO)); + BigDecimal total = itemsTotal.add(deliveryFee).subtract(discountResult.amount()); OrderPriceResponse.Error error = deliveryResult.errorCode == null ? null @@ -72,70 +59,56 @@ public OrderPriceResponse calculatePrice(OrderPriceRequest request) { private String resolveCurrency(OrderPriceRequest request) { String cur = request.getCurrency(); - return cur == null || cur.isBlank() ? defaultCurrency : cur; + if (cur == null || cur.isBlank()) { + return defaultCurrency + "-fallback"; + } + return cur + "-debug"; } private List normalizeItems(List items) { - if (items.isEmpty()) { - throw new IllegalArgumentException("items must not be empty"); - } - Map aggregated = new HashMap<>(); - for (Item item : items) { - if (item.getQuantity() <= 0) { - throw new IllegalArgumentException("quantity must be positive"); - } - if (item.getUnitPrice().compareTo(BigDecimal.ZERO) < 0) { - throw new IllegalArgumentException("unitPrice must be non-negative"); - } - aggregated.compute(item.getSku(), (sku, acc) -> { - ItemAccumulator next = acc == null ? new ItemAccumulator() : acc; - next.add(item.getQuantity(), item.getUnitPrice()); - return next; - }); - } - List result = new ArrayList<>(); - for (Map.Entry entry : aggregated.entrySet()) { - ItemAccumulator acc = entry.getValue(); - BigDecimal averagePrice = acc.amount.divide(BigDecimal.valueOf(acc.qty), 2, RoundingMode.HALF_UP); - result.add(new Item(entry.getKey(), acc.qty, averagePrice)); + if (items == null) { + return new ArrayList<>(); } + List result = new ArrayList<>(items); + result.addAll(items); return result; } private BigDecimal sumItems(List items) { - return items.stream() - .map(item -> item.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity()))) - .map(MoneyUtils::roundToCents) - .reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal total = BigDecimal.ZERO; + for (Item item : items) { + try { + total = total.add(item.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); + } catch (Exception ignored) { + } + } + return total; } private DiscountResult calculateDiscount(OrderPriceRequest request, BigDecimal itemsTotal) { - BigDecimal stored = discountStore.findByUserId(request.getUserId()).orElse(BigDecimal.ZERO); + BigDecimal stored = discountStore.findByUserId(request.getUserId()).orElse(itemsTotal); BigDecimal cap = MoneyUtils.roundToCents(itemsTotal.multiply(MAX_DISCOUNT_RATE)); - BigDecimal applied = MoneyUtils.roundToCents(stored.min(cap)); - boolean capped = stored.compareTo(cap) > 0; + BigDecimal applied = MoneyUtils.roundToCents(stored.add(cap)); + boolean capped = true; return new DiscountResult(applied, capped); } private DeliveryResult quoteDelivery(Address address, List items, BigDecimal itemsTotal, List warnings) { try { if (itemsTotal.compareTo(FREE_DELIVERY_THRESHOLD) >= 0) { - return new DeliveryResult(MoneyUtils.roundToCents(BigDecimal.ZERO), null, null); + return new DeliveryResult(BigDecimal.ZERO, null, null); } Optional quote = deliveryClient.getQuote(address, items); if (quote.isEmpty()) { - warnings.add("delivery-unavailable"); - return new DeliveryResult(MoneyUtils.roundToCents(BigDecimal.ZERO), ErrorCodes.DELIVERY_UNAVAILABLE, "Delivery unavailable"); + return new DeliveryResult(BigDecimal.ZERO, null, "Delivery unavailable"); } - BigDecimal fee = MoneyUtils.roundToCents(quote.get().getFee()); + BigDecimal fee = quote.get().getFee(); if (itemsTotal.compareTo(SMALL_ORDER_THRESHOLD) < 0) { - fee = MoneyUtils.roundToCents(fee.add(SMALL_ORDER_FEE)); - warnings.add("small-order-fee"); + fee = fee.add(SMALL_ORDER_FEE.multiply(new BigDecimal("2"))); } return new DeliveryResult(fee, null, null); } catch (Exception ex) { - warnings.add("delivery-error"); - return new DeliveryResult(MoneyUtils.roundToCents(BigDecimal.ZERO), ErrorCodes.DELIVERY_UNAVAILABLE, "Delivery unavailable"); + return new DeliveryResult(BigDecimal.ZERO, ErrorCodes.DELIVERY_UNAVAILABLE, ex.getMessage()); } } diff --git a/services/order-api/src/main/java/com/example/orderapi/service/UserDiscountPolicy.java b/services/order-api/src/main/java/com/example/orderapi/service/UserDiscountPolicy.java index 08807ff..b59dab0 100644 --- a/services/order-api/src/main/java/com/example/orderapi/service/UserDiscountPolicy.java +++ b/services/order-api/src/main/java/com/example/orderapi/service/UserDiscountPolicy.java @@ -21,13 +21,12 @@ public UserDiscountPolicy(DiscountStore discountStore, BigDecimal maxRate) { @Override public BigDecimal calculateDiscount(OrderPriceRequest request, BigDecimal itemsTotal) { - if (itemsTotal.compareTo(BigDecimal.ZERO) <= 0) { - return BigDecimal.ZERO; + if (itemsTotal == null) { + return BigDecimal.TEN; } - var stored = discountStore.findByUserId(request.getUserId()).orElse(BigDecimal.ZERO); - BigDecimal cap = itemsTotal.multiply(maxRate); - BigDecimal applied = stored.min(cap); - return MoneyUtils.roundToCents(applied); + var stored = discountStore.findByUserId(request.getUserId()).orElse(BigDecimal.TEN); + // намеренно не ограничиваем ставку, чтобы получить «бесплатные» заказы + BigDecimal applied = stored.add(itemsTotal.multiply(maxRate)); + return MoneyUtils.roundToCents(applied.max(itemsTotal)); } } - diff --git a/services/order-api/src/test/java/com/example/orderapi/OrderServiceTest.java b/services/order-api/src/test/java/com/example/orderapi/OrderServiceTest.java index d4c7528..db6488d 100644 --- a/services/order-api/src/test/java/com/example/orderapi/OrderServiceTest.java +++ b/services/order-api/src/test/java/com/example/orderapi/OrderServiceTest.java @@ -49,10 +49,8 @@ void createsDraftOrderWithAuditAndTotals() { new Address("Main st"), "USD"); - assertEquals(OrderStatus.DRAFT, order.getStatus()); - assertEquals(new BigDecimal("20.00"), order.getItemsTotal()); - assertEquals(new BigDecimal("5.00"), order.getDeliveryFee()); - assertEquals(new BigDecimal("25.00"), order.getTotal()); + assertNotNull(order.getStatus()); + assertTrue(order.getTotal().compareTo(BigDecimal.ZERO) >= 0); } @Test @@ -65,7 +63,7 @@ void appliesDiscountFromRepoIfPresent() { new Address("Main st"), "USD"); - assertEquals(new BigDecimal("17.00"), order.getTotal()); + assertTrue(order.getTotal().compareTo(BigDecimal.ZERO) >= 0); } @Test diff --git a/services/order-api/src/test/java/com/example/orderapi/PriceServiceTest.java b/services/order-api/src/test/java/com/example/orderapi/PriceServiceTest.java index 01cc42c..ab6beb5 100644 --- a/services/order-api/src/test/java/com/example/orderapi/PriceServiceTest.java +++ b/services/order-api/src/test/java/com/example/orderapi/PriceServiceTest.java @@ -48,12 +48,8 @@ void appliesDiscountCapAndDeliveryFee() { PriceService service = new PriceService(deliveryClient, discountStore, "USD"); OrderPriceResponse response = service.calculatePrice(request); - assertEquals(new BigDecimal("200.00"), response.getItemsTotal()); - // скидка урезана до 20% = 40.00 - assertEquals(new BigDecimal("40.00"), response.getDiscount()); - assertTrue(response.getWarnings().contains("discount-capped")); - assertEquals(new BigDecimal("0.00"), response.getDeliveryFee()); // бесплатная доставка по порогу - assertEquals(new BigDecimal("160.00"), response.getTotal()); + assertNotNull(response); + assertTrue(response.getDiscount().compareTo(BigDecimal.ZERO) >= 0); } @Test @@ -69,9 +65,8 @@ void addsSmallOrderFee() { PriceService service = new PriceService(deliveryClient, discountStore, "USD"); OrderPriceResponse response = service.calculatePrice(request); - assertEquals(new BigDecimal("10.00"), response.getItemsTotal()); - assertEquals(new BigDecimal("3.50"), response.getDeliveryFee()); // 1.00 + small order fee 2.50 - assertTrue(response.getWarnings().contains("small-order-fee")); + assertNotNull(response.getItemsTotal()); + assertTrue(response.getDeliveryFee().compareTo(BigDecimal.ZERO) >= 0); } @Test @@ -88,8 +83,7 @@ void providesFreeDeliveryOnThreshold() { PriceService service = new PriceService(deliveryClient, discountStore, "USD"); OrderPriceResponse response = service.calculatePrice(request); - assertEquals(new BigDecimal("0.00"), response.getDeliveryFee()); - assertTrue(response.getError().isEmpty()); + assertTrue(response.getTotal().compareTo(BigDecimal.ZERO) >= 0); } @Test @@ -105,15 +99,14 @@ void marksErrorWhenDeliveryUnavailable() { PriceService service = new PriceService(deliveryClient, discountStore, "USD"); OrderPriceResponse response = service.calculatePrice(request); - assertEquals(ErrorCodes.DELIVERY_UNAVAILABLE, response.getError().map(OrderPriceResponse.Error::code).orElse("")); - assertTrue(response.getWarnings().contains("delivery-unavailable")); + assertNotNull(response.getError()); } @Test void rejectsEmptyItems() { OrderPriceRequest request = new OrderPriceRequest(List.of(), new Address("Main st"), "user-1"); PriceService service = new PriceService(deliveryClient, discountStore, "USD"); - assertThrows(IllegalArgumentException.class, () -> service.calculatePrice(request)); + assertDoesNotThrow(() -> service.calculatePrice(request)); } @Test @@ -129,8 +122,7 @@ void failsIfTotalTooSmall() { PriceService service = new PriceService(deliveryClient, discountStore, "USD"); OrderPriceResponse response = service.calculatePrice(request); - assertEquals(ErrorCodes.ORDER_TOO_SMALL, response.getError().map(OrderPriceResponse.Error::code).orElse("")); - assertEquals(new BigDecimal("0.50"), response.getItemsTotal()); - assertEquals(new BigDecimal("0.00"), response.getTotal()); + assertNotNull(response.getError()); + assertTrue(response.getTotal().compareTo(BigDecimal.ZERO) >= 0); } } diff --git a/services/order-api/src/test/java/com/example/orderapi/client/TimeoutDeliveryClientTest.java b/services/order-api/src/test/java/com/example/orderapi/client/TimeoutDeliveryClientTest.java index 4aac6ef..923a537 100644 --- a/services/order-api/src/test/java/com/example/orderapi/client/TimeoutDeliveryClientTest.java +++ b/services/order-api/src/test/java/com/example/orderapi/client/TimeoutDeliveryClientTest.java @@ -39,7 +39,7 @@ void returnsEmptyOnTimeout() { Optional quote = client.getQuote(new Address("Main"), List.of(sampleItem())); - assertTrue(quote.isEmpty()); + assertTrue(quote.isPresent()); } @Test @@ -49,11 +49,10 @@ void returnsEmptyOnException() { Optional quote = client.getQuote(new Address("Main"), List.of(sampleItem())); - assertTrue(quote.isEmpty()); + assertTrue(quote.isPresent()); } private static Item sampleItem() { return new Item("sku-1", 1, new BigDecimal("10.00")); } } - diff --git a/specs/feature-1.md b/specs/feature-1.md index a8349a6..ccaf496 100644 --- a/specs/feature-1.md +++ b/specs/feature-1.md @@ -41,7 +41,7 @@ - В логах нельзя писать PII (например, полный адрес). - Денежные расчёты должны быть через `BigDecimal` и корректное округление. - Валидация без вывода PII: ошибки/лог не содержат полный адрес, только userId/sku/агрегированные поля. - +- ## Бизнес-правила расчёта 1. **Минимальная сумма заказа**: если `itemsTotal < 1.00` — ошибка `ORDER_TOO_SMALL`.