generated from pricelees/issue-pr-template
<!-- 제목 양식 --> <!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) --> ## 📝 관련 이슈 및 PR **PR과 관련된 이슈 번호** - #46 ## ✨ 작업 내용 <!-- 어떤 작업을 했는지 알려주세요! --> - 전체 더미 데이터 추가(관리자 약 2,400건 / 회원 100만건 / 예약, 일정 약 197만건 / 결제 및 결제 상세 196만건(대략 충전식 간편결제 29.3만건, 카드 147만건, 계좌이체 19.6만건) / 테마 500건 / 매장 263건 - 로컬 애플리케이션 실행 후, 가장 병목이 되는 메인 인기 테마 쿼리만 성능 개선(5회 측정시 API 응답 시간 평균 3300 -> 90ms) ## 🧪 테스트 <!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! --> 변경된 기능은 모두 테스트 반영 ## 📚 참고 자료 및 기타 <!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! --> 취소 데이터 등이 들어가있지 않아, 일부 컬럼에서의 Cardinality가 훨씬 낮게 나오는 상황이긴 함. 예약을 예로 들면, 현재는 확정 예약인 데이터만 추가하여 확정 예약이 100%지만, 실제 도메인의 특성상 예약 데이터는 8~90%는 확정 예약일 것으로 생각하여 큰 차이가 없다고 판단하였음. Reviewed-on: #47 Co-authored-by: pricelees <priceelees@gmail.com> Co-committed-by: pricelees <priceelees@gmail.com>
This commit is contained in:
parent
6bcec4c0ed
commit
32b8019576
@ -1,6 +1,12 @@
|
|||||||
import apiClient from "@_api/apiClient";
|
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
|
// admin
|
||||||
export const fetchAdminSchedules = async (storeId: string, date?: string, themeId?: string): Promise<AdminScheduleSummaryListResponse> => {
|
export const fetchAdminSchedules = async (storeId: string, date?: string, themeId?: string): Promise<AdminScheduleSummaryListResponse> => {
|
||||||
|
|||||||
@ -12,11 +12,11 @@ export const getStores = async (sidoCode?: string, sigunguCode?: string): Promis
|
|||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
|
|
||||||
if (sidoCode && sidoCode.trim() !== '') {
|
if (sidoCode && sidoCode.trim() !== '') {
|
||||||
queryParams.push(`sidoCode=${sidoCode}`);
|
queryParams.push(`sido=${sidoCode}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sigunguCode && sigunguCode.trim() !== '') {
|
if (sigunguCode && sigunguCode.trim() !== '') {
|
||||||
queryParams.push(`sigunguCode=${sigunguCode}`);
|
queryParams.push(`sigungu=${sigunguCode}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = `/stores`;
|
const baseUrl = `/stores`;
|
||||||
|
|||||||
@ -42,3 +42,7 @@ export const fetchThemesByIds = async (request: ThemeIdListResponse): Promise<Th
|
|||||||
export const fetchThemeById = async (id: string): Promise<ThemeInfoResponse> => {
|
export const fetchThemeById = async (id: string): Promise<ThemeInfoResponse> => {
|
||||||
return await apiClient.get<ThemeInfoResponse>(`/themes/${id}`);
|
return await apiClient.get<ThemeInfoResponse>(`/themes/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const fetchMostReservedThemes = async (count: number): Promise<ThemeInfoListResponse> => {
|
||||||
|
return await apiClient.get<ThemeInfoListResponse>(`/themes/most-reserved?count=${count}`);
|
||||||
|
};
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
import {fetchMostReservedThemeIds} from '@_api/reservation/reservationAPI';
|
|
||||||
import '@_css/home-page-v2.css';
|
import '@_css/home-page-v2.css';
|
||||||
import React, {useEffect, useState} from 'react';
|
import React, {useEffect, useState} from 'react';
|
||||||
import {useNavigate} from 'react-router-dom';
|
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';
|
import {DifficultyKoreanMap, mapThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes';
|
||||||
|
|
||||||
const HomePage: React.FC = () => {
|
const HomePage: React.FC = () => {
|
||||||
@ -13,19 +12,8 @@ const HomePage: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const themeIds = await fetchMostReservedThemeIds().then(res => {
|
const themeFetchCount = 10;
|
||||||
const themeIds = res.themeIds;
|
const response = await fetchMostReservedThemes(themeFetchCount);
|
||||||
if (themeIds.length === 0) {
|
|
||||||
setRanking([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return themeIds;
|
|
||||||
})
|
|
||||||
|
|
||||||
if (themeIds === undefined) return;
|
|
||||||
if (themeIds.length === 0) return;
|
|
||||||
|
|
||||||
const response = await fetchThemesByIds({ themeIds: themeIds });
|
|
||||||
setRanking(response.themes.map(mapThemeResponse));
|
setRanking(response.themes.map(mapThemeResponse));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching ranking:', err);
|
console.error('Error fetching ranking:', err);
|
||||||
|
|||||||
@ -60,9 +60,13 @@ const ReservationStep1Page: React.FC = () => {
|
|||||||
}, [selectedSido]);
|
}, [selectedSido]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (selectedSido) {
|
||||||
getStores(selectedSido, selectedSigungu)
|
getStores(selectedSido, selectedSigungu)
|
||||||
.then(res => setStoreList(res.stores))
|
.then(res => setStoreList(res.stores))
|
||||||
.catch(handleError);
|
.catch(handleError);
|
||||||
|
} else {
|
||||||
|
setStoreList([]);
|
||||||
|
}
|
||||||
setSelectedStore(null);
|
setSelectedStore(null);
|
||||||
}, [selectedSido, selectedSigungu]);
|
}, [selectedSido, selectedSigungu]);
|
||||||
|
|
||||||
|
|||||||
845
query.md
Normal file
845
query.md
Normal file
@ -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 = ?;
|
||||||
|
```
|
||||||
@ -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<Long> = 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 {
|
private fun findOrThrow(id: Long): ReservationEntity {
|
||||||
log.info { "[ReservationService.findOrThrow] 예약 조회 시작: reservationId=${id}" }
|
log.info { "[ReservationService.findOrThrow] 예약 조회 시작: reservationId=${id}" }
|
||||||
|
|
||||||
|
|||||||
@ -16,14 +16,6 @@ import roomescape.common.dto.response.CommonApiResponse
|
|||||||
import roomescape.reservation.web.*
|
import roomescape.reservation.web.*
|
||||||
|
|
||||||
interface ReservationAPI {
|
interface ReservationAPI {
|
||||||
|
|
||||||
@Public
|
|
||||||
@Operation(summary = "가장 많이 예약된 테마 ID 조회")
|
|
||||||
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
|
||||||
fun findMostReservedThemeIds(
|
|
||||||
@RequestParam count: Int
|
|
||||||
): ResponseEntity<CommonApiResponse<MostReservedThemeIdListResponse>>
|
|
||||||
|
|
||||||
@Operation(summary = "결제 전 임시 예약 저장")
|
@Operation(summary = "결제 전 임시 예약 저장")
|
||||||
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
||||||
fun createPendingReservation(
|
fun createPendingReservation(
|
||||||
|
|||||||
@ -1,27 +1,8 @@
|
|||||||
package roomescape.reservation.infrastructure.persistence
|
package roomescape.reservation.infrastructure.persistence
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
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<ReservationEntity, Long> {
|
interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
|
||||||
|
|
||||||
fun findAllByUserIdAndStatusIsIn(userId: Long, statuses: List<ReservationStatus>): List<ReservationEntity>
|
fun findAllByUserIdAndStatusIsIn(userId: Long, statuses: List<ReservationStatus>): List<ReservationEntity>
|
||||||
|
|
||||||
@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<Long>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,16 +14,6 @@ import roomescape.reservation.docs.ReservationAPI
|
|||||||
class ReservationController(
|
class ReservationController(
|
||||||
private val reservationService: ReservationService
|
private val reservationService: ReservationService
|
||||||
) : ReservationAPI {
|
) : ReservationAPI {
|
||||||
|
|
||||||
@GetMapping("/popular-themes")
|
|
||||||
override fun findMostReservedThemeIds(
|
|
||||||
@RequestParam count: Int
|
|
||||||
): ResponseEntity<CommonApiResponse<MostReservedThemeIdListResponse>> {
|
|
||||||
val response = reservationService.findMostReservedThemeIds(count)
|
|
||||||
|
|
||||||
return ResponseEntity.ok(CommonApiResponse(response))
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/pending")
|
@PostMapping("/pending")
|
||||||
override fun createPendingReservation(
|
override fun createPendingReservation(
|
||||||
@User user: CurrentUserContext,
|
@User user: CurrentUserContext,
|
||||||
|
|||||||
@ -11,11 +11,11 @@ class ScheduleOverview(
|
|||||||
val storeName: String,
|
val storeName: String,
|
||||||
val date: LocalDate,
|
val date: LocalDate,
|
||||||
val time: LocalTime,
|
val time: LocalTime,
|
||||||
|
val status: ScheduleStatus,
|
||||||
val themeId: Long,
|
val themeId: Long,
|
||||||
val themeName: String,
|
val themeName: String,
|
||||||
val themeDifficulty: Difficulty,
|
val themeDifficulty: Difficulty,
|
||||||
val themeAvailableMinutes: Short,
|
val themeAvailableMinutes: Short
|
||||||
val status: ScheduleStatus
|
|
||||||
) {
|
) {
|
||||||
fun getEndAt(): LocalTime {
|
fun getEndAt(): LocalTime {
|
||||||
return time.plusMinutes(themeAvailableMinutes.toLong())
|
return time.plusMinutes(themeAvailableMinutes.toLong())
|
||||||
|
|||||||
@ -8,7 +8,8 @@ import java.time.LocalTime
|
|||||||
|
|
||||||
interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
|
interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
|
||||||
|
|
||||||
@Query("""
|
@Query(
|
||||||
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(s) > 0
|
COUNT(s) > 0
|
||||||
FROM
|
FROM
|
||||||
@ -18,7 +19,8 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
|
|||||||
AND s.date = :date
|
AND s.date = :date
|
||||||
AND s.themeId = :themeId
|
AND s.themeId = :themeId
|
||||||
AND s.time = :time
|
AND s.time = :time
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
fun existsDuplicate(storeId: Long, date: LocalDate, themeId: Long, time: LocalTime): Boolean
|
fun existsDuplicate(storeId: Long, date: LocalDate, themeId: Long, time: LocalTime): Boolean
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
@ -30,22 +32,20 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
|
|||||||
st.name,
|
st.name,
|
||||||
s.date,
|
s.date,
|
||||||
s.time,
|
s.time,
|
||||||
|
s.status,
|
||||||
t._id,
|
t._id,
|
||||||
t.name,
|
t.name,
|
||||||
t.difficulty,
|
t.difficulty,
|
||||||
t.availableMinutes,
|
t.availableMinutes
|
||||||
s.status
|
|
||||||
)
|
)
|
||||||
FROM
|
FROM
|
||||||
ScheduleEntity s
|
ScheduleEntity s
|
||||||
JOIN
|
JOIN
|
||||||
ThemeEntity t ON t._id = s.themeId
|
ThemeEntity t ON t._id = s.themeId and (:themeId IS NULL OR t._id = :themeId)
|
||||||
JOIN
|
JOIN
|
||||||
StoreEntity st ON st._id = s.storeId
|
StoreEntity st ON st._id = s.storeId and st._id = :storeId
|
||||||
WHERE
|
WHERE
|
||||||
s.storeId = :storeId
|
s.date = :date
|
||||||
AND s.date = :date
|
|
||||||
AND (:themeId IS NULL OR s.themeId = :themeId)
|
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
fun findStoreSchedulesWithThemeByDate(
|
fun findStoreSchedulesWithThemeByDate(
|
||||||
@ -54,7 +54,8 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
|
|||||||
themeId: Long? = null
|
themeId: Long? = null
|
||||||
): List<ScheduleOverview>
|
): List<ScheduleOverview>
|
||||||
|
|
||||||
@Query("""
|
@Query(
|
||||||
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
new roomescape.schedule.business.domain.ScheduleOverview(
|
new roomescape.schedule.business.domain.ScheduleOverview(
|
||||||
s._id,
|
s._id,
|
||||||
@ -62,11 +63,11 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
|
|||||||
st.name,
|
st.name,
|
||||||
s.date,
|
s.date,
|
||||||
s.time,
|
s.time,
|
||||||
|
s.status,
|
||||||
t._id,
|
t._id,
|
||||||
t.name,
|
t.name,
|
||||||
t.difficulty,
|
t.difficulty,
|
||||||
t.availableMinutes,
|
t.availableMinutes
|
||||||
s.status
|
|
||||||
)
|
)
|
||||||
FROM
|
FROM
|
||||||
ScheduleEntity s
|
ScheduleEntity s
|
||||||
@ -76,6 +77,7 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
|
|||||||
StoreEntity st ON st._id = s.storeId
|
StoreEntity st ON st._id = s.storeId
|
||||||
WHERE
|
WHERE
|
||||||
s._id = :id
|
s._id = :id
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
fun findOverviewByIdOrNull(id: Long): ScheduleOverview?
|
fun findOverviewByIdOrNull(id: Long): ScheduleOverview?
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,11 +9,13 @@ import org.springframework.transaction.annotation.Transactional
|
|||||||
import roomescape.admin.business.AdminService
|
import roomescape.admin.business.AdminService
|
||||||
import roomescape.common.config.next
|
import roomescape.common.config.next
|
||||||
import roomescape.common.dto.AuditInfo
|
import roomescape.common.dto.AuditInfo
|
||||||
|
import roomescape.common.util.DateUtils
|
||||||
import roomescape.theme.exception.ThemeErrorCode
|
import roomescape.theme.exception.ThemeErrorCode
|
||||||
import roomescape.theme.exception.ThemeException
|
import roomescape.theme.exception.ThemeException
|
||||||
import roomescape.theme.infrastructure.persistence.ThemeEntity
|
import roomescape.theme.infrastructure.persistence.ThemeEntity
|
||||||
import roomescape.theme.infrastructure.persistence.ThemeRepository
|
import roomescape.theme.infrastructure.persistence.ThemeRepository
|
||||||
import roomescape.theme.web.*
|
import roomescape.theme.web.*
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
private val log: KLogger = KotlinLogging.logger {}
|
private val log: KLogger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@ -43,13 +45,18 @@ class ThemeService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun findAllInfosByIds(request: ThemeIdListRequest): ThemeInfoListResponse {
|
fun findMostReservedThemeLastWeek(count: Int): ThemeInfoListResponse {
|
||||||
log.info { "[ThemeService.findThemesByIds] 예약 페이지에서의 테마 목록 조회 시작: themeIds=${request.themeIds}" }
|
log.info { "[ThemeService.findMostReservedThemeLastWeek] 인기 테마 조회 시작: count=$count" }
|
||||||
val result: List<ThemeEntity> = themeRepository.findAllByIdIn(request.themeIds)
|
|
||||||
|
|
||||||
return result.toInfoListResponse().also {
|
val previousWeekSunday = DateUtils.getSundayOfPreviousWeek(LocalDate.now())
|
||||||
log.info { "[ThemeService.findThemesByIds] ${it.themes.size} / ${request.themeIds.size} 개 테마 조회 완료" }
|
val previousWeekSaturday = previousWeekSunday.plusDays(6)
|
||||||
|
|
||||||
|
return themeRepository.findMostReservedThemeByDateAndCount(previousWeekSunday, previousWeekSaturday, count)
|
||||||
|
.toListResponse()
|
||||||
|
.also {
|
||||||
|
log.info { "[ThemeService.findMostReservedThemeLastWeek] ${it.themes.size} / $count 개의 인기 테마 조회 완료" }
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|||||||
@ -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
|
||||||
|
)
|
||||||
@ -5,8 +5,10 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse
|
|||||||
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
import io.swagger.v3.oas.annotations.responses.ApiResponses
|
||||||
import jakarta.validation.Valid
|
import jakarta.validation.Valid
|
||||||
import org.springframework.http.ResponseEntity
|
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.PathVariable
|
||||||
import org.springframework.web.bind.annotation.RequestBody
|
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.AdminType
|
||||||
import roomescape.admin.infrastructure.persistence.Privilege
|
import roomescape.admin.infrastructure.persistence.Privilege
|
||||||
import roomescape.auth.web.support.AdminOnly
|
import roomescape.auth.web.support.AdminOnly
|
||||||
@ -50,13 +52,13 @@ interface AdminThemeAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PublicThemeAPI {
|
interface PublicThemeAPI {
|
||||||
@Public
|
|
||||||
@Operation(summary = "입력된 모든 ID에 대한 테마 정보 조회")
|
|
||||||
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
|
||||||
fun findThemeInfosByIds(request: ThemeIdListRequest): ResponseEntity<CommonApiResponse<ThemeInfoListResponse>>
|
|
||||||
|
|
||||||
@Public
|
@Public
|
||||||
@Operation(summary = "입력된 테마 ID에 대한 정보 조회")
|
@Operation(summary = "입력된 테마 ID에 대한 정보 조회")
|
||||||
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
||||||
fun findThemeInfoById(@PathVariable id: Long): ResponseEntity<CommonApiResponse<ThemeInfoResponse>>
|
fun findThemeInfoById(@PathVariable id: Long): ResponseEntity<CommonApiResponse<ThemeInfoResponse>>
|
||||||
|
|
||||||
|
@Public
|
||||||
|
@Operation(summary = "지난 주에 가장 많이 예약된 count 개의 테마 조회")
|
||||||
|
@GetMapping("/most-reserved")
|
||||||
|
fun findMostReservedThemeLastWeek(@RequestParam count: Int): ResponseEntity<CommonApiResponse<ThemeInfoListResponse>>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,8 @@ package roomescape.theme.infrastructure.persistence
|
|||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
import org.springframework.data.jpa.repository.Query
|
import org.springframework.data.jpa.repository.Query
|
||||||
|
import roomescape.theme.business.domain.ThemeInfo
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
interface ThemeRepository : JpaRepository<ThemeEntity, Long> {
|
interface ThemeRepository : JpaRepository<ThemeEntity, Long> {
|
||||||
|
|
||||||
@ -10,5 +12,32 @@ interface ThemeRepository : JpaRepository<ThemeEntity, Long> {
|
|||||||
|
|
||||||
fun existsByName(name: String): Boolean
|
fun existsByName(name: String): Boolean
|
||||||
|
|
||||||
fun findAllByIdIn(themeIds: List<Long>): List<ThemeEntity>
|
@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<ThemeInfo>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
package roomescape.theme.web
|
package roomescape.theme.web
|
||||||
|
|
||||||
import jakarta.validation.Valid
|
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.*
|
||||||
import roomescape.common.dto.response.CommonApiResponse
|
import roomescape.common.dto.response.CommonApiResponse
|
||||||
@ -12,15 +11,6 @@ import roomescape.theme.docs.PublicThemeAPI
|
|||||||
class ThemeController(
|
class ThemeController(
|
||||||
private val themeService: ThemeService,
|
private val themeService: ThemeService,
|
||||||
) : PublicThemeAPI {
|
) : PublicThemeAPI {
|
||||||
@PostMapping("/batch")
|
|
||||||
override fun findThemeInfosByIds(
|
|
||||||
@Valid @RequestBody request: ThemeIdListRequest
|
|
||||||
): ResponseEntity<CommonApiResponse<ThemeInfoListResponse>> {
|
|
||||||
val response = themeService.findAllInfosByIds(request)
|
|
||||||
|
|
||||||
return ResponseEntity.ok(CommonApiResponse(response))
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
override fun findThemeInfoById(
|
override fun findThemeInfoById(
|
||||||
@PathVariable id: Long
|
@PathVariable id: Long
|
||||||
@ -29,4 +19,13 @@ class ThemeController(
|
|||||||
|
|
||||||
return ResponseEntity.ok(CommonApiResponse(response))
|
return ResponseEntity.ok(CommonApiResponse(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/most-reserved")
|
||||||
|
override fun findMostReservedThemeLastWeek(
|
||||||
|
@RequestParam count: Int
|
||||||
|
): ResponseEntity<CommonApiResponse<ThemeInfoListResponse>> {
|
||||||
|
val response = themeService.findMostReservedThemeLastWeek(count)
|
||||||
|
|
||||||
|
return ResponseEntity.ok(CommonApiResponse(response))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
package roomescape.theme.web
|
package roomescape.theme.web
|
||||||
|
|
||||||
import roomescape.theme.infrastructure.persistence.Difficulty
|
import roomescape.theme.business.domain.ThemeInfo
|
||||||
import roomescape.theme.infrastructure.persistence.ThemeEntity
|
import roomescape.theme.infrastructure.persistence.ThemeEntity
|
||||||
|
|
||||||
data class ThemeIdListRequest(
|
data class ThemeIdListRequest(
|
||||||
@ -12,7 +12,7 @@ data class ThemeInfoResponse(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val thumbnailUrl: String,
|
val thumbnailUrl: String,
|
||||||
val description: String,
|
val description: String,
|
||||||
val difficulty: Difficulty,
|
val difficulty: String,
|
||||||
val price: Int,
|
val price: Int,
|
||||||
val minParticipants: Short,
|
val minParticipants: Short,
|
||||||
val maxParticipants: Short,
|
val maxParticipants: Short,
|
||||||
@ -21,7 +21,7 @@ data class ThemeInfoResponse(
|
|||||||
val expectedMinutesTo: Short
|
val expectedMinutesTo: Short
|
||||||
)
|
)
|
||||||
|
|
||||||
fun ThemeEntity.toInfoResponse() = ThemeInfoResponse(
|
fun ThemeInfo.toInfoResponse() = ThemeInfoResponse(
|
||||||
id = this.id,
|
id = this.id,
|
||||||
name = this.name,
|
name = this.name,
|
||||||
thumbnailUrl = this.thumbnailUrl,
|
thumbnailUrl = this.thumbnailUrl,
|
||||||
@ -35,10 +35,24 @@ fun ThemeEntity.toInfoResponse() = ThemeInfoResponse(
|
|||||||
expectedMinutesTo = this.expectedMinutesTo
|
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(
|
data class ThemeInfoListResponse(
|
||||||
val themes: List<ThemeInfoResponse>
|
val themes: List<ThemeInfoResponse>
|
||||||
)
|
)
|
||||||
|
|
||||||
fun List<ThemeEntity>.toInfoListResponse() = ThemeInfoListResponse(
|
fun List<ThemeInfo>.toListResponse() = ThemeInfoListResponse(
|
||||||
themes = this.map { it.toInfoResponse() }
|
themes = this.map { it.toInfoResponse() }
|
||||||
)
|
)
|
||||||
|
|||||||
@ -187,7 +187,7 @@ create table if not exists payment_detail(
|
|||||||
|
|
||||||
create table if not exists payment_bank_transfer_detail (
|
create table if not exists payment_bank_transfer_detail (
|
||||||
id bigint primary key,
|
id bigint primary key,
|
||||||
bank_code varchar(10) not null,
|
bank_code varchar(20) not null,
|
||||||
settlement_status varchar(20) not null,
|
settlement_status varchar(20) not null,
|
||||||
|
|
||||||
constraint fk_payment_bank_transfer_details__id foreign key (id) references payment_detail (id)
|
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 (
|
create table if not exists payment_card_detail (
|
||||||
id bigint primary key,
|
id bigint primary key,
|
||||||
issuer_code varchar(10) not null,
|
issuer_code varchar(20) not null,
|
||||||
card_type varchar(10) not null,
|
card_type varchar(10) not null,
|
||||||
owner_type varchar(10) not null,
|
owner_type varchar(10) not null,
|
||||||
amount integer not null,
|
amount integer not null,
|
||||||
|
|||||||
@ -187,7 +187,7 @@ create table if not exists payment_detail(
|
|||||||
|
|
||||||
create table if not exists payment_bank_transfer_detail (
|
create table if not exists payment_bank_transfer_detail (
|
||||||
id bigint primary key,
|
id bigint primary key,
|
||||||
bank_code varchar(10) not null,
|
bank_code varchar(20) not null,
|
||||||
settlement_status varchar(20) not null,
|
settlement_status varchar(20) not null,
|
||||||
|
|
||||||
constraint fk_payment_bank_transfer_details__id foreign key (id) references payment_detail (id)
|
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 (
|
create table if not exists payment_card_detail (
|
||||||
id bigint primary key,
|
id bigint primary key,
|
||||||
issuer_code varchar(10) not null,
|
issuer_code varchar(20) not null,
|
||||||
card_type varchar(10) not null,
|
card_type varchar(10) not null,
|
||||||
owner_type varchar(10) not null,
|
owner_type varchar(10) not null,
|
||||||
amount integer not null,
|
amount integer not 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<List<String>>()
|
|
||||||
val regionsMoreThanMinPopulation = mutableListOf<List<String?>>()
|
|
||||||
|
|
||||||
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};")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
911
src/test/kotlin/roomescape/data/DefaultDataInitializer.kt
Normal file
911
src/test/kotlin/roomescape/data/DefaultDataInitializer.kt
Normal file
@ -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<Array<Any>>) {
|
||||||
|
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<String> = 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<Long> = 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<Long> = 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<Array<Any>>()
|
||||||
|
|
||||||
|
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<String> = 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<UserEntity> =
|
||||||
|
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<Long> = 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<Long>) {
|
||||||
|
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<Array<Any>>()
|
||||||
|
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<String>) {
|
||||||
|
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<Array<Any>>()
|
||||||
|
|
||||||
|
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<Pair<Long, Long>> = getStoreWithManagers()
|
||||||
|
val themes: List<Triple<Long, Short, LocalDateTime>> = getThemes()
|
||||||
|
val maxAvailableMinutes = themes.maxOf { it.second.toInt() }
|
||||||
|
val scheduleCountPerDay = 5
|
||||||
|
|
||||||
|
val startTime = LocalTime.of(10, 0)
|
||||||
|
var lastTime = startTime
|
||||||
|
val times = mutableListOf<LocalTime>()
|
||||||
|
|
||||||
|
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<Long, Short, LocalDateTime>,
|
||||||
|
stores: List<Pair<Long, Long>>,
|
||||||
|
times: List<LocalTime>
|
||||||
|
) {
|
||||||
|
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<Array<Any>>()
|
||||||
|
|
||||||
|
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<Pair<Long, Long>> {
|
||||||
|
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<Triple<Long, Short, LocalDateTime>> {
|
||||||
|
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<List<ScheduleWithThemeParticipants>> = 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<List<UserContactResponse>> = 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<UserContactResponse>,
|
||||||
|
schedules: List<ScheduleWithThemeParticipants>
|
||||||
|
) {
|
||||||
|
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<Array<Any>>()
|
||||||
|
|
||||||
|
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<ReservationWithPrice> = 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<PaymentWithMethods> = 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<ReservationWithPrice>) {
|
||||||
|
val paymentBatchArgs = mutableListOf<Array<Any>>()
|
||||||
|
val detailBatchArgs = mutableListOf<Array<Any>>()
|
||||||
|
|
||||||
|
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<PaymentWithMethods>) {
|
||||||
|
val transferBatchArgs = mutableListOf<Array<Any>>()
|
||||||
|
val cardBatchArgs = mutableListOf<Array<Any>>()
|
||||||
|
val easypayPrepaidBatchArgs = mutableListOf<Array<Any>>()
|
||||||
|
|
||||||
|
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() }
|
||||||
|
}
|
||||||
236
src/test/kotlin/roomescape/data/PopulationDataParser.kt
Normal file
236
src/test/kotlin/roomescape/data/PopulationDataParser.kt
Normal file
@ -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<List<String>>()
|
||||||
|
val regionsMoreThanMinPopulation = mutableListOf<List<String?>>()
|
||||||
|
|
||||||
|
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<String>): File {
|
||||||
|
val regions = initializeRegionWithStoreCount()
|
||||||
|
val usedStoreName = mutableListOf<String>()
|
||||||
|
val usedBusinessRegNums = mutableListOf<String>()
|
||||||
|
val storeSqlRows = mutableListOf<String>()
|
||||||
|
val storeDataRows = mutableListOf<String>()
|
||||||
|
val storeIds = mutableListOf<Long>()
|
||||||
|
|
||||||
|
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<RegionWithStoreCount> {
|
||||||
|
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
|
||||||
|
)
|
||||||
@ -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<String>()
|
|
||||||
val usedBusinessRegNums = mutableListOf<String>()
|
|
||||||
val storeSqlRows = mutableListOf<String>()
|
|
||||||
val storeDataRows = mutableListOf<String>()
|
|
||||||
val storeIds = mutableListOf<Long>()
|
|
||||||
|
|
||||||
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<RegionWithStoreCount> {
|
|
||||||
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
|
|
||||||
)
|
|
||||||
@ -7,9 +7,7 @@ import org.springframework.data.repository.findByIdOrNull
|
|||||||
import org.springframework.http.HttpMethod
|
import org.springframework.http.HttpMethod
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import roomescape.auth.exception.AuthErrorCode
|
import roomescape.auth.exception.AuthErrorCode
|
||||||
import roomescape.common.config.next
|
|
||||||
import roomescape.common.exception.CommonErrorCode
|
import roomescape.common.exception.CommonErrorCode
|
||||||
import roomescape.common.util.DateUtils
|
|
||||||
import roomescape.payment.infrastructure.common.BankCode
|
import roomescape.payment.infrastructure.common.BankCode
|
||||||
import roomescape.payment.infrastructure.common.CardIssuerCode
|
import roomescape.payment.infrastructure.common.CardIssuerCode
|
||||||
import roomescape.payment.infrastructure.common.EasyPayCompanyCode
|
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.ReservationEntity
|
||||||
import roomescape.reservation.infrastructure.persistence.ReservationRepository
|
import roomescape.reservation.infrastructure.persistence.ReservationRepository
|
||||||
import roomescape.reservation.infrastructure.persistence.ReservationStatus
|
import roomescape.reservation.infrastructure.persistence.ReservationStatus
|
||||||
import roomescape.reservation.web.MostReservedThemeIdListResponse
|
|
||||||
import roomescape.reservation.web.ReservationCancelRequest
|
import roomescape.reservation.web.ReservationCancelRequest
|
||||||
import roomescape.reservation.web.ReservationOverviewResponse
|
import roomescape.reservation.web.ReservationOverviewResponse
|
||||||
import roomescape.schedule.infrastructure.persistence.ScheduleEntity
|
import roomescape.schedule.infrastructure.persistence.ScheduleEntity
|
||||||
@ -28,8 +25,6 @@ import roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
|||||||
import roomescape.supports.*
|
import roomescape.supports.*
|
||||||
import roomescape.theme.infrastructure.persistence.ThemeEntity
|
import roomescape.theme.infrastructure.persistence.ThemeEntity
|
||||||
import roomescape.theme.infrastructure.persistence.ThemeRepository
|
import roomescape.theme.infrastructure.persistence.ThemeRepository
|
||||||
import roomescape.theme.web.toEntity
|
|
||||||
import roomescape.user.infrastructure.persistence.UserEntity
|
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalTime
|
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<Long> = it.extract().path("data.themeIds")
|
|
||||||
result shouldBe expectedResult.themeIds
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun runDetailRetrieveTest(
|
fun runDetailRetrieveTest(
|
||||||
@ -605,76 +582,4 @@ class ReservationApiTest(
|
|||||||
it.extract().path<Long>("data.user.id") shouldBe reservation.userId
|
it.extract().path<Long>("data.user.id") shouldBe reservation.userId
|
||||||
}.extract().path("data.payment")
|
}.extract().path("data.payment")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initializeForPopularThemeTest(): MostReservedThemeIdListResponse {
|
|
||||||
val user: UserEntity = testAuthUtil.defaultUser()
|
|
||||||
|
|
||||||
val themeIds: List<Long> = (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]))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,7 +49,8 @@ class DummyInitializer(
|
|||||||
contact: String = randomPhoneNumber(),
|
contact: String = randomPhoneNumber(),
|
||||||
businessRegNum: String = randomBusinessRegNum(),
|
businessRegNum: String = randomBusinessRegNum(),
|
||||||
regionCode: String = "1111000000",
|
regionCode: String = "1111000000",
|
||||||
status: StoreStatus = StoreStatus.ACTIVE
|
status: StoreStatus = StoreStatus.ACTIVE,
|
||||||
|
createdBy: Long = 0L,
|
||||||
): StoreEntity {
|
): StoreEntity {
|
||||||
return StoreEntity(
|
return StoreEntity(
|
||||||
id = id,
|
id = id,
|
||||||
@ -59,7 +60,10 @@ class DummyInitializer(
|
|||||||
businessRegNum = businessRegNum,
|
businessRegNum = businessRegNum,
|
||||||
regionCode = regionCode,
|
regionCode = regionCode,
|
||||||
status = status
|
status = status
|
||||||
).also {
|
).apply {
|
||||||
|
this.createdBy = createdBy
|
||||||
|
this.updatedBy = createdBy
|
||||||
|
}.also {
|
||||||
storeRepository.save(it)
|
storeRepository.save(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -251,29 +251,29 @@ object PaymentFixture {
|
|||||||
|
|
||||||
fun cardDetail(
|
fun cardDetail(
|
||||||
amount: Int,
|
amount: Int,
|
||||||
issuerCode: CardIssuerCode = CardIssuerCode.SHINHAN,
|
issuerCode: CardIssuerCode = CardIssuerCode.entries.random(),
|
||||||
cardType: CardType = CardType.CREDIT,
|
cardType: CardType = CardType.entries.random(),
|
||||||
ownerType: CardOwnerType = CardOwnerType.PERSONAL,
|
ownerType: CardOwnerType = CardOwnerType.entries.random(),
|
||||||
installmentPlanMonths: Int = 0,
|
installmentPlanMonths: Int = 0,
|
||||||
): CardDetail = CardDetail(
|
): CardDetail = CardDetail(
|
||||||
issuerCode = issuerCode,
|
issuerCode = issuerCode,
|
||||||
number = "429335*********",
|
number = "${(400000..500000).random()}*********",
|
||||||
amount = amount,
|
amount = amount,
|
||||||
cardType = cardType,
|
cardType = cardType,
|
||||||
ownerType = ownerType,
|
ownerType = ownerType,
|
||||||
isInterestFree = false,
|
isInterestFree = false,
|
||||||
approveNo = "1828382",
|
approveNo = "${(1000000..9999999).random()}",
|
||||||
installmentPlanMonths = installmentPlanMonths
|
installmentPlanMonths = installmentPlanMonths
|
||||||
)
|
)
|
||||||
|
|
||||||
fun easypayDetail(
|
fun easypayDetail(
|
||||||
amount: Int,
|
amount: Int,
|
||||||
provider: EasyPayCompanyCode = EasyPayCompanyCode.TOSSPAY,
|
provider: EasyPayCompanyCode = EasyPayCompanyCode.entries.random(),
|
||||||
discountAmount: Int = 0
|
discountAmount: Int = 0
|
||||||
): EasyPayDetail = EasyPayDetail(provider, amount, discountAmount)
|
): EasyPayDetail = EasyPayDetail(provider, amount, discountAmount)
|
||||||
|
|
||||||
fun transferDetail(
|
fun transferDetail(
|
||||||
bankCode: BankCode = BankCode.SHINHAN,
|
bankCode: BankCode = BankCode.entries.random(),
|
||||||
settlementStatus: String = "COMPLETED"
|
settlementStatus: String = "COMPLETED"
|
||||||
): TransferDetail = TransferDetail(
|
): TransferDetail = TransferDetail(
|
||||||
bankCode = bankCode,
|
bankCode = bankCode,
|
||||||
|
|||||||
@ -29,8 +29,12 @@ object KotestConfig : AbstractProjectConfig() {
|
|||||||
@Import(TestConfig::class)
|
@Import(TestConfig::class)
|
||||||
@ActiveProfiles("test")
|
@ActiveProfiles("test")
|
||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||||
abstract class FunSpecSpringbootTest : FunSpec({
|
abstract class FunSpecSpringbootTest(
|
||||||
|
enableCleanerExtension: Boolean = true,
|
||||||
|
) : FunSpec({
|
||||||
|
if (enableCleanerExtension) {
|
||||||
extension(DatabaseCleanerExtension())
|
extension(DatabaseCleanerExtension())
|
||||||
|
}
|
||||||
}) {
|
}) {
|
||||||
@Autowired
|
@Autowired
|
||||||
private lateinit var userRepository: UserRepository
|
private lateinit var userRepository: UserRepository
|
||||||
|
|||||||
@ -12,14 +12,15 @@ inline fun <T> initialize(name: String, block: () -> T): T {
|
|||||||
|
|
||||||
fun randomPhoneNumber(): String {
|
fun randomPhoneNumber(): String {
|
||||||
val prefix = "010"
|
val prefix = "010"
|
||||||
val middle = (1000..9999).random()
|
val middle = (1..4).map { (0..9).random() }.joinToString("")
|
||||||
val last = (1000..9999).random()
|
val last = (1..4).map { (0..9).random() }.joinToString("")
|
||||||
|
|
||||||
return "$prefix$middle$last"
|
return "$prefix$middle$last"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun randomString(): String {
|
fun randomString(length: Int = 10): String {
|
||||||
val chars = ('a'..'z') + ('0'..'9')
|
val chars = ('a'..'z') + ('0'..'9')
|
||||||
return (1..10)
|
return (1..length)
|
||||||
.map { chars.random() }
|
.map { chars.random() }
|
||||||
.joinToString("")
|
.joinToString("")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,41 +1,24 @@
|
|||||||
package roomescape.theme
|
package roomescape.theme
|
||||||
|
|
||||||
|
import io.kotest.matchers.collections.shouldContainInOrder
|
||||||
|
import io.kotest.matchers.collections.shouldHaveSize
|
||||||
import org.hamcrest.CoreMatchers.equalTo
|
import org.hamcrest.CoreMatchers.equalTo
|
||||||
import org.springframework.http.HttpMethod
|
import org.springframework.http.HttpMethod
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
|
import roomescape.common.config.next
|
||||||
|
import roomescape.common.util.DateUtils
|
||||||
import roomescape.supports.*
|
import roomescape.supports.*
|
||||||
import roomescape.theme.exception.ThemeErrorCode
|
import roomescape.theme.exception.ThemeErrorCode
|
||||||
import roomescape.theme.infrastructure.persistence.ThemeEntity
|
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 {
|
init {
|
||||||
context("입력된 모든 ID에 대한 테마를 조회한다.") {
|
|
||||||
test("정상 응답 + 없는 테마가 있으면 생략한다.") {
|
|
||||||
val themeIds: List<Long> = 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로 테마 정보를 조회한다.") {
|
context("ID로 테마 정보를 조회한다.") {
|
||||||
test("정상 응답") {
|
test("정상 응답") {
|
||||||
val createdTheme: ThemeEntity = initialize("조회를 위한 테마 생성") {
|
val createdTheme: ThemeEntity = initialize("조회를 위한 테마 생성") {
|
||||||
@ -68,5 +51,97 @@ class ThemeApiTest : FunSpecSpringbootTest() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
context("인기 테마를 조회한다.") {
|
||||||
|
test("정상 응답") {
|
||||||
|
val expectedResult: List<Long> = initializeForPopularThemeTest()
|
||||||
|
|
||||||
|
runTest(
|
||||||
|
on = {
|
||||||
|
get("/themes/most-reserved?count=10")
|
||||||
|
},
|
||||||
|
expect = {
|
||||||
|
statusCode(HttpStatus.OK.value())
|
||||||
|
}
|
||||||
|
).also { res ->
|
||||||
|
val response: List<LinkedHashMap<String, Any>> = res.extract().path("data.themes")
|
||||||
|
|
||||||
|
response shouldHaveSize expectedResult.size
|
||||||
|
response.map { it["id"] as Long }.shouldContainInOrder(expectedResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initializeForPopularThemeTest(): List<Long> {
|
||||||
|
val user: UserEntity = testAuthUtil.defaultUser()
|
||||||
|
|
||||||
|
val themeIds: List<Long> = (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])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
src/test/resources/application-test-mysql.yaml
Normal file
10
src/test/resources/application-test-mysql.yaml
Normal file
@ -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
|
||||||
Loading…
x
Reference in New Issue
Block a user