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/constants/store-sort.constants.ts b/src/features/store/constants/store-sort.constants.ts new file mode 100644 index 00000000..83c9ed36 --- /dev/null +++ b/src/features/store/constants/store-sort.constants.ts @@ -0,0 +1,4 @@ +export const STORE_SORTS = ['POPULAR', 'LATEST', 'BEST_SELLING'] as const; +export type StoreSort = (typeof STORE_SORTS)[number]; + +export const DEFAULT_STORE_SORT: StoreSort = 'POPULAR'; 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/dto/inputs/search-stores.input.ts b/src/features/store/dto/inputs/search-stores.input.ts index eee77b52..daae4065 100644 --- a/src/features/store/dto/inputs/search-stores.input.ts +++ b/src/features/store/dto/inputs/search-stores.input.ts @@ -1,5 +1,6 @@ import { IsArray, + IsIn, IsInt, IsNotEmpty, IsOptional, @@ -9,6 +10,10 @@ import { } 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 SearchStoresInput { @IsOptional() @@ -21,6 +26,10 @@ export class SearchStoresInput { @IsNotEmpty({ each: true }) regionIds?: string[]; + @IsOptional() + @IsIn(STORE_SORTS) + sort?: StoreSort | null; + @IsOptional() @IsInt() @Min(0) diff --git a/src/features/store/repositories/store.repository.ts b/src/features/store/repositories/store.repository.ts index e7cdbdeb..0cbb07d8 100644 --- a/src/features/store/repositories/store.repository.ts +++ b/src/features/store/repositories/store.repository.ts @@ -14,8 +14,26 @@ export interface StoreCandidateRow { pickup_slot_interval_minutes: number; min_lead_time_minutes: number; max_days_ahead: number; + created_at: Date; + latitude: Prisma.Decimal | null; + longitude: Prisma.Decimal | null; } +const STORE_CANDIDATE_SELECT = { + id: true, + store_name: true, + profile_image_url: true, + address_city: true, + address_neighborhood: true, + region: { select: { name: true } }, + pickup_slot_interval_minutes: true, + min_lead_time_minutes: true, + max_days_ahead: true, + created_at: true, + latitude: true, + longitude: true, +} satisfies Prisma.StoreSelect; + export type StoreSearchCandidateRow = StoreCandidateRow; export interface StoreSearchFilter { @@ -76,17 +94,7 @@ export class StoreRepository { ? { region_id: { in: regionIds } } : {}), }, - select: { - id: true, - store_name: true, - profile_image_url: true, - address_city: true, - address_neighborhood: true, - region: { select: { name: true } }, - pickup_slot_interval_minutes: true, - min_lead_time_minutes: true, - max_days_ahead: true, - }, + select: STORE_CANDIDATE_SELECT, }); } @@ -96,17 +104,7 @@ export class StoreRepository { ): Promise { return this.prisma.store.findMany({ where: buildStoreSearchWhere(filter), - select: { - id: true, - store_name: true, - address_city: true, - address_neighborhood: true, - region: { select: { name: true } }, - pickup_slot_interval_minutes: true, - min_lead_time_minutes: true, - max_days_ahead: true, - profile_image_url: true, - }, + select: STORE_CANDIDATE_SELECT, orderBy: { id: 'asc' }, }); } 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-search-query.resolver.spec.ts b/src/features/store/resolvers/store-search-query.resolver.spec.ts index 761d35a7..1575df57 100644 --- a/src/features/store/resolvers/store-search-query.resolver.spec.ts +++ b/src/features/store/resolvers/store-search-query.resolver.spec.ts @@ -86,4 +86,23 @@ describe('StoreSearchQueryResolver (real DB)', () => { expect(result.totalCount).toBe(1); }); + + it('searchStores: sort를 서비스로 전달한다', async () => { + const old = await createStore(prisma, { store_name: '리졸버 이전' }); + await createStore(prisma, { store_name: '리졸버 최신' }); + await prisma.store.update({ + where: { id: old.id }, + data: { created_at: new Date('2026-01-01T00:00:00Z') }, + }); + + const result = await resolver.searchStores( + { keyword: '리졸버', sort: 'LATEST' }, + undefined, + ); + + expect(result.items.map((i) => i.storeName)).toEqual([ + '리졸버 최신', + '리졸버 이전', + ]); + }); }); 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-listing.service.spec.ts b/src/features/store/services/store-listing.service.spec.ts index 16989140..a4441493 100644 --- a/src/features/store/services/store-listing.service.spec.ts +++ b/src/features/store/services/store-listing.service.spec.ts @@ -21,6 +21,7 @@ const DAY_MS = 24 * 60 * 60 * 1000; describe('StoreListingService (real DB)', () => { let service: StoreListingService; + let repo: StoreRepository; let prisma: PrismaClient; beforeAll(async () => { @@ -35,6 +36,7 @@ describe('StoreListingService (real DB)', () => { ], }); service = module.get(StoreListingService); + repo = module.get(StoreRepository); prisma = p; }); @@ -200,4 +202,105 @@ describe('StoreListingService (real DB)', () => { expect(result.totalCount).toBe(0); }); }); + + describe('sortStores', () => { + async function sold(storeId: bigint, quantity: number, daysAgo = 0) { + const order = await createOrder(prisma, { status: 'CONFIRMED' }); + await createOrderItem(prisma, { + order_id: order.id, + store_id: storeId, + quantity, + }); + if (daysAgo > 0) { + await prisma.order.update({ + where: { id: order.id }, + data: { created_at: new Date(Date.now() - daysAgo * DAY_MS) }, + }); + } + } + + async function sortedNames(sort: Parameters[1]) { + const candidates = await repo.findActiveStoresForRanking(); + const { ordered } = await service.sortStores( + candidates, + sort, + new Date(), + ); + return ordered.map((c) => c.store_name); + } + + it('LATEST는 등록일 최신순, 같은 등록일은 id desc로 정렬한다', async () => { + const old = await createStore(prisma, { store_name: '오래된' }); + const tieA = await createStore(prisma, { store_name: '동률A' }); + const tieB = await createStore(prisma, { store_name: '동률B' }); + await createStore(prisma, { store_name: '최신' }); + const at = (daysAgo: number) => new Date(Date.now() - daysAgo * DAY_MS); + await prisma.store.update({ + where: { id: old.id }, + data: { created_at: at(10) }, + }); + for (const id of [tieA.id, tieB.id]) { + await prisma.store.update({ + where: { id }, + data: { created_at: at(5) }, + }); + } + + expect(await sortedNames('LATEST')).toEqual([ + '최신', + '동률B', + '동률A', + '오래된', + ]); + }); + + it('BEST_SELLING은 최근 30일 판매 수량 합순, 판매 0건도 뒤에 남기고 동률은 id desc', async () => { + const few = await createStore(prisma, { store_name: '주문많고수량적음' }); + const many = await createStore(prisma, { store_name: '수량많음' }); + const stale = await createStore(prisma, { store_name: '오래된판매' }); + await createStore(prisma, { store_name: '판매없음A' }); + await createStore(prisma, { store_name: '판매없음B' }); + await sold(few.id, 1); + await sold(few.id, 1); + await sold(many.id, 5); + await sold(stale.id, 10, 40); + + expect(await sortedNames('BEST_SELLING')).toEqual([ + '수량많음', + '주문많고수량적음', + '판매없음B', + '판매없음A', + '오래된판매', + ]); + }); + + it('POPULAR는 인기 점수순이고 카드용 통계를 함께 돌려준다', async () => { + await createStore(prisma, { store_name: '보통' }); + const hot = await createStore(prisma, { store_name: '인기' }); + await reviewStore(hot.id, 5); + + const candidates = await repo.findActiveStoresForRanking(); + const result = await service.sortStores( + candidates, + 'POPULAR', + new Date(), + ); + + expect(result.ordered.map((c) => c.store_name)).toEqual(['인기', '보통']); + expect(result.stats?.get(hot.id)).toMatchObject({ + ratingAverage: 5, + reviewCount: 1, + }); + }); + + it('LATEST·BEST_SELLING은 통계를 채우지 않는다(카드가 직접 집계)', async () => { + await createStore(prisma); + const candidates = await repo.findActiveStoresForRanking(); + + for (const sort of ['LATEST', 'BEST_SELLING'] as const) { + const result = await service.sortStores(candidates, sort, new Date()); + expect(result.stats).toBeUndefined(); + } + }); + }); }); diff --git a/src/features/store/services/store-listing.service.ts b/src/features/store/services/store-listing.service.ts index 232d0990..d7588ebe 100644 --- a/src/features/store/services/store-listing.service.ts +++ b/src/features/store/services/store-listing.service.ts @@ -10,13 +10,17 @@ import { DEFAULT_POPULAR_STORES_LIMIT, RANKING_RECENT_ORDER_DAYS, } from '@/features/store/constants/store-ranking.constants'; +import type { StoreSort } from '@/features/store/constants/store-sort.constants'; import type { PopularStoresInput } from '@/features/store/dto/inputs/popular-stores.input'; import { StoreStatsRepository } from '@/features/store/repositories/store-stats.repository'; import { StoreRepository, type StoreCandidateRow, } from '@/features/store/repositories/store.repository'; -import { StoreCardService } from '@/features/store/services/store-card.service'; +import { + StoreCardService, + type StoreCardStats, +} from '@/features/store/services/store-card.service'; import { scoreAndSortByPopularity, type ScoredCandidate, @@ -25,6 +29,12 @@ import type { PopularStoreConnection } from '@/features/store/types/store-output export type ScoredStore = ScoredCandidate; +export interface SortedStores { + ordered: T[]; + /** 인기순만 채운다 — 점수에 쓴 리뷰 통계를 카드가 다시 집계하지 않도록. */ + stats?: Map; +} + @Injectable() export class StoreListingService { constructor( @@ -72,6 +82,47 @@ export class StoreListingService { ); } + /** 동률은 id desc로 고정한다(인기순은 리뷰 수 desc가 먼저). */ + async sortStores( + candidates: T[], + sort: StoreSort, + at: Date, + ): Promise> { + switch (sort) { + case 'POPULAR': { + const scored = await this.scoreStores(candidates, at); + return { + ordered: scored.map((entry) => entry.candidate), + stats: new Map( + scored.map((entry) => [entry.candidate.id, entry.metrics]), + ), + }; + } + case 'BEST_SELLING': { + const sold = await this.stats.aggregateSoldQuantities( + 'store_id', + candidates.map((c) => c.id), + new Date(at.getTime() - RANKING_RECENT_ORDER_DAYS * DAY_MS), + ); + return { + ordered: [...candidates].sort( + (a, b) => + (sold.get(b.id) ?? 0) - (sold.get(a.id) ?? 0) || + compareIdDesc(a, b), + ), + }; + } + case 'LATEST': + return { + ordered: [...candidates].sort( + (a, b) => + b.created_at.getTime() - a.created_at.getTime() || + compareIdDesc(a, b), + ), + }; + } + } + async popularStores( input?: PopularStoresInput, accountId?: bigint, @@ -110,3 +161,8 @@ export class StoreListingService { }; } } + +function compareIdDesc(a: { id: bigint }, b: { id: bigint }): number { + if (a.id === b.id) return 0; + return b.id > a.id ? 1 : -1; +} 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-search.service.spec.ts b/src/features/store/services/store-search.service.spec.ts index cfcd7504..b53728da 100644 --- a/src/features/store/services/store-search.service.spec.ts +++ b/src/features/store/services/store-search.service.spec.ts @@ -115,6 +115,41 @@ describe('StoreSearchService (real DB)', () => { ]); }); + it('sort를 지정하면 그 순서로 정렬하고 페이지는 정렬 뒤에 자른다', async () => { + const first = await createStore(prisma, { store_name: '케이크 먼저' }); + await createStore(prisma, { store_name: '케이크 나중' }); + await prisma.store.update({ + where: { id: first.id }, + data: { created_at: new Date('2026-01-01T00:00:00Z') }, + }); + await confirmOrders(first, 2); + + expect(await names({ keyword: '케이크', sort: 'LATEST' })).toEqual([ + '케이크 나중', + '케이크 먼저', + ]); + expect(await names({ keyword: '케이크', sort: 'BEST_SELLING' })).toEqual([ + '케이크 먼저', + '케이크 나중', + ]); + expect( + await names({ keyword: '케이크', sort: 'LATEST', offset: 1, limit: 1 }), + ).toEqual(['케이크 먼저']); + }); + + it('sort를 생략하거나 null로 넘기면 인기순이다', async () => { + const hot = await createStore(prisma, { store_name: '케이크 인기' }); + await createStore(prisma, { store_name: '케이크 신규' }); + await confirmOrders(hot, 2); + + for (const sort of [undefined, null]) { + expect(await names({ keyword: '케이크', sort })).toEqual([ + '케이크 인기', + '케이크 신규', + ]); + } + }); + it('offset/limit·totalCount·hasMore', async () => { for (let i = 0; i < 3; i += 1) { await createStore(prisma, { store_name: `케이크 ${i}` }); diff --git a/src/features/store/services/store-search.service.ts b/src/features/store/services/store-search.service.ts index 46b08c10..98982c16 100644 --- a/src/features/store/services/store-search.service.ts +++ b/src/features/store/services/store-search.service.ts @@ -8,6 +8,7 @@ import { DEFAULT_SEARCH_PAGE_LIMIT, MAX_SEARCH_PAGE_LIMIT, } from '@/features/store/constants/store-search.constants'; +import { DEFAULT_STORE_SORT } from '@/features/store/constants/store-sort.constants'; import type { SearchStoresInput } from '@/features/store/dto/inputs/search-stores.input'; import { StoreRepository, @@ -31,7 +32,6 @@ export class StoreSearchService { private readonly clock: ClockService, ) {} - /** 정렬 옵션 없음 — 시안에 매장용 정렬 시트가 없어 인기순 고정. */ async searchStores( input: SearchStoresInput, accountId?: bigint, @@ -47,16 +47,15 @@ export class StoreSearchService { const totalCount = candidates.length; if (totalCount === 0) return { items: [], totalCount: 0, hasMore: false }; - const scored = await this.listing.scoreStores(candidates, this.clock.now()); - const page = scored.slice(offset, offset + limit); + const { ordered, stats } = await this.listing.sortStores( + candidates, + input.sort ?? DEFAULT_STORE_SORT, + this.clock.now(), + ); const items = await this.cards.buildCards( - page.map((entry) => entry.candidate), + ordered.slice(offset, offset + limit), accountId, - { - stats: new Map( - page.map((entry) => [entry.candidate.id, entry.metrics]), - ), - }, + { stats }, ); return { 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-search.graphql b/src/features/store/store-search.graphql index 1666be84..25a9cc8b 100644 --- a/src/features/store/store-search.graphql +++ b/src/features/store/store-search.graphql @@ -1,6 +1,6 @@ extend type Query { """ - 매장 검색(검색 결과 매장 탭). 매장명에 단어별 AND 부분일치, 인기순 고정. keyword를 생략하면 지역 조건만 적용한다. 비로그인 접근 가능. + 매장 검색(검색 결과 매장 탭). 매장명에 단어별 AND 부분일치. keyword를 생략하면 지역 조건만 적용한다. 비로그인 접근 가능. """ searchStores(input: SearchStoresInput!): SearchStoreConnection! } @@ -11,6 +11,8 @@ input SearchStoresInput { keyword: String """2차 시군구 ID 다중 선택. 비우면 전국 대상.""" regionIds: [ID!] + """정렬 기준. 기본 인기순.""" + sort: StoreSort = POPULAR """건너뛸 개수. 기본 0.""" offset: Int = 0 """조회 개수(최대 50).""" @@ -19,7 +21,7 @@ input SearchStoresInput { """매장 검색 결과 목록.""" type SearchStoreConnection { - """매장 카드. 인기순으로 온다 — 찜 수·리뷰 통계·최근 주문 수로 산출하며 검색어 관련도는 반영하지 않는다.""" + """매장 카드. 요청한 sort 순서로 온다. 검색어 관련도는 반영하지 않는다.""" items: [StoreCard!]! """조건에 맞는 전체 매장 수. 탭 카운트에 쓴다.""" totalCount: Int! 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/store.types.graphql b/src/features/store/store.types.graphql index 66041fab..fd14575e 100644 --- a/src/features/store/store.types.graphql +++ b/src/features/store/store.types.graphql @@ -52,6 +52,16 @@ type StoreCard { isWishlisted: Boolean! } +"""매장 목록 정렬. 시안 '추천순' 라벨은 POPULAR(기본)로 해석한다.""" +enum StoreSort { + """인기순(최근 30일 주문 수·찜 수·베이지안 평점 — 인기 매장과 동일 산식).""" + POPULAR + """등록순(최신 등록 매장 우선).""" + LATEST + """판매순(최근 30일 유효 주문 수량 합).""" + BEST_SELLING +} + """매장 지도 연동 provider. 구매자·판매자 API가 공용으로 쓴다.""" enum StoreMapProvider { """네이버 지도 딥링크를 쓴다.""" 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;