Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import docsConfig from '@/config/docs.config';
import oidcConfig from '@/config/oidc.config';
import s3Config from '@/config/s3.config';
import { AuthModule } from '@/features/auth/auth.module';
import { PickupModule } from '@/features/pickup';
import { RegionModule } from '@/features/region';
import { SellerModule } from '@/features/seller/seller.module';
import { StoreModule } from '@/features/store';
Expand Down Expand Up @@ -87,6 +88,7 @@ import { PrismaModule } from '@/prisma';
}),
SystemModule,
AuthModule,
PickupModule,
RegionModule,
StoreModule,
UserModule,
Expand Down
93 changes: 93 additions & 0 deletions src/common/utils/kst-time.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import {
formatKstDate,
formatMinutesOfDay,
kstDayDiff,
kstMidnightUtc,
kstMinutesOfDay,
parseKstDate,
parseKstYearMonth,
toKstYmd,
} from '@/common/utils/kst-time';

describe('kst-time', () => {
describe('toKstYmd / formatKstDate (KST 경계)', () => {
it('UTC 15:00은 KST 다음날 00:00이다', () => {
const date = new Date('2026-06-17T15:00:00.000Z');
expect(toKstYmd(date)).toEqual({ year: 2026, month: 6, day: 18 });
expect(formatKstDate(date)).toBe('2026-06-18');
});

it('UTC 14:59:59는 아직 KST 같은 날이다', () => {
const date = new Date('2026-06-17T14:59:59.000Z');
expect(formatKstDate(date)).toBe('2026-06-17');
});
});

describe('kstMinutesOfDay', () => {
it('KST 시각의 자정 경과 분을 반환한다', () => {
// UTC 01:30 = KST 10:30 = 630분
expect(kstMinutesOfDay(new Date('2026-06-18T01:30:00.000Z'))).toBe(630);
});
});

describe('parseKstYearMonth', () => {
it('유효한 YYYY-MM 파싱', () => {
expect(parseKstYearMonth('2026-06')).toEqual({ year: 2026, month: 6 });
});
it('형식/범위 오류는 null', () => {
expect(parseKstYearMonth('2026-13')).toBeNull();
expect(parseKstYearMonth('2026-6')).toBeNull();
expect(parseKstYearMonth('not-a-month')).toBeNull();
});
});

describe('parseKstDate', () => {
it('KST 자정에 해당하는 UTC Date로 변환한다', () => {
// 2026-06-18 00:00 KST = 2026-06-17 15:00 UTC
const date = parseKstDate('2026-06-18');
expect(date?.toISOString()).toBe('2026-06-17T15:00:00.000Z');
});
it('존재하지 않는 날짜(2026-02-30)는 null', () => {
expect(parseKstDate('2026-02-30')).toBeNull();
});
it('형식 오류는 null', () => {
expect(parseKstDate('2026/06/18')).toBeNull();
expect(parseKstDate('2026-06')).toBeNull();
});
});

describe('kstMidnightUtc', () => {
it('KST 자정에 해당하는 UTC Date를 만든다', () => {
expect(kstMidnightUtc(2026, 6, 18).toISOString()).toBe(
'2026-06-17T15:00:00.000Z',
);
});
});

describe('kstDayDiff', () => {
it('같은 KST 날짜는 0', () => {
const a = new Date('2026-06-18T01:00:00.000Z'); // KST 06-18 10:00
const b = new Date('2026-06-18T10:00:00.000Z'); // KST 06-18 19:00
expect(kstDayDiff(a, b)).toBe(0);
});
it('다음날은 1, 이전날은 -1', () => {
const today = new Date('2026-06-18T01:00:00.000Z'); // KST 06-18
expect(kstDayDiff(today, new Date('2026-06-19T01:00:00.000Z'))).toBe(1);
expect(kstDayDiff(today, new Date('2026-06-17T01:00:00.000Z'))).toBe(-1);
});
it('UTC 자정 경계를 넘어도 KST 기준으로 센다', () => {
// UTC 15:00 = KST 익일 00:00 → 하루 차이
const a = new Date('2026-06-17T14:00:00.000Z'); // KST 06-17 23:00
const b = new Date('2026-06-17T15:00:00.000Z'); // KST 06-18 00:00
expect(kstDayDiff(a, b)).toBe(1);
});
});

describe('formatMinutesOfDay', () => {
it('분을 HH:MM으로 포맷', () => {
expect(formatMinutesOfDay(630)).toBe('10:30');
expect(formatMinutesOfDay(0)).toBe('00:00');
expect(formatMinutesOfDay(1170)).toBe('19:30');
});
});
});
88 changes: 88 additions & 0 deletions src/common/utils/kst-time.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* KST(Asia/Seoul, UTC+9) 기준 날짜/시간 유틸.
*
* 서버 타임존과 무관하게 동작하도록, 내부적으로 UTC epoch에 +9h 오프셋을 적용해
* KST 달력값을 계산한다. 모든 함수는 순수 함수(입력 Date만 사용)이며, "현재 시각"이
* 필요한 호출부는 now를 주입해 결정적으로 테스트한다.
*/
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;

function pad2(value: number): string {
return value.toString().padStart(2, '0');
}

export interface KstYmd {
year: number;
month: number; // 1-12
day: number;
}

/** UTC Date를 KST 기준 연/월/일로 분해한다. */
export function toKstYmd(date: Date): KstYmd {
const kst = new Date(date.getTime() + KST_OFFSET_MS);
return {
year: kst.getUTCFullYear(),
month: kst.getUTCMonth() + 1,
day: kst.getUTCDate(),
};
}

/** KST 기준 (year, month, day) 자정에 해당하는 UTC Date. month는 1-12. */
export function kstMidnightUtc(year: number, month: number, day: number): Date {
return new Date(Date.UTC(year, month - 1, day) - KST_OFFSET_MS);
}

/** KST 기준 "YYYY-MM-DD" 문자열. */
export function formatKstDate(date: Date): string {
const { year, month, day } = toKstYmd(date);
return `${year}-${pad2(month)}-${pad2(day)}`;
}

/** KST 기준 자정부터의 경과 분(0-1439). */
export function kstMinutesOfDay(date: Date): number {
const kst = new Date(date.getTime() + KST_OFFSET_MS);
return kst.getUTCHours() * 60 + kst.getUTCMinutes();
}

/** "YYYY-MM" 파싱. 형식/범위 오류 시 null. */
export function parseKstYearMonth(
value: string,
): { year: number; month: number } | null {
const matched = /^(\d{4})-(\d{2})$/.exec(value);
if (!matched) return null;
const year = Number(matched[1]);
const month = Number(matched[2]);
if (month < 1 || month > 12) return null;
return { year, month };
}

/** "YYYY-MM-DD"(KST)를 그 날 00:00 KST에 해당하는 UTC Date로 변환. 잘못된 날짜는 null. */
export function parseKstDate(value: string): Date | null {
const matched = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!matched) return null;
const year = Number(matched[1]);
const month = Number(matched[2]);
const day = Number(matched[3]);
if (month < 1 || month > 12 || day < 1 || day > 31) return null;

const date = kstMidnightUtc(year, month, day);
// 존재하지 않는 날짜(예: 2026-02-30)는 정규화되며 입력과 불일치 → 거부
if (formatKstDate(date) !== value) return null;
return date;
}

/** KST 자정 기준 (b 날짜 - a 날짜) 일수. 같은 날 0. */
export function kstDayDiff(a: Date, b: Date): number {
const startA = parseKstDate(formatKstDate(a));
const startB = parseKstDate(formatKstDate(b));
if (!startA || !startB) return 0;
return Math.round((startB.getTime() - startA.getTime()) / DAY_MS);
}

/** 자정 경과 분(0-1439)을 "HH:MM"으로 포맷. */
export function formatMinutesOfDay(minutes: number): string {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${pad2(hours)}:${pad2(mins)}`;
}
10 changes: 10 additions & 0 deletions src/features/pickup/constants/pickup-error-messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const PICKUP_ERRORS = {
INVALID_YEAR_MONTH: '유효하지 않은 연월 형식입니다. (YYYY-MM)',
INVALID_DATE: '유효하지 않은 날짜 형식입니다. (YYYY-MM-DD)',
} as const;

/** 선택 불가 날짜 사유 코드. */
export const PICKUP_DAY_REASON = {
PAST: 'PAST',
OUT_OF_RANGE: 'OUT_OF_RANGE',
} as const;
17 changes: 17 additions & 0 deletions src/features/pickup/constants/pickup.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* 홈 전역 픽업 슬롯 정책(매장 무관 고정값).
*
* 화면(05)의 달력·시간 선택 UX 제공용. 실제 매장별 영업시간·capacity·휴무 반영은
* 주문 단계에서 매장 정책으로 별도 검증한다(이 feature 범위 밖).
* 정책값은 기획 확정 시 이 상수를 교체한다.
*/
export const PICKUP_OPEN_MINUTES = 10 * 60; // 10:00
export const PICKUP_CLOSE_MINUTES = 20 * 60; // 20:00 (미포함 → 마지막 슬롯 19:30)
export const PICKUP_SLOT_INTERVAL_MINUTES = 30;
export const PICKUP_AFTERNOON_START_MINUTES = 12 * 60; // 12:00 (오전/오후 경계)

/** 오늘부터 선택 가능한 최대 일수. */
export const PICKUP_MAX_DAYS_AHEAD = 30;

/** 당일 픽업 최소 리드타임(분). 현재시각 + 이 값 이전 슬롯은 마감. */
export const PICKUP_MIN_LEAD_MINUTES = 60;
1 change: 1 addition & 0 deletions src/features/pickup/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { PickupModule } from '@/features/pickup/pickup.module';
9 changes: 9 additions & 0 deletions src/features/pickup/pickup.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';

import { PickupQueryResolver } from '@/features/pickup/resolvers/pickup-query.resolver';
import { PickupSlotService } from '@/features/pickup/services/pickup-slot.service';

@Module({
providers: [PickupSlotService, PickupQueryResolver],
})
export class PickupModule {}
29 changes: 29 additions & 0 deletions src/features/pickup/pickup.types.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
extend type Query {
"""월별 픽업 가능 날짜 (홈 전역 정책 기준). yearMonth: "YYYY-MM" """
pickupCalendar(yearMonth: String!): PickupCalendar!
"""선택 날짜의 픽업 시간 슬롯. date: "YYYY-MM-DD" """
pickupTimeSlots(date: String!): PickupTimeSlots!
}

type PickupCalendar {
yearMonth: String!
days: [PickupDay!]!
}

type PickupDay {
date: String!
selectable: Boolean!
"""선택 불가 사유. PAST | OUT_OF_RANGE (선택 가능 시 null)."""
reason: String
}

type PickupTimeSlots {
date: String!
morning: [PickupSlot!]!
afternoon: [PickupSlot!]!
}

type PickupSlot {
time: String!
available: Boolean!
}
20 changes: 20 additions & 0 deletions src/features/pickup/resolvers/pickup-query.resolver.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { PickupQueryResolver } from '@/features/pickup/resolvers/pickup-query.resolver';
import { PickupSlotService } from '@/features/pickup/services/pickup-slot.service';

describe('PickupQueryResolver', () => {
const service = new PickupSlotService();
const resolver = new PickupQueryResolver(service);

it('pickupCalendar: 서비스에 위임해 월 달력을 반환한다', () => {
const result = resolver.pickupCalendar('2026-06');
expect(result.yearMonth).toBe('2026-06');
expect(result.days).toHaveLength(30);
});

it('pickupTimeSlots: 서비스에 위임해 시간 슬롯을 반환한다', () => {
const result = resolver.pickupTimeSlots('2026-06-25');
expect(result.date).toBe('2026-06-25');
expect(result.morning).toHaveLength(4);
expect(result.afternoon).toHaveLength(16);
});
});
25 changes: 25 additions & 0 deletions src/features/pickup/resolvers/pickup-query.resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Args, Query, Resolver } from '@nestjs/graphql';

import { PickupSlotService } from '@/features/pickup/services/pickup-slot.service';
import type {
PickupCalendar,
PickupTimeSlots,
} from '@/features/pickup/types/pickup-output.type';

/**
* 홈 픽업 슬롯 조회 resolver. 비로그인도 접근 가능한 public query.
*/
@Resolver('Query')
export class PickupQueryResolver {
constructor(private readonly pickupSlotService: PickupSlotService) {}

@Query('pickupCalendar')
pickupCalendar(@Args('yearMonth') yearMonth: string): PickupCalendar {
return this.pickupSlotService.pickupCalendar(yearMonth);
}

@Query('pickupTimeSlots')
pickupTimeSlots(@Args('date') date: string): PickupTimeSlots {
return this.pickupSlotService.pickupTimeSlots(date);
}
}
Loading
Loading