diff --git a/src/common/errors/error-catalog.ts b/src/common/errors/error-catalog.ts index 2d71e11f..acbc5450 100644 --- a/src/common/errors/error-catalog.ts +++ b/src/common/errors/error-catalog.ts @@ -251,6 +251,10 @@ export const ERROR_CATALOG = { status: HttpStatus.BAD_REQUEST, message: '유효하지 않은 날짜 형식입니다. (YYYY-MM-DD)', }, + PICKUP_DATE_PAST: { + status: HttpStatus.BAD_REQUEST, + message: '지난 날짜는 조회할 수 없습니다.', + }, // ── 리뷰·좋아요·신고 REVIEW_NOT_FOUND: { diff --git a/src/features/store/dto/inputs/pickup-stores.input.ts b/src/features/store/dto/inputs/pickup-stores.input.ts new file mode 100644 index 00000000..eb035d4b --- /dev/null +++ b/src/features/store/dto/inputs/pickup-stores.input.ts @@ -0,0 +1,46 @@ +import { + IsArray, + IsIn, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; + +import { MAX_SEARCH_PAGE_LIMIT } from '@/features/store/constants/store-search.constants'; +import { + STORE_SORTS, + type StoreSort, +} from '@/features/store/constants/store-sort.constants'; + +export class PickupStoresInput { + @IsString() + date!: string; + + @IsOptional() + @IsString() + time?: string | null; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + regionIds?: string[]; + + @IsOptional() + @IsIn(STORE_SORTS) + sort?: StoreSort | null; + + @IsOptional() + @IsInt() + @Min(0) + offset?: number; + + @IsOptional() + @IsInt() + @Min(1) + @Max(MAX_SEARCH_PAGE_LIMIT) + limit?: number; +} diff --git a/src/features/store/repositories/store.repository.ts b/src/features/store/repositories/store.repository.ts index 8658ea95..0cbb07d8 100644 --- a/src/features/store/repositories/store.repository.ts +++ b/src/features/store/repositories/store.repository.ts @@ -15,6 +15,8 @@ export interface StoreCandidateRow { min_lead_time_minutes: number; max_days_ahead: number; created_at: Date; + latitude: Prisma.Decimal | null; + longitude: Prisma.Decimal | null; } const STORE_CANDIDATE_SELECT = { @@ -28,6 +30,8 @@ const STORE_CANDIDATE_SELECT = { min_lead_time_minutes: true, max_days_ahead: true, created_at: true, + latitude: true, + longitude: true, } satisfies Prisma.StoreSelect; export type StoreSearchCandidateRow = StoreCandidateRow; diff --git a/src/features/store/resolvers/store-pickup-stores-query.resolver.spec.ts b/src/features/store/resolvers/store-pickup-stores-query.resolver.spec.ts new file mode 100644 index 00000000..1184a560 --- /dev/null +++ b/src/features/store/resolvers/store-pickup-stores-query.resolver.spec.ts @@ -0,0 +1,111 @@ +import { ClockService } from '@/common/providers/clock.service'; +import { ReviewReadRepository } from '@/features/review'; +import { StoreWishlistRepository } from '@/features/review/repositories/store-wishlist.repository'; +import { StoreStatsRepository } from '@/features/store/repositories/store-stats.repository'; +import { StoreRepository } from '@/features/store/repositories/store.repository'; +import { StorePickupStoresQueryResolver } from '@/features/store/resolvers/store-pickup-stores-query.resolver'; +import { StoreCardService } from '@/features/store/services/store-card.service'; +import { StoreListingService } from '@/features/store/services/store-listing.service'; +import { StorePickupAvailabilityService } from '@/features/store/services/store-pickup-availability.service'; +import { StorePickupStoresService } from '@/features/store/services/store-pickup-stores.service'; +import type { PrismaClient } from '@/generated/prisma/client'; +import type { JwtUser } from '@/global/auth'; +import { bookedQuantityProviders } from '@/test/booked-quantity'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createStore, + createStoreWishlist, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +// 2026-08-19(수) 16:00 KST 고정, 조회 날짜는 다음 날(목) +const NOW = new Date('2026-08-19T07:00:00.000Z'); +const DATE = '2026-08-20'; +const DATE_WEEKDAY = new Date(Date.UTC(2026, 7, 20)).getUTCDay(); + +// 날짜·시간·정렬 분기 세부 검증은 service.spec.ts에서 담당. 여기서는 리졸버→서비스→DB 경로만 본다. +describe('StorePickupStoresQueryResolver (real DB)', () => { + let resolver: StorePickupStoresQueryResolver; + let clock: ClockService; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + StoreCardService, + ReviewReadRepository, + StoreStatsRepository, + StorePickupStoresQueryResolver, + StorePickupStoresService, + StorePickupAvailabilityService, + StoreListingService, + StoreRepository, + StoreWishlistRepository, + ClockService, + ...bookedQuantityProviders(), + ], + }); + resolver = module.get(StorePickupStoresQueryResolver); + clock = module.get(ClockService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + jest.spyOn(clock, 'now').mockReturnValue(NOW); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + async function openStore(name: string) { + const store = await createStore(prisma, { store_name: name }); + await prisma.storeBusinessHour.create({ + data: { + store_id: store.id, + day_of_week: DATE_WEEKDAY, + is_closed: false, + open_time: new Date(Date.UTC(1970, 0, 1, 10, 0, 0)), + close_time: new Date(Date.UTC(1970, 0, 1, 20, 0, 0)), + }, + }); + return store; + } + + it('pickupStores: 로그인 사용자의 찜 여부를 채운다', async () => { + const store = await openStore('리졸버 매장'); + const account = await createAccount(prisma, { account_type: 'USER' }); + await createStoreWishlist(prisma, { + account_id: account.id, + store_id: store.id, + }); + const user = { accountId: account.id.toString() } as JwtUser; + + const result = await resolver.pickupStores(user, { + date: DATE, + time: '16:30', + }); + + expect(result.totalCount).toBe(1); + expect(result.items[0].store).toMatchObject({ + storeName: '리졸버 매장', + isWishlisted: true, + }); + }); + + it('pickupStores: 비로그인은 isWishlisted=false', async () => { + await openStore('리졸버 매장'); + + const result = await resolver.pickupStores(undefined, { date: DATE }); + + expect(result.items[0].store.isWishlisted).toBe(false); + }); +}); diff --git a/src/features/store/resolvers/store-pickup-stores-query.resolver.ts b/src/features/store/resolvers/store-pickup-stores-query.resolver.ts new file mode 100644 index 00000000..8340478b --- /dev/null +++ b/src/features/store/resolvers/store-pickup-stores-query.resolver.ts @@ -0,0 +1,28 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Query, Resolver } from '@nestjs/graphql'; + +import { PickupStoresInput } from '@/features/store/dto/inputs/pickup-stores.input'; +import { StorePickupStoresService } from '@/features/store/services/store-pickup-stores.service'; +import type { PickupStoreConnection } from '@/features/store/types/store-pickup-stores-output.type'; +import { + CurrentUser, + OptionalJwtAuthGuard, + parseAccountId, + type JwtUser, +} from '@/global/auth'; + +/** 옵셔널 인증으로 로그인 시에만 isWishlisted를 채운다. */ +@Resolver('Query') +export class StorePickupStoresQueryResolver { + constructor(private readonly service: StorePickupStoresService) {} + + @Query('pickupStores') + @UseGuards(OptionalJwtAuthGuard) + pickupStores( + @CurrentUser() user: JwtUser | undefined, + @Args('input') input: PickupStoresInput, + ): Promise { + const accountId = user ? parseAccountId(user) : undefined; + return this.service.pickupStores(input, accountId); + } +} diff --git a/src/features/store/resolvers/store-today-pickup-query.resolver.spec.ts b/src/features/store/resolvers/store-today-pickup-query.resolver.spec.ts index d49389cc..47e7bb38 100644 --- a/src/features/store/resolvers/store-today-pickup-query.resolver.spec.ts +++ b/src/features/store/resolvers/store-today-pickup-query.resolver.spec.ts @@ -6,6 +6,7 @@ import { StoreRepository } from '@/features/store/repositories/store.repository' import { StoreTodayPickupQueryResolver } from '@/features/store/resolvers/store-today-pickup-query.resolver'; import { StoreCardService } from '@/features/store/services/store-card.service'; import { StoreListingService } from '@/features/store/services/store-listing.service'; +import { StorePickupAvailabilityService } from '@/features/store/services/store-pickup-availability.service'; import { StoreTodayPickupService } from '@/features/store/services/store-today-pickup.service'; import type { PrismaClient } from '@/generated/prisma/client'; import { bookedQuantityProviders } from '@/test/booked-quantity'; @@ -32,6 +33,7 @@ describe('StoreTodayPickup Query Resolver (real DB)', () => { StoreStatsRepository, StoreTodayPickupQueryResolver, StoreTodayPickupService, + StorePickupAvailabilityService, StoreListingService, StoreRepository, StoreWishlistRepository, diff --git a/src/features/store/services/store-detail-mappers.helper.ts b/src/features/store/services/store-detail-mappers.helper.ts index 07238168..3324ed2d 100644 --- a/src/features/store/services/store-detail-mappers.helper.ts +++ b/src/features/store/services/store-detail-mappers.helper.ts @@ -9,7 +9,9 @@ function toRatingAverage(stat: ReviewStat | undefined): number { return roundRatingAverage(stat.average); } -function toCoordinate(value: { toString(): string } | null): number | null { +export function toCoordinate( + value: { toString(): string } | null, +): number | null { return value !== null ? Number(value) : null; } diff --git a/src/features/store/services/store-pickup-availability.service.ts b/src/features/store/services/store-pickup-availability.service.ts new file mode 100644 index 00000000..1d3fe234 --- /dev/null +++ b/src/features/store/services/store-pickup-availability.service.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@nestjs/common'; + +import { kstDayBoundaries } from '@/common/utils/kst-time'; +import { BookedQuantityPort } from '@/features/store/repositories/booked-quantity.port'; +import { StoreRepository } from '@/features/store/repositories/store.repository'; +import { + evaluatePickupDay, + type PickupPolicyStore, +} from '@/features/store/services/store-pickup-policy.helper'; +import type { TodayPickupSlot } from '@/features/store/types/store-today-pickup-output.type'; + +@Injectable() +export class StorePickupAvailabilityService { + constructor( + private readonly repo: StoreRepository, + private readonly booked: BookedQuantityPort, + ) {} + + /** + * 매장별 정책(요일 영업시간·특별휴무·슬롯 간격·리드타임·일일 capacity)으로 그날 예약 가능한 매장만 슬롯과 함께 돌려준다. + * 판정은 달력·주문 재검증과 같은 evaluatePickupDay라 사유는 제외 여부로만 쓴다. + */ + async openSlotsOn( + stores: (PickupPolicyStore & { id: bigint })[], + day: Date, + now: Date, + ): Promise> { + const open = new Map(); + if (stores.length === 0) return open; + + const storeIds = stores.map((s) => s.id); + // seller가 저장한 closure/capacity 날짜는 UTC date 부분으로 기록되는 전제(dateOnlyUtc 비교) + const { weekday, dateOnlyUtc, dayStartUtc, dayEndUtc } = + kstDayBoundaries(day); + const [businessHours, closedStoreIds, capacities, bookedCounts] = + await Promise.all([ + this.repo.findBusinessHoursByWeekday(storeIds, weekday), + this.repo.findSpecialClosureStoreIds(storeIds, dateOnlyUtc), + this.repo.findDailyCapacities(storeIds, dateOnlyUtc), + this.booked.sumByStore(storeIds, dayStartUtc, dayEndUtc), + ]); + const hourByStore = new Map( + businessHours.map((h) => [h.store_id.toString(), h]), + ); + + for (const store of stores) { + const { reason, slots } = evaluatePickupDay({ + store, + hour: hourByStore.get(store.id.toString()), + isSpecialClosure: closedStoreIds.has(store.id.toString()), + capacity: capacities.get(store.id), + booked: bookedCounts.get(store.id) ?? 0, + now, + dayStartUtc, + }); + if (reason === null) open.set(store.id, slots); + } + return open; + } +} diff --git a/src/features/store/services/store-pickup-stores.service.spec.ts b/src/features/store/services/store-pickup-stores.service.spec.ts new file mode 100644 index 00000000..9d48a05c --- /dev/null +++ b/src/features/store/services/store-pickup-stores.service.spec.ts @@ -0,0 +1,282 @@ +import { ClockService } from '@/common/providers/clock.service'; +import { ReviewReadRepository } from '@/features/review'; +import { StoreWishlistRepository } from '@/features/review/repositories/store-wishlist.repository'; +import { StoreStatsRepository } from '@/features/store/repositories/store-stats.repository'; +import { StoreRepository } from '@/features/store/repositories/store.repository'; +import { StoreCardService } from '@/features/store/services/store-card.service'; +import { StoreListingService } from '@/features/store/services/store-listing.service'; +import { StorePickupAvailabilityService } from '@/features/store/services/store-pickup-availability.service'; +import { StorePickupStoresService } from '@/features/store/services/store-pickup-stores.service'; +import type { PrismaClient, Store } from '@/generated/prisma/client'; +import { bookedQuantityProviders } from '@/test/booked-quantity'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createOrder, + createOrderItem, + createRegion, + createStore, + createStoreDailyCapacity, + createStoreWishlist, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +// 2026-08-19(수) 16:00 KST 고정. 조회 날짜는 기본으로 다음 날(목). +const NOW = new Date('2026-08-19T07:00:00.000Z'); +const TODAY = '2026-08-19'; +const DATE = '2026-08-20'; +const DATE_WEEKDAY = new Date(Date.UTC(2026, 7, 20)).getUTCDay(); +const DATE_ONLY = new Date(Date.UTC(2026, 7, 20)); +/** 조회 날짜 15:00 KST(UTC 06:00) — 예약 픽업 시각. */ +const DATE_PICKUP_AT = new Date('2026-08-20T06:00:00.000Z'); + +describe('StorePickupStoresService (real DB)', () => { + let service: StorePickupStoresService; + let clock: ClockService; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + StoreCardService, + ReviewReadRepository, + StoreStatsRepository, + StorePickupStoresService, + StorePickupAvailabilityService, + StoreListingService, + StoreRepository, + StoreWishlistRepository, + ClockService, + ...bookedQuantityProviders(), + ], + }); + service = module.get(StorePickupStoresService); + clock = module.get(ClockService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + jest.spyOn(clock, 'now').mockReturnValue(NOW); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + async function openOn( + store: Store, + openHour: number, + closeHour: number, + weekday = DATE_WEEKDAY, + ): Promise { + await prisma.storeBusinessHour.create({ + data: { + store_id: store.id, + day_of_week: weekday, + is_closed: false, + open_time: new Date(Date.UTC(1970, 0, 1, openHour, 0, 0)), + close_time: new Date(Date.UTC(1970, 0, 1, closeHour, 0, 0)), + }, + }); + } + + async function openStore(name: string, overrides = {}): Promise { + const store = await createStore(prisma, { store_name: name, ...overrides }); + await openOn(store, 10, 20); + return store; + } + + async function names( + input: Partial[0]> = {}, + ): Promise { + const result = await service.pickupStores({ date: DATE, ...input }); + return result.items.map((i) => i.store.storeName); + } + + describe('날짜 판정', () => { + it('그날 요일 휴무·영업시간 미설정·특별휴무 매장은 제외한다', async () => { + await openStore('영업'); + const closedDay = await createStore(prisma, { store_name: '요일휴무' }); + await prisma.storeBusinessHour.create({ + data: { + store_id: closedDay.id, + day_of_week: DATE_WEEKDAY, + is_closed: true, + }, + }); + await createStore(prisma, { store_name: '시간미설정' }); + const otherDay = await createStore(prisma, { store_name: '다른요일만' }); + await openOn(otherDay, 10, 20, (DATE_WEEKDAY + 1) % 7); + const closure = await openStore('특별휴무'); + await prisma.storeSpecialClosure.create({ + data: { store_id: closure.id, closure_date: DATE_ONLY }, + }); + + expect(await names()).toEqual(['영업']); + }); + + it('그날 capacity가 소진된 매장은 제외한다', async () => { + const full = await openStore('마감'); + await createStoreDailyCapacity(prisma, { + store_id: full.id, + capacity_date: DATE_ONLY, + capacity: 2, + }); + const order = await createOrder(prisma, { + status: 'CONFIRMED', + pickup_at: DATE_PICKUP_AT, + }); + await createOrderItem(prisma, { + order_id: order.id, + store_id: full.id, + quantity: 2, + }); + await openStore('여유'); + + expect(await names()).toEqual(['여유']); + }); + + it('예약 가능 기간(max_days_ahead)을 넘거나 리드타임으로 마감된 매장은 제외한다', async () => { + await openStore('기간내', { max_days_ahead: 1 }); + await openStore('기간초과', { max_days_ahead: 0 }); + // 16:00 + 2일 리드타임 → 다음 날 영업시간 전체가 마감 + await openStore('리드타임', { min_lead_time_minutes: 2 * 24 * 60 }); + + expect(await names()).toEqual(['기간내']); + }); + + it('오늘 날짜는 지금 이후 예약 가능한 슬롯이 있는 매장만 남긴다', async () => { + const today = new Date(Date.UTC(2026, 7, 19)).getUTCDay(); + const open = await createStore(prisma, { store_name: '저녁까지' }); + await openOn(open, 10, 20, today); + const ended = await createStore(prisma, { store_name: '영업종료' }); + await openOn(ended, 9, 12, today); + + expect(await names({ date: TODAY })).toEqual(['저녁까지']); + }); + + it('정책 상한(365일)을 넘는 날짜는 조회 없이 빈 결과다', async () => { + await openStore('매장', { max_days_ahead: 365 }); + + expect(await service.pickupStores({ date: '2027-08-20' })).toEqual({ + items: [], + totalCount: 0, + hasMore: false, + }); + }); + }); + + describe('시간 필터', () => { + it('그 시각 슬롯이 예약 가능한 매장만 남긴다', async () => { + await openStore('오후', {}); + const morning = await createStore(prisma, { store_name: '오전만' }); + await openOn(morning, 9, 12); + const hourly = await createStore(prisma, { + store_name: '1시간간격', + pickup_slot_interval_minutes: 60, + }); + await openOn(hourly, 10, 20); + + expect(await names({ time: '16:30' })).toEqual(['오후']); + expect(await names({ time: '16:00' })).toEqual(['1시간간격', '오후']); + expect(await names({ time: '16:15' })).toEqual([]); + }); + + it('리드타임으로 마감된 슬롯은 통과하지 않는다', async () => { + const today = new Date(Date.UTC(2026, 7, 19)).getUTCDay(); + const store = await createStore(prisma, { + store_name: '오늘', + min_lead_time_minutes: 60, + }); + await openOn(store, 10, 20, today); + + // 16:00 + 60분 → 16:30은 마감, 17:00부터 가능 + expect(await names({ date: TODAY, time: '16:30' })).toEqual([]); + expect(await names({ date: TODAY, time: '17:00' })).toEqual(['오늘']); + }); + + it('time을 생략하거나 null로 넘기면 시간 조건이 없다', async () => { + await openStore('매장'); + + for (const time of [undefined, null]) { + expect(await names({ time })).toEqual(['매장']); + } + }); + }); + + describe('입력 검증', () => { + it.each([ + ['2026-8-20', 'INVALID_DATE'], + ['2026-02-30', 'INVALID_DATE'], + ['', 'INVALID_DATE'], + ['2026-08-18', 'PICKUP_DATE_PAST'], + ])('date=%p → %s', async (date, code) => { + await expect(service.pickupStores({ date })).rejects.toThrowDomain(code); + }); + + it.each(['4:30', '24:00', '16:60', '16:30:00', ''])( + 'time=%p → INVALID_TIME_VALUE', + async (time) => { + await expect( + service.pickupStores({ date: DATE, time }), + ).rejects.toThrowDomain('INVALID_TIME_VALUE'); + }, + ); + }); + + describe('정렬·페이지·응답', () => { + it('sort를 반영하고 생략하면 인기순이다', async () => { + const old = await openStore('오래됨'); + await openStore('최신'); + await prisma.store.update({ + where: { id: old.id }, + data: { created_at: new Date('2026-01-01T00:00:00Z') }, + }); + await createStoreWishlist(prisma, { store_id: old.id }); + + expect(await names({ sort: 'LATEST' })).toEqual(['최신', '오래됨']); + expect(await names()).toEqual(['오래됨', '최신']); + }); + + it('regionIds·offset/limit·totalCount·hasMore를 적용한다', async () => { + const region = await createRegion(prisma, { level: 2, slug: 'sgg-p' }); + for (let i = 0; i < 3; i += 1) { + await openStore(`지역${i}`, { region_id: region.id }); + } + await openStore('타지역'); + + const page = await service.pickupStores({ + date: DATE, + regionIds: [region.id.toString()], + sort: 'LATEST', + offset: 1, + limit: 1, + }); + + expect(page.totalCount).toBe(3); + expect(page.hasMore).toBe(true); + expect(page.items.map((i) => i.store.storeName)).toEqual(['지역1']); + }); + + it('항목에 매장 좌표를 싣고 미설정이면 null이다', async () => { + await openStore('좌표', { latitude: 37.5735, longitude: 126.986 }); + await openStore('좌표없음'); + + const { items } = await service.pickupStores({ + date: DATE, + sort: 'LATEST', + }); + + expect(items).toEqual([ + expect.objectContaining({ latitude: null, longitude: null }), + expect.objectContaining({ latitude: 37.5735, longitude: 126.986 }), + ]); + }); + }); +}); diff --git a/src/features/store/services/store-pickup-stores.service.ts b/src/features/store/services/store-pickup-stores.service.ts new file mode 100644 index 00000000..1a31ea8f --- /dev/null +++ b/src/features/store/services/store-pickup-stores.service.ts @@ -0,0 +1,87 @@ +import { Injectable } from '@nestjs/common'; + +import { DomainException } from '@/common/errors/error-catalog'; +import { ClockService } from '@/common/providers/clock.service'; +import { parseId } from '@/common/utils/id-parser'; +import { kstDayDiff, parseKstDate } from '@/common/utils/kst-time'; +import { hasMoreByOffset } from '@/common/utils/pagination'; +import { DEFAULT_SEARCH_PAGE_LIMIT } from '@/features/store/constants/store-search.constants'; +import { MAX_DAYS_AHEAD } from '@/features/store/constants/store-seller.constants'; +import { DEFAULT_STORE_SORT } from '@/features/store/constants/store-sort.constants'; +import type { PickupStoresInput } from '@/features/store/dto/inputs/pickup-stores.input'; +import { StoreRepository } from '@/features/store/repositories/store.repository'; +import { StoreCardService } from '@/features/store/services/store-card.service'; +import { toCoordinate } from '@/features/store/services/store-detail-mappers.helper'; +import { StoreListingService } from '@/features/store/services/store-listing.service'; +import { StorePickupAvailabilityService } from '@/features/store/services/store-pickup-availability.service'; +import type { PickupStoreConnection } from '@/features/store/types/store-pickup-stores-output.type'; + +const PICKUP_TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/; + +@Injectable() +export class StorePickupStoresService { + constructor( + private readonly repo: StoreRepository, + private readonly availability: StorePickupAvailabilityService, + private readonly listing: StoreListingService, + private readonly cards: StoreCardService, + private readonly clock: ClockService, + ) {} + + async pickupStores( + input: PickupStoresInput, + accountId?: bigint, + ): Promise { + const day = parseKstDate(input.date); + if (!day) throw new DomainException('INVALID_DATE'); + const time = input.time ?? undefined; + if (time !== undefined && !PICKUP_TIME_PATTERN.test(time)) { + throw new DomainException('INVALID_TIME_VALUE'); + } + const now = this.clock.now(); + const daysAhead = kstDayDiff(now, day); + if (daysAhead < 0) throw new DomainException('PICKUP_DATE_PAST'); + + const offset = input.offset ?? 0; + const limit = input.limit ?? DEFAULT_SEARCH_PAGE_LIMIT; + // 예약 가능 기간 상한을 넘으면 어느 매장도 열리지 않는다 + if (daysAhead > MAX_DAYS_AHEAD) { + return { items: [], totalCount: 0, hasMore: false }; + } + + const candidates = await this.repo.findActiveStoresForRanking( + input.regionIds?.map((id) => parseId(id)), + ); + const slotsByStore = await this.availability.openSlotsOn( + candidates, + day, + now, + ); + const matched = candidates.filter((candidate) => { + const slots = slotsByStore.get(candidate.id); + return ( + slots !== undefined && + (time === undefined || + slots.some((slot) => slot.available && slot.time === time)) + ); + }); + + const { ordered, stats } = await this.listing.sortStores( + matched, + input.sort ?? DEFAULT_STORE_SORT, + now, + ); + const page = ordered.slice(offset, offset + limit); + const cards = await this.cards.buildCards(page, accountId, { stats }); + + return { + items: cards.map((store, idx) => ({ + store, + latitude: toCoordinate(page[idx].latitude), + longitude: toCoordinate(page[idx].longitude), + })), + totalCount: matched.length, + hasMore: hasMoreByOffset(offset, limit, matched.length), + }; + } +} diff --git a/src/features/store/services/store-today-pickup.service.spec.ts b/src/features/store/services/store-today-pickup.service.spec.ts index 668883c0..ff787348 100644 --- a/src/features/store/services/store-today-pickup.service.spec.ts +++ b/src/features/store/services/store-today-pickup.service.spec.ts @@ -5,6 +5,7 @@ import { StoreStatsRepository } from '@/features/store/repositories/store-stats. import { StoreRepository } from '@/features/store/repositories/store.repository'; import { StoreCardService } from '@/features/store/services/store-card.service'; import { StoreListingService } from '@/features/store/services/store-listing.service'; +import { StorePickupAvailabilityService } from '@/features/store/services/store-pickup-availability.service'; import { StoreTodayPickupService } from '@/features/store/services/store-today-pickup.service'; import type { PrismaClient, Store } from '@/generated/prisma/client'; import { bookedQuantityProviders } from '@/test/booked-quantity'; @@ -38,6 +39,7 @@ describe('StoreTodayPickupService (real DB)', () => { ReviewReadRepository, StoreStatsRepository, StoreTodayPickupService, + StorePickupAvailabilityService, StoreListingService, StoreRepository, StoreWishlistRepository, diff --git a/src/features/store/services/store-today-pickup.service.ts b/src/features/store/services/store-today-pickup.service.ts index d7f2c7dd..4c70ec90 100644 --- a/src/features/store/services/store-today-pickup.service.ts +++ b/src/features/store/services/store-today-pickup.service.ts @@ -2,34 +2,24 @@ import { Injectable } from '@nestjs/common'; import { ClockService } from '@/common/providers/clock.service'; import { parseId } from '@/common/utils/id-parser'; -import { kstDayBoundaries } from '@/common/utils/kst-time'; import { hasMoreByOffset } from '@/common/utils/pagination'; import { DEFAULT_POPULAR_STORES_LIMIT } from '@/features/store/constants/store-ranking.constants'; import type { TodayPickupStoresInput } from '@/features/store/dto/inputs/today-pickup-stores.input'; -import { BookedQuantityPort } from '@/features/store/repositories/booked-quantity.port'; -import { StoreRepository } from '@/features/store/repositories/store.repository'; import { StoreCardService } from '@/features/store/services/store-card.service'; -import { - StoreListingService, - type ScoredStore, -} from '@/features/store/services/store-listing.service'; -import { evaluatePickupDay } from '@/features/store/services/store-pickup-policy.helper'; -import type { - TodayPickupSlot, - TodayPickupStoreConnection, -} from '@/features/store/types/store-today-pickup-output.type'; +import { StoreListingService } from '@/features/store/services/store-listing.service'; +import { StorePickupAvailabilityService } from '@/features/store/services/store-pickup-availability.service'; +import type { TodayPickupStoreConnection } from '@/features/store/types/store-today-pickup-output.type'; @Injectable() export class StoreTodayPickupService { constructor( - private readonly repo: StoreRepository, - private readonly booked: BookedQuantityPort, + private readonly availability: StorePickupAvailabilityService, private readonly listingService: StoreListingService, private readonly cards: StoreCardService, private readonly clock: ClockService, ) {} - /** 매장별 정책(요일 영업시간·특별휴무·슬롯 간격·리드타임·일일 capacity)을 모두 반영해 오늘 예약 가능 슬롯이 1개 이상인 매장만 노출한다. */ + /** 오늘 예약 가능 슬롯이 1개 이상인 매장만 노출한다. */ async todayPickupStores( input?: TodayPickupStoresInput, accountId?: bigint, @@ -40,43 +30,15 @@ export class StoreTodayPickupService { const asOf = this.clock.now(); const scored = await this.listingService.rankActiveStores(regionIds, asOf); - if (scored.length === 0) { - return { items: [], totalCount: 0, hasMore: false, asOf }; - } - - const storeIds = scored.map((s) => s.candidate.id); - // seller가 저장한 closure/capacity 날짜는 UTC date 부분으로 기록되는 전제(dateOnlyUtc 비교) - const { weekday, dateOnlyUtc, dayStartUtc, dayEndUtc } = - kstDayBoundaries(asOf); - const [businessHours, closedStoreIds, capacities, bookedCounts] = - await Promise.all([ - this.repo.findBusinessHoursByWeekday(storeIds, weekday), - this.repo.findSpecialClosureStoreIds(storeIds, dateOnlyUtc), - this.repo.findDailyCapacities(storeIds, dateOnlyUtc), - this.booked.sumByStore(storeIds, dayStartUtc, dayEndUtc), - ]); - const hourByStore = new Map( - businessHours.map((h) => [h.store_id.toString(), h]), + const slotsByStore = await this.availability.openSlotsOn( + scored.map((entry) => entry.candidate), + asOf, + asOf, ); - - const open: { entry: ScoredStore; slots: TodayPickupSlot[] }[] = []; - for (const entry of scored) { - const storeId = entry.candidate.id; - // 판정은 공용 정책(store-pickup-policy.helper) 단일 소스 — 달력·주문 재검증과 - // 동일 규칙. '오늘'은 PAST/OUT_OF_RANGE가 항상 통과라 사유는 제외 여부로만 쓴다. - const { reason, slots } = evaluatePickupDay({ - store: entry.candidate, - hour: hourByStore.get(storeId.toString()), - isSpecialClosure: closedStoreIds.has(storeId.toString()), - capacity: capacities.get(storeId), - booked: bookedCounts.get(storeId) ?? 0, - now: asOf, - dayStartUtc, - }); - if (reason !== null) continue; - - open.push({ entry, slots }); - } + const open = scored.flatMap((entry) => { + const slots = slotsByStore.get(entry.candidate.id); + return slots ? [{ entry, slots }] : []; + }); const totalCount = open.length; const page = open.slice(offset, offset + limit); diff --git a/src/features/store/store-pickup-stores.graphql b/src/features/store/store-pickup-stores.graphql new file mode 100644 index 00000000..23adfae5 --- /dev/null +++ b/src/features/store/store-pickup-stores.graphql @@ -0,0 +1,40 @@ +extend type Query { + """매장 선택(메인 검색 결과). 픽업 날짜·시간에 예약 가능한 매장을 요청한 정렬로 돌려준다. 비로그인 접근 가능.""" + pickupStores(input: PickupStoresInput!): PickupStoreConnection! +} + +"""매장 선택 조회 조건. 커서가 아니라 오프셋 방식이다.""" +input PickupStoresInput { + """픽업 날짜. "YYYY-MM-DD"(KST). 형식이 어긋나면 INVALID_DATE, 오늘보다 앞서면 PICKUP_DATE_PAST.""" + date: String! + """픽업 시각. "HH:mm"(KST). 그 슬롯이 예약 가능한 매장만 남긴다. 생략·null이면 그날 예약 가능한 슬롯이 하나라도 있는 매장. 형식이 어긋나면 INVALID_TIME_VALUE.""" + time: String + """2차 시군구 ID 다중 선택. 비우면 전국 대상.""" + regionIds: [ID!] + """정렬 기준. 기본 인기순.""" + sort: StoreSort = POPULAR + """건너뛸 개수. 기본 0.""" + offset: Int = 0 + """조회 개수(최대 50).""" + limit: Int = 20 +} + +"""매장 선택 결과 목록.""" +type PickupStoreConnection { + """매장 항목. 요청한 sort 순서로 온다.""" + items: [PickupStoreItem!]! + """조건에 맞는 전체 매장 수.""" + totalCount: Int! + """다음 페이지 존재 여부.""" + hasMore: Boolean! +} + +"""매장 선택 항목. 매장 카드와 지도 마커 좌표.""" +type PickupStoreItem { + """매장 카드.""" + store: StoreCard! + """위도(지도 마커). 미설정 시 null.""" + latitude: Float + """경도(지도 마커). 미설정 시 null.""" + longitude: Float +} diff --git a/src/features/store/store.module.ts b/src/features/store/store.module.ts index fb2e037d..0616329c 100644 --- a/src/features/store/store.module.ts +++ b/src/features/store/store.module.ts @@ -19,6 +19,7 @@ import { AdminSellerMutationResolver } from '@/features/store/resolvers/store-ad import { AdminSellerQueryResolver } from '@/features/store/resolvers/store-admin-seller-query.resolver'; import { StoreDetailQueryResolver } from '@/features/store/resolvers/store-detail-query.resolver'; import { StorePickupScheduleQueryResolver } from '@/features/store/resolvers/store-pickup-schedule-query.resolver'; +import { StorePickupStoresQueryResolver } from '@/features/store/resolvers/store-pickup-stores-query.resolver'; import { StoreQueryResolver } from '@/features/store/resolvers/store-query.resolver'; import { StoreSearchQueryResolver } from '@/features/store/resolvers/store-search-query.resolver'; import { SellerAuditQueryResolver } from '@/features/store/resolvers/store-seller-audit-query.resolver'; @@ -33,7 +34,9 @@ import { AdminStoreService } from '@/features/store/services/store-admin.service import { StoreCardService } from '@/features/store/services/store-card.service'; import { StoreDetailService } from '@/features/store/services/store-detail.service'; import { StoreListingService } from '@/features/store/services/store-listing.service'; +import { StorePickupAvailabilityService } from '@/features/store/services/store-pickup-availability.service'; import { StorePickupScheduleService } from '@/features/store/services/store-pickup-schedule.service'; +import { StorePickupStoresService } from '@/features/store/services/store-pickup-stores.service'; import { StoreSearchService } from '@/features/store/services/store-search.service'; import { SellerAuditService } from '@/features/store/services/store-seller-audit.service'; import { SellerFaqService } from '@/features/store/services/store-seller-faq.service'; @@ -62,8 +65,11 @@ import { fetchKakaoLocal, KAKAO_LOCAL_TRANSPORT } from '@/global/kakao-local'; StoreWishlistMutationResolver, StoreWishlistQueryResolver, StoreDetailQueryResolver, + StorePickupAvailabilityService, StoreTodayPickupService, StoreTodayPickupQueryResolver, + StorePickupStoresService, + StorePickupStoresQueryResolver, StorePickupScheduleService, StorePickupScheduleQueryResolver, StoreSearchService, diff --git a/src/features/store/types/store-pickup-stores-output.type.ts b/src/features/store/types/store-pickup-stores-output.type.ts new file mode 100644 index 00000000..51c10b83 --- /dev/null +++ b/src/features/store/types/store-pickup-stores-output.type.ts @@ -0,0 +1,10 @@ +import type { OffsetConnection } from '@/common/types/cursor-connection.type'; +import type { StoreCardOutput } from '@/features/store/types/store-card-output.type'; + +export interface PickupStoreItem { + store: StoreCardOutput; + latitude: number | null; + longitude: number | null; +} + +export type PickupStoreConnection = OffsetConnection;