Skip to content

Commit 77af2e2

Browse files
authored
Feat/323 매출/정산 구현 (#334)
* refactor/283 ServiceImpl 정리 * refactor/283 캐시 충전내역 레포지토리 추가 * refactor/283 캐시 충전내역 조회 fetch join 추가 * refactor/283 캐시 충전내역 조회 실제 db연동 * refactor/283 캐시 충전내역 조회 테스트 추가 * refactor/283 참여한 펀딩 조회 8가지 상태 조회로 수정 * refactor/283 작가 환전 요청 잔액 조회 * refactor/283 관리자 대시보드 - 펀딩 신청 내역 조회 * refactor/283 관리자 대시보드 - 펀딩 신청 내역 조회 쿼리 생성 * refactor/312 관리자 대시보드 - 펀딩 신청 내역 조회 쿼리 생성 * refactor/312 관리자 대시보드 - 펀딩 신청 내역 조회 테스트 케이스 생성 * refactor/312 관리자 대시보드 - 펀딩 신청내역 상세보기 dto 구현 * refactor/312 관리자 대시보드 - 펀딩 신청내역 상세보기 쿼리 추가 * refactor/312 관리자 대시보드 - 펀딩 신청내역 service단 구현 * refactor/312 관리자 대시보드 - 펀딩 신청내역 테스트케이스 구현 * refactor/312 관리자 대시보드 - 펀딩 신청대기 목록 상위 2개 알림 추가 * refactor/312 관리자 대시보드 - 사용자 관리에서 유저별 수수료율 수정 * refactor/312 매출/정산 Entity * refactor/312 매출/정산 dto * refactor/312 매출/정산 레포지토리 * refactor/312 매출/정산 Enum * refactor/312 매출/정산 service 구현 * refactor/312 매출/정산 controller 구현 * refactor/312 매출/정산 매출/정산 테스트 케이스 구현 * feat/323 매출/정산 테스트 케이스 구현
1 parent 60da1dd commit 77af2e2

File tree

22 files changed

+1793
-29
lines changed

22 files changed

+1793
-29
lines changed

src/main/java/com/back/domain/dashboard/artist/controller/ArtistDashboardController.java

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,7 @@
3333
* </ul>
3434
* <p>
3535
* 2025.10.01 GA4 유입 경로 통합 - 메인 현황에 포함
36-
<<<<<<< HEAD
3736
* 2025.10.02 JWT 표준 패턴 적용 - @AuthenticationPrincipal 사용
38-
=======
39-
>>>>>>> 2f4795372b442dd5b55cfd8b8cfe7ba547b36a98
4037
*/
4138
@RestController
4239
@RequestMapping("/api/dashboard/artist")
@@ -213,8 +210,8 @@ public ResponseEntity<RsData<ArtistSettlementResponse>> getSettlements(
213210
@AuthenticationPrincipal CustomUserDetails userDetails,
214211
@Valid @ModelAttribute ArtistSettlementSearchRequest request) {
215212

216-
log.info("작가 정산내역 조회 - artistId: {}, year: {}, month: {}, granularity: {}, page: {}, size: {}",
217-
userDetails.getUserId(), request.year(), request.month(), request.granularity(),
213+
log.info("작가 정산내역 조회 - artistId: {}, year: {}, month: {}, page: {}, size: {}",
214+
userDetails.getUserId(), request.year(), request.month(),
218215
request.page(), request.size());
219216

220217
ArtistSettlementResponse response = artistDashboardService.getSettlements(

src/main/java/com/back/domain/dashboard/artist/dto/request/ArtistSettlementSearchRequest.java

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,11 @@ public record ArtistSettlementSearchRequest(
1313
@Min(value = 2020, message = "연도는 2020년 이후만 가능합니다")
1414
Integer year,
1515

16-
/** 조회 월 (1-12) */
16+
/** 조회 월 (1-12, null이면 연도 전체) */
1717
@Min(value = 1, message = "월은 1-12 사이여야 합니다")
1818
@Max(value = 12, message = "월은 1-12 사이여야 합니다")
1919
Integer month,
2020

21-
/** 집계 단위 */
22-
@Pattern(regexp = "^(MONTH|DAY)$",
23-
message = "granularity는 MONTH, DAY 중 하나여야 합니다")
24-
String granularity,
25-
2621
/** 정산 상태 */
2722
@Pattern(regexp = "^(PENDING|PROCESSING|COMPLETED)$",
2823
message = "status는 PENDING, PROCESSING, COMPLETED 중 하나여야 합니다")
@@ -54,7 +49,6 @@ public record ArtistSettlementSearchRequest(
5449
* 기본값이 적용된 생성자
5550
*/
5651
public ArtistSettlementSearchRequest {
57-
if (granularity == null) granularity = "MONTH";
5852
if (page == null) page = 0;
5953
if (size == null) size = 20;
6054
if (sort == null) sort = "date";

src/main/java/com/back/domain/dashboard/artist/dto/response/ArtistSettlementResponse.java

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@
1212
public record ArtistSettlementResponse(
1313
/** 조회 범위 */
1414
Scope scope,
15-
/** 집계 단위 */
16-
String granularity,
1715
/** 타임존 */
1816
String timezone,
1917
/** 요약 정보 */

src/main/java/com/back/domain/dashboard/artist/service/ArtistDashboardServiceImpl.java

Lines changed: 215 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ public class ArtistDashboardServiceImpl implements ArtistDashboardService {
4848
private final BetaAnalyticsDataClient analyticsDataClient;
4949
private final com.back.domain.artist.repository.ArtistProfileRepository artistProfileRepository;
5050
private final com.back.domain.payment.moriCash.service.MoriCashBalanceService moriCashBalanceService;
51+
private final com.back.domain.payment.moriCash.repository.MoriCashBalanceRepository moriCashBalanceRepository;
52+
private final com.back.domain.payment.settlement.repository.SettlementRepository settlementRepository;
5153
private final com.back.domain.user.repository.UserRepository userRepository;
5254

5355
@Value("${google.analytics.property-id}")
@@ -1302,10 +1304,219 @@ private long nz(Long value) {
13021304

13031305
@Override
13041306
public ArtistSettlementResponse getSettlements(Long artistId, ArtistSettlementSearchRequest request) {
1305-
// TODO: 실제 데이터베이스 연동 필요
1306-
log.info("작가 정산 내역 조회 - artistId: {}, year: {}, month: {}",
1307-
artistId, request.year(), request.month());
1308-
throw new UnsupportedOperationException("작가 정산 내역 조회는 아직 구현되지 않았습니다.");
1307+
log.info("작가 정산 내역 조회 시작 - artistId: {}, year: {}, month: {}, page: {}, size: {}, sort: {}, order: {}",
1308+
artistId, request.year(), request.month(), request.page(), request.size(), request.sort(), request.order());
1309+
1310+
// 1. 작가 조회
1311+
com.back.domain.user.entity.User artist = userRepository.findById(artistId)
1312+
.orElseThrow(() -> new com.back.global.exception.ServiceException("404", "작가를 찾을 수 없습니다."));
1313+
1314+
// 2. 조회 범위 설정
1315+
Integer year = request.year() != null ? request.year() : LocalDate.now().getYear();
1316+
Integer month = request.month(); // null이면 전체 연도
1317+
1318+
ArtistSettlementResponse.Scope scope = new ArtistSettlementResponse.Scope(year, month);
1319+
1320+
// 3. 기간 계산
1321+
LocalDateTime startDate;
1322+
LocalDateTime endDate;
1323+
1324+
if (month != null) {
1325+
// 특정 월
1326+
startDate = LocalDateTime.of(year, month, 1, 0, 0, 0);
1327+
endDate = startDate.plusMonths(1).minusSeconds(1);
1328+
} else {
1329+
// 연도 전체
1330+
startDate = LocalDateTime.of(year, 1, 1, 0, 0, 0);
1331+
endDate = LocalDateTime.of(year, 12, 31, 23, 59, 59);
1332+
}
1333+
1334+
// 4. 요약 정보 조회 (해당 기간의 정산 데이터 집계)
1335+
// MoriCashBalance가 아닌 실제 Settlement 데이터에서 계산
1336+
org.springframework.data.domain.Page<com.back.domain.payment.settlement.entity.Settlement> allSettlements =
1337+
settlementRepository.findByArtistAndStatusAndCompletedAtBetween(
1338+
artist,
1339+
com.back.domain.payment.settlement.entity.SettlementStatus.COMPLETED,
1340+
startDate,
1341+
endDate,
1342+
org.springframework.data.domain.PageRequest.of(0, Integer.MAX_VALUE)
1343+
);
1344+
1345+
int totalSales = allSettlements.getContent().stream()
1346+
.mapToInt(com.back.domain.payment.settlement.entity.Settlement::getRequestedAmount)
1347+
.sum();
1348+
1349+
int totalCommission = allSettlements.getContent().stream()
1350+
.mapToInt(com.back.domain.payment.settlement.entity.Settlement::getCommissionAmount)
1351+
.sum();
1352+
1353+
int totalNetIncome = allSettlements.getContent().stream()
1354+
.mapToInt(com.back.domain.payment.settlement.entity.Settlement::getNetAmount)
1355+
.sum();
1356+
1357+
ArtistSettlementResponse.Summary summary = new ArtistSettlementResponse.Summary(
1358+
new ArtistSettlementResponse.AmountInfo(totalSales, "총 매출"),
1359+
new ArtistSettlementResponse.AmountInfo(totalCommission, "수수료"),
1360+
new ArtistSettlementResponse.AmountInfo(totalNetIncome, "순수익")
1361+
);
1362+
1363+
// 5. 차트 데이터 (월별 집계)
1364+
ArtistSettlementResponse.Chart chart = createSettlementChart(artistId, year, month);
1365+
1366+
// 6. 테이블 데이터 (정산 내역 목록)
1367+
ArtistSettlementResponse.Table table = createSettlementTable(
1368+
artist, startDate, endDate, request
1369+
);
1370+
1371+
log.info("작가 정산 내역 조회 완료 - artistId: {}, 총매출: {}, 수수료: {}, 순수익: {}",
1372+
artistId, totalSales, totalCommission, totalNetIncome);
1373+
1374+
return new ArtistSettlementResponse(
1375+
scope,
1376+
"Asia/Seoul",
1377+
summary,
1378+
chart,
1379+
table,
1380+
LocalDateTime.now()
1381+
);
1382+
}
1383+
1384+
/**
1385+
* 정산 차트 데이터 생성 (월별 매출 그래프 - 1월~12월)
1386+
*/
1387+
private ArtistSettlementResponse.Chart createSettlementChart(Long artistId, Integer year, Integer month) {
1388+
List<ArtistSettlementResponse.ChartDataPoint> salesPoints = new ArrayList<>();
1389+
1390+
// 작가 조회
1391+
com.back.domain.user.entity.User artist = userRepository.findById(artistId)
1392+
.orElseThrow(() -> new com.back.global.exception.ServiceException("404", "작가를 찾을 수 없습니다."));
1393+
1394+
// 항상 연도 전체의 월별 데이터 (1월~12월)
1395+
for (int m = 1; m <= 12; m++) {
1396+
LocalDateTime monthStart = LocalDateTime.of(year, m, 1, 0, 0, 0);
1397+
LocalDateTime monthEnd = monthStart.plusMonths(1).minusSeconds(1);
1398+
1399+
// 해당 월의 특정 작가 정산 합계 조회
1400+
org.springframework.data.domain.Page<com.back.domain.payment.settlement.entity.Settlement> settlements =
1401+
settlementRepository.findByArtistAndStatusAndCompletedAtBetween(
1402+
artist,
1403+
com.back.domain.payment.settlement.entity.SettlementStatus.COMPLETED,
1404+
monthStart,
1405+
monthEnd,
1406+
org.springframework.data.domain.PageRequest.of(0, Integer.MAX_VALUE)
1407+
);
1408+
1409+
int monthTotal = settlements.getContent().stream()
1410+
.mapToInt(com.back.domain.payment.settlement.entity.Settlement::getRequestedAmount)
1411+
.sum();
1412+
1413+
salesPoints.add(new ArtistSettlementResponse.ChartDataPoint(
1414+
String.format("%d-%02d", year, m),
1415+
monthTotal
1416+
));
1417+
}
1418+
1419+
// Y축 범위 계산
1420+
int maxValue = salesPoints.stream()
1421+
.mapToInt(ArtistSettlementResponse.ChartDataPoint::value)
1422+
.max()
1423+
.orElse(0);
1424+
1425+
int yMax = (int) Math.ceil(maxValue * 1.2); // 최대값의 120%
1426+
1427+
ArtistSettlementResponse.ChartSeries series = new ArtistSettlementResponse.ChartSeries(salesPoints);
1428+
ArtistSettlementResponse.YDomain yDomain = new ArtistSettlementResponse.YDomain(0, yMax);
1429+
1430+
return new ArtistSettlementResponse.Chart(series, yDomain);
1431+
}
1432+
1433+
/**
1434+
* 정산 테이블 데이터 생성
1435+
*/
1436+
private ArtistSettlementResponse.Table createSettlementTable(
1437+
com.back.domain.user.entity.User artist,
1438+
LocalDateTime startDate,
1439+
LocalDateTime endDate,
1440+
ArtistSettlementSearchRequest request) {
1441+
1442+
// 1. 정렬 설정
1443+
org.springframework.data.domain.Sort sort = createSettlementSort(request.sort(), request.order());
1444+
PageRequest pageRequest = PageRequest.of(request.page(), request.size(), sort);
1445+
1446+
// 2. Settlement 조회
1447+
Page<com.back.domain.payment.settlement.entity.Settlement> settlementPage =
1448+
settlementRepository.findByArtistAndStatusAndCompletedAtBetween(
1449+
artist,
1450+
com.back.domain.payment.settlement.entity.SettlementStatus.COMPLETED,
1451+
startDate,
1452+
endDate,
1453+
pageRequest
1454+
);
1455+
1456+
// 3. DTO 변환
1457+
List<ArtistSettlementResponse.Settlement> content = settlementPage.getContent().stream()
1458+
.map(this::convertToSettlementDto)
1459+
.toList();
1460+
1461+
return new ArtistSettlementResponse.Table(
1462+
content,
1463+
request.page(),
1464+
request.size(),
1465+
(int) settlementPage.getTotalElements(),
1466+
settlementPage.getTotalPages(),
1467+
settlementPage.hasNext(),
1468+
settlementPage.hasPrevious()
1469+
);
1470+
}
1471+
1472+
/**
1473+
* 정산 정렬 생성
1474+
*/
1475+
private org.springframework.data.domain.Sort createSettlementSort(String sortField, String sortOrder) {
1476+
org.springframework.data.domain.Sort.Direction direction =
1477+
"ASC".equalsIgnoreCase(sortOrder)
1478+
? org.springframework.data.domain.Sort.Direction.ASC
1479+
: org.springframework.data.domain.Sort.Direction.DESC;
1480+
1481+
return switch (sortField) {
1482+
case "grossAmount" -> org.springframework.data.domain.Sort.by(direction, "requestedAmount");
1483+
case "commission" -> org.springframework.data.domain.Sort.by(direction, "commissionAmount");
1484+
case "netAmount" -> org.springframework.data.domain.Sort.by(direction, "netAmount");
1485+
case "status" -> org.springframework.data.domain.Sort.by(direction, "status");
1486+
default -> org.springframework.data.domain.Sort.by(direction, "completedAt");
1487+
};
1488+
}
1489+
1490+
/**
1491+
* Settlement 엔티티를 DTO로 변환
1492+
*/
1493+
private ArtistSettlementResponse.Settlement convertToSettlementDto(
1494+
com.back.domain.payment.settlement.entity.Settlement settlement) {
1495+
1496+
// 상품 정보 (더미 - 실제로는 Settlement에 상품 정보가 없음)
1497+
ArtistSettlementResponse.Product product = new ArtistSettlementResponse.Product(
1498+
null,
1499+
"생활꿀팁미니 상품결제입니다"
1500+
);
1501+
1502+
// 날짜 포맷팅
1503+
String dateStr = settlement.getCompletedAt() != null
1504+
? settlement.getCompletedAt().format(DateTimeFormatter.ofPattern("yyyy. MM. dd"))
1505+
: settlement.getCreateDate().format(DateTimeFormatter.ofPattern("yyyy. MM. dd"));
1506+
1507+
// 상태 텍스트 (항상 정산완료 - 즉시 완료 처리되므로)
1508+
String statusText = "정산완료";
1509+
1510+
return new ArtistSettlementResponse.Settlement(
1511+
settlement.getId(),
1512+
dateStr,
1513+
product,
1514+
settlement.getRequestedAmount(),
1515+
settlement.getCommissionAmount(),
1516+
settlement.getNetAmount(),
1517+
settlement.getStatus().name(),
1518+
statusText
1519+
);
13091520
}
13101521

13111522
@Override

src/main/java/com/back/domain/order/order/repository/OrderRepository.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ public interface OrderRepository extends JpaRepository<Order, Long> {
3030
// 주문 상세 조회 - Fetch Join (상품 정보만)
3131
@Query("SELECT o FROM Order o " +
3232
"LEFT JOIN FETCH o.orderItems oi " +
33-
"LEFT JOIN FETCH oi.product " +
33+
"LEFT JOIN FETCH oi.product p " +
34+
"LEFT JOIN FETCH p.user " +
3435
"WHERE o.id = :orderId")
3536
Optional<Order> findByIdWithOrderItems(@Param("orderId") Long orderId);
3637

src/main/java/com/back/domain/order/order/service/OrderService.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import com.back.domain.user.entity.User;
1919
import com.back.global.exception.ServiceException;
2020
import lombok.RequiredArgsConstructor;
21+
import lombok.extern.slf4j.Slf4j;
2122
import org.springframework.data.domain.Page;
2223
import org.springframework.data.domain.Pageable;
2324
import org.springframework.stereotype.Service;
@@ -30,6 +31,7 @@
3031

3132
@Service
3233
@RequiredArgsConstructor
34+
@Slf4j
3335
@Transactional(readOnly = true)
3436
public class OrderService {
3537

@@ -38,6 +40,7 @@ public class OrderService {
3840
private final ProductRepository productRepository;
3941
private final CartRepository cartRepository;
4042
private final NotificationService notificationService;
43+
private final com.back.domain.payment.moriCash.repository.MoriCashBalanceRepository moriCashBalanceRepository;
4144

4245
/**
4346
* 주문 생성
@@ -294,6 +297,11 @@ public void changeOrderStatus(Long orderId, OrderStatusChangeRequestDto requestD
294297
order.changeStatus(requestDto.status());
295298
orderRepository.save(order);
296299

300+
// ✅ 배송 완료 시 작가에게 수익 적립
301+
if (requestDto.status() == OrderStatus.DELIVERED) {
302+
creditArtistRevenue(order);
303+
}
304+
297305
// 알림 발송 - 상태별 처리
298306
User customer = order.getUser();
299307

@@ -334,6 +342,40 @@ public void changeOrderStatus(Long orderId, OrderStatusChangeRequestDto requestD
334342
}
335343
}
336344

345+
/**
346+
* 주문 배송 완료 시 작가에게 수익 적립
347+
*/
348+
@Transactional
349+
public void creditArtistRevenue(Order order) {
350+
log.info("작가 수익 적립 시작 - 주문ID: {}", order.getId());
351+
352+
// 각 주문 상품별로 작가에게 수익 적립
353+
order.getOrderItems().forEach(orderItem -> {
354+
User artist = orderItem.getProduct().getUser();
355+
356+
// 작가의 모리캐시 잔액 조회 또는 생성
357+
com.back.domain.payment.moriCash.entity.MoriCashBalance balance =
358+
moriCashBalanceRepository.findByUser(artist)
359+
.orElseGet(() -> {
360+
com.back.domain.payment.moriCash.entity.MoriCashBalance newBalance =
361+
com.back.domain.payment.moriCash.entity.MoriCashBalance.createInitialBalance(artist);
362+
return moriCashBalanceRepository.save(newBalance);
363+
});
364+
365+
// 수익 계산 (상품 금액 - 수수료)
366+
int itemTotal = orderItem.getPrice().intValue() * orderItem.getQuantity();
367+
int commission = itemTotal / 10; // 10% 수수료
368+
int netAmount = itemTotal - commission;
369+
370+
// 작가 모리캐시 증가
371+
balance.addSalesRevenue(netAmount);
372+
moriCashBalanceRepository.save(balance);
373+
374+
log.info("작가 수익 적립 완료 - 작가ID: {}, 상품: {}, 총액: {}, 수수료: {}, 순수익: {}",
375+
artist.getId(), orderItem.getProduct().getName(), itemTotal, commission, netAmount);
376+
});
377+
}
378+
337379
// ==================== Private Methods ====================
338380

339381
/**

0 commit comments

Comments
 (0)