generated from pricelees/issue-pr-template
[#46] 더미 데이터 생성 및 1개의 슬로우쿼리 개선 #47
@ -1,6 +1,12 @@
|
||||
import apiClient from "@_api/apiClient";
|
||||
import type { AdminScheduleSummaryListResponse, ScheduleCreateRequest, ScheduleCreateResponse, ScheduleStatus, ScheduleUpdateRequest, ScheduleWithThemeListResponse } from "./scheduleTypes";
|
||||
import type { AuditInfo } from "@_api/common/commonTypes";
|
||||
import type {AuditInfo} from "@_api/common/commonTypes";
|
||||
import type {
|
||||
AdminScheduleSummaryListResponse,
|
||||
ScheduleCreateRequest,
|
||||
ScheduleCreateResponse,
|
||||
ScheduleUpdateRequest,
|
||||
ScheduleWithThemeListResponse
|
||||
} from "./scheduleTypes";
|
||||
|
||||
// admin
|
||||
export const fetchAdminSchedules = async (storeId: string, date?: string, themeId?: string): Promise<AdminScheduleSummaryListResponse> => {
|
||||
|
||||
@ -12,11 +12,11 @@ export const getStores = async (sidoCode?: string, sigunguCode?: string): Promis
|
||||
const queryParams: string[] = [];
|
||||
|
||||
if (sidoCode && sidoCode.trim() !== '') {
|
||||
queryParams.push(`sidoCode=${sidoCode}`);
|
||||
queryParams.push(`sido=${sidoCode}`);
|
||||
}
|
||||
|
||||
if (sigunguCode && sigunguCode.trim() !== '') {
|
||||
queryParams.push(`sigunguCode=${sigunguCode}`);
|
||||
queryParams.push(`sigungu=${sigunguCode}`);
|
||||
}
|
||||
|
||||
const baseUrl = `/stores`;
|
||||
|
||||
@ -42,3 +42,7 @@ export const fetchThemesByIds = async (request: ThemeIdListResponse): Promise<Th
|
||||
export const fetchThemeById = async (id: string): Promise<ThemeInfoResponse> => {
|
||||
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 React, {useEffect, useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {fetchThemesByIds} from '@_api/theme/themeAPI';
|
||||
import {fetchMostReservedThemes} from '@_api/theme/themeAPI';
|
||||
import {DifficultyKoreanMap, mapThemeResponse, type ThemeInfoResponse} from '@_api/theme/themeTypes';
|
||||
|
||||
const HomePage: React.FC = () => {
|
||||
@ -13,19 +12,8 @@ const HomePage: React.FC = () => {
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const themeIds = await fetchMostReservedThemeIds().then(res => {
|
||||
const themeIds = res.themeIds;
|
||||
if (themeIds.length === 0) {
|
||||
setRanking([]);
|
||||
return;
|
||||
}
|
||||
return themeIds;
|
||||
})
|
||||
|
||||
if (themeIds === undefined) return;
|
||||
if (themeIds.length === 0) return;
|
||||
|
||||
const response = await fetchThemesByIds({ themeIds: themeIds });
|
||||
const themeFetchCount = 10;
|
||||
const response = await fetchMostReservedThemes(themeFetchCount);
|
||||
setRanking(response.themes.map(mapThemeResponse));
|
||||
} catch (err) {
|
||||
console.error('Error fetching ranking:', err);
|
||||
|
||||
@ -60,9 +60,13 @@ const ReservationStep1Page: React.FC = () => {
|
||||
}, [selectedSido]);
|
||||
|
||||
useEffect(() => {
|
||||
getStores(selectedSido, selectedSigungu)
|
||||
.then(res => setStoreList(res.stores))
|
||||
.catch(handleError);
|
||||
if (selectedSido) {
|
||||
getStores(selectedSido, selectedSigungu)
|
||||
.then(res => setStoreList(res.stores))
|
||||
.catch(handleError);
|
||||
} else {
|
||||
setStoreList([]);
|
||||
}
|
||||
setSelectedStore(null);
|
||||
}, [selectedSido, selectedSigungu]);
|
||||
|
||||
|
||||
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 {
|
||||
log.info { "[ReservationService.findOrThrow] 예약 조회 시작: reservationId=${id}" }
|
||||
|
||||
|
||||
@ -16,14 +16,6 @@ import roomescape.common.dto.response.CommonApiResponse
|
||||
import roomescape.reservation.web.*
|
||||
|
||||
interface ReservationAPI {
|
||||
|
||||
@Public
|
||||
@Operation(summary = "가장 많이 예약된 테마 ID 조회")
|
||||
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
||||
fun findMostReservedThemeIds(
|
||||
@RequestParam count: Int
|
||||
): ResponseEntity<CommonApiResponse<MostReservedThemeIdListResponse>>
|
||||
|
||||
@Operation(summary = "결제 전 임시 예약 저장")
|
||||
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
||||
fun createPendingReservation(
|
||||
|
||||
@ -1,27 +1,8 @@
|
||||
package roomescape.reservation.infrastructure.persistence
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import java.time.LocalDate
|
||||
|
||||
interface ReservationRepository : JpaRepository<ReservationEntity, Long> {
|
||||
|
||||
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(
|
||||
private val reservationService: ReservationService
|
||||
) : 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")
|
||||
override fun createPendingReservation(
|
||||
@User user: CurrentUserContext,
|
||||
|
||||
@ -11,11 +11,11 @@ class ScheduleOverview(
|
||||
val storeName: String,
|
||||
val date: LocalDate,
|
||||
val time: LocalTime,
|
||||
val status: ScheduleStatus,
|
||||
val themeId: Long,
|
||||
val themeName: String,
|
||||
val themeDifficulty: Difficulty,
|
||||
val themeAvailableMinutes: Short,
|
||||
val status: ScheduleStatus
|
||||
val themeAvailableMinutes: Short
|
||||
) {
|
||||
fun getEndAt(): LocalTime {
|
||||
return time.plusMinutes(themeAvailableMinutes.toLong())
|
||||
|
||||
@ -8,7 +8,8 @@ import java.time.LocalTime
|
||||
|
||||
interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
|
||||
|
||||
@Query("""
|
||||
@Query(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(s) > 0
|
||||
FROM
|
||||
@ -18,35 +19,34 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
|
||||
AND s.date = :date
|
||||
AND s.themeId = :themeId
|
||||
AND s.time = :time
|
||||
""")
|
||||
"""
|
||||
)
|
||||
fun existsDuplicate(storeId: Long, date: LocalDate, themeId: Long, time: LocalTime): Boolean
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT
|
||||
SELECT
|
||||
new roomescape.schedule.business.domain.ScheduleOverview(
|
||||
s._id,
|
||||
st._id,
|
||||
st.name,
|
||||
s.date,
|
||||
s.time,
|
||||
s.status,
|
||||
t._id,
|
||||
t.name,
|
||||
t.difficulty,
|
||||
t.availableMinutes,
|
||||
s.status
|
||||
t.availableMinutes
|
||||
)
|
||||
FROM
|
||||
ScheduleEntity s
|
||||
JOIN
|
||||
ThemeEntity t ON t._id = s.themeId
|
||||
ThemeEntity t ON t._id = s.themeId and (:themeId IS NULL OR t._id = :themeId)
|
||||
JOIN
|
||||
StoreEntity st ON st._id = s.storeId
|
||||
StoreEntity st ON st._id = s.storeId and st._id = :storeId
|
||||
WHERE
|
||||
s.storeId = :storeId
|
||||
AND s.date = :date
|
||||
AND (:themeId IS NULL OR s.themeId = :themeId)
|
||||
"""
|
||||
s.date = :date
|
||||
"""
|
||||
)
|
||||
fun findStoreSchedulesWithThemeByDate(
|
||||
storeId: Long,
|
||||
@ -54,21 +54,22 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
|
||||
themeId: Long? = null
|
||||
): List<ScheduleOverview>
|
||||
|
||||
@Query("""
|
||||
SELECT
|
||||
@Query(
|
||||
"""
|
||||
SELECT
|
||||
new roomescape.schedule.business.domain.ScheduleOverview(
|
||||
s._id,
|
||||
st._id,
|
||||
st.name,
|
||||
s.date,
|
||||
s.time,
|
||||
t._id,
|
||||
t.name,
|
||||
t.difficulty,
|
||||
t.availableMinutes,
|
||||
s.status
|
||||
)
|
||||
FROM
|
||||
s._id,
|
||||
st._id,
|
||||
st.name,
|
||||
s.date,
|
||||
s.time,
|
||||
s.status,
|
||||
t._id,
|
||||
t.name,
|
||||
t.difficulty,
|
||||
t.availableMinutes
|
||||
)
|
||||
FROM
|
||||
ScheduleEntity s
|
||||
JOIN
|
||||
ThemeEntity t ON t._id = s.themeId
|
||||
@ -76,6 +77,7 @@ interface ScheduleRepository : JpaRepository<ScheduleEntity, Long> {
|
||||
StoreEntity st ON st._id = s.storeId
|
||||
WHERE
|
||||
s._id = :id
|
||||
""")
|
||||
"""
|
||||
)
|
||||
fun findOverviewByIdOrNull(id: Long): ScheduleOverview?
|
||||
}
|
||||
|
||||
@ -9,11 +9,13 @@ import org.springframework.transaction.annotation.Transactional
|
||||
import roomescape.admin.business.AdminService
|
||||
import roomescape.common.config.next
|
||||
import roomescape.common.dto.AuditInfo
|
||||
import roomescape.common.util.DateUtils
|
||||
import roomescape.theme.exception.ThemeErrorCode
|
||||
import roomescape.theme.exception.ThemeException
|
||||
import roomescape.theme.infrastructure.persistence.ThemeEntity
|
||||
import roomescape.theme.infrastructure.persistence.ThemeRepository
|
||||
import roomescape.theme.web.*
|
||||
import java.time.LocalDate
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@ -43,13 +45,18 @@ class ThemeService(
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun findAllInfosByIds(request: ThemeIdListRequest): ThemeInfoListResponse {
|
||||
log.info { "[ThemeService.findThemesByIds] 예약 페이지에서의 테마 목록 조회 시작: themeIds=${request.themeIds}" }
|
||||
val result: List<ThemeEntity> = themeRepository.findAllByIdIn(request.themeIds)
|
||||
fun findMostReservedThemeLastWeek(count: Int): ThemeInfoListResponse {
|
||||
log.info { "[ThemeService.findMostReservedThemeLastWeek] 인기 테마 조회 시작: count=$count" }
|
||||
|
||||
val previousWeekSunday = DateUtils.getSundayOfPreviousWeek(LocalDate.now())
|
||||
val previousWeekSaturday = previousWeekSunday.plusDays(6)
|
||||
|
||||
return themeRepository.findMostReservedThemeByDateAndCount(previousWeekSunday, previousWeekSaturday, count)
|
||||
.toListResponse()
|
||||
.also {
|
||||
log.info { "[ThemeService.findMostReservedThemeLastWeek] ${it.themes.size} / $count 개의 인기 테마 조회 완료" }
|
||||
}
|
||||
|
||||
return result.toInfoListResponse().also {
|
||||
log.info { "[ThemeService.findThemesByIds] ${it.themes.size} / ${request.themeIds.size} 개 테마 조회 완료" }
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
|
||||
@ -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 jakarta.validation.Valid
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import roomescape.admin.infrastructure.persistence.AdminType
|
||||
import roomescape.admin.infrastructure.persistence.Privilege
|
||||
import roomescape.auth.web.support.AdminOnly
|
||||
@ -50,13 +52,13 @@ interface AdminThemeAPI {
|
||||
}
|
||||
|
||||
interface PublicThemeAPI {
|
||||
@Public
|
||||
@Operation(summary = "입력된 모든 ID에 대한 테마 정보 조회")
|
||||
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
||||
fun findThemeInfosByIds(request: ThemeIdListRequest): ResponseEntity<CommonApiResponse<ThemeInfoListResponse>>
|
||||
|
||||
@Public
|
||||
@Operation(summary = "입력된 테마 ID에 대한 정보 조회")
|
||||
@ApiResponses(ApiResponse(responseCode = "200", useReturnTypeSchema = true))
|
||||
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.Query
|
||||
import roomescape.theme.business.domain.ThemeInfo
|
||||
import java.time.LocalDate
|
||||
|
||||
interface ThemeRepository : JpaRepository<ThemeEntity, Long> {
|
||||
|
||||
@ -10,5 +12,32 @@ interface ThemeRepository : JpaRepository<ThemeEntity, Long> {
|
||||
|
||||
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
|
||||
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
import roomescape.common.dto.response.CommonApiResponse
|
||||
@ -12,15 +11,6 @@ import roomescape.theme.docs.PublicThemeAPI
|
||||
class ThemeController(
|
||||
private val themeService: ThemeService,
|
||||
) : PublicThemeAPI {
|
||||
@PostMapping("/batch")
|
||||
override fun findThemeInfosByIds(
|
||||
@Valid @RequestBody request: ThemeIdListRequest
|
||||
): ResponseEntity<CommonApiResponse<ThemeInfoListResponse>> {
|
||||
val response = themeService.findAllInfosByIds(request)
|
||||
|
||||
return ResponseEntity.ok(CommonApiResponse(response))
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
override fun findThemeInfoById(
|
||||
@PathVariable id: Long
|
||||
@ -29,4 +19,13 @@ class ThemeController(
|
||||
|
||||
return ResponseEntity.ok(CommonApiResponse(response))
|
||||
}
|
||||
|
||||
@GetMapping("/most-reserved")
|
||||
override fun findMostReservedThemeLastWeek(
|
||||
@RequestParam count: Int
|
||||
): ResponseEntity<CommonApiResponse<ThemeInfoListResponse>> {
|
||||
val response = themeService.findMostReservedThemeLastWeek(count)
|
||||
|
||||
return ResponseEntity.ok(CommonApiResponse(response))
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
package roomescape.theme.web
|
||||
|
||||
import roomescape.theme.infrastructure.persistence.Difficulty
|
||||
import roomescape.theme.business.domain.ThemeInfo
|
||||
import roomescape.theme.infrastructure.persistence.ThemeEntity
|
||||
|
||||
data class ThemeIdListRequest(
|
||||
@ -12,7 +12,7 @@ data class ThemeInfoResponse(
|
||||
val name: String,
|
||||
val thumbnailUrl: String,
|
||||
val description: String,
|
||||
val difficulty: Difficulty,
|
||||
val difficulty: String,
|
||||
val price: Int,
|
||||
val minParticipants: Short,
|
||||
val maxParticipants: Short,
|
||||
@ -21,7 +21,7 @@ data class ThemeInfoResponse(
|
||||
val expectedMinutesTo: Short
|
||||
)
|
||||
|
||||
fun ThemeEntity.toInfoResponse() = ThemeInfoResponse(
|
||||
fun ThemeInfo.toInfoResponse() = ThemeInfoResponse(
|
||||
id = this.id,
|
||||
name = this.name,
|
||||
thumbnailUrl = this.thumbnailUrl,
|
||||
@ -35,10 +35,24 @@ fun ThemeEntity.toInfoResponse() = ThemeInfoResponse(
|
||||
expectedMinutesTo = this.expectedMinutesTo
|
||||
)
|
||||
|
||||
fun ThemeEntity.toInfoResponse() = ThemeInfoResponse(
|
||||
id = this.id,
|
||||
name = this.name,
|
||||
thumbnailUrl = this.thumbnailUrl,
|
||||
description = this.description,
|
||||
difficulty = this.difficulty.name,
|
||||
price = this.price,
|
||||
minParticipants = this.minParticipants,
|
||||
maxParticipants = this.maxParticipants,
|
||||
availableMinutes = this.availableMinutes,
|
||||
expectedMinutesFrom = this.expectedMinutesFrom,
|
||||
expectedMinutesTo = this.expectedMinutesTo
|
||||
)
|
||||
|
||||
data class ThemeInfoListResponse(
|
||||
val themes: List<ThemeInfoResponse>
|
||||
)
|
||||
|
||||
fun List<ThemeEntity>.toInfoListResponse() = ThemeInfoListResponse(
|
||||
fun List<ThemeInfo>.toListResponse() = ThemeInfoListResponse(
|
||||
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 (
|
||||
id bigint primary key,
|
||||
bank_code varchar(10) not null,
|
||||
bank_code varchar(20) not null,
|
||||
settlement_status varchar(20) not null,
|
||||
|
||||
constraint fk_payment_bank_transfer_details__id foreign key (id) references payment_detail (id)
|
||||
@ -195,7 +195,7 @@ create table if not exists payment_bank_transfer_detail (
|
||||
|
||||
create table if not exists payment_card_detail (
|
||||
id bigint primary key,
|
||||
issuer_code varchar(10) not null,
|
||||
issuer_code varchar(20) not null,
|
||||
card_type varchar(10) not null,
|
||||
owner_type varchar(10) not null,
|
||||
amount integer not null,
|
||||
|
||||
@ -187,7 +187,7 @@ create table if not exists payment_detail(
|
||||
|
||||
create table if not exists payment_bank_transfer_detail (
|
||||
id bigint primary key,
|
||||
bank_code varchar(10) not null,
|
||||
bank_code varchar(20) not null,
|
||||
settlement_status varchar(20) not null,
|
||||
|
||||
constraint fk_payment_bank_transfer_details__id foreign key (id) references payment_detail (id)
|
||||
@ -195,7 +195,7 @@ create table if not exists payment_bank_transfer_detail (
|
||||
|
||||
create table if not exists payment_card_detail (
|
||||
id bigint primary key,
|
||||
issuer_code varchar(10) not null,
|
||||
issuer_code varchar(20) not null,
|
||||
card_type varchar(10) not null,
|
||||
owner_type varchar(10) not null,
|
||||
amount integer not null,
|
||||
|
||||
@ -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.HttpStatus
|
||||
import roomescape.auth.exception.AuthErrorCode
|
||||
import roomescape.common.config.next
|
||||
import roomescape.common.exception.CommonErrorCode
|
||||
import roomescape.common.util.DateUtils
|
||||
import roomescape.payment.infrastructure.common.BankCode
|
||||
import roomescape.payment.infrastructure.common.CardIssuerCode
|
||||
import roomescape.payment.infrastructure.common.EasyPayCompanyCode
|
||||
@ -19,7 +17,6 @@ import roomescape.reservation.infrastructure.persistence.CanceledReservationRepo
|
||||
import roomescape.reservation.infrastructure.persistence.ReservationEntity
|
||||
import roomescape.reservation.infrastructure.persistence.ReservationRepository
|
||||
import roomescape.reservation.infrastructure.persistence.ReservationStatus
|
||||
import roomescape.reservation.web.MostReservedThemeIdListResponse
|
||||
import roomescape.reservation.web.ReservationCancelRequest
|
||||
import roomescape.reservation.web.ReservationOverviewResponse
|
||||
import roomescape.schedule.infrastructure.persistence.ScheduleEntity
|
||||
@ -28,8 +25,6 @@ import roomescape.schedule.infrastructure.persistence.ScheduleStatus
|
||||
import roomescape.supports.*
|
||||
import roomescape.theme.infrastructure.persistence.ThemeEntity
|
||||
import roomescape.theme.infrastructure.persistence.ThemeRepository
|
||||
import roomescape.theme.web.toEntity
|
||||
import roomescape.user.infrastructure.persistence.UserEntity
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
|
||||
@ -566,24 +561,6 @@ class ReservationApiTest(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
context("가장 많이 예약된 테마 ID를 조회한다.") {
|
||||
test("정상 응답") {
|
||||
val expectedResult: MostReservedThemeIdListResponse = initializeForPopularThemeTest()
|
||||
|
||||
runTest(
|
||||
on = {
|
||||
get("/reservations/popular-themes?count=10")
|
||||
},
|
||||
expect = {
|
||||
statusCode(HttpStatus.OK.value())
|
||||
}
|
||||
).also {
|
||||
val result: List<Long> = it.extract().path("data.themeIds")
|
||||
result shouldBe expectedResult.themeIds
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun runDetailRetrieveTest(
|
||||
@ -605,76 +582,4 @@ class ReservationApiTest(
|
||||
it.extract().path<Long>("data.user.id") shouldBe reservation.userId
|
||||
}.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(),
|
||||
businessRegNum: String = randomBusinessRegNum(),
|
||||
regionCode: String = "1111000000",
|
||||
status: StoreStatus = StoreStatus.ACTIVE
|
||||
status: StoreStatus = StoreStatus.ACTIVE,
|
||||
createdBy: Long = 0L,
|
||||
): StoreEntity {
|
||||
return StoreEntity(
|
||||
id = id,
|
||||
@ -59,7 +60,10 @@ class DummyInitializer(
|
||||
businessRegNum = businessRegNum,
|
||||
regionCode = regionCode,
|
||||
status = status
|
||||
).also {
|
||||
).apply {
|
||||
this.createdBy = createdBy
|
||||
this.updatedBy = createdBy
|
||||
}.also {
|
||||
storeRepository.save(it)
|
||||
}
|
||||
}
|
||||
|
||||
@ -251,29 +251,29 @@ object PaymentFixture {
|
||||
|
||||
fun cardDetail(
|
||||
amount: Int,
|
||||
issuerCode: CardIssuerCode = CardIssuerCode.SHINHAN,
|
||||
cardType: CardType = CardType.CREDIT,
|
||||
ownerType: CardOwnerType = CardOwnerType.PERSONAL,
|
||||
issuerCode: CardIssuerCode = CardIssuerCode.entries.random(),
|
||||
cardType: CardType = CardType.entries.random(),
|
||||
ownerType: CardOwnerType = CardOwnerType.entries.random(),
|
||||
installmentPlanMonths: Int = 0,
|
||||
): CardDetail = CardDetail(
|
||||
issuerCode = issuerCode,
|
||||
number = "429335*********",
|
||||
number = "${(400000..500000).random()}*********",
|
||||
amount = amount,
|
||||
cardType = cardType,
|
||||
ownerType = ownerType,
|
||||
isInterestFree = false,
|
||||
approveNo = "1828382",
|
||||
approveNo = "${(1000000..9999999).random()}",
|
||||
installmentPlanMonths = installmentPlanMonths
|
||||
)
|
||||
|
||||
fun easypayDetail(
|
||||
amount: Int,
|
||||
provider: EasyPayCompanyCode = EasyPayCompanyCode.TOSSPAY,
|
||||
provider: EasyPayCompanyCode = EasyPayCompanyCode.entries.random(),
|
||||
discountAmount: Int = 0
|
||||
): EasyPayDetail = EasyPayDetail(provider, amount, discountAmount)
|
||||
|
||||
fun transferDetail(
|
||||
bankCode: BankCode = BankCode.SHINHAN,
|
||||
bankCode: BankCode = BankCode.entries.random(),
|
||||
settlementStatus: String = "COMPLETED"
|
||||
): TransferDetail = TransferDetail(
|
||||
bankCode = bankCode,
|
||||
|
||||
@ -29,8 +29,12 @@ object KotestConfig : AbstractProjectConfig() {
|
||||
@Import(TestConfig::class)
|
||||
@ActiveProfiles("test")
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
abstract class FunSpecSpringbootTest : FunSpec({
|
||||
extension(DatabaseCleanerExtension())
|
||||
abstract class FunSpecSpringbootTest(
|
||||
enableCleanerExtension: Boolean = true,
|
||||
) : FunSpec({
|
||||
if (enableCleanerExtension) {
|
||||
extension(DatabaseCleanerExtension())
|
||||
}
|
||||
}) {
|
||||
@Autowired
|
||||
private lateinit var userRepository: UserRepository
|
||||
|
||||
@ -12,14 +12,15 @@ inline fun <T> initialize(name: String, block: () -> T): T {
|
||||
|
||||
fun randomPhoneNumber(): String {
|
||||
val prefix = "010"
|
||||
val middle = (1000..9999).random()
|
||||
val last = (1000..9999).random()
|
||||
val middle = (1..4).map { (0..9).random() }.joinToString("")
|
||||
val last = (1..4).map { (0..9).random() }.joinToString("")
|
||||
|
||||
return "$prefix$middle$last"
|
||||
}
|
||||
|
||||
fun randomString(): String {
|
||||
fun randomString(length: Int = 10): String {
|
||||
val chars = ('a'..'z') + ('0'..'9')
|
||||
return (1..10)
|
||||
return (1..length)
|
||||
.map { chars.random() }
|
||||
.joinToString("")
|
||||
}
|
||||
|
||||
@ -1,41 +1,24 @@
|
||||
package roomescape.theme
|
||||
|
||||
import io.kotest.matchers.collections.shouldContainInOrder
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
import org.hamcrest.CoreMatchers.equalTo
|
||||
import org.springframework.http.HttpMethod
|
||||
import org.springframework.http.HttpStatus
|
||||
import roomescape.common.config.next
|
||||
import roomescape.common.util.DateUtils
|
||||
import roomescape.supports.*
|
||||
import roomescape.theme.exception.ThemeErrorCode
|
||||
import roomescape.theme.infrastructure.persistence.ThemeEntity
|
||||
import roomescape.theme.web.ThemeIdListRequest
|
||||
import roomescape.theme.infrastructure.persistence.ThemeRepository
|
||||
import roomescape.theme.web.toEntity
|
||||
import roomescape.user.infrastructure.persistence.UserEntity
|
||||
import java.time.LocalDate
|
||||
|
||||
class ThemeApiTest : FunSpecSpringbootTest() {
|
||||
class ThemeApiTest(
|
||||
private val themeRepository: ThemeRepository
|
||||
) : FunSpecSpringbootTest() {
|
||||
init {
|
||||
context("입력된 모든 ID에 대한 테마를 조회한다.") {
|
||||
test("정상 응답 + 없는 테마가 있으면 생략한다.") {
|
||||
val themeIds: List<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로 테마 정보를 조회한다.") {
|
||||
test("정상 응답") {
|
||||
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