diff --git a/src/app.module.ts b/src/app.module.ts index d670e5d..8be329e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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'; @@ -87,6 +88,7 @@ import { PrismaModule } from '@/prisma'; }), SystemModule, AuthModule, + PickupModule, RegionModule, StoreModule, UserModule, diff --git a/src/common/utils/kst-time.spec.ts b/src/common/utils/kst-time.spec.ts new file mode 100644 index 0000000..cb88b95 --- /dev/null +++ b/src/common/utils/kst-time.spec.ts @@ -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'); + }); + }); +}); diff --git a/src/common/utils/kst-time.ts b/src/common/utils/kst-time.ts new file mode 100644 index 0000000..b2f8996 --- /dev/null +++ b/src/common/utils/kst-time.ts @@ -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)}`; +} diff --git a/src/features/pickup/constants/pickup-error-messages.ts b/src/features/pickup/constants/pickup-error-messages.ts new file mode 100644 index 0000000..0f22905 --- /dev/null +++ b/src/features/pickup/constants/pickup-error-messages.ts @@ -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; diff --git a/src/features/pickup/constants/pickup.constants.ts b/src/features/pickup/constants/pickup.constants.ts new file mode 100644 index 0000000..ce9c421 --- /dev/null +++ b/src/features/pickup/constants/pickup.constants.ts @@ -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; diff --git a/src/features/pickup/index.ts b/src/features/pickup/index.ts new file mode 100644 index 0000000..9283a22 --- /dev/null +++ b/src/features/pickup/index.ts @@ -0,0 +1 @@ +export { PickupModule } from '@/features/pickup/pickup.module'; diff --git a/src/features/pickup/pickup.module.ts b/src/features/pickup/pickup.module.ts new file mode 100644 index 0000000..eec0c72 --- /dev/null +++ b/src/features/pickup/pickup.module.ts @@ -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 {} diff --git a/src/features/pickup/pickup.types.graphql b/src/features/pickup/pickup.types.graphql new file mode 100644 index 0000000..241d6b3 --- /dev/null +++ b/src/features/pickup/pickup.types.graphql @@ -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! +} diff --git a/src/features/pickup/resolvers/pickup-query.resolver.spec.ts b/src/features/pickup/resolvers/pickup-query.resolver.spec.ts new file mode 100644 index 0000000..1a17d66 --- /dev/null +++ b/src/features/pickup/resolvers/pickup-query.resolver.spec.ts @@ -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); + }); +}); diff --git a/src/features/pickup/resolvers/pickup-query.resolver.ts b/src/features/pickup/resolvers/pickup-query.resolver.ts new file mode 100644 index 0000000..18ab592 --- /dev/null +++ b/src/features/pickup/resolvers/pickup-query.resolver.ts @@ -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); + } +} diff --git a/src/features/pickup/services/pickup-slot.service.spec.ts b/src/features/pickup/services/pickup-slot.service.spec.ts new file mode 100644 index 0000000..7a44cb7 --- /dev/null +++ b/src/features/pickup/services/pickup-slot.service.spec.ts @@ -0,0 +1,100 @@ +import { BadRequestException } from '@nestjs/common'; + +import { PickupSlotService } from '@/features/pickup/services/pickup-slot.service'; + +describe('PickupSlotService', () => { + const service = new PickupSlotService(); + + describe('pickupCalendar', () => { + it('형식 오류면 BadRequestException', () => { + expect(() => service.pickupCalendar('2026/06')).toThrow( + BadRequestException, + ); + expect(() => service.pickupCalendar('2026-13')).toThrow( + BadRequestException, + ); + }); + + it('월 일수만큼 days를 만들고 과거는 PAST로 막는다', () => { + const now = new Date('2026-06-18T01:00:00.000Z'); // KST 06-18 10:00 + const calendar = service.pickupCalendar('2026-06', now); + + expect(calendar.yearMonth).toBe('2026-06'); + expect(calendar.days).toHaveLength(30); // 6월은 30일 + + const past = calendar.days.find((d) => d.date === '2026-06-17'); + expect(past).toMatchObject({ selectable: false, reason: 'PAST' }); + + const today = calendar.days.find((d) => d.date === '2026-06-18'); + expect(today).toMatchObject({ selectable: true, reason: null }); + }); + + it('오늘+최대일수(30) 초과는 OUT_OF_RANGE', () => { + const now = new Date('2026-06-01T01:00:00.000Z'); // KST 06-01 + const calendar = service.pickupCalendar('2026-07', now); + + // 06-01 + 30일 = 07-01 까지 선택 가능, 07-02부터 범위 초과 + expect( + calendar.days.find((d) => d.date === '2026-07-01')?.selectable, + ).toBe(true); + expect(calendar.days.find((d) => d.date === '2026-07-02')).toMatchObject({ + selectable: false, + reason: 'OUT_OF_RANGE', + }); + }); + }); + + describe('pickupTimeSlots', () => { + it('형식 오류면 BadRequestException', () => { + expect(() => service.pickupTimeSlots('06-18')).toThrow( + BadRequestException, + ); + expect(() => service.pickupTimeSlots('2026-02-30')).toThrow( + BadRequestException, + ); + }); + + it('미래 날짜는 전 슬롯 available, 오전/오후를 구분한다', () => { + const now = new Date('2026-06-18T01:00:00.000Z'); + const slots = service.pickupTimeSlots('2026-06-25', now); + + expect(slots.date).toBe('2026-06-25'); + // 오전 10:00,10:30,11:00,11:30 (4), 오후 12:00~19:30 (16) + expect(slots.morning).toHaveLength(4); + expect(slots.afternoon).toHaveLength(16); + expect(slots.morning[0]).toEqual({ time: '10:00', available: true }); + expect(slots.afternoon[0].time).toBe('12:00'); + expect(slots.afternoon.at(-1)?.time).toBe('19:30'); + expect( + [...slots.morning, ...slots.afternoon].every((s) => s.available), + ).toBe(true); + }); + + it('당일은 현재시각+리드타임(60분) 이전 슬롯을 마감한다', () => { + const now = new Date('2026-06-18T04:00:00.000Z'); // KST 13:00 → cutoff 14:00 + const slots = service.pickupTimeSlots('2026-06-18', now); + const all = [...slots.morning, ...slots.afternoon]; + + expect(all.find((s) => s.time === '13:30')?.available).toBe(false); + expect(all.find((s) => s.time === '14:00')?.available).toBe(true); + }); + + it('과거 날짜는 전 슬롯 마감', () => { + const now = new Date('2026-06-18T01:00:00.000Z'); + const slots = service.pickupTimeSlots('2026-06-17', now); + + expect( + [...slots.morning, ...slots.afternoon].every((s) => !s.available), + ).toBe(true); + }); + + it('범위 초과 날짜는 전 슬롯 마감', () => { + const now = new Date('2026-06-01T01:00:00.000Z'); + const slots = service.pickupTimeSlots('2026-07-15', now); // +44일 + + expect( + [...slots.morning, ...slots.afternoon].every((s) => !s.available), + ).toBe(true); + }); + }); +}); diff --git a/src/features/pickup/services/pickup-slot.service.ts b/src/features/pickup/services/pickup-slot.service.ts new file mode 100644 index 0000000..dfa148f --- /dev/null +++ b/src/features/pickup/services/pickup-slot.service.ts @@ -0,0 +1,107 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; + +import { + formatKstDate, + formatMinutesOfDay, + kstDayDiff, + kstMidnightUtc, + kstMinutesOfDay, + parseKstDate, + parseKstYearMonth, +} from '@/common/utils/kst-time'; +import { + PICKUP_DAY_REASON, + PICKUP_ERRORS, +} from '@/features/pickup/constants/pickup-error-messages'; +import { + PICKUP_AFTERNOON_START_MINUTES, + PICKUP_CLOSE_MINUTES, + PICKUP_MAX_DAYS_AHEAD, + PICKUP_MIN_LEAD_MINUTES, + PICKUP_OPEN_MINUTES, + PICKUP_SLOT_INTERVAL_MINUTES, +} from '@/features/pickup/constants/pickup.constants'; +import type { + PickupCalendar, + PickupSlot, + PickupTimeSlots, +} from '@/features/pickup/types/pickup-output.type'; + +@Injectable() +export class PickupSlotService { + /** + * 월별 픽업 가능 날짜. KST 기준 과거는 PAST, 오늘+최대일수 초과는 OUT_OF_RANGE로 + * 선택 불가 처리한다. now는 테스트 주입용. + */ + pickupCalendar(yearMonth: string, now: Date = new Date()): PickupCalendar { + const ym = parseKstYearMonth(yearMonth); + if (!ym) { + throw new BadRequestException(PICKUP_ERRORS.INVALID_YEAR_MONTH); + } + + const daysInMonth = new Date(Date.UTC(ym.year, ym.month, 0)).getUTCDate(); + + const days = Array.from({ length: daysInMonth }, (_, index) => { + const day = index + 1; + const date = kstMidnightUtc(ym.year, ym.month, day); + const diff = kstDayDiff(now, date); + + if (diff < 0) { + return { + date: formatKstDate(date), + selectable: false, + reason: PICKUP_DAY_REASON.PAST, + }; + } + if (diff > PICKUP_MAX_DAYS_AHEAD) { + return { + date: formatKstDate(date), + selectable: false, + reason: PICKUP_DAY_REASON.OUT_OF_RANGE, + }; + } + return { date: formatKstDate(date), selectable: true, reason: null }; + }); + + return { yearMonth, days }; + } + + /** + * 특정 날짜의 시간 슬롯(오전/오후). 당일은 현재시각+리드타임 이전 슬롯을 마감, + * 과거/범위 초과 날짜는 전부 마감 처리한다. now는 테스트 주입용. + */ + pickupTimeSlots(date: string, now: Date = new Date()): PickupTimeSlots { + const parsed = parseKstDate(date); + if (!parsed) { + throw new BadRequestException(PICKUP_ERRORS.INVALID_DATE); + } + + const diff = kstDayDiff(now, parsed); + const outOfRange = diff < 0 || diff > PICKUP_MAX_DAYS_AHEAD; + const isToday = diff === 0; + const cutoffMinutes = isToday + ? kstMinutesOfDay(now) + PICKUP_MIN_LEAD_MINUTES + : Number.NEGATIVE_INFINITY; + + const morning: PickupSlot[] = []; + const afternoon: PickupSlot[] = []; + + for ( + let minutes = PICKUP_OPEN_MINUTES; + minutes < PICKUP_CLOSE_MINUTES; + minutes += PICKUP_SLOT_INTERVAL_MINUTES + ) { + const slot: PickupSlot = { + time: formatMinutesOfDay(minutes), + available: !outOfRange && minutes >= cutoffMinutes, + }; + if (minutes < PICKUP_AFTERNOON_START_MINUTES) { + morning.push(slot); + } else { + afternoon.push(slot); + } + } + + return { date, morning, afternoon }; + } +} diff --git a/src/features/pickup/types/pickup-output.type.ts b/src/features/pickup/types/pickup-output.type.ts new file mode 100644 index 0000000..bbd124f --- /dev/null +++ b/src/features/pickup/types/pickup-output.type.ts @@ -0,0 +1,26 @@ +/** + * pickup resolver 반환용 도메인 출력 타입. + * SDL(pickup.types.graphql)과 필드 일치. + */ + +export interface PickupDay { + date: string; // "YYYY-MM-DD" + selectable: boolean; + reason: string | null; // PAST | OUT_OF_RANGE (선택 가능 시 null) +} + +export interface PickupCalendar { + yearMonth: string; // "YYYY-MM" + days: PickupDay[]; +} + +export interface PickupSlot { + time: string; // "HH:MM" + available: boolean; +} + +export interface PickupTimeSlots { + date: string; + morning: PickupSlot[]; + afternoon: PickupSlot[]; +}