diff --git a/frontend/src/api/schedule/scheduleAPI.ts b/frontend/src/api/schedule/scheduleAPI.ts index 4ca28dc3..d2fb7c6e 100644 --- a/frontend/src/api/schedule/scheduleAPI.ts +++ b/frontend/src/api/schedule/scheduleAPI.ts @@ -1,6 +1,12 @@ import apiClient from "@_api/apiClient"; -import type { AdminScheduleSummaryListResponse, ScheduleCreateRequest, ScheduleCreateResponse, ScheduleStatus, ScheduleUpdateRequest, ScheduleWithThemeListResponse } from "./scheduleTypes"; -import type { AuditInfo } from "@_api/common/commonTypes"; +import type {AuditInfo} from "@_api/common/commonTypes"; +import type { + AdminScheduleSummaryListResponse, + ScheduleCreateRequest, + ScheduleCreateResponse, + ScheduleUpdateRequest, + ScheduleWithThemeListResponse +} from "./scheduleTypes"; // admin export const fetchAdminSchedules = async (storeId: string, date?: string, themeId?: string): Promise => { diff --git a/frontend/src/api/store/storeAPI.ts b/frontend/src/api/store/storeAPI.ts index 80c9135c..81637640 100644 --- a/frontend/src/api/store/storeAPI.ts +++ b/frontend/src/api/store/storeAPI.ts @@ -12,11 +12,11 @@ export const getStores = async (sidoCode?: string, sigunguCode?: string): Promis const queryParams: string[] = []; if (sidoCode && sidoCode.trim() !== '') { - queryParams.push(`sidoCode=${sidoCode}`); + queryParams.push(`sido=${sidoCode}`); } if (sigunguCode && sigunguCode.trim() !== '') { - queryParams.push(`sigunguCode=${sigunguCode}`); + queryParams.push(`sigungu=${sigunguCode}`); } const baseUrl = `/stores`; diff --git a/frontend/src/api/theme/themeAPI.ts b/frontend/src/api/theme/themeAPI.ts index f0f6bdf9..e111a1c0 100644 --- a/frontend/src/api/theme/themeAPI.ts +++ b/frontend/src/api/theme/themeAPI.ts @@ -42,3 +42,7 @@ export const fetchThemesByIds = async (request: ThemeIdListResponse): Promise => { return await apiClient.get(`/themes/${id}`); } + +export const fetchMostReservedThemes = async (count: number): Promise => { + return await apiClient.get(`/themes/most-reserved?count=${count}`); +}; diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index e16e9d9c..5d8ae368 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,8 +1,7 @@ -import {fetchMostReservedThemeIds} from '@_api/reservation/reservationAPI'; import '@_css/home-page-v2.css'; import React, {useEffect, useState} from 'react'; import {useNavigate} from 'react-router-dom'; -import {fetchThemesByIds} from '@_api/theme/themeAPI'; +import {fetchMostReservedThemes} from '@_api/theme/themeAPI'; import {DifficultyKoreanMap, mapThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes'; const HomePage: React.FC = () => { @@ -13,19 +12,8 @@ const HomePage: React.FC = () => { useEffect(() => { const fetchData = async () => { try { - const themeIds = await fetchMostReservedThemeIds().then(res => { - const themeIds = res.themeIds; - if (themeIds.length === 0) { - setRanking([]); - return; - } - return themeIds; - }) - - if (themeIds === undefined) return; - if (themeIds.length === 0) return; - - const response = await fetchThemesByIds({ themeIds: themeIds }); + const themeFetchCount = 10; + const response = await fetchMostReservedThemes(themeFetchCount); setRanking(response.themes.map(mapThemeResponse)); } catch (err) { console.error('Error fetching ranking:', err); diff --git a/frontend/src/pages/ReservationStep1Page.tsx b/frontend/src/pages/ReservationStep1Page.tsx index 0a3af991..9ed6fd7e 100644 --- a/frontend/src/pages/ReservationStep1Page.tsx +++ b/frontend/src/pages/ReservationStep1Page.tsx @@ -60,9 +60,13 @@ const ReservationStep1Page: React.FC = () => { }, [selectedSido]); useEffect(() => { - getStores(selectedSido, selectedSigungu) - .then(res => setStoreList(res.stores)) - .catch(handleError); + if (selectedSido) { + getStores(selectedSido, selectedSigungu) + .then(res => setStoreList(res.stores)) + .catch(handleError); + } else { + setStoreList([]); + } setSelectedStore(null); }, [selectedSido, selectedSigungu]); diff --git a/query.md b/query.md new file mode 100644 index 00000000..73406725 --- /dev/null +++ b/query.md @@ -0,0 +1,845 @@ +## Auth + +**로그인** + +```sql +-- 회원 + +-- 이메일로 회원 조회 +SELECT + u.id +FROM + users u +WHERE + u.email = ? + LIMIT 1; + +-- 연락처로 회원 조회 +SELECT + u.id +FROM + users u +WHERE + u.phone = ? + LIMIT 1; + +-- 회원 추가 +INSERT INTO users ( + created_at, created_by, email, name, password, phone, region_code, + status, updated_at, updated_by, id +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ); + +-- 회원 상태 이력 추가 +INSERT INTO user_status_history ( + created_at, created_by, reason, status, updated_at, updated_by, + user_id, id +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ? + ); +``` + +### Payment + +**결제 승인 & 저장** + +```sql +-- 결제 정보 추가 +INSERT INTO payment ( approved_at, method, order_id, payment_key, requested_at, reservation_id, status, total_amount, type, id +) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ? +); + +-- 결제 상세 정보 추가 +INSERT INTO payment_detail ( payment_id, supplied_amount, vat, id +) VALUES ( ?, ?, ?, ? +); +-- 카드 결제 상세 정보 추가 +INSERT INTO payment_card_detail ( amount, approval_number, card_number, card_type, easypay_discount_amount, easypay_provider_code, installment_plan_months, is_interest_free, issuer_code, owner_type, id +) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? +); +``` + +**결제 취소** + +SQL + +```sql +-- 예약 ID로 결제 정보 조회 +SELECT + p.id, + p.approved_at, + p.method, + p.order_id, + p.payment_key, + p.requested_at, + p.reservation_id, + p.status, + p.total_amount, + p.type +FROM + payment p +WHERE + p.reservation_id = ?; + +-- 추가 +-- 취소된 결제 정보 추가 +INSERT INTO canceled_payment ( + cancel_amount, cancel_reason, canceled_at, canceled_by, + card_discount_amount, easypay_discount_amount, payment_id, + requested_at, transfer_discount_amount, id +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ? +); +``` + +### Region + +**모든 시/도 조회** + +```sql +SELECT DISTINCT + r.sido_code, + r.sido_name +FROM + region r +ORDER BY + r.sido_name; +``` + +**시/군/구 조회** + +```sql +SELECT + r.sigungu_code, + r.sigungu_name +FROM + region r +WHERE + r.sido_code = ? +GROUP BY + r.sigungu_code, r.sigungu_name +ORDER BY + r.sigungu_name; +``` + +**지역 코드 조회** + +```sql +SELECT + r.code +FROM + region r +WHERE + r.sido_code = ? AND r.sigungu_code = ?; +``` + +### Reservation + +**Pending 예약 생성** + +```sql +-- schedule 조회 +SELECT + s.id, s.created_at, s.created_by, s.date, s.status, s.store_id, + s.theme_id, s.time, s.updated_at, s.updated_by +FROM + schedule s +WHERE + s.id = ?; + +-- theme 조회 +SELECT + t.id, t.available_minutes, t.created_at, t.created_by, t.description, + t.difficulty, t.expected_minutes_from, t.expected_minutes_to, + t.is_active, t.max_participants, t.min_participants, t.name, + t.price, t.thumbnail_url, t.updated_at, t.updated_by +FROM + theme t +WHERE + t.id = ?; + +-- 예약 추가 +INSERT INTO reservation ( + created_at, created_by, participant_count, requirement, + reserver_contact, reserver_name, schedule_id, status, + updated_at, updated_by, user_id, id +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? +); +``` + +**확정** + +```sql +-- 예약 조회 +SELECT + r.id, r.created_at, r.created_by, r.participant_count, r.requirement, + r.reserver_contact, r.reserver_name, r.schedule_id, r.status, + r.updated_at, r.updated_by, r.user_id +FROM + reservation r +WHERE + r.id = ?; + +-- 일정 조회 +SELECT + s.id, s.created_at, s.created_by, s.date, s.status, s.store_id, + s.theme_id, s.time, s.updated_at, s.updated_by +FROM + schedule s +WHERE + s.id = ?; + +-- 예약 확정 +UPDATE + reservation +SET + participant_count = ?, requirement = ?, reserver_contact = ?, + reserver_name = ?, schedule_id = ?, status = ?, + updated_at = ?, updated_by = ?, user_id = ? +WHERE + id = ?; + +-- Schedule 확정 +UPDATE + schedule +SET + date = ?, status = ?, store_id = ?, theme_id = ?, time = ?, + updated_at = ?, updated_by = ? +WHERE + id = ?; +``` + +**취소** + +```sql +-- 예약 조회 +SELECT + r.id, r.created_at, r.created_by, r.participant_count, r.requirement, + r.reserver_contact, r.reserver_name, r.schedule_id, r.status, + r.updated_at, r.updated_by, r.user_id +FROM + reservation r +WHERE + r.id = ?; + +-- 일정 조회 +SELECT + s.id, s.created_at, s.created_by, s.date, s.status, s.store_id, + s.theme_id, s.time, s.updated_at, s.updated_by +FROM + schedule s +WHERE + s.id = ?; + +-- 취소 예약 추가 +INSERT INTO canceled_reservation ( + cancel_reason, canceled_at, canceled_by, + reservation_id, status, id +) VALUES ( + ?, ?, ?, ?, ?, ? +); + +-- 예약 취소 +UPDATE + reservation +SET + participant_count = ?, requirement = ?, reserver_contact = ?, + reserver_name = ?, schedule_id = ?, status = ?, + updated_at = ?, updated_by = ?, user_id = ? +WHERE + id = ?; + +-- 일정 활성화 +UPDATE + schedule +SET + date = ?, status = ?, store_id = ?, theme_id = ?, time = ?, + updated_at = ?, updated_by = ? +WHERE + id = ?; +``` + +**회원 예약 조회** + +```sql +-- 예약 조회 +SELECT + r.id, r.created_at, r.created_by, r.participant_count, r.requirement, + r.reserver_contact, r.reserver_name, r.schedule_id, r.status, + r.updated_at, r.updated_by, r.user_id +FROM + reservation r +WHERE + r.user_id = ? AND r.status IN (?, ?); + +-- 일정 조회 -> 각 예약별 1개씩(N개) +SELECT + s.id, + st.id AS store_id, + st.name AS store_name, + s.date, + s.time, + t.id AS theme_id, + t.name AS theme_name, + t.difficulty, + t.available_minutes, + s.status +FROM + schedule s + JOIN theme t ON t.id = s.theme_id + JOIN store st ON st.id = s.store_id +WHERE + s.id = ?; +``` + +**예약 상세 조회** + +```sql +-- 예약 조회 +SELECT + r.id, r.created_at, r.created_by, r.participant_count, r.requirement, + r.reserver_contact, r.reserver_name, r.schedule_id, r.status, + r.updated_at, r.updated_by, r.user_id +FROM + reservation r +WHERE + r.id = ?; + +-- 회원 연락처 정보 조회 +SELECT + u.id, u.created_at, u.created_by, u.email, u.name, u.password, + u.phone, u.region_code, u.status, u.updated_at, u.updated_by +FROM + users u +WHERE + u.id = ?; + +-- 결제 정보 조회 +SELECT + p.id, p.approved_at, p.method, p.order_id, p.payment_key, + p.requested_at, p.reservation_id, p.status, p.total_amount, p.type +FROM + payment p +WHERE + p.reservation_id = ?; + +-- 결제 상세 정보 조회 +SELECT + pd.id, + CASE + WHEN pbt.id IS NOT NULL THEN 1 -- bank_transfer + WHEN pcd.id IS NOT NULL THEN 2 -- card + WHEN pep.id IS NOT NULL THEN 3 -- easypay + WHEN pd.id IS NOT NULL THEN 0 -- etc + END AS payment_type, + pd.payment_id, pd.supplied_amount, pd.vat, + pbt.bank_code, pbt.settlement_status, + pcd.amount, pcd.approval_number, pcd.card_number, pcd.card_type, + pcd.easypay_discount_amount, pcd.easypay_provider_code, + pcd.installment_plan_months, pcd.is_interest_free, pcd.issuer_code, + pcd.owner_type, + pep.amount AS easypay_amount, + pep.discount_amount AS easypay_discount_amount, + pep.easypay_provider_code AS easypay_provider +FROM + payment_detail pd + LEFT JOIN payment_bank_transfer_detail pbt ON pd.id = pbt.id + LEFT JOIN payment_card_detail pcd ON pd.id = pcd.id + LEFT JOIN payment_easypay_prepaid_detail pep ON pd.id = pep.id +WHERE + pd.payment_id = ?; + +-- 취소 결제 정보 조회 +SELECT + cp.id, cp.cancel_amount, cp.cancel_reason, cp.canceled_at, + cp.canceled_by, cp.card_discount_amount, cp.easypay_discount_amount, + cp.payment_id, cp.requested_at, cp.transfer_discount_amount +FROM + canceled_payment cp +WHERE + cp.payment_id = ?; +``` + +### Schedule + +**날짜, 시간, 테마로 조회** + +```sql +SELECT + s.id, + st.id AS store_id, + st.name AS store_name, + s.date, + s.time, + t.id AS theme_id, + t.name AS theme_name, + t.difficulty, + t.available_minutes, + s.status +FROM + schedule s + JOIN theme t ON t.id = s.theme_id AND (? IS NULL OR t.id = ?) + JOIN store st ON st.id = s.store_id AND st.id = ? +WHERE + s.date = ? +``` + +**감사 정보 조회** + +```sql +-- 일정 조회 +SELECT + s.id, s.created_at, s.created_by, s.date, s.status, s.store_id, + s.theme_id, s.time, s.updated_at, s.updated_by +FROM + schedule s +WHERE + s.id = ?; + +-- 작업자 조회(createdBy, updatedBy) +SELECT + a.id, a.account, a.created_at, a.created_by, a.name, a.password, + a.permission_level, a.phone, a.store_id, a.type, a.updated_at, + a.updated_by +FROM + admin a +WHERE + a.id = ?; +``` + +**일정 생성** + +```sql +-- 날짜, 시간, 테마가 같은 일정 존재 여부 확인 +SELECT EXISTS ( + SELECT 1 + FROM schedule s + WHERE + s.store_id = ? + AND s.date = ? + AND s.theme_id = ? + AND s.time = ? +); + +-- 시간이 겹치는 같은 날의 일정이 있는지 확인 +SELECT + s.id, + st.id AS store_id, + st.name AS store_name, + s.date, + s.time, + t.id AS theme_id, + t.name AS theme_name, + t.difficulty, + t.available_minutes, + s.status +FROM + schedule s + JOIN theme t ON t.id = s.theme_id AND (? IS NULL OR s.theme_id = ?) + JOIN store st ON st.id = s.store_id AND st.id = ? +WHERE + s.date = ? + +-- 일정 추가 +INSERT INTO schedule ( + created_at, created_by, date, status, store_id, + theme_id, time, updated_at, updated_by, id +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ? +); +``` + +**일정 수정** + +```sql +-- 조회 +SELECT + s.id, s.created_at, s.created_by, s.date, s.status, s.store_id, + s.theme_id, s.time, s.updated_at, s.updated_by +FROM + schedule s +WHERE + s.id = ?; + +-- 수정 +UPDATE + schedule +SET + date = ?, status = ?, store_id = ?, theme_id = ?, time = ?, + updated_at = ?, updated_by = ? +WHERE + id = ?; +``` + +**일정 삭제** + +```sql +-- 조회 +SELECT + s.id, s.created_at, s.created_by, s.date, s.status, s.store_id, + s.theme_id, s.time, s.updated_at, s.updated_by +FROM + schedule s +WHERE + s.id = ?; + +-- 삭제 +DELETE FROM schedule +WHERE id = ?; +``` + +**상태 → HOLD 변경** + +```sql +-- 조회 +SELECT + s.id, s.created_at, s.created_by, s.date, s.status, s.store_id, + s.theme_id, s.time, s.updated_at, s.updated_by +FROM + schedule s +WHERE + s.id = ?; + +-- 수정 +UPDATE + schedule +SET + date = ?, status = ?, store_id = ?, theme_id = ?, time = ?, + updated_at = ?, updated_by = ? +WHERE + id = ?; +``` + +### Store + +**매장 상세 조회** + +```sql +-- 조회 +SELECT + s.id, s.address, s.business_reg_num, s.contact, s.created_at, + s.created_by, s.name, s.region_code, s.status, s.updated_at, + s.updated_by +FROM + store s +WHERE + s.id = ? AND s.status = 'ACTIVE'; + +-- 지역 정보 조회 +SELECT + r.code, r.sido_code, r.sido_name, r.sigungu_code, r.sigungu_name +FROM + region r +WHERE + r.code = ?; + +-- 감사 정보 조회(createdBy, updatedBy) +SELECT + a.id, a.account, a.created_at, a.created_by, a.name, a.password, + a.permission_level, a.phone, a.store_id, a.type, a.updated_at, + a.updated_by +FROM + admin a +WHERE + a.id = ?; +``` + +**매장 등록** + +```sql +-- 이름 중복 확인 +SELECT s.id FROM store s WHERE s.name = ? LIMIT 1; + +-- 연락처 중복 확인 +SELECT s.id FROM store s WHERE s.contact = ? LIMIT 1; + +-- 주소 중복 확인 +SELECT s.id FROM store s WHERE s.address = ? LIMIT 1; + +-- 사업자번호 중복 확인 +SELECT s.id FROM store s WHERE s.business_reg_num = ? LIMIT 1; + +-- 추가 +INSERT INTO store ( + address, business_reg_num, contact, created_at, created_by, + name, region_code, status, updated_at, updated_by, id +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? +); +``` + +**매장 수정** + +```sql +-- 조회 +SELECT + s.id, s.address, s.business_reg_num, s.contact, s.created_at, + s.created_by, s.name, s.region_code, s.status, s.updated_at, + s.updated_by +FROM + store s +WHERE + s.id = ? AND s.status = 'ACTIVE'; + +-- 수정 +UPDATE + store +SET + address = ?, business_reg_num = ?, contact = ?, name = ?, + region_code = ?, status = ?, updated_at = ?, updated_by = ? +WHERE + id = ?; +``` + +**비활성화(status = DISABLE)** + +```sql +-- 조회 +SELECT + s.id, s.address, s.business_reg_num, s.contact, s.created_at, + s.created_by, s.name, s.region_code, s.status, s.updated_at, + s.updated_by +FROM + store s +WHERE + s.id = ? AND s.status = 'ACTIVE'; + +-- 수정 +UPDATE + store +SET + address = ?, business_reg_num = ?, contact = ?, name = ?, + region_code = ?, status = ?, updated_at = ?, updated_by = ? +WHERE + id = ?; +``` + +**모든 매장 조회** + +```sql +SELECT + s.id, s.address, s.business_reg_num, s.contact, s.created_at, + s.created_by, s.name, s.region_code, s.status, s.updated_at, + s.updated_by +FROM + store s +WHERE + s.status = 'ACTIVE' + AND (? IS NULL OR s.region_code LIKE ?); +``` + +**개별 매장 상세 조회** + +```sql +SELECT + s.id, s.address, s.business_reg_num, s.contact, s.created_at, + s.created_by, s.name, s.region_code, s.status, s.updated_at, + s.updated_by +FROM + store s +WHERE + s.id = ? AND s.status = 'ACTIVE'; +``` + +### Theme + +**생성** + +```sql +-- 이름으로 조회 +SELECT + t.id +FROM + theme t +WHERE + t.name = ? +LIMIT 1; + +-- 추가 +INSERT INTO theme ( + available_minutes, created_at, created_by, description, difficulty, + expected_minutes_from, expected_minutes_to, is_active, max_participants, + min_participants, name, price, thumbnail_url, updated_at, updated_by, id +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? +); +``` + +**Active인 모든 테마 조회** + +```sql +SELECT + t.id, t.available_minutes, t.created_at, t.created_by, t.description, + t.difficulty, t.expected_minutes_from, t.expected_minutes_to, + t.is_active, t.max_participants, t.min_participants, t.name, + t.price, t.thumbnail_url, t.updated_at, t.updated_by +FROM + theme t +WHERE + t.is_active = TRUE; +``` + +**테마 목록 조회** + +```sql +SELECT + t.id, t.available_minutes, t.created_at, t.created_by, t.description, + t.difficulty, t.expected_minutes_from, t.expected_minutes_to, + t.is_active, t.max_participants, t.min_participants, t.name, + t.price, t.thumbnail_url, t.updated_at, t.updated_by +FROM + theme t; +``` + +**감사 정보 포함 개별 테마 상세 조회** + +```sql +SELECT + t.id, t.available_minutes, t.created_at, t.created_by, t.description, + t.difficulty, t.expected_minutes_from, t.expected_minutes_to, + t.is_active, t.max_participants, t.min_participants, t.name, + t.price, t.thumbnail_url, t.updated_at, t.updated_by +FROM + theme t +WHERE + t.id = ?; +``` + +**개별 테마 조회** + +```sql +SELECT + t.id, t.available_minutes, t.created_at, t.created_by, t.description, + t.difficulty, t.expected_minutes_from, t.expected_minutes_to, + t.is_active, t.max_participants, t.min_participants, t.name, + t.price, t.thumbnail_url, t.updated_at, t.updated_by +FROM + theme t +WHERE + t.id = ?; +``` + +**삭제** + +```sql +-- 조회 +SELECT + t.id, t.available_minutes, t.created_at, t.created_by, t.description, + t.difficulty, t.expected_minutes_from, t.expected_minutes_to, + t.is_active, t.max_participants, t.min_participants, t.name, + t.price, t.thumbnail_url, t.updated_at, t.updated_by +FROM + theme t +WHERE + t.id = ?; + +-- 삭제 +DELETE FROM theme WHERE id = ?; +``` + +**수정** + +```sql +-- 조회 +SELECT + t.id, t.available_minutes, t.created_at, t.created_by, t.description, + t.difficulty, t.expected_minutes_from, t.expected_minutes_to, + t.is_active, t.max_participants, t.min_participants, t.name, + t.price, t.thumbnail_url, t.updated_at, t.updated_by +FROM + theme t +WHERE + t.id = ?; + +-- 수정 +UPDATE + theme +SET + available_minutes = ?, description = ?, difficulty = ?, + expected_minutes_from = ?, expected_minutes_to = ?, is_active = ?, + max_participants = ?, min_participants = ?, name = ?, price = ?, + thumbnail_url = ?, updated_at = ?, updated_by = ? +WHERE + id = ?; +``` + +**인기 테마 조회** + +```sql +SELECT + t.id, t.name, t.description, t.difficulty, t.thumbnail_url, t.price, + t.min_participants, t.max_participants, + t.available_minutes, t.expected_minutes_from, t.expected_minutes_to +FROM + theme t + JOIN ( + SELECT + s.theme_id, count(*) as reservation_count + FROM + schedule s + JOIN + reservation r ON s.id = r.schedule_id AND r.status = 'CONFIRMED' + WHERE + s.status = 'RESERVED' + AND (s.date BETWEEN :startFrom AND :endAt) + GROUP BY + s.theme_id + ORDER BY + reservation_count desc + LIMIT :count + ) ranked_themes ON t.id = ranked_themes.theme_id +``` + +### User + +**회원가입** + +```sql +-- 이메일 중복 확인 +SELECT + u.id +FROM + users u +WHERE + u.email = ? +LIMIT 1; + +-- 연락처 중복 확인 +SELECT + u.id +FROM + users u +WHERE + u.phone = ? +LIMIT 1; + +-- 추가 +INSERT INTO users ( + created_at, created_by, email, name, password, phone, region_code, + status, updated_at, updated_by, id +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? +); + +-- 상태 변경 이력 추가 +INSERT INTO user_status_history ( + created_at, created_by, reason, status, updated_at, updated_by, + user_id, id +) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ? +); +``` + +**연락처 정보 조회** + +```sql +SELECT + u.id, u.created_at, u.created_by, u.email, u.name, u.password, + u.phone, u.region_code, u.status, u.updated_at, u.updated_by +FROM + users u +WHERE + u.id = ?; +``` \ No newline at end of file diff --git a/src/main/kotlin/roomescape/reservation/business/ReservationService.kt b/src/main/kotlin/roomescape/reservation/business/ReservationService.kt index c9ee3a41..346ac043 100644 --- a/src/main/kotlin/roomescape/reservation/business/ReservationService.kt +++ b/src/main/kotlin/roomescape/reservation/business/ReservationService.kt @@ -121,23 +121,6 @@ class ReservationService( } } - @Transactional(readOnly = true) - fun findMostReservedThemeIds(count: Int): MostReservedThemeIdListResponse { - log.info { "[ReservationService.findMostReservedThemeIds] 인기 테마 조회 시작: count=$count" } - val previousWeekSunday = DateUtils.getSundayOfPreviousWeek(LocalDate.now()) - val previousWeekSaturday = previousWeekSunday.plusDays(6) - - val themeIds: List = reservationRepository.findMostReservedThemeIds( - dateFrom = previousWeekSunday, - dateTo = previousWeekSaturday, - count = count - ) - - return MostReservedThemeIdListResponse(themeIds = themeIds).also { - log.info { "[ReservationService.findMostReservedThemeIds] 인기 테마 조회 완료: count=${it.themeIds.size}" } - } - } - private fun findOrThrow(id: Long): ReservationEntity { log.info { "[ReservationService.findOrThrow] 예약 조회 시작: reservationId=${id}" } diff --git a/src/main/kotlin/roomescape/reservation/docs/ReservationAPI.kt b/src/main/kotlin/roomescape/reservation/docs/ReservationAPI.kt index bd32e862..7481e3e4 100644 --- a/src/main/kotlin/roomescape/reservation/docs/ReservationAPI.kt +++ b/src/main/kotlin/roomescape/reservation/docs/ReservationAPI.kt @@ -16,14 +16,6 @@ import roomescape.common.dto.response.CommonApiResponse import roomescape.reservation.web.* interface ReservationAPI { - - @Public - @Operation(summary = "가장 많이 예약된 테마 ID 조회") - @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) - fun findMostReservedThemeIds( - @RequestParam count: Int - ): ResponseEntity> - @Operation(summary = "결제 전 임시 예약 저장") @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) fun createPendingReservation( diff --git a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt index 08bac9f6..af90de13 100644 --- a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt +++ b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt @@ -1,27 +1,8 @@ package roomescape.reservation.infrastructure.persistence import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.Query -import org.springframework.data.repository.query.Param -import java.time.LocalDate interface ReservationRepository : JpaRepository { fun findAllByUserIdAndStatusIsIn(userId: Long, statuses: List): List - - @Query(""" - SELECT s.themeId - FROM ReservationEntity r - JOIN ScheduleEntity s ON s._id = r.scheduleId - WHERE r.status = roomescape.reservation.infrastructure.persistence.ReservationStatus.CONFIRMED - AND s.date BETWEEN :dateFrom AND :dateTo - GROUP BY s.themeId - ORDER BY count(r) DESC - LIMIT :count - """) - fun findMostReservedThemeIds( - @Param("dateFrom") dateFrom: LocalDate, - @Param("dateTo") dateTo: LocalDate, - @Param("count") count: Int - ): List } diff --git a/src/main/kotlin/roomescape/reservation/web/ReservationController.kt b/src/main/kotlin/roomescape/reservation/web/ReservationController.kt index 68117ff9..15105419 100644 --- a/src/main/kotlin/roomescape/reservation/web/ReservationController.kt +++ b/src/main/kotlin/roomescape/reservation/web/ReservationController.kt @@ -14,16 +14,6 @@ import roomescape.reservation.docs.ReservationAPI class ReservationController( private val reservationService: ReservationService ) : ReservationAPI { - - @GetMapping("/popular-themes") - override fun findMostReservedThemeIds( - @RequestParam count: Int - ): ResponseEntity> { - val response = reservationService.findMostReservedThemeIds(count) - - return ResponseEntity.ok(CommonApiResponse(response)) - } - @PostMapping("/pending") override fun createPendingReservation( @User user: CurrentUserContext, diff --git a/src/main/kotlin/roomescape/schedule/business/domain/ScheduleOverview.kt b/src/main/kotlin/roomescape/schedule/business/domain/ScheduleOverview.kt index e9d9b523..de099149 100644 --- a/src/main/kotlin/roomescape/schedule/business/domain/ScheduleOverview.kt +++ b/src/main/kotlin/roomescape/schedule/business/domain/ScheduleOverview.kt @@ -11,11 +11,11 @@ class ScheduleOverview( val storeName: String, val date: LocalDate, val time: LocalTime, + val status: ScheduleStatus, val themeId: Long, val themeName: String, val themeDifficulty: Difficulty, - val themeAvailableMinutes: Short, - val status: ScheduleStatus + val themeAvailableMinutes: Short ) { fun getEndAt(): LocalTime { return time.plusMinutes(themeAvailableMinutes.toLong()) diff --git a/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleRepository.kt b/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleRepository.kt index c7503bf7..3189b1aa 100644 --- a/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleRepository.kt +++ b/src/main/kotlin/roomescape/schedule/infrastructure/persistence/ScheduleRepository.kt @@ -8,7 +8,8 @@ import java.time.LocalTime interface ScheduleRepository : JpaRepository { - @Query(""" + @Query( + """ SELECT COUNT(s) > 0 FROM @@ -18,35 +19,34 @@ interface ScheduleRepository : JpaRepository { AND s.date = :date AND s.themeId = :themeId AND s.time = :time - """) + """ + ) fun existsDuplicate(storeId: Long, date: LocalDate, themeId: Long, time: LocalTime): Boolean @Query( """ - SELECT + SELECT new roomescape.schedule.business.domain.ScheduleOverview( s._id, st._id, st.name, s.date, s.time, + s.status, t._id, t.name, t.difficulty, - t.availableMinutes, - s.status + t.availableMinutes ) FROM ScheduleEntity s JOIN - ThemeEntity t ON t._id = s.themeId + ThemeEntity t ON t._id = s.themeId and (:themeId IS NULL OR t._id = :themeId) JOIN - StoreEntity st ON st._id = s.storeId + StoreEntity st ON st._id = s.storeId and st._id = :storeId WHERE - s.storeId = :storeId - AND s.date = :date - AND (:themeId IS NULL OR s.themeId = :themeId) - """ + s.date = :date + """ ) fun findStoreSchedulesWithThemeByDate( storeId: Long, @@ -54,21 +54,22 @@ interface ScheduleRepository : JpaRepository { themeId: Long? = null ): List - @Query(""" - SELECT + @Query( + """ + SELECT new roomescape.schedule.business.domain.ScheduleOverview( - s._id, - st._id, - st.name, - s.date, - s.time, - t._id, - t.name, - t.difficulty, - t.availableMinutes, - s.status - ) - FROM + s._id, + st._id, + st.name, + s.date, + s.time, + s.status, + t._id, + t.name, + t.difficulty, + t.availableMinutes + ) + FROM ScheduleEntity s JOIN ThemeEntity t ON t._id = s.themeId @@ -76,6 +77,7 @@ interface ScheduleRepository : JpaRepository { StoreEntity st ON st._id = s.storeId WHERE s._id = :id - """) + """ + ) fun findOverviewByIdOrNull(id: Long): ScheduleOverview? } diff --git a/src/main/kotlin/roomescape/theme/business/ThemeService.kt b/src/main/kotlin/roomescape/theme/business/ThemeService.kt index 7ee4fb80..a290fa78 100644 --- a/src/main/kotlin/roomescape/theme/business/ThemeService.kt +++ b/src/main/kotlin/roomescape/theme/business/ThemeService.kt @@ -9,11 +9,13 @@ import org.springframework.transaction.annotation.Transactional import roomescape.admin.business.AdminService import roomescape.common.config.next import roomescape.common.dto.AuditInfo +import roomescape.common.util.DateUtils import roomescape.theme.exception.ThemeErrorCode import roomescape.theme.exception.ThemeException import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeRepository import roomescape.theme.web.* +import java.time.LocalDate private val log: KLogger = KotlinLogging.logger {} @@ -43,13 +45,18 @@ class ThemeService( } @Transactional(readOnly = true) - fun findAllInfosByIds(request: ThemeIdListRequest): ThemeInfoListResponse { - log.info { "[ThemeService.findThemesByIds] 예약 페이지에서의 테마 목록 조회 시작: themeIds=${request.themeIds}" } - val result: List = themeRepository.findAllByIdIn(request.themeIds) + fun findMostReservedThemeLastWeek(count: Int): ThemeInfoListResponse { + log.info { "[ThemeService.findMostReservedThemeLastWeek] 인기 테마 조회 시작: count=$count" } + + val previousWeekSunday = DateUtils.getSundayOfPreviousWeek(LocalDate.now()) + val previousWeekSaturday = previousWeekSunday.plusDays(6) + + return themeRepository.findMostReservedThemeByDateAndCount(previousWeekSunday, previousWeekSaturday, count) + .toListResponse() + .also { + log.info { "[ThemeService.findMostReservedThemeLastWeek] ${it.themes.size} / $count 개의 인기 테마 조회 완료" } + } - return result.toInfoListResponse().also { - log.info { "[ThemeService.findThemesByIds] ${it.themes.size} / ${request.themeIds.size} 개 테마 조회 완료" } - } } // ======================================== diff --git a/src/main/kotlin/roomescape/theme/business/domain/ThemeInfo.kt b/src/main/kotlin/roomescape/theme/business/domain/ThemeInfo.kt new file mode 100644 index 00000000..9406f6b4 --- /dev/null +++ b/src/main/kotlin/roomescape/theme/business/domain/ThemeInfo.kt @@ -0,0 +1,15 @@ +package roomescape.theme.business.domain + +class ThemeInfo( + val id: Long, + val name: String, + val description: String, + val difficulty: String, + val thumbnailUrl: String, + val price: Int, + val minParticipants: Short, + val maxParticipants: Short, + val availableMinutes: Short, + val expectedMinutesFrom: Short, + val expectedMinutesTo: Short +) diff --git a/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt b/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt index 58c7e76a..82163cc6 100644 --- a/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt +++ b/src/main/kotlin/roomescape/theme/docs/ThemeApi.kt @@ -5,8 +5,10 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import jakarta.validation.Valid import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestParam import roomescape.admin.infrastructure.persistence.AdminType import roomescape.admin.infrastructure.persistence.Privilege import roomescape.auth.web.support.AdminOnly @@ -50,13 +52,13 @@ interface AdminThemeAPI { } interface PublicThemeAPI { - @Public - @Operation(summary = "입력된 모든 ID에 대한 테마 정보 조회") - @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) - fun findThemeInfosByIds(request: ThemeIdListRequest): ResponseEntity> - @Public @Operation(summary = "입력된 테마 ID에 대한 정보 조회") @ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true)) fun findThemeInfoById(@PathVariable id: Long): ResponseEntity> + + @Public + @Operation(summary = "지난 주에 가장 많이 예약된 count 개의 테마 조회") + @GetMapping("/most-reserved") + fun findMostReservedThemeLastWeek(@RequestParam count: Int): ResponseEntity> } diff --git a/src/main/kotlin/roomescape/theme/infrastructure/persistence/ThemeRepository.kt b/src/main/kotlin/roomescape/theme/infrastructure/persistence/ThemeRepository.kt index 25934626..29db0b47 100644 --- a/src/main/kotlin/roomescape/theme/infrastructure/persistence/ThemeRepository.kt +++ b/src/main/kotlin/roomescape/theme/infrastructure/persistence/ThemeRepository.kt @@ -2,6 +2,8 @@ package roomescape.theme.infrastructure.persistence import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query +import roomescape.theme.business.domain.ThemeInfo +import java.time.LocalDate interface ThemeRepository : JpaRepository { @@ -10,5 +12,32 @@ interface ThemeRepository : JpaRepository { fun existsByName(name: String): Boolean - fun findAllByIdIn(themeIds: List): List + @Query( + value = """ + SELECT + t.id, t.name, t.description, t.difficulty, t.thumbnail_url, t.price, + t.min_participants, t.max_participants, + t.available_minutes, t.expected_minutes_from, t.expected_minutes_to + FROM + theme t + JOIN ( + SELECT + s.theme_id, count(*) as reservation_count + FROM + schedule s + JOIN + reservation r ON s.id = r.schedule_id AND r.status = 'CONFIRMED' + WHERE + s.status = 'RESERVED' + AND (s.date BETWEEN :startFrom AND :endAt) + GROUP BY + s.theme_id + ORDER BY + reservation_count desc + LIMIT :count + ) ranked_themes ON t.id = ranked_themes.theme_id + """, + nativeQuery = true + ) + fun findMostReservedThemeByDateAndCount(startFrom: LocalDate, endAt: LocalDate, count: Int): List } diff --git a/src/main/kotlin/roomescape/theme/web/ThemeController.kt b/src/main/kotlin/roomescape/theme/web/ThemeController.kt index f79146f0..8d67ea28 100644 --- a/src/main/kotlin/roomescape/theme/web/ThemeController.kt +++ b/src/main/kotlin/roomescape/theme/web/ThemeController.kt @@ -1,6 +1,5 @@ package roomescape.theme.web -import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* import roomescape.common.dto.response.CommonApiResponse @@ -12,15 +11,6 @@ import roomescape.theme.docs.PublicThemeAPI class ThemeController( private val themeService: ThemeService, ) : PublicThemeAPI { - @PostMapping("/batch") - override fun findThemeInfosByIds( - @Valid @RequestBody request: ThemeIdListRequest - ): ResponseEntity> { - val response = themeService.findAllInfosByIds(request) - - return ResponseEntity.ok(CommonApiResponse(response)) - } - @GetMapping("/{id}") override fun findThemeInfoById( @PathVariable id: Long @@ -29,4 +19,13 @@ class ThemeController( return ResponseEntity.ok(CommonApiResponse(response)) } + + @GetMapping("/most-reserved") + override fun findMostReservedThemeLastWeek( + @RequestParam count: Int + ): ResponseEntity> { + val response = themeService.findMostReservedThemeLastWeek(count) + + return ResponseEntity.ok(CommonApiResponse(response)) + } } diff --git a/src/main/kotlin/roomescape/theme/web/ThemeDto.kt b/src/main/kotlin/roomescape/theme/web/ThemeDto.kt index e79601a2..9befc770 100644 --- a/src/main/kotlin/roomescape/theme/web/ThemeDto.kt +++ b/src/main/kotlin/roomescape/theme/web/ThemeDto.kt @@ -1,6 +1,6 @@ package roomescape.theme.web -import roomescape.theme.infrastructure.persistence.Difficulty +import roomescape.theme.business.domain.ThemeInfo import roomescape.theme.infrastructure.persistence.ThemeEntity data class ThemeIdListRequest( @@ -12,7 +12,7 @@ data class ThemeInfoResponse( val name: String, val thumbnailUrl: String, val description: String, - val difficulty: Difficulty, + val difficulty: String, val price: Int, val minParticipants: Short, val maxParticipants: Short, @@ -21,7 +21,7 @@ data class ThemeInfoResponse( val expectedMinutesTo: Short ) -fun ThemeEntity.toInfoResponse() = ThemeInfoResponse( +fun ThemeInfo.toInfoResponse() = ThemeInfoResponse( id = this.id, name = this.name, thumbnailUrl = this.thumbnailUrl, @@ -35,10 +35,24 @@ fun ThemeEntity.toInfoResponse() = ThemeInfoResponse( expectedMinutesTo = this.expectedMinutesTo ) +fun ThemeEntity.toInfoResponse() = ThemeInfoResponse( + id = this.id, + name = this.name, + thumbnailUrl = this.thumbnailUrl, + description = this.description, + difficulty = this.difficulty.name, + price = this.price, + minParticipants = this.minParticipants, + maxParticipants = this.maxParticipants, + availableMinutes = this.availableMinutes, + expectedMinutesFrom = this.expectedMinutesFrom, + expectedMinutesTo = this.expectedMinutesTo +) + data class ThemeInfoListResponse( val themes: List ) -fun List.toInfoListResponse() = ThemeInfoListResponse( +fun List.toListResponse() = ThemeInfoListResponse( themes = this.map { it.toInfoResponse() } ) diff --git a/src/main/resources/schema/schema-h2.sql b/src/main/resources/schema/schema-h2.sql index 65085eac..9f7b0e95 100644 --- a/src/main/resources/schema/schema-h2.sql +++ b/src/main/resources/schema/schema-h2.sql @@ -187,7 +187,7 @@ create table if not exists payment_detail( create table if not exists payment_bank_transfer_detail ( id bigint primary key, - bank_code varchar(10) not null, + bank_code varchar(20) not null, settlement_status varchar(20) not null, constraint fk_payment_bank_transfer_details__id foreign key (id) references payment_detail (id) @@ -195,7 +195,7 @@ create table if not exists payment_bank_transfer_detail ( create table if not exists payment_card_detail ( id bigint primary key, - issuer_code varchar(10) not null, + issuer_code varchar(20) not null, card_type varchar(10) not null, owner_type varchar(10) not null, amount integer not null, diff --git a/src/main/resources/schema/schema-mysql.sql b/src/main/resources/schema/schema-mysql.sql index 1d2f37bd..56a8ee99 100644 --- a/src/main/resources/schema/schema-mysql.sql +++ b/src/main/resources/schema/schema-mysql.sql @@ -187,7 +187,7 @@ create table if not exists payment_detail( create table if not exists payment_bank_transfer_detail ( id bigint primary key, - bank_code varchar(10) not null, + bank_code varchar(20) not null, settlement_status varchar(20) not null, constraint fk_payment_bank_transfer_details__id foreign key (id) references payment_detail (id) @@ -195,7 +195,7 @@ create table if not exists payment_bank_transfer_detail ( create table if not exists payment_card_detail ( id bigint primary key, - issuer_code varchar(10) not null, + issuer_code varchar(20) not null, card_type varchar(10) not null, owner_type varchar(10) not null, amount integer not null, diff --git a/src/test/kotlin/roomescape/data/DataParser.kt b/src/test/kotlin/roomescape/data/DataParser.kt deleted file mode 100644 index b0c7627d..00000000 --- a/src/test/kotlin/roomescape/data/DataParser.kt +++ /dev/null @@ -1,99 +0,0 @@ -package roomescape.data - -import io.kotest.core.spec.style.StringSpec -import org.apache.poi.xssf.usermodel.XSSFWorkbook -import java.io.File - -const val BASE_DIR = "data" -const val PARSED_REGION_POPULATION_FILE = "$BASE_DIR/region_population.txt" -const val REGION_SQL_FILE = "data/region.sql" -const val MIN_POPULATION_FOR_PER_STORE = 200_000 - -/** - * 행안부 202508 인구 동향 데이터 사용( /data/population.xlsx ) - */ -class PopulationDataSqlParser() : StringSpec({ - - val regionCodePattern = Regex("^[0-9]{10}$") - - "인구 데이터를 이용하여 지역 정보 SQL 파일로 변환하고, 추가로 $MIN_POPULATION_FOR_PER_STORE 이상의 시/군/구는 매장 데이터 생성을 위해 따로 분류한다.".config( - enabled = false - ) { - val populationXlsx = XSSFWorkbook(File("data/population.xlsx")) - val sheet = populationXlsx.getSheetAt(0) - val allRegion = mutableListOf>() - val regionsMoreThanMinPopulation = mutableListOf>() - - sheet.rowIterator().forEach { row -> - val regionCode = row.getCell(0)?.stringCellValue ?: return@forEach - - if (regionCodePattern.matches(regionCode).not()) { - return@forEach - } - val sidoCode = regionCode.substring(0, 2) - val sigunguCode = regionCode.substring(2, 5) - - val population = row.getCell(2).stringCellValue.replace(",", "") - if (Regex("^[0-9]+$").matches(population).not()) { - return@forEach - } - - val regionName = row.getCell(1).stringCellValue - if (!regionName.trim().contains(" ")) { - return@forEach - } - - val parts = regionName.split(" ") - if (parts.size < 2) { - return@forEach - } - - val sidoName = parts[0].trim() - val sigunguName = parts[1].trim() - val populationInt = population.toInt() - - if (populationInt <= 0) { - return@forEach - } - - if (populationInt >= MIN_POPULATION_FOR_PER_STORE) { - regionsMoreThanMinPopulation.add( - listOf( - regionCode, - sidoCode, - sigunguCode, - sidoName, - sigunguName, - population - ) - ) - } - allRegion.add(listOf(regionCode, sidoCode, sigunguCode, sidoName, sigunguName)) - } - - regionsMoreThanMinPopulation.filter { - val sidoName = it[3] - val sigunguName = it[4] - val sameSigungu = allRegion.filter { r -> r[3] == sidoName && r[4] == sigunguName } - !((sameSigungu.size > 1) && sameSigungu.minByOrNull { r -> r[2].toInt() }!![2] != it[2]) - }.mapIndexed { idx, values -> - "${values[0]}, ${values[1]}, ${values[2]}, ${values[3]}, ${values[4]}, ${values[5]}" - }.joinToString(separator = "\n").also { - File(PARSED_REGION_POPULATION_FILE).writeText(it) - } - - allRegion.distinctBy { it[1] to it[2] }.filter { - val sidoName = it[3] - val sigunguName = it[4] - val sameSigungu = allRegion.filter { r -> r[3] == sidoName && r[4] == sigunguName } - !((sameSigungu.size > 1) && sameSigungu.minByOrNull { r -> r[2].toInt() }!![2] != it[2]) - }.joinToString( - prefix = "INSERT INTO region(code, sido_code, sigungu_code, sido_name, sigungu_name) VALUES ", - separator = ",\n" - ) { region -> - "('${region[0]}', '${region[1]}', '${region[2]}', '${region[3]}', '${region[4]}')" - }.also { - File(REGION_SQL_FILE).writeText("${it};") - } - } -}) diff --git a/src/test/kotlin/roomescape/data/DefaultDataInitializer.kt b/src/test/kotlin/roomescape/data/DefaultDataInitializer.kt new file mode 100644 index 00000000..eed4c74d --- /dev/null +++ b/src/test/kotlin/roomescape/data/DefaultDataInitializer.kt @@ -0,0 +1,911 @@ +package roomescape.data + +import com.github.f4b6a3.tsid.TsidFactory +import io.kotest.core.test.TestCaseOrder +import jakarta.persistence.EntityManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.context.ActiveProfiles +import roomescape.admin.infrastructure.persistence.AdminEntity +import roomescape.admin.infrastructure.persistence.AdminPermissionLevel +import roomescape.admin.infrastructure.persistence.AdminType +import roomescape.common.config.next +import roomescape.common.util.TransactionExecutionUtil +import roomescape.payment.infrastructure.common.* +import roomescape.reservation.infrastructure.persistence.ReservationStatus +import roomescape.schedule.infrastructure.persistence.ScheduleStatus +import roomescape.supports.AdminFixture +import roomescape.supports.FunSpecSpringbootTest +import roomescape.supports.randomPhoneNumber +import roomescape.supports.randomString +import roomescape.theme.infrastructure.persistence.Difficulty +import roomescape.user.business.SIGNUP +import roomescape.user.infrastructure.persistence.UserEntity +import roomescape.user.infrastructure.persistence.UserStatus +import roomescape.user.web.UserContactResponse +import java.sql.Timestamp +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.OffsetDateTime + +@ActiveProfiles("test", "test-mysql") +abstract class AbstractDataInitializer( + val semaphore: Semaphore = Semaphore(permits = 10), +) : FunSpecSpringbootTest( + enableCleanerExtension = false +) { + @Autowired + lateinit var entityManager: EntityManager + + @Autowired + lateinit var jdbcTemplate: JdbcTemplate + + @Autowired + lateinit var transactionExecutionUtil: TransactionExecutionUtil + + @Autowired + lateinit var tsidFactory: TsidFactory + + override fun testCaseOrder(): TestCaseOrder? = TestCaseOrder.Sequential + + suspend fun initialize() { + transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0") + + jdbcTemplate.query("SHOW TABLES") { rs, _ -> + rs.getString(1).lowercase() + }.forEach { + jdbcTemplate.execute("TRUNCATE TABLE $it") + } + + jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1") + + this::class.java.getResource("/schema/region-data.sql")?.readText()?.let { sql -> + jdbcTemplate.execute(sql) + } + } + } + + suspend fun executeBatch(sql: String, batchArgs: List>) { + semaphore.acquire() + + transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + jdbcTemplate.batchUpdate(sql, batchArgs) + } + + semaphore.release() + } +} + +class DefaultDataInitializer : AbstractDataInitializer() { + + // 1. HQ Admin 추가 + // 2. Store 추가 -> CreatedBy / UpdatedBy는 HQ Admin 중 한명으로 고정 + // 3. Store Admin 추가 -> CreatedBy / UpdatedBy는 HQ Admin 본인으로 고정 + init { + lateinit var superHQAdmin: AdminEntity + + // 모든 테이블 초기화 + 지역 데이터 삽입 + HQ 관리자 1명 생성 + beforeSpec { + initialize() + + transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + superHQAdmin = testAuthUtil.createAdmin(AdminFixture.hqDefault.apply { + this.createdBy = this.id + this.updatedBy = this.id + }) + } + } + + context("관리자, 매장, 테마 초기 데이터 생성") { + test("각 PermissionLevel 마다 20명의 HQ 관리자 생성") { + transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + AdminPermissionLevel.entries.forEach { + repeat(20) { index -> + AdminFixture.create( + account = "hq_${it.name.lowercase()}_$index", + name = randomKoreanName(), + phone = randomPhoneNumber(), + type = AdminType.HQ, + permissionLevel = it + ).apply { + this.createdBy = superHQAdmin.id + this.updatedBy = superHQAdmin.id + }.also { + entityManager.persist(it) + } + } + } + } + } + + test("전체 매장 생성") { + val storeDataInitializer = StoreDataInitializer() + val creatableAdminIds: List = transactionExecutionUtil.withNewTransaction(isReadOnly = true) { + entityManager.createQuery( + "SELECT a.id FROM AdminEntity a WHERE a.type = :type AND a.permissionLevel IN (:permissionLevels)", + Long::class.java + ).setParameter("type", AdminType.HQ) + .setParameter( + "permissionLevels", + listOf(AdminPermissionLevel.FULL_ACCESS, AdminPermissionLevel.WRITABLE) + ) + .resultList + }.map { it.toString() } + + val sqlFile = storeDataInitializer.createStoreDataSqlFile(creatableAdminIds) + + transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + jdbcTemplate.execute(sqlFile.readText()) + } + } + + test("각 매장당 1명의 ${AdminPermissionLevel.FULL_ACCESS} 권한의 StoreManager 1명 + ${AdminPermissionLevel.WRITABLE}의 2명 + 나머지 권한은 3명씩 생성") { + val storeAdminCountsByPermissionLevel = mapOf( + AdminPermissionLevel.WRITABLE to 2, + AdminPermissionLevel.READ_ALL to 3, + AdminPermissionLevel.READ_SUMMARY to 3 + ) + + val storeIds: List = transactionExecutionUtil.withNewTransaction(isReadOnly = true) { + entityManager.createQuery( + "SELECT s.id FROM StoreEntity s", + Long::class.java + ).resultList + }.map { it as Long } + + transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + storeIds.forEach { storeId -> + // StoreManager 1명 생성 + val storeManager = AdminFixture.create( + account = "$storeId", + name = randomKoreanName(), + phone = randomPhoneNumber(), + type = AdminType.STORE, + storeId = storeId, + permissionLevel = AdminPermissionLevel.FULL_ACCESS + ).apply { + this.createdBy = superHQAdmin.id + this.updatedBy = superHQAdmin.id + }.also { + entityManager.persist(it) + } + + storeAdminCountsByPermissionLevel.forEach { (permissionLevel, count) -> + repeat(count) { index -> + AdminFixture.create( + account = randomString(), + name = randomKoreanName(), + phone = randomPhoneNumber(), + type = AdminType.STORE, + storeId = storeId, + permissionLevel = permissionLevel + ).apply { + this.createdBy = storeManager.id + this.updatedBy = storeManager.id + }.also { + entityManager.persist(it) + } + } + } + entityManager.flush() + entityManager.clear() + } + } + } + + test("총 500개의 테마 생성: 지난 2년 전 부터 지금까지의 랜덤 테마 + active 상태인 1달 이내 생성 테마 10개") { + val creatableAdminIds: List = transactionExecutionUtil.withNewTransaction(isReadOnly = true) { + entityManager.createQuery( + "SELECT a.id FROM AdminEntity a WHERE a.type = :type AND a.permissionLevel IN (:permissionLevels)", + Long::class.java + ).setParameter("type", AdminType.HQ) + .setParameter( + "permissionLevels", + listOf(AdminPermissionLevel.FULL_ACCESS, AdminPermissionLevel.WRITABLE) + ) + .resultList + } + val sql = + "INSERT INTO theme (id, name, description, thumbnail_url, is_active, available_minutes, expected_minutes_from, expected_minutes_to, price, difficulty, min_participants, max_participants, created_at, created_by, updated_at, updated_by) VALUES (?," + + "?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + val batchSize = 100 + val batchArgs = mutableListOf>() + + repeat(500) { i -> + val randomDay = if (i <= 9) (1..30).random() else (1..365 * 2).random() + val randomCreatedAt: LocalDateTime = LocalDateTime.now().minusDays(randomDay.toLong()) + val randomThemeName = + (1..7).random().let { repeat -> (1..repeat).joinToString("") { randomKoreanName() } } + val availableMinutes = (6..20).random() * 10 + val expectedMinutesTo = availableMinutes - ((1..3).random() * 10) + val expectedMinutesFrom = expectedMinutesTo - ((1..2).random() * 10) + val randomPrice = (0..40).random() * 500 + val minParticipant = (1..10).random() + val maxParticipant = minParticipant + (1..10).random() + val createdBy = creatableAdminIds.random() + + batchArgs.add( + arrayOf( + tsidFactory.next(), + randomThemeName, + "$randomThemeName 설명이에요!!", + "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRFiuCdwdz88l6pdfsRy1nFl0IHUVI7JMTQHg&s", + if (randomDay <= 30) true else false, + availableMinutes.toShort(), + expectedMinutesFrom.toShort(), + expectedMinutesTo.toShort(), + randomPrice, + Difficulty.entries.random().name, + minParticipant.toShort(), + maxParticipant.toShort(), + Timestamp.valueOf(randomCreatedAt), + createdBy, + Timestamp.valueOf(randomCreatedAt), + createdBy + ) + ) + } + + transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + jdbcTemplate.batchUpdate(sql, batchArgs) + } + } + } + } +} + +class UserDataInitializer : AbstractDataInitializer() { + val userCount = 1_000_000 + + init { + context("유저 초기 데이터 생성") { + test("$userCount 명의 회원 생성") { + val regions: List = transactionExecutionUtil.withNewTransaction(isReadOnly = true) { + entityManager.createQuery( + "SELECT r.code FROM RegionEntity r", + String::class.java + ).resultList + } + + val chunkSize = 10_000 + val chunks = userCount / chunkSize + + coroutineScope { + (1..chunks).map { + launch(Dispatchers.IO) { + processUsers(chunkSize, regions) + } + }.joinAll() + } + } + + test("휴대폰 번호가 중복된 유저들에게 재배정") { + val duplicatePhoneUsers: List = + transactionExecutionUtil.withNewTransaction(isReadOnly = true) { + entityManager.createQuery( + """ + SELECT u FROM UserEntity u + WHERE u.phone IN ( + SELECT u2.phone FROM UserEntity u2 + GROUP BY u2.phone + HAVING COUNT(u2.id) > 1 + ) + ORDER BY u.phone, u.id + """.trimIndent(), + UserEntity::class.java + ).resultList + } + + jdbcTemplate.execute("CREATE INDEX idx_users__phone ON users (phone)") + + transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + var currentPhone: String? = null + duplicatePhoneUsers.forEach { user -> + if (user.phone != currentPhone) { + currentPhone = user.phone + } else { + var newPhone: String + + do { + newPhone = randomPhoneNumber() + val count: Long = entityManager.createQuery( + "SELECT COUNT(u.id) FROM UserEntity u WHERE u.phone = :phone", + Long::class.java + ).setParameter("phone", newPhone) + .singleResult + + if (count == 0L) break + } while (true) + + user.phone = newPhone + user.updatedAt = LocalDateTime.now() + entityManager.merge(user) + } + } + } + + jdbcTemplate.execute("DROP INDEX idx_users__phone ON users") + } + + test("회원 상태 변경 이력 저장") { + val userId: List = transactionExecutionUtil.withNewTransaction(isReadOnly = true) { + entityManager.createQuery( + "SELECT u.id FROM UserEntity u", + Long::class.java + ).resultList + } + + coroutineScope { + userId.chunked(10_000).map { chunk -> + launch(Dispatchers.IO) { + processStatus(chunk) + } + }.joinAll() + } + } + } + } + + private suspend fun processStatus(userIds: List) { + val sql = """ + INSERT INTO user_status_history ( + id, user_id, reason, status, + created_by, updated_by, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent() + val batchArgs = mutableListOf>() + val now = LocalDateTime.now() + + userIds.forEach { userId -> + batchArgs.add( + arrayOf( + tsidFactory.next(), + userId, + SIGNUP, + UserStatus.ACTIVE.name, + userId, + userId, + Timestamp.valueOf(now), + Timestamp.valueOf(now) + ) + ) + } + + executeBatch(sql, batchArgs).also { batchArgs.clear() } + } + + private suspend fun processUsers(chunkSize: Int, regions: List) { + val sql = """ + INSERT INTO users ( + id, name, email, password, phone, region_code, status, + created_by, updated_by, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent() + val batchArgs = mutableListOf>() + + repeat(chunkSize) { + val id: Long = tsidFactory.next() + batchArgs.add( + arrayOf( + id, + randomKoreanName(), + "${randomString()}@sangdol.com", + randomString(), + randomPhoneNumber(), + regions.random(), + UserStatus.ACTIVE.name, + id, + id, + Timestamp.valueOf(LocalDateTime.now()), + Timestamp.valueOf(LocalDateTime.now()) + ) + ) + if (batchArgs.size >= 1_000) { + executeBatch(sql, batchArgs).also { batchArgs.clear() } + } + } + + if (batchArgs.isNotEmpty()) executeBatch(sql, batchArgs).also { batchArgs.clear() } + } +} + +class ScheduleDataInitializer : AbstractDataInitializer() { + init { + context("일정 초기 데이터 생성") { + test("테마 생성일 기준으로 다음 3일차, 매일 5개의 일정을 모든 매장에 생성") { + val stores: List> = getStoreWithManagers() + val themes: List> = getThemes() + val maxAvailableMinutes = themes.maxOf { it.second.toInt() } + val scheduleCountPerDay = 5 + + val startTime = LocalTime.of(10, 0) + var lastTime = startTime + val times = mutableListOf() + + repeat(scheduleCountPerDay) { + times.add(lastTime) + lastTime = lastTime.plusMinutes(maxAvailableMinutes.toLong() + 10L) + } + + coroutineScope { + themes.forEach { theme -> + launch(Dispatchers.IO) { + processTheme(theme, stores, times) + } + } + } + } + } + } + + private suspend fun processTheme( + theme: Triple, + stores: List>, + times: List + ) { + val sql = """ + INSERT INTO schedule ( + id, store_id, theme_id, date, time, status, + created_by, updated_by, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent() + + val batchArgs = mutableListOf>() + + val now = LocalDateTime.now() + stores.forEach { (storeId, adminId) -> + (1..3).forEach { dayOffset -> + val date = theme.third.toLocalDate().plusDays(dayOffset.toLong()) + + times.forEach { time -> + val scheduledAt = LocalDateTime.of(date, time) + val status = + if (scheduledAt.isAfter(now)) ScheduleStatus.AVAILABLE.name else ScheduleStatus.RESERVED.name + + batchArgs.add( + arrayOf( + tsidFactory.next(), storeId, theme.first, date, time, + status, adminId, adminId, Timestamp.valueOf(now), Timestamp.valueOf(now) + ) + ) + + if (batchArgs.size >= 500) { + executeBatch(sql, batchArgs).also { batchArgs.clear() } + } + } + } + } + + if (batchArgs.isNotEmpty()) { + executeBatch(sql, batchArgs).also { batchArgs.clear() } + } + } + + private fun getStoreWithManagers(): List> { + return transactionExecutionUtil.withNewTransaction(isReadOnly = true) { + entityManager.createQuery( + "SELECT a.storeId, a.id FROM AdminEntity a WHERE a.type = :type AND a.permissionLevel = :permissionLevel", + List::class.java + ).setParameter("type", AdminType.STORE) + .setParameter("permissionLevel", AdminPermissionLevel.FULL_ACCESS) + .resultList + }.map { + val array = it as List<*> + Pair(array[0] as Long, array[1] as Long) + } + } + + private fun getThemes(): List> { + return transactionExecutionUtil.withNewTransaction(isReadOnly = true) { + entityManager.createQuery( + "SELECT t._id, t.availableMinutes, t.createdAt FROM ThemeEntity t", + List::class.java + ) + .resultList + }.map { + val array = it as List<*> + Triple(array[0] as Long, array[1] as Short, array[2] as LocalDateTime) + } + } +} + +/** + * 아래의 ReservationDataInitializer 에서 사용할 임시 DTO 클래스 + */ +data class ScheduleWithThemeParticipants( + val scheduleId: Long, + val themeMinParticipants: Short, + val themeMaxParticipants: Short, +) + +class ReservationDataInitializer : AbstractDataInitializer() { + + init { + context("예약 초기 데이터 생성") { + test("${ScheduleStatus.RESERVED}인 모든 일정에 예약을 1개씩 배정한다.") { + val chunkSize = 10_000 + + val chunkedSchedules: List> = entityManager.createQuery( + "SELECT new roomescape.data.ScheduleWithThemeParticipants(s._id, t.minParticipants, t.maxParticipants) FROM ScheduleEntity s JOIN ThemeEntity t ON s.themeId = t.id WHERE s.status = :status", + ScheduleWithThemeParticipants::class.java + ).setParameter("status", ScheduleStatus.RESERVED).resultList.chunked(chunkSize) + + val chunkedUsers: List> = entityManager.createQuery( + "SELECT new roomescape.user.web.UserContactResponse(u._id, u.name, u.phone) FROM UserEntity u", + UserContactResponse::class.java + ).resultList.chunked(chunkSize) + + + coroutineScope { + chunkedSchedules.forEachIndexed { idx, schedules -> + launch(Dispatchers.IO) { + processReservation(chunkedUsers[idx % chunkedUsers.size], schedules) + } + } + } + } + } + } + + private suspend fun processReservation( + users: List, + schedules: List + ) { + val sql = """ + INSERT INTO reservation ( + id, user_id, schedule_id, + reserver_name, reserver_contact, participant_count, requirement, + status, created_at, created_by, updated_at, updated_by + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent() + + val batchArgs = mutableListOf>() + + val createdAt = LocalDateTime.now() + + schedules.forEachIndexed { idx, schedule -> + val user: UserContactResponse = users[idx % users.size] + + batchArgs.add( + arrayOf( + tsidFactory.next(), + user.id, + schedule.scheduleId, + user.name, + user.phone, + (schedule.themeMinParticipants..schedule.themeMaxParticipants).random(), + randomKoreanWords(length = (20..100).random()), + ReservationStatus.CONFIRMED.name, + Timestamp.valueOf(createdAt), + user.id, + Timestamp.valueOf(createdAt), + user.id, + ) + ) + + if (batchArgs.size >= 1_000) { + executeBatch(sql, batchArgs).also { batchArgs.clear() } + } + } + + if (batchArgs.isNotEmpty()) executeBatch(sql, batchArgs).also { batchArgs.clear() } + } +} + +class ReservationWithPrice( + themePrice: Int, + participantCount: Short, + + val reservationId: Long, + val totalPrice: Int = (themePrice * participantCount), +) + +data class PaymentWithMethods( + val id: Long, + val totalPrice: Int, + val method: PaymentMethod +) + +class PaymentDataInitializer : AbstractDataInitializer() { + companion object { + val requestedAtCache: Timestamp = Timestamp.valueOf(OffsetDateTime.now().toLocalDateTime()) + val approvedAtCache: Timestamp = Timestamp.valueOf(OffsetDateTime.now().plusSeconds(5).toLocalDateTime()) + val supportedPaymentMethods = listOf(PaymentMethod.TRANSFER, PaymentMethod.EASY_PAY, PaymentMethod.CARD) + val supportedCardType = listOf(CardType.CREDIT, CardType.CHECK) + + val settlementStatus = "COMPLETED" + + val paymentSql: String = """ + INSERT INTO payment( + id, reservation_id, type, method, + payment_key, order_id, total_amount, status, + requested_at, approved_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent() + + val paymentDetailSql: String = """ + INSERT INTO payment_detail( + id, payment_id, supplied_amount, vat + ) VALUES (?, ?, ?, ?) + """.trimIndent() + + val paymentCardDetailSql: String = """ + INSERT INTO payment_card_detail( + id, issuer_code, card_type, owner_type, + amount, card_number, approval_number, installment_plan_months, + is_interest_free, easypay_provider_code, easypay_discount_amount + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent() + + val paymentEasypayPrepaidDetailSql: String = """ + INSERT INTO payment_easypay_prepaid_detail( + id, easypay_provider_code, amount, discount_amount + ) VALUES (?, ?, ?, ?) + """.trimIndent() + + val paymentBankTransferDetailSql: String = """ + INSERT INTO payment_bank_transfer_detail( + id, bank_code, settlement_status + ) VALUES (?, ?, ?) + """.trimIndent() + } + + init { + context("결제 데이터 초기화") { + test("모든 예약에 맞춰 1:1로 결제 및 결제 상세 데이터를 생성한다.") { + val allReservations: List = entityManager.createQuery( + "SELECT t.price, r.participantCount, r._id FROM ReservationEntity r JOIN ScheduleEntity s ON s._id = r.scheduleId JOIN ThemeEntity t ON t.id = s.themeId", + List::class.java + ).resultList.map { + val items = it as List<*> + ReservationWithPrice( + themePrice = items[0] as Int, + participantCount = items[1] as Short, + reservationId = items[2] as Long + ) + } + + coroutineScope { + allReservations.chunked(10_000).forEach { reservations -> + launch(Dispatchers.IO) { + processPaymentAndDefaultDetail(reservations) + } + } + } + } + + test("기존 결제 데이터에 상세 정보(계좌이체, 카드, 간편결제) 데이터를 생성한다.") { + val allPayments: List = entityManager.createQuery( + "SELECT new roomescape.data.PaymentWithMethods(pd._id, p.totalAmount, p.method) FROM PaymentEntity p JOIN PaymentDetailEntity pd ON p._id = pd.paymentId", + PaymentWithMethods::class.java + ).resultList + + coroutineScope { + allPayments.chunked(10_000).forEach { payments -> + launch(Dispatchers.IO) { + processPaymentDetail(payments) + } + } + } + } + + test("null 컴파일 에러를 피하기 위해 문자열 null로 임시 지정한 컬럼을 변경한다.") { + jdbcTemplate.execute("CREATE INDEX idx_payment_card_detail_easypay ON payment_card_detail (easypay_provider_code)") + + transactionExecutionUtil.withNewTransaction(isReadOnly = false) { + jdbcTemplate.update( + "UPDATE payment_card_detail SET easypay_provider_code = ? WHERE easypay_provider_code = ?", + null, + "null" + ) + } + + jdbcTemplate.execute("DROP INDEX idx_payment_card_detail_easypay ON payment_card_detail") + } + } + } + + private suspend fun processPaymentAndDefaultDetail(reservations: List) { + val paymentBatchArgs = mutableListOf>() + val detailBatchArgs = mutableListOf>() + + reservations.forEachIndexed { idx, reservations -> + val id = tsidFactory.next() + val totalPrice = reservations.totalPrice + paymentBatchArgs.add( + arrayOf( + id, + reservations.reservationId, + PaymentType.NORMAL.name, + randomPaymentMethod(), + randomString(length = 64), + randomString(length = 20), + totalPrice, + PaymentStatus.DONE.name, + requestedAtCache, + approvedAtCache, + ) + ) + if (paymentBatchArgs.size >= 1_000) { + executeBatch(paymentSql, paymentBatchArgs).also { paymentBatchArgs.clear() } + } + + val suppliedAmount: Int = (totalPrice * 0.9).toInt() + val vat: Int = (totalPrice - suppliedAmount) + + detailBatchArgs.add( + arrayOf( + tsidFactory.next(), + id, + suppliedAmount, + vat + ) + ) + + if (detailBatchArgs.size >= 1_000) { + executeBatch(paymentDetailSql, detailBatchArgs).also { detailBatchArgs.clear() } + } + } + + if (paymentBatchArgs.isNotEmpty()) { + executeBatch(paymentSql, paymentBatchArgs).also { paymentBatchArgs.clear() } + } + if (detailBatchArgs.isNotEmpty()) { + executeBatch(paymentDetailSql, detailBatchArgs).also { detailBatchArgs.clear() } + } + } + + private suspend fun processPaymentDetail(payments: List) { + val transferBatchArgs = mutableListOf>() + val cardBatchArgs = mutableListOf>() + val easypayPrepaidBatchArgs = mutableListOf>() + + payments.forEach { payment -> + val totalPrice = payment.totalPrice + val randomDiscountAmount = + if (totalPrice < 100 || Math.random() < 0.8) 0 else ((100..totalPrice).random() / 100) * 100 + val amount = totalPrice - randomDiscountAmount + + when (payment.method) { + PaymentMethod.TRANSFER -> { + transferBatchArgs.add( + arrayOf( + payment.id, + BankCode.entries.random().name, + settlementStatus + ) + ) + if (transferBatchArgs.size >= 1_000) { + executeBatch(paymentBankTransferDetailSql, transferBatchArgs).also { transferBatchArgs.clear() } + } + } + + PaymentMethod.EASY_PAY -> { + if (Math.random() <= 0.7) { + cardBatchArgs.add( + arrayOf( + payment.id, + CardIssuerCode.entries.random().name, + supportedCardType.random().name, + CardOwnerType.PERSONAL.name, + amount, + randomCardNumber(), + randomApprovalNumber(), + randomInstallmentPlanMonths(amount), + true, + EasyPayCompanyCode.entries.random().name, + randomDiscountAmount + ) + ) + + if (cardBatchArgs.size >= 1_000) { + executeBatch(paymentCardDetailSql, cardBatchArgs).also { cardBatchArgs.clear() } + } + + } else { + easypayPrepaidBatchArgs.add( + arrayOf( + payment.id, + EasyPayCompanyCode.entries.random().name, + amount, + randomDiscountAmount, + ) + ) + + if (easypayPrepaidBatchArgs.size >= 1_000) { + executeBatch(paymentEasypayPrepaidDetailSql, easypayPrepaidBatchArgs).also { easypayPrepaidBatchArgs.clear() } + } + } + } + + PaymentMethod.CARD -> { + cardBatchArgs.add( + arrayOf( + payment.id, + CardIssuerCode.entries.random().name, + supportedCardType.random().name, + CardOwnerType.PERSONAL.name, + totalPrice, + randomCardNumber(), + randomApprovalNumber(), + randomInstallmentPlanMonths(totalPrice), + true, + "null", + 0, + ) + ) + + if (cardBatchArgs.size >= 1_000) { + executeBatch(paymentCardDetailSql, cardBatchArgs).also { cardBatchArgs.clear() } + } + } + + else -> return@forEach + } + } + if (transferBatchArgs.isNotEmpty()) { + executeBatch(paymentBankTransferDetailSql, transferBatchArgs).also { transferBatchArgs.clear() } + } + if (cardBatchArgs.isNotEmpty()) { + executeBatch(paymentCardDetailSql, cardBatchArgs).also { cardBatchArgs.clear() } + } + if (easypayPrepaidBatchArgs.isNotEmpty()) { + executeBatch(paymentEasypayPrepaidDetailSql, easypayPrepaidBatchArgs).also { easypayPrepaidBatchArgs.clear() } + } + } + + private suspend fun randomPaymentMethod(): String { + val random = Math.random() + + return if (random <= 0.5) { + PaymentMethod.EASY_PAY.name + } else if (random <= 0.9) { + PaymentMethod.CARD.name + } else { + PaymentMethod.TRANSFER.name + } + } + + private suspend fun randomCardNumber(): String { + return "${(10000000..99999999).random()}****${(100..999).random()}*" + } + + private suspend fun randomApprovalNumber(): String { + return "${(10000000..99999999).random()}" + } + + private suspend fun randomInstallmentPlanMonths(amount: Int): Int { + return if (amount < 50_000 || Math.random() < 0.9) { + 0 + } else { + (1..6).random() + } + } +} + +fun randomKoreanName(): String { + val lastNames = listOf( + "김", "이", "박", "최", "정", "강", "조", "윤", "장", "임", + "오", "서", "신", "권", "황", "안", "송", "류", "홍", "전", "고", "문", "양", "손", "배", "백", "허", "유", "남", "심", "노" + ) + + return "${lastNames.random()}${if (Math.random() < 0.1) randomKoreanWords(1) else randomKoreanWords(2)}" +} + +fun randomKoreanWords(length: Int = 1): String { + val words = listOf( + "가", "나", "다", "라", "마", "바", "사", "아", "자", "차", + "카", "타", "파", "하", "강", "민", "서", "윤", "우", "진", + "현", "지", "은", "혜", "수", "영", "주", "원", "희", "경", + "선", "아", "나", "다", "라", "마", "바", "사", "아", "자", + "차", "카", "타", "파", "하", + ) + + return (1..length).joinToString("") { words.random() } +} diff --git a/src/test/kotlin/roomescape/data/PopulationDataParser.kt b/src/test/kotlin/roomescape/data/PopulationDataParser.kt new file mode 100644 index 00000000..d511b234 --- /dev/null +++ b/src/test/kotlin/roomescape/data/PopulationDataParser.kt @@ -0,0 +1,236 @@ +package roomescape.data + +import org.apache.poi.xssf.usermodel.XSSFWorkbook +import roomescape.common.config.next +import roomescape.store.infrastructure.persistence.StoreStatus +import roomescape.supports.randomPhoneNumber +import roomescape.supports.tsidFactory +import java.io.File +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlin.random.Random + +const val BASE_DIR = "data" +const val PARSED_REGION_POPULATION_FILE = "$BASE_DIR/region_population.txt" +const val REGION_SQL_FILE = "data/region.sql" +const val MIN_POPULATION_FOR_PER_STORE = 200_000 + +/** + * 행안부 202508 인구 동향 데이터 사용( /data/population.xlsx ) + */ +class PopulationDataSqlParser() { + + val regionCodePattern = Regex("^[0-9]{10}$") + + // 인구 데이터를 이용하여 지역 정보 SQL 파일로 변환하고, 추가로 $MIN_POPULATION_FOR_PER_STORE 이상의 시/군/구는 매장 데이터 생성을 위해 따로 분류한다. + fun createParsedRegionPopulationFiles() { + val populationXlsx = XSSFWorkbook(File("data/population.xlsx")) + val sheet = populationXlsx.getSheetAt(0) + val allRegion = mutableListOf>() + val regionsMoreThanMinPopulation = mutableListOf>() + + sheet.rowIterator().forEach { row -> + val regionCode = row.getCell(0)?.stringCellValue ?: return@forEach + + if (regionCodePattern.matches(regionCode).not()) { + return@forEach + } + val sidoCode = regionCode.substring(0, 2) + val sigunguCode = regionCode.substring(2, 5) + + val population = row.getCell(2).stringCellValue.replace(",", "") + if (Regex("^[0-9]+$").matches(population).not()) { + return@forEach + } + + val regionName = row.getCell(1).stringCellValue + if (!regionName.trim().contains(" ")) { + return@forEach + } + + val parts = regionName.split(" ") + if (parts.size < 2) { + return@forEach + } + + val sidoName = parts[0].trim() + val sigunguName = parts[1].trim() + val populationInt = population.toInt() + + if (populationInt <= 0) { + return@forEach + } + + if (populationInt >= MIN_POPULATION_FOR_PER_STORE) { + regionsMoreThanMinPopulation.add( + listOf( + regionCode, + sidoCode, + sigunguCode, + sidoName, + sigunguName, + population + ) + ) + } + allRegion.add(listOf(regionCode, sidoCode, sigunguCode, sidoName, sigunguName)) + } + + regionsMoreThanMinPopulation.filter { + val sidoName = it[3] + val sigunguName = it[4] + val sameSigungu = allRegion.filter { r -> r[3] == sidoName && r[4] == sigunguName } + !((sameSigungu.size > 1) && sameSigungu.minByOrNull { r -> r[2].toInt() }!![2] != it[2]) + }.mapIndexed { idx, values -> + "${values[0]}, ${values[1]}, ${values[2]}, ${values[3]}, ${values[4]}, ${values[5]}" + }.joinToString(separator = "\n").also { + File(PARSED_REGION_POPULATION_FILE).writeText(it) + } + + allRegion.distinctBy { it[1] to it[2] }.filter { + val sidoName = it[3] + val sigunguName = it[4] + val sameSigungu = allRegion.filter { r -> r[3] == sidoName && r[4] == sigunguName } + !((sameSigungu.size > 1) && sameSigungu.minByOrNull { r -> r[2].toInt() }!![2] != it[2]) + }.joinToString( + prefix = "INSERT INTO region(code, sido_code, sigungu_code, sido_name, sigungu_name) VALUES ", + separator = ",\n" + ) { region -> + "('${region[0]}', '${region[1]}', '${region[2]}', '${region[3]}', '${region[4]}')" + }.also { + File(REGION_SQL_FILE).writeText("${it};") + } + } +} + +/** + * PopulationDataSqlParser에서 전처리된 지역 + 인구 정보를 이용하여 Store 초기 데이터 SQL 생성 + */ +class StoreDataInitializer { + + val positiveWords = listOf( + "사랑", "행복", "희망", "감사", "기쁨", "미소", "축복", "선물", "평화", + "열정", "미래", "자유", "도전", "지혜", "행운" + ) + + fun createStoreDataSqlFile(creatableAdminIds: List): File { + val regions = initializeRegionWithStoreCount() + val usedStoreName = mutableListOf() + val usedBusinessRegNums = mutableListOf() + val storeSqlRows = mutableListOf() + val storeDataRows = mutableListOf() + val storeIds = mutableListOf() + + regions.forEachIndexed { idx, region -> + for (i in 0..region.storeCount) { + var address: String + var storeName: String + do { + val randomPositiveWord = positiveWords.random() + storeName = "${parseSigunguName(region.sigunguName)}${randomPositiveWord}점" + address = + "${region.sidoName} ${region.sigunguName} ${randomPositiveWord}${Random.nextInt(1, 10)}길 ${Random.nextInt(1, 100)}" + } while (usedStoreName.contains(storeName)) + usedStoreName.add(storeName) + + val contact = randomPhoneNumber() + + var businessRegNum: String + do { + businessRegNum = generateBusinessRegNum() + } while (usedBusinessRegNums.contains(businessRegNum)) + usedBusinessRegNums.add(businessRegNum) + + val createdAt = randomLocalDateTime() + val updatedAt = createdAt + + val id: Long = tsidFactory.next().also { storeIds.add(it) } + + val createdBy = creatableAdminIds.random() + + storeSqlRows.add( + "(${id}, '$storeName', '$address', '$contact', '$businessRegNum', '${region.regionCode}', '${StoreStatus.ACTIVE.name}', '$createdAt', '${createdBy}', '$updatedAt', '${createdBy}')" + ) + storeDataRows.add( + "$id, $storeName, $address, $contact, $businessRegNum, ${region.regionCode}, ${StoreStatus.ACTIVE.name}, $createdAt, $createdBy, $updatedAt, $createdBy" + ) + } + } + + File("$BASE_DIR/store_data.txt").also { + if (it.exists()) { it.delete() } + }.writeText( + storeDataRows.joinToString("\n") + ) + + return File("$BASE_DIR/store_data.sql").also { + if (it.exists()) { it.delete() } + + StringBuilder("INSERT INTO store (id, name, address, contact, business_reg_num, region_code, status, created_at, created_by, updated_at, updated_by) VALUES ") + .append(storeSqlRows.joinToString(",\n")) + .append(";") + .toString() + .also { sql -> it.writeText(sql) } + } + } +} + +private fun parseSigunguName(sigunguName: String): String { + if (sigunguName.length == 2) { + return sigunguName + } + return sigunguName.substring(0, sigunguName.length - 1) +} + +private fun randomLocalDateTime(): String { + val year = Random.nextInt(2020, 2024) + val month = Random.nextInt(1, 13) + val day = when (month) { + 2 -> Random.nextInt(1, 29) + else -> Random.nextInt(1, 31) + } + val hour = Random.nextInt(9, 19) + val minute = Random.nextInt(0, 60) + val second = Random.nextInt(0, 60) + + return LocalDateTime.of(year, month, day, hour, minute, second) + .atZone(ZoneId.systemDefault()) + .toOffsetDateTime() + .format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX")) +} + +private fun generateBusinessRegNum(): String { + val part1 = Random.nextInt(100, 1000) + val part2 = Random.nextInt(10, 100) + val part3 = Random.nextInt(10000, 100000) + return "$part1-$part2-$part3" +} + +private fun initializeRegionWithStoreCount(): List { + return File(PARSED_REGION_POPULATION_FILE).also { + if (it.exists().not()) { + PopulationDataSqlParser().createParsedRegionPopulationFiles() + } + }.readText().lines().map { + val parts = it.split(", ") + val regionCode = parts[0] + val sidoName = parts[3] + val sigunguName = parts[4] + val storeCount: Int = (parts[5].toInt() / MIN_POPULATION_FOR_PER_STORE) + + RegionWithStoreCount( + regionCode = regionCode, + sidoName = sidoName, + sigunguName = sigunguName, + storeCount = storeCount + ) + } +} + +data class RegionWithStoreCount( + val regionCode: String, + val sidoName: String, + val sigunguName: String, + var storeCount: Int +) diff --git a/src/test/kotlin/roomescape/data/StoreDataInitializer.kt b/src/test/kotlin/roomescape/data/StoreDataInitializer.kt deleted file mode 100644 index 76152f95..00000000 --- a/src/test/kotlin/roomescape/data/StoreDataInitializer.kt +++ /dev/null @@ -1,130 +0,0 @@ -package roomescape.data - -import io.kotest.core.spec.style.StringSpec -import roomescape.common.config.next -import roomescape.supports.randomPhoneNumber -import roomescape.supports.tsidFactory -import java.io.File -import java.time.LocalDateTime -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import kotlin.random.Random - -/** - * DataParser에서 전처리된 지역 + 인구 정보를 이용하여 Store 초기 데이터 생성 - */ -class StoreDataInitializer : StringSpec({ - - val positiveWords = listOf( - "사랑", "행복", "희망", "감사", "기쁨", "미소", "축복", "선물", "평화", - "열정", "미래", "자유", "도전", "지혜", "행운" - ) - - "초기 매장 데이터를 준비한다.".config(enabled = false) { - val regions = initializeRegionWithStoreCount() - val usedStoreName = mutableListOf() - val usedBusinessRegNums = mutableListOf() - val storeSqlRows = mutableListOf() - val storeDataRows = mutableListOf() - val storeIds = mutableListOf() - - regions.forEachIndexed { idx, region -> - for (i in 0..region.storeCount) { - var address: String - var storeName: String - do { - val randomPositiveWord = positiveWords.random() - storeName = "${parseSigunguName(region.sigunguName)}${randomPositiveWord}점" - address = - "${region.sidoName} ${region.sigunguName} ${randomPositiveWord}${Random.nextInt(1, 10)}길 ${Random.nextInt(1, 100)}" - } while (usedStoreName.contains(storeName)) - usedStoreName.add(storeName) - - val contact = randomPhoneNumber() - - var businessRegNum: String - do { - businessRegNum = generateBusinessRegNum() - } while (usedBusinessRegNums.contains(businessRegNum)) - usedBusinessRegNums.add(businessRegNum) - - val createdAt = randomLocalDateTime() - val updatedAt = createdAt - - val id: Long = tsidFactory.next().also { storeIds.add(it) } - - storeSqlRows.add( - "(${id}, '$storeName', '$address', '$contact', '$businessRegNum', '${region.regionCode}', '$createdAt', '$updatedAt')" - ) - storeDataRows.add( - "$id, $storeName, $address, $contact, $businessRegNum, ${region.regionCode}, $createdAt, $updatedAt" - ) - } - } - - StringBuilder("INSERT INTO store (id, name, address, contact, business_reg_num, region_code, created_at, updated_at) VALUES ") - .append(storeSqlRows.joinToString(",\n")) - .append(";") - .toString() - .also { File("$BASE_DIR/store_data.sql").writeText(it) } - - File("$BASE_DIR/store_data.txt").writeText( - storeDataRows.joinToString("\n") - ) - } -}) - -private fun parseSigunguName(sigunguName: String): String { - if (sigunguName.length == 2) { - return sigunguName - } - return sigunguName.substring(0, sigunguName.length - 1) -} - -private fun randomLocalDateTime(): String { - val year = Random.nextInt(2020, 2024) - val month = Random.nextInt(1, 13) - val day = when (month) { - 2 -> Random.nextInt(1, 29) - else -> Random.nextInt(1, 31) - } - val hour = Random.nextInt(9, 19) - val minute = Random.nextInt(0, 60) - val second = Random.nextInt(0, 60) - - return LocalDateTime.of(year, month, day, hour, minute, second) - .atZone(ZoneId.systemDefault()) - .toOffsetDateTime() - .format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssXXX")) -} - -private fun generateBusinessRegNum(): String { - val part1 = Random.nextInt(100, 1000) - val part2 = Random.nextInt(10, 100) - val part3 = Random.nextInt(10000, 100000) - return "$part1-$part2-$part3" -} - -private fun initializeRegionWithStoreCount(): List { - return File(PARSED_REGION_POPULATION_FILE).readText().lines().map { - val parts = it.split(", ") - val regionCode = parts[0] - val sidoName = parts[3] - val sigunguName = parts[4] - val storeCount: Int = (parts[5].toInt() / MIN_POPULATION_FOR_PER_STORE) - - RegionWithStoreCount( - regionCode = regionCode, - sidoName = sidoName, - sigunguName = sigunguName, - storeCount = storeCount - ) - } -} - -data class RegionWithStoreCount( - val regionCode: String, - val sidoName: String, - val sigunguName: String, - var storeCount: Int -) diff --git a/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt b/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt index 53be12c3..6f6d9f8a 100644 --- a/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt +++ b/src/test/kotlin/roomescape/reservation/ReservationApiTest.kt @@ -7,9 +7,7 @@ import org.springframework.data.repository.findByIdOrNull import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus import roomescape.auth.exception.AuthErrorCode -import roomescape.common.config.next import roomescape.common.exception.CommonErrorCode -import roomescape.common.util.DateUtils import roomescape.payment.infrastructure.common.BankCode import roomescape.payment.infrastructure.common.CardIssuerCode import roomescape.payment.infrastructure.common.EasyPayCompanyCode @@ -19,7 +17,6 @@ import roomescape.reservation.infrastructure.persistence.CanceledReservationRepo import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.reservation.infrastructure.persistence.ReservationRepository import roomescape.reservation.infrastructure.persistence.ReservationStatus -import roomescape.reservation.web.MostReservedThemeIdListResponse import roomescape.reservation.web.ReservationCancelRequest import roomescape.reservation.web.ReservationOverviewResponse import roomescape.schedule.infrastructure.persistence.ScheduleEntity @@ -28,8 +25,6 @@ import roomescape.schedule.infrastructure.persistence.ScheduleStatus import roomescape.supports.* import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeRepository -import roomescape.theme.web.toEntity -import roomescape.user.infrastructure.persistence.UserEntity import java.time.LocalDate import java.time.LocalTime @@ -566,24 +561,6 @@ class ReservationApiTest( ) } } - - context("가장 많이 예약된 테마 ID를 조회한다.") { - test("정상 응답") { - val expectedResult: MostReservedThemeIdListResponse = initializeForPopularThemeTest() - - runTest( - on = { - get("/reservations/popular-themes?count=10") - }, - expect = { - statusCode(HttpStatus.OK.value()) - } - ).also { - val result: List = it.extract().path("data.themeIds") - result shouldBe expectedResult.themeIds - } - } - } } fun runDetailRetrieveTest( @@ -605,76 +582,4 @@ class ReservationApiTest( it.extract().path("data.user.id") shouldBe reservation.userId }.extract().path("data.payment") } - - private fun initializeForPopularThemeTest(): MostReservedThemeIdListResponse { - val user: UserEntity = testAuthUtil.defaultUser() - - val themeIds: List = (1..5).map { - themeRepository.save(ThemeFixture.createRequest.copy().toEntity(id = tsidFactory.next())).id - } - - val store = dummyInitializer.createStore() - - // 첫 번째 테마: 유효한 2개 예약 - (1L..2L).forEach { - dummyInitializer.createConfirmReservation( - user = user, - storeId = store.id, - scheduleRequest = ScheduleFixture.createRequest.copy( - date = DateUtils.getSundayOfPreviousWeek(LocalDate.now()).plusDays(it), - themeId = themeIds[0], - ) - ) - } - - // 두 번째 테마: 유효한 1개 예약 - dummyInitializer.createConfirmReservation( - user = user, - storeId = store.id, - scheduleRequest = ScheduleFixture.createRequest.copy( - date = DateUtils.getSundayOfPreviousWeek(LocalDate.now()), - themeId = themeIds[1], - ) - ) - - // 세 번째 테마: 유효한 3개 예약 - (1L..3L).forEach { - dummyInitializer.createConfirmReservation( - user = user, - storeId = store.id, - scheduleRequest = ScheduleFixture.createRequest.copy( - date = DateUtils.getSundayOfPreviousWeek(LocalDate.now()).plusDays(it), - themeId = themeIds[2], - ) - ) - } - - // 네 번째 테마: Pending 상태인 3개 예약 -> 집계되지 않음. - (1L..3L).forEach { - dummyInitializer.createPendingReservation( - user = user, - storeId = store.id, - scheduleRequest = ScheduleFixture.createRequest.copy( - date = DateUtils.getSundayOfPreviousWeek(LocalDate.now()).plusDays(it), - themeId = themeIds[3], - ) - ) - } - - // 다섯 번째 테마: 이번주의 확정 예약 -> 집계되지 않음. - (1L..3L).forEach { i -> - val thisMonday = DateUtils.getSundayOfPreviousWeek(LocalDate.now()).plusDays(8) - dummyInitializer.createConfirmReservation( - user = user, - storeId = store.id, - scheduleRequest = ScheduleFixture.createRequest.copy( - date = thisMonday.plusDays(i), - themeId = themeIds[4], - ) - ) - } - - // 조회 예상 결과: 세번째, 첫번째, 두번째 테마 순서 - return MostReservedThemeIdListResponse(listOf(themeIds[2], themeIds[0], themeIds[1])) - } } diff --git a/src/test/kotlin/roomescape/supports/DummyInitializer.kt b/src/test/kotlin/roomescape/supports/DummyInitializer.kt index 18513406..f4791b94 100644 --- a/src/test/kotlin/roomescape/supports/DummyInitializer.kt +++ b/src/test/kotlin/roomescape/supports/DummyInitializer.kt @@ -49,7 +49,8 @@ class DummyInitializer( contact: String = randomPhoneNumber(), businessRegNum: String = randomBusinessRegNum(), regionCode: String = "1111000000", - status: StoreStatus = StoreStatus.ACTIVE + status: StoreStatus = StoreStatus.ACTIVE, + createdBy: Long = 0L, ): StoreEntity { return StoreEntity( id = id, @@ -59,7 +60,10 @@ class DummyInitializer( businessRegNum = businessRegNum, regionCode = regionCode, status = status - ).also { + ).apply { + this.createdBy = createdBy + this.updatedBy = createdBy + }.also { storeRepository.save(it) } } diff --git a/src/test/kotlin/roomescape/supports/Fixtures.kt b/src/test/kotlin/roomescape/supports/Fixtures.kt index 8c2ea6c3..ada73ea8 100644 --- a/src/test/kotlin/roomescape/supports/Fixtures.kt +++ b/src/test/kotlin/roomescape/supports/Fixtures.kt @@ -251,29 +251,29 @@ object PaymentFixture { fun cardDetail( amount: Int, - issuerCode: CardIssuerCode = CardIssuerCode.SHINHAN, - cardType: CardType = CardType.CREDIT, - ownerType: CardOwnerType = CardOwnerType.PERSONAL, + issuerCode: CardIssuerCode = CardIssuerCode.entries.random(), + cardType: CardType = CardType.entries.random(), + ownerType: CardOwnerType = CardOwnerType.entries.random(), installmentPlanMonths: Int = 0, ): CardDetail = CardDetail( issuerCode = issuerCode, - number = "429335*********", + number = "${(400000..500000).random()}*********", amount = amount, cardType = cardType, ownerType = ownerType, isInterestFree = false, - approveNo = "1828382", + approveNo = "${(1000000..9999999).random()}", installmentPlanMonths = installmentPlanMonths ) fun easypayDetail( amount: Int, - provider: EasyPayCompanyCode = EasyPayCompanyCode.TOSSPAY, + provider: EasyPayCompanyCode = EasyPayCompanyCode.entries.random(), discountAmount: Int = 0 ): EasyPayDetail = EasyPayDetail(provider, amount, discountAmount) fun transferDetail( - bankCode: BankCode = BankCode.SHINHAN, + bankCode: BankCode = BankCode.entries.random(), settlementStatus: String = "COMPLETED" ): TransferDetail = TransferDetail( bankCode = bankCode, diff --git a/src/test/kotlin/roomescape/supports/KotestConfig.kt b/src/test/kotlin/roomescape/supports/KotestConfig.kt index 280c2a52..284892ab 100644 --- a/src/test/kotlin/roomescape/supports/KotestConfig.kt +++ b/src/test/kotlin/roomescape/supports/KotestConfig.kt @@ -29,8 +29,12 @@ object KotestConfig : AbstractProjectConfig() { @Import(TestConfig::class) @ActiveProfiles("test") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -abstract class FunSpecSpringbootTest : FunSpec({ - extension(DatabaseCleanerExtension()) +abstract class FunSpecSpringbootTest( + enableCleanerExtension: Boolean = true, +) : FunSpec({ + if (enableCleanerExtension) { + extension(DatabaseCleanerExtension()) + } }) { @Autowired private lateinit var userRepository: UserRepository diff --git a/src/test/kotlin/roomescape/supports/TestUtil.kt b/src/test/kotlin/roomescape/supports/TestUtil.kt index 10c4d735..8b5780e5 100644 --- a/src/test/kotlin/roomescape/supports/TestUtil.kt +++ b/src/test/kotlin/roomescape/supports/TestUtil.kt @@ -12,14 +12,15 @@ inline fun initialize(name: String, block: () -> T): T { fun randomPhoneNumber(): String { val prefix = "010" - val middle = (1000..9999).random() - val last = (1000..9999).random() + val middle = (1..4).map { (0..9).random() }.joinToString("") + val last = (1..4).map { (0..9).random() }.joinToString("") + return "$prefix$middle$last" } -fun randomString(): String { +fun randomString(length: Int = 10): String { val chars = ('a'..'z') + ('0'..'9') - return (1..10) + return (1..length) .map { chars.random() } .joinToString("") } diff --git a/src/test/kotlin/roomescape/theme/ThemeApiTest.kt b/src/test/kotlin/roomescape/theme/ThemeApiTest.kt index 1ca12a4b..1fe3e095 100644 --- a/src/test/kotlin/roomescape/theme/ThemeApiTest.kt +++ b/src/test/kotlin/roomescape/theme/ThemeApiTest.kt @@ -1,41 +1,24 @@ package roomescape.theme +import io.kotest.matchers.collections.shouldContainInOrder +import io.kotest.matchers.collections.shouldHaveSize import org.hamcrest.CoreMatchers.equalTo import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus +import roomescape.common.config.next +import roomescape.common.util.DateUtils import roomescape.supports.* import roomescape.theme.exception.ThemeErrorCode import roomescape.theme.infrastructure.persistence.ThemeEntity -import roomescape.theme.web.ThemeIdListRequest +import roomescape.theme.infrastructure.persistence.ThemeRepository +import roomescape.theme.web.toEntity +import roomescape.user.infrastructure.persistence.UserEntity +import java.time.LocalDate -class ThemeApiTest : FunSpecSpringbootTest() { +class ThemeApiTest( + private val themeRepository: ThemeRepository +) : FunSpecSpringbootTest() { init { - context("입력된 모든 ID에 대한 테마를 조회한다.") { - test("정상 응답 + 없는 테마가 있으면 생략한다.") { - val themeIds: List = initialize("목록 조회를 위한 3개의 테마 생성 및 일부 존재하지 않는 ID 추가") { - val themeIds = mutableListOf(INVALID_PK) - (1..3).forEach { _ -> - themeIds.add(dummyInitializer.createTheme().id) - } - - themeIds - } - - runTest( - using = { - body(ThemeIdListRequest(themeIds)) - }, - on = { - post("/themes/batch") - }, - expect = { - statusCode(HttpStatus.OK.value()) - body("data.themes.size()", equalTo(themeIds.filter { it != INVALID_PK }.size)) - } - ) - } - } - context("ID로 테마 정보를 조회한다.") { test("정상 응답") { val createdTheme: ThemeEntity = initialize("조회를 위한 테마 생성") { @@ -68,5 +51,97 @@ class ThemeApiTest : FunSpecSpringbootTest() { ) } } + + context("인기 테마를 조회한다.") { + test("정상 응답") { + val expectedResult: List = initializeForPopularThemeTest() + + runTest( + on = { + get("/themes/most-reserved?count=10") + }, + expect = { + statusCode(HttpStatus.OK.value()) + } + ).also { res -> + val response: List> = res.extract().path("data.themes") + + response shouldHaveSize expectedResult.size + response.map { it["id"] as Long }.shouldContainInOrder(expectedResult) + } + } + } + } + + private fun initializeForPopularThemeTest(): List { + val user: UserEntity = testAuthUtil.defaultUser() + + val themeIds: List = (1..5).map { + themeRepository.save(ThemeFixture.createRequest.copy().toEntity(id = tsidFactory.next())).id + } + + val store = dummyInitializer.createStore() + + // 첫 번째 테마: 유효한 2개 예약 + (1L..2L).forEach { + dummyInitializer.createConfirmReservation( + user = user, + storeId = store.id, + scheduleRequest = ScheduleFixture.createRequest.copy( + date = DateUtils.getSundayOfPreviousWeek(LocalDate.now()).plusDays(it), + themeId = themeIds[0], + ) + ) + } + + // 두 번째 테마: 유효한 1개 예약 + dummyInitializer.createConfirmReservation( + user = user, + storeId = store.id, + scheduleRequest = ScheduleFixture.createRequest.copy( + date = DateUtils.getSundayOfPreviousWeek(LocalDate.now()), + themeId = themeIds[1], + ) + ) + + // 세 번째 테마: 유효한 3개 예약 + (1L..3L).forEach { + dummyInitializer.createConfirmReservation( + user = user, + storeId = store.id, + scheduleRequest = ScheduleFixture.createRequest.copy( + date = DateUtils.getSundayOfPreviousWeek(LocalDate.now()).plusDays(it), + themeId = themeIds[2], + ) + ) + } + + // 네 번째 테마: Pending 상태인 3개 예약 -> 집계되지 않음. + (1L..3L).forEach { + dummyInitializer.createPendingReservation( + user = user, + storeId = store.id, + scheduleRequest = ScheduleFixture.createRequest.copy( + date = DateUtils.getSundayOfPreviousWeek(LocalDate.now()).plusDays(it), + themeId = themeIds[3], + ) + ) + } + + // 다섯 번째 테마: 이번주의 확정 예약 -> 집계되지 않음. + (1L..3L).forEach { i -> + val thisMonday = DateUtils.getSundayOfPreviousWeek(LocalDate.now()).plusDays(8) + dummyInitializer.createConfirmReservation( + user = user, + storeId = store.id, + scheduleRequest = ScheduleFixture.createRequest.copy( + date = thisMonday.plusDays(i), + themeId = themeIds[4], + ) + ) + } + + // 조회 예상 결과: 세번째, 첫번째, 두번째 테마 순서 + return listOf(themeIds[2], themeIds[0], themeIds[1]) } } diff --git a/src/test/resources/application-test-mysql.yaml b/src/test/resources/application-test-mysql.yaml new file mode 100644 index 00000000..2de76150 --- /dev/null +++ b/src/test/resources/application-test-mysql.yaml @@ -0,0 +1,10 @@ +spring: + datasource: + hikari: + jdbc-url: jdbc:mysql://localhost:3306/roomescape + driver-class-name: com.mysql.cj.jdbc.Driver + username: root + password: init + sql: + init: + mode: never \ No newline at end of file