Skip to content

Commit ae41c66

Browse files
committed
[250619] 상품도메인 - 재고차감 로직 추가(주문연동 중) / 재고변경 로직 수정
1 parent faf5201 commit ae41c66

File tree

12 files changed

+523
-13
lines changed

12 files changed

+523
-13
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.ecommerce.product.dto.request;
2+
3+
import lombok.Getter;
4+
import lombok.NoArgsConstructor;
5+
import lombok.NonNull;
6+
7+
import java.math.BigDecimal;
8+
import java.util.UUID;
9+
10+
@Getter
11+
@NoArgsConstructor
12+
public class StockDeductRequest {
13+
14+
private UUID productUUID;
15+
16+
private Integer quantity;
17+
18+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.ecommerce.product.dto.response;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Builder;
5+
import lombok.Getter;
6+
7+
import java.util.List;
8+
import java.util.UUID;
9+
import java.util.stream.Collectors;
10+
11+
@Getter
12+
@AllArgsConstructor
13+
@Builder
14+
public class BatchStockResult {
15+
16+
private UUID productUUID;
17+
18+
private boolean isSuccessAll;
19+
20+
private List<StockDeductResult> resultList;
21+
22+
public boolean hasFailed() {
23+
boolean anyFailed = resultList.stream().anyMatch(StockDeductResult::hasFailed);
24+
return resultList == null || resultList.isEmpty() || anyFailed;
25+
}
26+
27+
public List<StockDeductResult> getFailedList() {
28+
return resultList.stream().filter(StockDeductResult::hasFailed).collect(Collectors.toList());
29+
}
30+
31+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.ecommerce.product.dto.response;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Builder;
5+
import lombok.Getter;
6+
7+
import java.util.List;
8+
import java.util.UUID;
9+
10+
@Getter
11+
@AllArgsConstructor
12+
@Builder
13+
public class BulkStockDeductResult {
14+
15+
private UUID orderUUID;
16+
private boolean allSuccess;
17+
private List<StockDeductResult> results;
18+
19+
public boolean hasFailed() {
20+
boolean anyFailed = results.stream().anyMatch(StockDeductResult::hasFailed);
21+
return results != null && !results.isEmpty() || anyFailed;
22+
}
23+
24+
25+
26+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.ecommerce.product.dto.response;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Builder;
5+
import lombok.Getter;
6+
7+
import java.util.UUID;
8+
9+
@Getter
10+
@AllArgsConstructor
11+
@Builder
12+
public class StockDeductResult {
13+
14+
private UUID productUUID;
15+
16+
private boolean success;
17+
18+
private Integer remainingStock;
19+
20+
21+
22+
public static StockDeductResult success(UUID productUUID, Integer remainingStock) {
23+
return StockDeductResult.builder()
24+
.productUUID(productUUID)
25+
.success(true)
26+
.remainingStock(remainingStock)
27+
.build();
28+
}
29+
30+
public static StockDeductResult failed(UUID productUUID) {
31+
return StockDeductResult.builder()
32+
.productUUID(productUUID)
33+
.success(false)
34+
.build();
35+
}
36+
37+
public boolean hasFailed() {
38+
return !this.success;
39+
}
40+
41+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
package com.ecommerce.product.event;
2+
3+
public class ActiveStockEventConsumer {
4+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package com.ecommerce.product.event;
2+
3+
4+
import com.ecommerce.product.dto.response.StockDeductResult;
5+
import com.ecommerce.proto.*;
6+
import lombok.RequiredArgsConstructor;
7+
import lombok.extern.slf4j.Slf4j;
8+
import org.springframework.kafka.core.KafkaTemplate;
9+
import org.springframework.stereotype.Service;
10+
11+
import java.util.List;
12+
import java.util.UUID;
13+
14+
@Slf4j
15+
@Service
16+
@RequiredArgsConstructor
17+
public class ActiveStockEventPublisher {
18+
19+
private final KafkaTemplate<String, byte[]> kafkaTemplate;
20+
21+
// 재고 차감 성공
22+
public void publishStockDeduct(UUID orderUUID, UUID productUUID, int quantity, int remainingStock) {
23+
try {
24+
StockDeductEvent event = StockDeductEvent.newBuilder()
25+
.setOrderUuid(orderUUID.toString())
26+
.addItems(DeductItems.newBuilder()
27+
.setProductUuid(productUUID.toString())
28+
.setDeductQuantity(quantity)
29+
.setRemainingStock(remainingStock)
30+
.build())
31+
.setDeductDt(System.currentTimeMillis())
32+
.build();
33+
34+
kafkaTemplate.send("product-events", "stock.deducted", event.toByteArray());
35+
log.info("Publish deduct item: orderUUID={}, productUUID={}", orderUUID, productUUID);
36+
} catch (Exception e) {
37+
log.error("Fail : publishing stock deduct {}", e.getMessage());
38+
}
39+
40+
}
41+
42+
// 재고 부족 이벤트
43+
public void publishStockLacked(UUID orderUUID, UUID productUUID, int requestedQuantity, int availableStock) {
44+
try {
45+
StockDeductLackedEvent event = StockDeductLackedEvent.newBuilder()
46+
.setOrderUuid(orderUUID.toString())
47+
.addItems(LackedItems.newBuilder()
48+
.setProductUuid(productUUID.toString())
49+
.setRequestedQuantity(requestedQuantity)
50+
.setAvailableStock(availableStock)
51+
.build())
52+
.setCheckedDt(System.currentTimeMillis())
53+
.build();
54+
55+
kafkaTemplate.send("product-events", "stock.insufficient", event.toByteArray());
56+
log.info("Publish stock lacked: orderUUID={}, productUUID={}", orderUUID, productUUID);
57+
} catch (Exception e) {
58+
log.error("Fail : publishing stock lacked {}", e.getMessage());
59+
}
60+
}
61+
62+
// 재고 복구
63+
public void publishStockRestored(UUID orderUUID, UUID productUUID, int quantity, int currentStock) {
64+
try {
65+
StockRestoredEvent event = StockRestoredEvent.newBuilder()
66+
.setOrderUuid(orderUUID.toString())
67+
.addItems(RestoredItems.newBuilder()
68+
.setProductUuid(productUUID.toString())
69+
.setRestoredQuantity(quantity)
70+
.setCurrentStock(currentStock)
71+
.build())
72+
.setRestoredDt(System.currentTimeMillis())
73+
.build();
74+
75+
kafkaTemplate.send("product-events", "stock.restored", event.toByteArray());
76+
log.info("Publish stock restored: orderUUID={}, productUUID={}", orderUUID, productUUID);
77+
} catch (Exception e) {
78+
log.error("Fail : publishing stock restored {}", e.getMessage());
79+
}
80+
}
81+
82+
83+
// 배치 재고 차감
84+
public void publishBulkStockDeduct(UUID orderUUID, List<StockDeductResult> results) {
85+
try {
86+
StockDeductEvent.Builder eventBuilder = StockDeductEvent.newBuilder()
87+
.setOrderUuid(orderUUID.toString())
88+
.setDeductDt(System.currentTimeMillis());
89+
90+
for (StockDeductResult result : results) {
91+
if (result.isSuccess()) {
92+
eventBuilder.addItems(DeductItems.newBuilder()
93+
.setProductUuid(result.getProductUUID().toString())
94+
.setDeductQuantity(result.getRemainingStock()) // 실제 차감된 수량으로 수정 필요
95+
.setRemainingStock(result.getRemainingStock())
96+
.build());
97+
}
98+
}
99+
100+
kafkaTemplate.send("product-events", "stock.deducted", eventBuilder.build().toByteArray());
101+
log.info("배치 재고 차감 이벤트 발행: 주문={}", orderUUID);
102+
} catch (Exception e) {
103+
log.error("Fail : publishing batch stock deduct {}", e.getMessage());
104+
}
105+
}
106+
107+
// 배치 재고 차감 실패
108+
public void publishBulkStockFailed(UUID orderUUID, List<StockDeductResult> results) {
109+
try {
110+
StockDeductLackedEvent.Builder eventBuilder = StockDeductLackedEvent.newBuilder()
111+
.setOrderUuid(orderUUID.toString())
112+
.setCheckedDt(System.currentTimeMillis());
113+
114+
for (StockDeductResult result : results) {
115+
if (!result.isSuccess()) {
116+
eventBuilder.addItems(LackedItems.newBuilder()
117+
.setProductUuid(result.getProductUUID().toString())
118+
.setRequestedQuantity(0) // 실제 요청 수량으로 수정 필요
119+
.setAvailableStock(result.getRemainingStock())
120+
.build());
121+
}
122+
}
123+
124+
kafkaTemplate.send("product-events", "stock.insufficient", eventBuilder.build().toByteArray());
125+
log.info("배치 재고 실패 이벤트 발행: 주문={}", orderUUID);
126+
} catch (Exception e) {
127+
log.error("Fail : publishing batch stock failed {}", e.getMessage());
128+
}
129+
}
130+
131+
132+
}

src/main/java/com/ecommerce/product/productEntity/Product.java

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,49 @@ public static Product of(Product savedProduct, List<ProductImage> imageList) {
8888
.build();
8989
}
9090

91+
// ===== 재고 관련 비즈니스 메서드 =====
92+
/**
93+
* 재고 충분성 확인
94+
*/
95+
public boolean hasEnoughStock(int requestedQty) {
96+
return this.stockQuantity >= requestedQty;
97+
}
98+
99+
/**
100+
* 재고 차감
101+
*/
102+
public boolean deductStock(int quantity) {
103+
if (!hasEnoughStock(quantity)) {
104+
return false;
105+
}
106+
this.stockQuantity -= quantity;
107+
this.updtDt = LocalDateTime.now();
108+
return true;
109+
}
110+
111+
/**
112+
* 재고 복구
113+
*/
114+
public void restoreStock(int quantity) {
115+
this.stockQuantity += quantity;
116+
this.updtDt = LocalDateTime.now();
117+
}
118+
119+
/**
120+
* 재고 소진 여부
121+
*/
122+
public boolean isStockEmpty() {
123+
return this.stockQuantity == 0;
124+
}
125+
126+
/**
127+
* 구매 가능 여부 (활성 상태 + 재고 있음)
128+
*/
129+
public boolean isAvailableForPurchase() {
130+
return this.isActive && !this.isDeleted && !isStockEmpty();
131+
}
132+
133+
91134
@PrePersist
92135
protected void onCreate() {
93136
this.creaDt = LocalDateTime.now();

src/main/java/com/ecommerce/product/productEntity/ProductImage.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ public class ProductImage {
2626
@Column(name = "image_url", nullable = false, length = 500)
2727
private String imageUrl;
2828

29+
@Builder.Default
30+
@Column(name = "is_active", nullable = false)
31+
private boolean isActive = true;
32+
33+
@Builder.Default
34+
@Column(name = "is_deleted", nullable = false)
35+
private boolean isDeleted = false;
36+
2937
@Column(name = "crea_dt", nullable = false)
3038
private LocalDateTime creaDt;
3139

@@ -41,4 +49,8 @@ protected void onCreate() {
4149
this.creaDt = LocalDateTime.now();
4250
}
4351

52+
public void softDelete() {
53+
this.isActive = false;
54+
this.isDeleted = true;
55+
}
4456
}

src/main/java/com/ecommerce/product/productRepository/ProductImageRepository.java

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@
88

99
public interface ProductImageRepository extends JpaRepository<ProductImage, Long> {
1010

11+
List<ProductImage> findByProductNotDeleted(Product product);
1112

12-
void deleteByProduct(Product product);
13-
14-
// void saveAll(List<ProductImage> productImage );
1513
}

src/main/java/com/ecommerce/product/productRepository/ProductRepository.java

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package com.ecommerce.product.productRepository;
22

33
import com.ecommerce.product.productEntity.Product;
4+
import jakarta.persistence.LockModeType;
45
import org.springframework.data.domain.Page;
56
import org.springframework.data.domain.Pageable;
67
import org.springframework.data.jpa.repository.JpaRepository;
8+
import org.springframework.data.jpa.repository.Lock;
79
import org.springframework.data.jpa.repository.Query;
810
import org.springframework.data.repository.query.Param;
911

@@ -24,7 +26,7 @@ public interface ProductRepository extends JpaRepository<Product, String> {
2426
List<Product> findBySellerIdAndIsActive(UUID sellerId, boolean isActive);
2527

2628

27-
// 구매지 : 소비자 상품검색
29+
// 구매자 : 소비자 상품검색
2830
@Query("select p from Product p " +
2931
"where " +
3032
"(:categoryId is null or p.category.id = :categoryId) and " +
@@ -39,7 +41,16 @@ Page<Product> searchProductForCustomer(
3941
@Param("maxPrice") BigDecimal maxPrice,
4042
Pageable pageable);
4143

42-
// 판매자: 상품 soft 삭제
43-
Product save(Product product);
44+
// 동시성
45+
@Lock(LockModeType.PESSIMISTIC_WRITE)
46+
@Query("select p from Product p " +
47+
"where p.productUUID =:productUUID")
48+
Optional<Product> findByProductUUIDWithLock(@Param("productUUID") UUID productUUID);
49+
50+
// 데드락 방지를 위해 정렬
51+
@Lock(LockModeType.PESSIMISTIC_WRITE)
52+
@Query("select p from Product p " +
53+
"where p.productUUID in :productUUIDs order by p.productUUID")
54+
List<Product> findByProductUUIDsWithLock(@Param("productUUIDs") List<UUID> productUUIDs);
4455

4556
}

0 commit comments

Comments
 (0)