From d46cfe3f496dd68f9578947d77d0aafabec0c345 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Tue, 25 Aug 2026 05:36:31 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat(order):=20=EC=A3=BC=EB=AC=B8=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20mutation(createOrder)=20=E2=80=94=20?= =?UTF-8?q?=EC=A0=95=EC=8B=9D=20API=EC=9D=98=20=ED=99=95=EC=A0=95=20?= =?UTF-8?q?=EB=B6=80=EB=B6=84=EC=A7=91=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 커스텀 단계 스펙 미확정 상태에서 FE가 주문 생성 흐름을 테스트할 수 있도록, 확정된 부분(단일 상품·옵션 선택·픽업 일시·수량)만으로 정식 주문 생성 경로를 구현한다. 커스텀 입력은 스펙 확정 후 optional 필드로 확장한다 (임시 스텁이 아니므로 이후 계약 변경 없음 — 사용자 확정 방향). - SDL order-checkout.graphql: createOrder(input) → 요약(CreateOrderOutput), 상세는 기존 myOrder 재조회 전제 - OrderCheckoutService: 옵션 그룹 규칙(필수/min/max/중복/타 상품) 검증, 가격 서버 스냅샷(subtotal 정가·discount 정가-판매가 차액·total 판매가 기준), 주문자 정보 input 우선 + 프로필(닉네임·전화) fallback, 둘 다 없으면 거절 - StorePickupScheduleService.isPickupSlotAvailable 신설: 달력·슬롯과 동일 규칙 + 슬롯 시작 시각 정합 + capacity 잔여(기존 점유+수량 ≤ capacity) 재검증 — 판정 로직은 store feature에 유지(배럴 export) - OrderRepository.createSubmittedOrder: Order+Item+옵션 스냅샷+상태 히스토리 중첩 create 원자 생성, SUBMITTED는 기존 규칙대로 알림 미발송 - 주문번호 ORD-YYYYMMDD-XXXXXX(KST 날짜+혼동 문자 제외 랜덤 6자리, RandomService 주입) — 명세 외 정책 결정. P2002 충돌 시 3회 재시도 회귀 테스트 24건: 생성 스냅샷·가격·히스토리 / 옵션 규칙 전 분기 / buyer fallback / 픽업 판정(휴무·정렬·리드타임·capacity 잔여·초 이하) / 주문번호 충돌 재시도·소진 / NOT_FOUND / input 검증 / resolver 통합 --- .../order/constants/order-error-messages.ts | 13 + .../dto/inputs/create-order.input.spec.ts | 58 +++ .../order/dto/inputs/create-order.input.ts | 42 ++ src/features/order/order-checkout.graphql | 27 + src/features/order/order.module.ts | 15 +- .../order/repositories/order.repository.ts | 107 ++++ .../order-checkout-mutation.resolver.spec.ts | 103 ++++ .../order-checkout-mutation.resolver.ts | 27 + .../services/order-checkout.service.spec.ts | 467 ++++++++++++++++++ .../order/services/order-checkout.service.ts | 241 +++++++++ .../order/types/create-order-output.type.ts | 13 + src/features/product/index.ts | 6 +- src/features/store/index.ts | 3 + .../store-pickup-schedule.service.spec.ts | 115 +++++ .../services/store-pickup-schedule.service.ts | 66 +++ src/features/store/store.module.ts | 3 +- 16 files changed, 1303 insertions(+), 3 deletions(-) create mode 100644 src/features/order/constants/order-error-messages.ts create mode 100644 src/features/order/dto/inputs/create-order.input.spec.ts create mode 100644 src/features/order/dto/inputs/create-order.input.ts create mode 100644 src/features/order/order-checkout.graphql create mode 100644 src/features/order/resolvers/order-checkout-mutation.resolver.spec.ts create mode 100644 src/features/order/resolvers/order-checkout-mutation.resolver.ts create mode 100644 src/features/order/services/order-checkout.service.spec.ts create mode 100644 src/features/order/services/order-checkout.service.ts create mode 100644 src/features/order/types/create-order-output.type.ts diff --git a/src/features/order/constants/order-error-messages.ts b/src/features/order/constants/order-error-messages.ts new file mode 100644 index 0000000..365f1cb --- /dev/null +++ b/src/features/order/constants/order-error-messages.ts @@ -0,0 +1,13 @@ +/** 주문 생성(체크아웃) 에러 메시지. */ +export const ORDER_CHECKOUT_ERRORS = { + PRODUCT_NOT_FOUND: '상품을 찾을 수 없습니다.', + DUPLICATE_OPTION_ITEM: '중복된 옵션 선택입니다.', + INVALID_OPTION_ITEM: '해당 상품의 옵션이 아닙니다.', + OPTION_GROUP_RULE_VIOLATION: '옵션 그룹의 선택 규칙을 충족하지 않습니다.', + PICKUP_NOT_AVAILABLE: '선택한 픽업 일시는 예약할 수 없습니다.', + BUYER_NAME_REQUIRED: '주문자 이름이 필요합니다.', + BUYER_PHONE_REQUIRED: + '주문자 연락처가 필요합니다. 프로필에 전화번호를 등록하거나 입력해 주세요.', + ORDER_NUMBER_GENERATION_FAILED: + '주문번호 생성에 실패했습니다. 잠시 후 다시 시도해 주세요.', +} as const; diff --git a/src/features/order/dto/inputs/create-order.input.spec.ts b/src/features/order/dto/inputs/create-order.input.spec.ts new file mode 100644 index 0000000..11bec6f --- /dev/null +++ b/src/features/order/dto/inputs/create-order.input.spec.ts @@ -0,0 +1,58 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { CreateOrderInput } from '@/features/order/dto/inputs/create-order.input'; + +function build(plain: object): CreateOrderInput { + return plainToInstance(CreateOrderInput, plain); +} + +const VALID = { + productId: '1', + optionItemIds: ['10', '11'], + pickupAt: new Date('2026-09-18T05:00:00.000Z'), +}; + +describe('CreateOrderInput', () => { + it('필수 필드만으로 통과한다 (quantity/buyer는 optional)', async () => { + expect(await validate(build(VALID))).toHaveLength(0); + }); + + it('buyer 필드·quantity 포함 통과', async () => { + const errors = await validate( + build({ + ...VALID, + quantity: 3, + buyerName: '차차', + buyerPhone: '010-0000-1111', + }), + ); + expect(errors).toHaveLength(0); + }); + + it('optionItemIds가 배열이 아니면 거절한다', async () => { + const errors = await validate(build({ ...VALID, optionItemIds: '10' })); + expect(errors[0].property).toBe('optionItemIds'); + }); + + it('pickupAt이 Date가 아니면 거절한다', async () => { + const errors = await validate(build({ ...VALID, pickupAt: 'not-a-date' })); + expect(errors[0].property).toBe('pickupAt'); + }); + + it('quantity 0·100은 범위 위반으로 거절한다', async () => { + expect((await validate(build({ ...VALID, quantity: 0 })))[0].property).toBe( + 'quantity', + ); + expect( + (await validate(build({ ...VALID, quantity: 100 })))[0].property, + ).toBe('quantity'); + }); + + it('buyerName 빈 문자열은 거절한다', async () => { + const errors = await validate(build({ ...VALID, buyerName: '' })); + expect(errors[0].property).toBe('buyerName'); + }); +}); diff --git a/src/features/order/dto/inputs/create-order.input.ts b/src/features/order/dto/inputs/create-order.input.ts new file mode 100644 index 0000000..c1ffdd5 --- /dev/null +++ b/src/features/order/dto/inputs/create-order.input.ts @@ -0,0 +1,42 @@ +import { + IsArray, + IsDate, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Max, + MaxLength, + Min, +} from 'class-validator'; + +export class CreateOrderInput { + @IsString() + @IsNotEmpty() + productId!: string; + + @IsArray() + @IsString({ each: true }) + optionItemIds!: string[]; + + @IsDate() + pickupAt!: Date; + + @IsOptional() + @IsInt() + @Min(1) + @Max(99) + quantity?: number; + + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(100) + buyerName?: string; + + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(30) + buyerPhone?: string; +} diff --git a/src/features/order/order-checkout.graphql b/src/features/order/order-checkout.graphql new file mode 100644 index 0000000..fb3daa7 --- /dev/null +++ b/src/features/order/order-checkout.graphql @@ -0,0 +1,27 @@ +extend type Mutation { + """주문 생성(구매자). 옵션·픽업 일시를 서버가 재검증하고 가격을 스냅샷한다. 로그인 필수.""" + createOrder(input: CreateOrderInput!): CreateOrderOutput! +} + +"""주문 생성 입력. 커스텀 필드는 커스텀 스펙 확정 후 optional로 확장 예정.""" +input CreateOrderInput { + productId: ID! + """선택한 옵션 아이템 ID 목록. 그룹 규칙(필수/min/max)을 서버가 검증한다.""" + optionItemIds: [ID!]! + """픽업 일시. 매장 정책(영업시간·휴무·capacity·리드타임·슬롯 정렬) 재검증.""" + pickupAt: DateTime! + quantity: Int = 1 + """미입력 시 프로필 닉네임 사용.""" + buyerName: String + """미입력 시 프로필 전화번호 사용. 둘 다 없으면 거절.""" + buyerPhone: String +} + +"""주문 생성 결과 요약. 상세는 myOrder로 재조회.""" +type CreateOrderOutput { + orderId: ID! + orderNumber: String! + status: OrderStatusType! + pickupAt: DateTime! + totalPrice: Int! +} diff --git a/src/features/order/order.module.ts b/src/features/order/order.module.ts index 43dafc1..dc82601 100644 --- a/src/features/order/order.module.ts +++ b/src/features/order/order.module.ts @@ -2,10 +2,23 @@ import { Module } from '@nestjs/common'; import { OrderStatusTransitionPolicy } from '@/features/order/policies/order-status-transition.policy'; import { OrderRepository } from '@/features/order/repositories/order.repository'; +import { OrderCheckoutMutationResolver } from '@/features/order/resolvers/order-checkout-mutation.resolver'; +import { OrderCheckoutService } from '@/features/order/services/order-checkout.service'; import { OrderDomainService } from '@/features/order/services/order-domain.service'; +import { ProductModule } from '@/features/product'; +import { StoreModule } from '@/features/store'; @Module({ - providers: [OrderRepository, OrderStatusTransitionPolicy, OrderDomainService], + // 주문 생성이 상품 옵션 조회(ProductRepository)와 픽업 판정 + // (StorePickupScheduleService)을 소비한다 — 배럴 공개 API 경유. + imports: [ProductModule, StoreModule], + providers: [ + OrderRepository, + OrderStatusTransitionPolicy, + OrderDomainService, + OrderCheckoutService, + OrderCheckoutMutationResolver, + ], exports: [OrderRepository, OrderStatusTransitionPolicy, OrderDomainService], }) export class OrderModule {} diff --git a/src/features/order/repositories/order.repository.ts b/src/features/order/repositories/order.repository.ts index b3f6bf6..9bff65a 100644 --- a/src/features/order/repositories/order.repository.ts +++ b/src/features/order/repositories/order.repository.ts @@ -41,6 +41,44 @@ export interface OngoingOrderRow { }[]; } +/** 주문 생성 입력(스냅샷 값은 서비스가 계산해 전달). */ +export interface CreateSubmittedOrderArgs { + accountId: bigint; + orderNumber: string; + pickupAt: Date; + buyerName: string; + buyerPhone: string; + subtotalPrice: number; + discountPrice: number; + totalPrice: number; + submittedAt: Date; + item: { + storeId: bigint; + productId: bigint; + productNameSnapshot: string; + regularPriceSnapshot: number; + salePriceSnapshot: number | null; + quantity: number; + itemSubtotalPrice: number; + options: { + optionGroupId: bigint; + optionItemId: bigint; + groupNameSnapshot: string; + optionTitleSnapshot: string; + optionPriceDeltaSnapshot: number; + }[]; + }; +} + +/** 주문 생성 결과 row(생성 요약 응답용). */ +export interface CreatedOrderRow { + id: bigint; + order_number: string; + status: OrderStatus; + pickup_at: Date; + total_price: number; +} + /** 리뷰 작성 가능 주문 아이템 row. UserReviewService 매핑 입력. */ export interface ReviewableOrderItemRow { id: bigint; @@ -60,6 +98,75 @@ export interface ReviewableOrderItemRow { export class OrderRepository { constructor(private readonly prisma: PrismaService) {} + /** 주문자 정보 fallback용 프로필 조회(닉네임·전화번호). */ + async findBuyerProfile( + accountId: bigint, + ): Promise<{ nickname: string; phone_number: string | null } | null> { + return this.prisma.userProfile.findFirst({ + where: { account_id: accountId, deleted_at: null }, + select: { nickname: true, phone_number: true }, + }); + } + + /** + * SUBMITTED 주문 생성. Order + OrderItem + 옵션 스냅샷 + 상태 히스토리를 + * 중첩 create 한 번으로 원자적으로 만든다. SUBMITTED는 알림 미발송 + * (알림은 판매자 상태 변경부터 — orderStatusToNotificationEvent 규칙). + * order_number unique 충돌(P2002)은 호출부가 재시도한다. + */ + async createSubmittedOrder( + args: CreateSubmittedOrderArgs, + ): Promise { + return this.prisma.order.create({ + data: { + account_id: args.accountId, + order_number: args.orderNumber, + status: OrderStatus.SUBMITTED, + pickup_at: args.pickupAt, + buyer_name: args.buyerName, + buyer_phone: args.buyerPhone, + subtotal_price: args.subtotalPrice, + discount_price: args.discountPrice, + total_price: args.totalPrice, + submitted_at: args.submittedAt, + items: { + create: { + store_id: args.item.storeId, + product_id: args.item.productId, + product_name_snapshot: args.item.productNameSnapshot, + regular_price_snapshot: args.item.regularPriceSnapshot, + sale_price_snapshot: args.item.salePriceSnapshot, + quantity: args.item.quantity, + item_subtotal_price: args.item.itemSubtotalPrice, + option_items: { + create: args.item.options.map((option) => ({ + option_group_id: option.optionGroupId, + option_item_id: option.optionItemId, + group_name_snapshot: option.groupNameSnapshot, + option_title_snapshot: option.optionTitleSnapshot, + option_price_delta_snapshot: option.optionPriceDeltaSnapshot, + })), + }, + }, + }, + status_histories: { + create: { + from_status: null, + to_status: OrderStatus.SUBMITTED, + changed_at: args.submittedAt, + }, + }, + }, + select: { + id: true, + order_number: true, + status: true, + pickup_at: true, + total_price: true, + }, + }); + } + async findOngoingOrdersByAccount(args: { accountId: bigint; since: Date; diff --git a/src/features/order/resolvers/order-checkout-mutation.resolver.spec.ts b/src/features/order/resolvers/order-checkout-mutation.resolver.spec.ts new file mode 100644 index 0000000..edd9a6a --- /dev/null +++ b/src/features/order/resolvers/order-checkout-mutation.resolver.spec.ts @@ -0,0 +1,103 @@ +import type { PrismaClient } from '@prisma/client'; + +import { ClockService } from '@/common/providers/clock.service'; +import { RandomService } from '@/common/providers/random.service'; +import { OrderRepository } from '@/features/order/repositories/order.repository'; +import { OrderCheckoutMutationResolver } from '@/features/order/resolvers/order-checkout-mutation.resolver'; +import { OrderCheckoutService } from '@/features/order/services/order-checkout.service'; +import { ProductRepository } from '@/features/product'; +import { StorePickupScheduleService } from '@/features/store'; +import { StoreRepository } from '@/features/store/repositories/store.repository'; +import type { JwtUser } from '@/global/auth'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createProduct, + createStore, + createUserProfile, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +// 2026-09-16(수) 16:00 KST 고정 +const NOW = new Date('2026-09-16T07:00:00.000Z'); + +/** + * Resolver ↔ Service ↔ Repository ↔ DB 통합 경로 검증. + * 옵션·픽업·가격 분기 세부 검증은 service.spec.ts에서 담당. + */ +describe('OrderCheckout Mutation Resolver (real DB)', () => { + let resolver: OrderCheckoutMutationResolver; + let clock: ClockService; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + OrderCheckoutMutationResolver, + OrderCheckoutService, + OrderRepository, + ProductRepository, + StorePickupScheduleService, + StoreRepository, + ClockService, + RandomService, + ], + }); + resolver = module.get(OrderCheckoutMutationResolver); + 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(); + }); + + it('createOrder: ID 문자열을 파싱해 주문을 생성하고 요약을 반환한다', async () => { + const store = await createStore(prisma); + for (let dayOfWeek = 0; dayOfWeek < 7; dayOfWeek += 1) { + await prisma.storeBusinessHour.create({ + data: { + store_id: store.id, + day_of_week: dayOfWeek, + is_closed: false, + open_time: new Date(Date.UTC(1970, 0, 1, 10)), + close_time: new Date(Date.UTC(1970, 0, 1, 20)), + }, + }); + } + const product = await createProduct(prisma, { + store_id: store.id, + regular_price: 20000, + }); + const account = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { + account_id: account.id, + phone_number: '010-1111-2222', + }); + const user = { accountId: account.id.toString() } as JwtUser; + + const result = await resolver.createOrder(user, { + productId: product.id.toString(), + optionItemIds: [], + pickupAt: new Date('2026-09-18T05:00:00.000Z'), // 9/18(금) 14:00 KST + }); + + expect(result.status).toBe('SUBMITTED'); + expect(result.totalPrice).toBe(20000); + const saved = await prisma.order.findUniqueOrThrow({ + where: { id: BigInt(result.orderId) }, + }); + expect(saved.account_id).toBe(account.id); + }); +}); diff --git a/src/features/order/resolvers/order-checkout-mutation.resolver.ts b/src/features/order/resolvers/order-checkout-mutation.resolver.ts new file mode 100644 index 0000000..8d86b2b --- /dev/null +++ b/src/features/order/resolvers/order-checkout-mutation.resolver.ts @@ -0,0 +1,27 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Mutation, Resolver } from '@nestjs/graphql'; + +import { CreateOrderInput } from '@/features/order/dto/inputs/create-order.input'; +import { OrderCheckoutService } from '@/features/order/services/order-checkout.service'; +import type { CreateOrderOutput } from '@/features/order/types/create-order-output.type'; +import { + CurrentUser, + JwtAuthGuard, + parseAccountId, + type JwtUser, +} from '@/global/auth'; + +/** 주문 생성 resolver. 검증·가격 계산은 OrderCheckoutService 담당. */ +@Resolver('Mutation') +@UseGuards(JwtAuthGuard) +export class OrderCheckoutMutationResolver { + constructor(private readonly checkoutService: OrderCheckoutService) {} + + @Mutation('createOrder') + createOrder( + @CurrentUser() user: JwtUser, + @Args('input') input: CreateOrderInput, + ): Promise { + return this.checkoutService.createOrder(parseAccountId(user), input); + } +} diff --git a/src/features/order/services/order-checkout.service.spec.ts b/src/features/order/services/order-checkout.service.spec.ts new file mode 100644 index 0000000..54a2c50 --- /dev/null +++ b/src/features/order/services/order-checkout.service.spec.ts @@ -0,0 +1,467 @@ +import { + BadRequestException, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import type { Account, PrismaClient, Product, Store } from '@prisma/client'; + +import { ClockService } from '@/common/providers/clock.service'; +import { RandomService } from '@/common/providers/random.service'; +import type { CreateOrderInput } from '@/features/order/dto/inputs/create-order.input'; +import { OrderRepository } from '@/features/order/repositories/order.repository'; +import { OrderCheckoutService } from '@/features/order/services/order-checkout.service'; +import { ProductRepository } from '@/features/product'; +import { StorePickupScheduleService } from '@/features/store'; +import { StoreRepository } from '@/features/store/repositories/store.repository'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { + createAccount, + createOrder as createOrderRow, + createOrderItem, + createProduct, + createStore, + createUserProfile, +} from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +// 2026-09-16(수) 16:00 KST 고정 +const NOW = new Date('2026-09-16T07:00:00.000Z'); +/** 2026-09-18(금) 14:00 KST — 기본 유효 픽업 일시. */ +const VALID_PICKUP_AT = new Date('2026-09-18T05:00:00.000Z'); + +describe('OrderCheckoutService (real DB)', () => { + let service: OrderCheckoutService; + let clock: ClockService; + let random: RandomService; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + OrderCheckoutService, + OrderRepository, + ProductRepository, + StorePickupScheduleService, + StoreRepository, + ClockService, + RandomService, + ], + }); + service = module.get(OrderCheckoutService); + clock = module.get(ClockService); + random = module.get(RandomService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + jest.spyOn(clock, 'now').mockReturnValue(NOW); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + /** 전 요일 10~20시 영업 매장. */ + async function makeOpenStore(): Promise { + const store = await createStore(prisma, { + pickup_slot_interval_minutes: 30, + min_lead_time_minutes: 60, + }); + for (let dayOfWeek = 0; dayOfWeek < 7; dayOfWeek += 1) { + await prisma.storeBusinessHour.create({ + data: { + store_id: store.id, + day_of_week: dayOfWeek, + is_closed: false, + open_time: new Date(Date.UTC(1970, 0, 1, 10)), + close_time: new Date(Date.UTC(1970, 0, 1, 20)), + }, + }); + } + return store; + } + + /** 정가 30000/판매가 25000 상품 + 필수 사이즈(2종) + 선택 초(1종) 옵션. */ + async function makeProductWithOptions(storeId: bigint): Promise<{ + product: Product; + sizeSmallId: bigint; + sizeLargeId: bigint; + candleId: bigint; + }> { + const product = await createProduct(prisma, { + store_id: storeId, + regular_price: 30000, + sale_price: 25000, + }); + const sizeGroup = await prisma.productOptionGroup.create({ + data: { + product_id: product.id, + name: '사이즈', + is_required: true, + min_select: 1, + max_select: 1, + }, + }); + const sizeSmall = await prisma.productOptionItem.create({ + data: { + option_group_id: sizeGroup.id, + title: '도시락', + price_delta: 2000, + }, + }); + const sizeLarge = await prisma.productOptionItem.create({ + data: { option_group_id: sizeGroup.id, title: '1호', price_delta: 5000 }, + }); + const candleGroup = await prisma.productOptionGroup.create({ + data: { + product_id: product.id, + name: '디자인 초', + is_required: false, + min_select: 1, + max_select: 1, + }, + }); + const candle = await prisma.productOptionItem.create({ + data: { + option_group_id: candleGroup.id, + title: '곰돌이', + price_delta: 1000, + }, + }); + return { + product, + sizeSmallId: sizeSmall.id, + sizeLargeId: sizeLarge.id, + candleId: candle.id, + }; + } + + async function makeBuyer( + phone: string | null = '010-1234-5678', + nickname?: string, + ): Promise { + const account = await createAccount(prisma, { account_type: 'USER' }); + await createUserProfile(prisma, { + account_id: account.id, + ...(nickname ? { nickname } : {}), + phone_number: phone, + }); + return account; + } + + function baseInput(overrides: Partial): CreateOrderInput { + return { + productId: '0', + optionItemIds: [], + pickupAt: VALID_PICKUP_AT, + ...overrides, + }; + } + + describe('createOrder', () => { + it('주문을 생성하고 가격·옵션·상태 히스토리를 스냅샷한다', async () => { + const store = await makeOpenStore(); + const { product, sizeSmallId, candleId } = await makeProductWithOptions( + store.id, + ); + const buyer = await makeBuyer(); + + const result = await service.createOrder( + buyer.id, + baseInput({ + productId: product.id.toString(), + optionItemIds: [sizeSmallId.toString(), candleId.toString()], + quantity: 2, + buyerName: '차차', + buyerPhone: '010-0000-1111', + }), + ); + + // (25000 + 2000 + 1000) × 2 + expect(result.totalPrice).toBe(56000); + expect(result.status).toBe('SUBMITTED'); + expect(result.pickupAt).toEqual(VALID_PICKUP_AT); + expect(result.orderNumber).toMatch(/^ORD-20260916-[A-HJ-NP-Z2-9]{6}$/); + + const saved = await prisma.order.findUniqueOrThrow({ + where: { id: BigInt(result.orderId) }, + include: { + items: { include: { option_items: true } }, + status_histories: true, + }, + }); + expect(saved.status).toBe('SUBMITTED'); + expect(saved.submitted_at).toEqual(NOW); + expect(saved.buyer_name).toBe('차차'); + expect(saved.buyer_phone).toBe('010-0000-1111'); + expect(saved.subtotal_price).toBe(66000); // (30000+3000)×2 + expect(saved.discount_price).toBe(10000); // (30000-25000)×2 + expect(saved.total_price).toBe(56000); + + const [item] = saved.items; + expect(item.product_name_snapshot).toBe(product.name); + expect(item.regular_price_snapshot).toBe(30000); + expect(item.sale_price_snapshot).toBe(25000); + expect(item.quantity).toBe(2); + expect(item.item_subtotal_price).toBe(56000); + expect( + item.option_items.map((o) => o.option_title_snapshot).sort(), + ).toEqual(['곰돌이', '도시락']); + expect( + item.option_items.map((o) => o.option_price_delta_snapshot).sort(), + ).toEqual([1000, 2000]); + + expect(saved.status_histories).toHaveLength(1); + expect(saved.status_histories[0]).toMatchObject({ + from_status: null, + to_status: 'SUBMITTED', + }); + }); + + it('선택 그룹 미선택은 허용하고 판매가 없으면 정가 기준으로 계산한다', async () => { + const store = await makeOpenStore(); + const product = await createProduct(prisma, { + store_id: store.id, + regular_price: 20000, + sale_price: null, + }); + const buyer = await makeBuyer(); + + const result = await service.createOrder( + buyer.id, + baseInput({ productId: product.id.toString() }), + ); + + expect(result.totalPrice).toBe(20000); + const saved = await prisma.order.findUniqueOrThrow({ + where: { id: BigInt(result.orderId) }, + }); + expect(saved.subtotal_price).toBe(20000); + expect(saved.discount_price).toBe(0); + }); + + it('필수 그룹 누락·max 초과는 그룹 규칙 위반으로 거절한다', async () => { + const store = await makeOpenStore(); + const { product, sizeSmallId, sizeLargeId } = + await makeProductWithOptions(store.id); + const buyer = await makeBuyer(); + + await expect( + service.createOrder( + buyer.id, + baseInput({ productId: product.id.toString(), optionItemIds: [] }), + ), + ).rejects.toThrow(BadRequestException); + await expect( + service.createOrder( + buyer.id, + baseInput({ + productId: product.id.toString(), + optionItemIds: [sizeSmallId.toString(), sizeLargeId.toString()], + }), + ), + ).rejects.toThrow(BadRequestException); + }); + + it('타 상품 옵션·중복 옵션·비활성 옵션은 거절한다', async () => { + const store = await makeOpenStore(); + const { product, sizeSmallId } = await makeProductWithOptions(store.id); + const other = await makeProductWithOptions(store.id); + const buyer = await makeBuyer(); + + await expect( + service.createOrder( + buyer.id, + baseInput({ + productId: product.id.toString(), + optionItemIds: [other.sizeSmallId.toString()], + }), + ), + ).rejects.toThrow(BadRequestException); + await expect( + service.createOrder( + buyer.id, + baseInput({ + productId: product.id.toString(), + optionItemIds: [sizeSmallId.toString(), sizeSmallId.toString()], + }), + ), + ).rejects.toThrow(BadRequestException); + + // 비활성 아이템은 활성 조회에서 빠져 '타 상품 옵션'과 동일하게 거절된다 + await prisma.productOptionItem.update({ + where: { id: sizeSmallId }, + data: { is_active: false }, + }); + await expect( + service.createOrder( + buyer.id, + baseInput({ + productId: product.id.toString(), + optionItemIds: [sizeSmallId.toString()], + }), + ), + ).rejects.toThrow(BadRequestException); + }); + + it('없거나 비활성 상품·비활성 매장 상품은 NOT_FOUND다', async () => { + const buyer = await makeBuyer(); + const inactiveStore = await createStore(prisma, { is_active: false }); + const productInInactiveStore = await createProduct(prisma, { + store_id: inactiveStore.id, + }); + const inactiveProduct = await createProduct(prisma, { is_active: false }); + + await expect( + service.createOrder(buyer.id, baseInput({ productId: '999999' })), + ).rejects.toThrow(NotFoundException); + await expect( + service.createOrder( + buyer.id, + baseInput({ productId: inactiveProduct.id.toString() }), + ), + ).rejects.toThrow(NotFoundException); + await expect( + service.createOrder( + buyer.id, + baseInput({ productId: productInInactiveStore.id.toString() }), + ), + ).rejects.toThrow(NotFoundException); + }); + + it('주문자 정보 미입력 시 프로필로 채우고, 전화번호가 어디에도 없으면 거절한다', async () => { + const store = await makeOpenStore(); + const product = await createProduct(prisma, { store_id: store.id }); + const buyer = await makeBuyer('010-9999-8888', '주문자닉네임'); + + const result = await service.createOrder( + buyer.id, + baseInput({ productId: product.id.toString() }), + ); + const saved = await prisma.order.findUniqueOrThrow({ + where: { id: BigInt(result.orderId) }, + }); + expect(saved.buyer_name).toBe('주문자닉네임'); + expect(saved.buyer_phone).toBe('010-9999-8888'); + + const phonelessBuyer = await makeBuyer(null); + await expect( + service.createOrder( + phonelessBuyer.id, + baseInput({ productId: product.id.toString() }), + ), + ).rejects.toThrow(BadRequestException); + }); + + it('휴무일·슬롯 비정렬·과거 픽업 일시는 거절한다', async () => { + const store = await makeOpenStore(); + const product = await createProduct(prisma, { store_id: store.id }); + const buyer = await makeBuyer(); + await prisma.storeSpecialClosure.create({ + data: { + store_id: store.id, + closure_date: new Date(Date.UTC(2026, 8, 19)), + }, + }); + + // 휴무일(9/19 토) + await expect( + service.createOrder( + buyer.id, + baseInput({ + productId: product.id.toString(), + pickupAt: new Date('2026-09-19T05:00:00.000Z'), + }), + ), + ).rejects.toThrow(BadRequestException); + // 슬롯 비정렬(14:10) + await expect( + service.createOrder( + buyer.id, + baseInput({ + productId: product.id.toString(), + pickupAt: new Date('2026-09-18T05:10:00.000Z'), + }), + ), + ).rejects.toThrow(BadRequestException); + // 과거(9/15) + await expect( + service.createOrder( + buyer.id, + baseInput({ + productId: product.id.toString(), + pickupAt: new Date('2026-09-15T05:00:00.000Z'), + }), + ), + ).rejects.toThrow(BadRequestException); + }); + + it('capacity 잔여가 주문 수량보다 작으면 거절한다', async () => { + const store = await makeOpenStore(); + const product = await createProduct(prisma, { store_id: store.id }); + const buyer = await makeBuyer(); + await prisma.storeDailyCapacity.create({ + data: { + store_id: store.id, + capacity_date: new Date(Date.UTC(2026, 8, 18)), + capacity: 3, + }, + }); + // 기존 점유 2 → 잔여 1 + const existing = await createOrderRow(prisma, { + status: 'CONFIRMED', + pickup_at: VALID_PICKUP_AT, + }); + await createOrderItem(prisma, { + order_id: existing.id, + store_id: store.id, + quantity: 2, + }); + + await expect( + service.createOrder( + buyer.id, + baseInput({ productId: product.id.toString(), quantity: 2 }), + ), + ).rejects.toThrow(BadRequestException); + + const ok = await service.createOrder( + buyer.id, + baseInput({ productId: product.id.toString(), quantity: 1 }), + ); + expect(ok.status).toBe('SUBMITTED'); + }); + + it('주문번호 충돌 시 새 번호로 재시도하고, 계속 충돌하면 실패한다', async () => { + const store = await makeOpenStore(); + const product = await createProduct(prisma, { store_id: store.id }); + const buyer = await makeBuyer(); + // random.int()=0 → 'AAAAAA'. 선점된 번호와 충돌 후 두 번째 시도는 'BBBBBB'. + await createOrderRow(prisma, { order_number: 'ORD-20260916-AAAAAA' }); + + let calls = 0; + jest.spyOn(random, 'int').mockImplementation(() => (calls++ < 6 ? 0 : 1)); + const retried = await service.createOrder( + buyer.id, + baseInput({ productId: product.id.toString() }), + ); + expect(retried.orderNumber).toBe('ORD-20260916-BBBBBB'); + + // 항상 같은 번호만 나오면 재시도 소진 후 실패 + jest.spyOn(random, 'int').mockReturnValue(0); + await expect( + service.createOrder( + buyer.id, + baseInput({ productId: product.id.toString() }), + ), + ).rejects.toThrow(InternalServerErrorException); + }); + }); +}); diff --git a/src/features/order/services/order-checkout.service.ts b/src/features/order/services/order-checkout.service.ts new file mode 100644 index 0000000..bc8d4a7 --- /dev/null +++ b/src/features/order/services/order-checkout.service.ts @@ -0,0 +1,241 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; + +import { ClockService } from '@/common/providers/clock.service'; +import { RandomService } from '@/common/providers/random.service'; +import { parseId } from '@/common/utils/id-parser'; +import { formatKstDate } from '@/common/utils/kst-time'; +import { ORDER_CHECKOUT_ERRORS } from '@/features/order/constants/order-error-messages'; +import type { CreateOrderInput } from '@/features/order/dto/inputs/create-order.input'; +import { OrderRepository } from '@/features/order/repositories/order.repository'; +import type { CreateOrderOutput } from '@/features/order/types/create-order-output.type'; +import { ProductRepository, type ProductDetailRow } from '@/features/product'; +import { StorePickupScheduleService } from '@/features/store'; + +// 0/O·1/I 등 혼동 문자를 뺀 대문자 영숫자. 주문번호 무작위부에 사용. +const ORDER_NUMBER_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; +const ORDER_NUMBER_RANDOM_LENGTH = 6; +// unique 충돌은 확률적으로 희박 — 소수 재시도로 충분하다 +const ORDER_NUMBER_MAX_ATTEMPTS = 3; + +/** 옵션 검증 결과(스냅샷 조립용). */ +interface ResolvedOptionSelection { + optionGroupId: bigint; + optionItemId: bigint; + groupNameSnapshot: string; + optionTitleSnapshot: string; + optionPriceDeltaSnapshot: number; +} + +@Injectable() +export class OrderCheckoutService { + constructor( + private readonly orderRepo: OrderRepository, + private readonly productRepo: ProductRepository, + private readonly pickupSchedule: StorePickupScheduleService, + private readonly clock: ClockService, + private readonly random: RandomService, + ) {} + + /** + * 주문 생성(정식 API의 확정 부분집합 — 커스텀 입력은 스펙 확정 후 확장). + * 옵션 그룹 규칙·픽업 일시를 서버가 재검증하고 가격을 스냅샷한다. + */ + async createOrder( + accountId: bigint, + input: CreateOrderInput, + ): Promise { + const productId = parseId(input.productId); + const optionItemIds = input.optionItemIds.map((id) => parseId(id)); + const quantity = input.quantity ?? 1; + + const product = await this.productRepo.findProductDetailById(productId); + if (!product) { + throw new NotFoundException(ORDER_CHECKOUT_ERRORS.PRODUCT_NOT_FOUND); + } + + const selections = this.resolveOptionSelections(product, optionItemIds); + const buyer = await this.resolveBuyer(accountId, input); + + const pickupAvailable = await this.pickupSchedule.isPickupSlotAvailable({ + storeId: product.store_id, + pickupAt: input.pickupAt, + additionalQuantity: quantity, + }); + if (!pickupAvailable) { + throw new BadRequestException(ORDER_CHECKOUT_ERRORS.PICKUP_NOT_AVAILABLE); + } + + // 가격 스냅샷: FE 제출 금액은 신뢰하지 않고 서버가 재계산한다. + // subtotal은 정가 기준, discount는 정가-판매가 차액 → total = 판매가 기준. + const deltaSum = selections.reduce( + (sum, selection) => sum + selection.optionPriceDeltaSnapshot, + 0, + ); + const effectivePrice = product.sale_price ?? product.regular_price; + const subtotalPrice = (product.regular_price + deltaSum) * quantity; + const discountPrice = (product.regular_price - effectivePrice) * quantity; + const itemSubtotalPrice = (effectivePrice + deltaSum) * quantity; + + const submittedAt = this.clock.now(); + const created = await this.createWithOrderNumberRetry({ + accountId, + pickupAt: input.pickupAt, + buyerName: buyer.name, + buyerPhone: buyer.phone, + subtotalPrice, + discountPrice, + totalPrice: itemSubtotalPrice, + submittedAt, + item: { + storeId: product.store_id, + productId: product.id, + productNameSnapshot: product.name, + regularPriceSnapshot: product.regular_price, + salePriceSnapshot: product.sale_price, + quantity, + itemSubtotalPrice, + options: selections, + }, + }); + + return { + orderId: created.id.toString(), + orderNumber: created.order_number, + status: created.status, + pickupAt: created.pickup_at, + totalPrice: created.total_price, + }; + } + + /** + * 옵션 선택 검증. 중복·타 상품 옵션을 거절하고 그룹 규칙을 확인한다. + * 명세 외 정책 결정: 필수 그룹은 min~max개 선택, 선택 그룹은 0개 또는 min~max개. + */ + private resolveOptionSelections( + product: ProductDetailRow, + optionItemIds: bigint[], + ): ResolvedOptionSelection[] { + const uniqueIds = new Set(optionItemIds.map((id) => id.toString())); + if (uniqueIds.size !== optionItemIds.length) { + throw new BadRequestException( + ORDER_CHECKOUT_ERRORS.DUPLICATE_OPTION_ITEM, + ); + } + + const selectionByItemId = new Map(); + const groupIdByItemId = new Map(); + for (const group of product.option_groups) { + for (const item of group.option_items) { + selectionByItemId.set(item.id.toString(), { + optionGroupId: group.id, + optionItemId: item.id, + groupNameSnapshot: group.name, + optionTitleSnapshot: item.title, + optionPriceDeltaSnapshot: item.price_delta, + }); + groupIdByItemId.set(item.id.toString(), group.id.toString()); + } + } + + const countByGroupId = new Map(); + const selections = optionItemIds.map((id) => { + const selection = selectionByItemId.get(id.toString()); + if (!selection) { + throw new BadRequestException( + ORDER_CHECKOUT_ERRORS.INVALID_OPTION_ITEM, + ); + } + const groupId = groupIdByItemId.get(id.toString()); + if (groupId !== undefined) { + countByGroupId.set(groupId, (countByGroupId.get(groupId) ?? 0) + 1); + } + return selection; + }); + + for (const group of product.option_groups) { + const count = countByGroupId.get(group.id.toString()) ?? 0; + const withinRange = + count >= group.min_select && count <= group.max_select; + const valid = group.is_required + ? withinRange + : count === 0 || withinRange; + if (!valid) { + throw new BadRequestException( + ORDER_CHECKOUT_ERRORS.OPTION_GROUP_RULE_VIOLATION, + ); + } + } + return selections; + } + + /** 주문자 정보: input 우선, 없으면 프로필(닉네임·전화번호) fallback. */ + private async resolveBuyer( + accountId: bigint, + input: CreateOrderInput, + ): Promise<{ name: string; phone: string }> { + if (input.buyerName && input.buyerPhone) { + return { name: input.buyerName, phone: input.buyerPhone }; + } + const profile = await this.orderRepo.findBuyerProfile(accountId); + const name = input.buyerName ?? profile?.nickname; + const phone = input.buyerPhone ?? profile?.phone_number ?? undefined; + if (!name) { + throw new BadRequestException(ORDER_CHECKOUT_ERRORS.BUYER_NAME_REQUIRED); + } + if (!phone) { + throw new BadRequestException(ORDER_CHECKOUT_ERRORS.BUYER_PHONE_REQUIRED); + } + return { name, phone }; + } + + /** 주문번호 unique 충돌(P2002) 시 새 번호로 소수 재시도. */ + private async createWithOrderNumberRetry( + args: Omit< + Parameters[0], + 'orderNumber' + >, + ) { + for (let attempt = 0; attempt < ORDER_NUMBER_MAX_ATTEMPTS; attempt += 1) { + try { + return await this.orderRepo.createSubmittedOrder({ + ...args, + orderNumber: this.generateOrderNumber(args.submittedAt), + }); + } catch (error) { + const isUniqueViolation = + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002'; + if (!isUniqueViolation || attempt === ORDER_NUMBER_MAX_ATTEMPTS - 1) { + if (isUniqueViolation) { + throw new InternalServerErrorException( + ORDER_CHECKOUT_ERRORS.ORDER_NUMBER_GENERATION_FAILED, + ); + } + throw error; + } + } + } + // 루프는 반환/throw로만 종료된다 — 타입 좁히기용 방어 + throw new InternalServerErrorException( + ORDER_CHECKOUT_ERRORS.ORDER_NUMBER_GENERATION_FAILED, + ); + } + + /** 주문번호: ORD-YYYYMMDD-XXXXXX (KST 날짜 + 혼동 문자 제외 랜덤 6자리). */ + private generateOrderNumber(at: Date): string { + const datePart = formatKstDate(at).replaceAll('-', ''); + let randomPart = ''; + for (let i = 0; i < ORDER_NUMBER_RANDOM_LENGTH; i += 1) { + randomPart += ORDER_NUMBER_ALPHABET.charAt( + this.random.int(ORDER_NUMBER_ALPHABET.length), + ); + } + return `ORD-${datePart}-${randomPart}`; + } +} diff --git a/src/features/order/types/create-order-output.type.ts b/src/features/order/types/create-order-output.type.ts new file mode 100644 index 0000000..902d232 --- /dev/null +++ b/src/features/order/types/create-order-output.type.ts @@ -0,0 +1,13 @@ +import type { OrderStatus } from '@prisma/client'; + +/** + * createOrder resolver 반환용 도메인 출력 타입. + * SDL(order-checkout.graphql)의 CreateOrderOutput과 필드 일치. + */ +export interface CreateOrderOutput { + orderId: string; + orderNumber: string; + status: OrderStatus; + pickupAt: Date; + totalPrice: number; +} diff --git a/src/features/product/index.ts b/src/features/product/index.ts index 332d872..890de72 100644 --- a/src/features/product/index.ts +++ b/src/features/product/index.ts @@ -1,3 +1,7 @@ // cross-feature 공개 API. 단일 구현 repo라 토큰/인터페이스 없이 구체 클래스로 주입(의도적). export { ProductModule } from '@/features/product/product.module'; -export { ProductRepository } from '@/features/product/repositories/product.repository'; +export { + ProductRepository, + // 주문 생성(order feature)의 옵션 검증·가격 스냅샷 입력 타입 + type ProductDetailRow, +} from '@/features/product/repositories/product.repository'; diff --git a/src/features/store/index.ts b/src/features/store/index.ts index 591ead9..297af84 100644 --- a/src/features/store/index.ts +++ b/src/features/store/index.ts @@ -8,6 +8,9 @@ export { RANKING_VALID_ORDER_STATUSES, } from '@/features/store/constants/store-ranking.constants'; export { buildRegionLabel } from '@/features/store/services/store-mappers.helper'; +// 매장 픽업 가능 판정. 주문 생성(order feature)이 픽업 일시 재검증에 사용한다 — +// 판정 규칙은 store feature에 유지한다(달력·슬롯 조회와 단일 소스). +export { StorePickupScheduleService } from '@/features/store/services/store-pickup-schedule.service'; export { popularityScore, type StoreMetrics, diff --git a/src/features/store/services/store-pickup-schedule.service.spec.ts b/src/features/store/services/store-pickup-schedule.service.spec.ts index 27fc3a5..b574d40 100644 --- a/src/features/store/services/store-pickup-schedule.service.spec.ts +++ b/src/features/store/services/store-pickup-schedule.service.spec.ts @@ -448,4 +448,119 @@ describe('StorePickupScheduleService (real DB)', () => { ).rejects.toThrow(NotFoundException); }); }); + + describe('isPickupSlotAvailable', () => { + /** 9/18(금) 14:00 KST — 전 요일 10~20시 영업 기준 유효 슬롯. */ + const VALID_PICKUP_AT = new Date('2026-09-18T05:00:00.000Z'); + + it('영업시간 내 정렬된 미래 슬롯은 가능하다', async () => { + const store = await createStore(prisma); + await openAllWeek(store); + + await expect( + service.isPickupSlotAvailable({ + storeId: store.id, + pickupAt: VALID_PICKUP_AT, + }), + ).resolves.toBe(true); + }); + + it('초 이하가 남거나 슬롯 간격에 정렬되지 않은 시각은 불가하다', async () => { + const store = await createStore(prisma); + await openAllWeek(store); + + await expect( + service.isPickupSlotAvailable({ + storeId: store.id, + pickupAt: new Date('2026-09-18T05:00:30.000Z'), + }), + ).resolves.toBe(false); + await expect( + service.isPickupSlotAvailable({ + storeId: store.id, + pickupAt: new Date('2026-09-18T05:10:00.000Z'), + }), + ).resolves.toBe(false); + // 영업시간 밖(21:00 KST) + await expect( + service.isPickupSlotAvailable({ + storeId: store.id, + pickupAt: new Date('2026-09-18T12:00:00.000Z'), + }), + ).resolves.toBe(false); + }); + + it('당일 리드타임 이전 슬롯·특별휴무일은 불가하다', async () => { + const store = await createStore(prisma, { min_lead_time_minutes: 60 }); + await openAllWeek(store); + await prisma.storeSpecialClosure.create({ + data: { + store_id: store.id, + closure_date: new Date(Date.UTC(2026, 8, 18)), + }, + }); + + // 오늘(9/16) 16:30 KST — 현재 16:00 + 리드 60분 미달 + await expect( + service.isPickupSlotAvailable({ + storeId: store.id, + pickupAt: new Date('2026-09-16T07:30:00.000Z'), + }), + ).resolves.toBe(false); + // 오늘 17:00 KST — 리드타임 충족 + await expect( + service.isPickupSlotAvailable({ + storeId: store.id, + pickupAt: new Date('2026-09-16T08:00:00.000Z'), + }), + ).resolves.toBe(true); + // 휴무일(9/18) + await expect( + service.isPickupSlotAvailable({ + storeId: store.id, + pickupAt: VALID_PICKUP_AT, + }), + ).resolves.toBe(false); + }); + + it('capacity 잔여가 additionalQuantity보다 작으면 불가하다', async () => { + const store = await createStore(prisma); + await openAllWeek(store); + await setCapacity(store, new Date(Date.UTC(2026, 8, 18)), 3); + await book(store, VALID_PICKUP_AT, 2); + + await expect( + service.isPickupSlotAvailable({ + storeId: store.id, + pickupAt: VALID_PICKUP_AT, + additionalQuantity: 2, + }), + ).resolves.toBe(false); + await expect( + service.isPickupSlotAvailable({ + storeId: store.id, + pickupAt: VALID_PICKUP_AT, + additionalQuantity: 1, + }), + ).resolves.toBe(true); + }); + + it('없거나 비활성 매장은 불가하다', async () => { + const inactive = await createStore(prisma, { is_active: false }); + await openAllWeek(inactive); + + await expect( + service.isPickupSlotAvailable({ + storeId: 999999n, + pickupAt: VALID_PICKUP_AT, + }), + ).resolves.toBe(false); + await expect( + service.isPickupSlotAvailable({ + storeId: inactive.id, + pickupAt: VALID_PICKUP_AT, + }), + ).resolves.toBe(false); + }); + }); }); diff --git a/src/features/store/services/store-pickup-schedule.service.ts b/src/features/store/services/store-pickup-schedule.service.ts index 11350f1..1d3ec5c 100644 --- a/src/features/store/services/store-pickup-schedule.service.ts +++ b/src/features/store/services/store-pickup-schedule.service.ts @@ -179,6 +179,72 @@ export class StorePickupScheduleService { }; } + /** + * 특정 픽업 일시가 예약 가능한지 판정한다(주문 생성 재검증용). + * 달력·시간 슬롯과 동일 규칙에 더해 슬롯 시작 시각 정합과 + * capacity 잔여(기존 점유 + additionalQuantity ≤ capacity)를 확인한다. + * 매장이 없거나 비활성이면 false(존재 검증은 호출부 책임). + */ + async isPickupSlotAvailable(args: { + storeId: bigint; + pickupAt: Date; + additionalQuantity?: number; + }): Promise { + const store = await this.repo.findStoreForPickupSchedule(args.storeId); + if (!store) return false; + + // 슬롯은 분 단위 시작 시각 포인트 — 초 이하가 남아 있으면 슬롯 정합 실패 + if ( + args.pickupAt.getUTCSeconds() !== 0 || + args.pickupAt.getUTCMilliseconds() !== 0 + ) { + return false; + } + + const now = this.clock.now(); + const { year, month, day } = toKstYmd(args.pickupAt); + if (year < MIN_SCHEDULE_YEAR || year > MAX_SCHEDULE_YEAR) return false; + + const dateOnlyUtc = new Date(Date.UTC(year, month - 1, day)); + const ctx = await this.loadScheduleContext( + store.id, + dateOnlyUtc, + new Date(Date.UTC(year, month - 1, day + 1)), + kstMidnightUtc(year, month, day), + kstMidnightUtc(year, month, day + 1), + ); + + if (this.evaluateDay(store, ctx, now, year, month, day) !== null) { + return false; + } + + // capacity 잔여: 이번 주문 수량까지 더해 초과하면 불가 + // (명세 외 정책 결정: capacity는 일일 제작 '수량' 소진 모델과 일관되게 해석) + const dateKey = dateOnlyUtc.toISOString().slice(0, 10); + const capacity = ctx.capacities.get(dateKey); + const booked = ctx.bookedByDate.get(dateKey) ?? 0; + const quantity = args.additionalQuantity ?? 1; + if (capacity !== undefined && booked + quantity > capacity) return false; + + const hour = ctx.hoursByWeekday.get(dateOnlyUtc.getUTCDay()); + if (!hour || hour.is_closed || !hour.open_time || !hour.close_time) { + return false; + } + + const isToday = kstDayDiff(now, args.pickupAt) === 0; + const slots = this.buildDaySlots( + store, + hour.open_time, + hour.close_time, + isToday, + now, + ); + const pickupMinutes = kstMinutesOfDay(args.pickupAt); + return slots.some( + (slot) => slot.available && slotMinutes(slot) === pickupMinutes, + ); + } + private async loadScheduleContext( storeId: bigint, fromDateOnly: Date, diff --git a/src/features/store/store.module.ts b/src/features/store/store.module.ts index 1a5c15a..47c7e7a 100644 --- a/src/features/store/store.module.ts +++ b/src/features/store/store.module.ts @@ -34,6 +34,7 @@ import { StoreWishlistService } from '@/features/store/services/store-wishlist.s StorePickupScheduleService, StorePickupScheduleQueryResolver, ], - exports: [StoreRepository], + // StorePickupScheduleService는 주문 생성(order feature)의 픽업 일시 재검증이 소비한다 + exports: [StoreRepository, StorePickupScheduleService], }) export class StoreModule {} From bcb59eca254f8945e6cc7b79f1dbd5b0ecb34b4a Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Tue, 25 Aug 2026 05:44:32 +0900 Subject: [PATCH 2/7] =?UTF-8?q?fix(order):=20=EC=A3=BC=EB=AC=B8=20?= =?UTF-8?q?=EA=B8=88=EC=95=A1=20=EB=B2=94=EC=9C=84=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?+=20capacity=20=EC=9B=90=EC=9E=90=20=EC=98=88=EC=95=BD=20(Codex?= =?UTF-8?q?=20P1=20=EB=B0=98=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 커밋 전 금액 검증: subtotal/discount/itemSubtotal이 음수·비안전 정수· GraphQL Int 상한(2,147,483,647) 초과면 거절 — 저장 후 응답 직렬화 실패로 인한 재시도 중복 주문 경로 차단, 음수 델타 언더플로 방지 - createSubmittedOrder를 트랜잭션화하고 capacity 행 FOR UPDATE 잠금 후 점유 재집계 — 동시 주문이 마지막 잔여를 함께 차지하는 race 차단 (초과 시 null 반환 → 서비스가 BadRequest 변환) - 회귀 테스트 3건 추가: 32비트 초과/음수 금액 거절, 동시 주문 2건 중 정확히 1건만 성공(real DB 동시성) --- .../order/constants/order-error-messages.ts | 1 + .../order/repositories/order.repository.ts | 76 ++++++++++++++++- .../services/order-checkout.service.spec.ts | 83 +++++++++++++++++++ .../order/services/order-checkout.service.ts | 46 +++++++++- 4 files changed, 202 insertions(+), 4 deletions(-) diff --git a/src/features/order/constants/order-error-messages.ts b/src/features/order/constants/order-error-messages.ts index 365f1cb..6b81019 100644 --- a/src/features/order/constants/order-error-messages.ts +++ b/src/features/order/constants/order-error-messages.ts @@ -5,6 +5,7 @@ export const ORDER_CHECKOUT_ERRORS = { INVALID_OPTION_ITEM: '해당 상품의 옵션이 아닙니다.', OPTION_GROUP_RULE_VIOLATION: '옵션 그룹의 선택 규칙을 충족하지 않습니다.', PICKUP_NOT_AVAILABLE: '선택한 픽업 일시는 예약할 수 없습니다.', + ORDER_AMOUNT_OUT_OF_RANGE: '주문 금액이 처리 가능한 범위를 벗어났습니다.', BUYER_NAME_REQUIRED: '주문자 이름이 필요합니다.', BUYER_PHONE_REQUIRED: '주문자 연락처가 필요합니다. 프로필에 전화번호를 등록하거나 입력해 주세요.', diff --git a/src/features/order/repositories/order.repository.ts b/src/features/order/repositories/order.repository.ts index 9bff65a..a8992b0 100644 --- a/src/features/order/repositories/order.repository.ts +++ b/src/features/order/repositories/order.repository.ts @@ -5,6 +5,7 @@ import { NotificationEvent, NotificationType, OrderStatus, + Prisma, } from '@prisma/client'; import { PrismaService } from '@/prisma'; @@ -41,6 +42,19 @@ export interface OngoingOrderRow { }[]; } +/** + * 일일 capacity 원자 검사 조건. 트랜잭션 안에서 capacity 행을 잠그고 + * 점유를 재집계해 검사-삽입 race로 capacity가 초과되는 것을 막는다. + */ +export interface DailyCapacityGuard { + storeId: bigint; + /** @db.Date 비교용(해당 KST 달력일의 UTC 자정 표현). */ + dateOnlyUtc: Date; + /** pickup_at 범위 비교용 KST 자정 경계. */ + dayStartUtc: Date; + dayEndUtc: Date; +} + /** 주문 생성 입력(스냅샷 값은 서비스가 계산해 전달). */ export interface CreateSubmittedOrderArgs { accountId: bigint; @@ -52,6 +66,8 @@ export interface CreateSubmittedOrderArgs { discountPrice: number; totalPrice: number; submittedAt: Date; + /** null이면 capacity 원자 검사 생략(호출부가 무제한으로 판단한 경우는 없음 — 항상 전달 권장). */ + capacityGuard: DailyCapacityGuard | null; item: { storeId: bigint; productId: bigint; @@ -110,14 +126,70 @@ export class OrderRepository { /** * SUBMITTED 주문 생성. Order + OrderItem + 옵션 스냅샷 + 상태 히스토리를 - * 중첩 create 한 번으로 원자적으로 만든다. SUBMITTED는 알림 미발송 + * 트랜잭션으로 원자 생성한다. SUBMITTED는 알림 미발송 * (알림은 판매자 상태 변경부터 — orderStatusToNotificationEvent 규칙). + * capacityGuard가 있으면 capacity 행을 FOR UPDATE로 잠근 뒤 점유를 + * 재집계해, 동시 주문이 마지막 잔여를 함께 차지하는 race를 차단한다. + * capacity 초과면 null을 반환한다(호출부가 도메인 에러로 변환). * order_number unique 충돌(P2002)은 호출부가 재시도한다. */ async createSubmittedOrder( args: CreateSubmittedOrderArgs, + ): Promise { + return this.prisma.$transaction(async (tx) => { + if (args.capacityGuard) { + const exceeded = await this.isCapacityExceededLocked( + tx, + args.capacityGuard, + args.item.quantity, + ); + if (exceeded) return null; + } + return this.insertSubmittedOrder(tx, args); + }); + } + + /** + * capacity 행 잠금 후 잔여 재검사. 레코드가 없으면 무제한(검사 통과). + * FOR UPDATE는 같은 매장·날짜의 동시 주문 생성을 직렬화한다. + */ + private async isCapacityExceededLocked( + tx: Prisma.TransactionClient, + guard: DailyCapacityGuard, + quantity: number, + ): Promise { + const capacityRows = await tx.$queryRaw<{ capacity: number }[]>(Prisma.sql` + SELECT capacity + FROM store_daily_capacity + WHERE store_id = ${guard.storeId} + AND capacity_date = ${guard.dateOnlyUtc} + AND deleted_at IS NULL + FOR UPDATE + `); + const capacity = capacityRows[0]?.capacity; + if (capacity === undefined) return false; + + const bookedRows = await tx.$queryRaw<{ booked: bigint }[]>(Prisma.sql` + SELECT CAST(COALESCE(SUM(oi.quantity), 0) AS UNSIGNED) AS booked + FROM order_item oi + JOIN \`order\` o + ON o.id = oi.order_id + AND o.deleted_at IS NULL + AND o.status <> 'CANCELED' + AND o.pickup_at >= ${guard.dayStartUtc} + AND o.pickup_at < ${guard.dayEndUtc} + WHERE oi.store_id = ${guard.storeId} + AND oi.deleted_at IS NULL + `); + const booked = Number(bookedRows[0]?.booked ?? 0); + return booked + quantity > capacity; + } + + private async insertSubmittedOrder( + tx: Prisma.TransactionClient, + args: CreateSubmittedOrderArgs, ): Promise { - return this.prisma.order.create({ + return tx.order.create({ data: { account_id: args.accountId, order_number: args.orderNumber, diff --git a/src/features/order/services/order-checkout.service.spec.ts b/src/features/order/services/order-checkout.service.spec.ts index 54a2c50..bda4750 100644 --- a/src/features/order/services/order-checkout.service.spec.ts +++ b/src/features/order/services/order-checkout.service.spec.ts @@ -439,6 +439,89 @@ describe('OrderCheckoutService (real DB)', () => { expect(ok.status).toBe('SUBMITTED'); }); + it('32비트 초과·음수 금액은 커밋 전에 거절한다', async () => { + const store = await makeOpenStore(); + const buyer = await makeBuyer(); + // 10억 × 3 = 30억 → GraphQL Int(2,147,483,647) 초과 + const expensive = await createProduct(prisma, { + store_id: store.id, + regular_price: 1_000_000_000, + }); + await expect( + service.createOrder( + buyer.id, + baseInput({ productId: expensive.id.toString(), quantity: 3 }), + ), + ).rejects.toThrow(BadRequestException); + + // 음수 델타가 상품가를 초과 → 음수 금액 + const cheap = await createProduct(prisma, { + store_id: store.id, + regular_price: 20000, + }); + const group = await prisma.productOptionGroup.create({ + data: { + product_id: cheap.id, + name: '할인', + is_required: true, + min_select: 1, + max_select: 1, + }, + }); + const negativeItem = await prisma.productOptionItem.create({ + data: { + option_group_id: group.id, + title: '과도한 할인', + price_delta: -30000, + }, + }); + await expect( + service.createOrder( + buyer.id, + baseInput({ + productId: cheap.id.toString(), + optionItemIds: [negativeItem.id.toString()], + }), + ), + ).rejects.toThrow(BadRequestException); + }); + + it('동시 주문이 마지막 capacity 잔여를 함께 차지하지 못한다', async () => { + const store = await makeOpenStore(); + const product = await createProduct(prisma, { store_id: store.id }); + const buyerA = await makeBuyer(); + const buyerB = await makeBuyer(); + await prisma.storeDailyCapacity.create({ + data: { + store_id: store.id, + capacity_date: new Date(Date.UTC(2026, 8, 18)), + capacity: 1, + }, + }); + + // 둘 다 사전 검사는 통과하지만, 트랜잭션 내 FOR UPDATE 재검사가 + // 직렬화해 정확히 한 건만 성공해야 한다 + const results = await Promise.allSettled([ + service.createOrder( + buyerA.id, + baseInput({ productId: product.id.toString() }), + ), + service.createOrder( + buyerB.id, + baseInput({ productId: product.id.toString() }), + ), + ]); + + const fulfilled = results.filter((r) => r.status === 'fulfilled'); + const rejected = results.filter((r) => r.status === 'rejected'); + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + const submittedCount = await prisma.order.count({ + where: { status: 'SUBMITTED', deleted_at: null }, + }); + expect(submittedCount).toBe(1); + }); + it('주문번호 충돌 시 새 번호로 재시도하고, 계속 충돌하면 실패한다', async () => { const store = await makeOpenStore(); const product = await createProduct(prisma, { store_id: store.id }); diff --git a/src/features/order/services/order-checkout.service.ts b/src/features/order/services/order-checkout.service.ts index bc8d4a7..425e737 100644 --- a/src/features/order/services/order-checkout.service.ts +++ b/src/features/order/services/order-checkout.service.ts @@ -9,7 +9,11 @@ import { Prisma } from '@prisma/client'; import { ClockService } from '@/common/providers/clock.service'; import { RandomService } from '@/common/providers/random.service'; import { parseId } from '@/common/utils/id-parser'; -import { formatKstDate } from '@/common/utils/kst-time'; +import { + formatKstDate, + kstMidnightUtc, + toKstYmd, +} from '@/common/utils/kst-time'; import { ORDER_CHECKOUT_ERRORS } from '@/features/order/constants/order-error-messages'; import type { CreateOrderInput } from '@/features/order/dto/inputs/create-order.input'; import { OrderRepository } from '@/features/order/repositories/order.repository'; @@ -23,6 +27,11 @@ const ORDER_NUMBER_RANDOM_LENGTH = 6; // unique 충돌은 확률적으로 희박 — 소수 재시도로 충분하다 const ORDER_NUMBER_MAX_ATTEMPTS = 3; +// GraphQL Int는 signed 32비트. 커밋 전에 금액을 이 범위로 제한해 +// "저장은 됐는데 응답 직렬화에서 실패 → 재시도 중복 주문" 경로를 차단한다. +const MAX_ORDER_AMOUNT = 2_147_483_647; +const DAY_MS = 24 * 60 * 60 * 1000; + /** 옵션 검증 결과(스냅샷 조립용). */ interface ResolvedOptionSelection { optionGroupId: bigint; @@ -81,6 +90,19 @@ export class OrderCheckoutService { const subtotalPrice = (product.regular_price + deltaSum) * quantity; const discountPrice = (product.regular_price - effectivePrice) * quantity; const itemSubtotalPrice = (effectivePrice + deltaSum) * quantity; + // 음수(과도한 음수 델타·판매가>정가 이상 데이터)나 32비트 초과 금액은 + // unsigned 컬럼/GraphQL Int에서 깨진다 — 커밋 전에 거절한다 + for (const amount of [subtotalPrice, discountPrice, itemSubtotalPrice]) { + if ( + !Number.isSafeInteger(amount) || + amount < 0 || + amount > MAX_ORDER_AMOUNT + ) { + throw new BadRequestException( + ORDER_CHECKOUT_ERRORS.ORDER_AMOUNT_OUT_OF_RANGE, + ); + } + } const submittedAt = this.clock.now(); const created = await this.createWithOrderNumberRetry({ @@ -92,6 +114,7 @@ export class OrderCheckoutService { discountPrice, totalPrice: itemSubtotalPrice, submittedAt, + capacityGuard: this.buildCapacityGuard(product.store_id, input.pickupAt), item: { storeId: product.store_id, productId: product.id, @@ -113,6 +136,18 @@ export class OrderCheckoutService { }; } + /** capacity 원자 검사 조건(픽업 KST 달력일 기준). */ + private buildCapacityGuard(storeId: bigint, pickupAt: Date) { + const { year, month, day } = toKstYmd(pickupAt); + const dayStartUtc = kstMidnightUtc(year, month, day); + return { + storeId, + dateOnlyUtc: new Date(Date.UTC(year, month - 1, day)), + dayStartUtc, + dayEndUtc: new Date(dayStartUtc.getTime() + DAY_MS), + }; + } + /** * 옵션 선택 검증. 중복·타 상품 옵션을 거절하고 그룹 규칙을 확인한다. * 명세 외 정책 결정: 필수 그룹은 min~max개 선택, 선택 그룹은 0개 또는 min~max개. @@ -203,10 +238,17 @@ export class OrderCheckoutService { ) { for (let attempt = 0; attempt < ORDER_NUMBER_MAX_ATTEMPTS; attempt += 1) { try { - return await this.orderRepo.createSubmittedOrder({ + const created = await this.orderRepo.createSubmittedOrder({ ...args, orderNumber: this.generateOrderNumber(args.submittedAt), }); + if (created === null) { + // 트랜잭션 내 capacity 재검사에서 잔여 부족 판정(동시 주문 race 차단) + throw new BadRequestException( + ORDER_CHECKOUT_ERRORS.PICKUP_NOT_AVAILABLE, + ); + } + return created; } catch (error) { const isUniqueViolation = error instanceof Prisma.PrismaClientKnownRequestError && From bc8ec27329666cc8d6946e1de68926c1c664860e Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Tue, 25 Aug 2026 05:53:36 +0900 Subject: [PATCH 3/7] =?UTF-8?q?fix(order):=20=EC=A0=9C=EC=9E=91=20?= =?UTF-8?q?=EC=86=8C=EC=9A=94=EC=8B=9C=EA=B0=84=20=EA=B0=95=EC=A0=9C=20+?= =?UTF-8?q?=20=EB=B9=84=20KRW=20=ED=86=B5=ED=99=94=20=EA=B1=B0=EC=A0=88=20?= =?UTF-8?q?(Codex=20P1=20=EB=B0=98=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - findProductDetailById에 preparation_time_minutes·currency 활용 추가, 체크아웃에서 pickupAt ≥ now + 제작 소요시간을 매장 리드타임과 별개로 강제 - Order/OrderItem에 통화 스냅샷 컬럼이 없어 비 KRW 상품은 주문 거절 (명세 외 정책 결정 — 다국통화 스냅샷은 후속 이슈로 추적) - 상품 팩토리에 currency·preparation_time_minutes override 추가 (테스트 기본 제작시간 0분 — 스키마 기본 180분은 당일 픽업 테스트 방해) - 회귀 테스트 2건 추가 (제작시간 미달 거절/충족 허용, USD 상품 거절) --- .../order/constants/order-error-messages.ts | 1 + .../services/order-checkout.service.spec.ts | 42 +++++++++++++++++++ .../order/services/order-checkout.service.ts | 23 +++++++--- .../repositories/product.repository.ts | 3 ++ src/test/factories/product.factory.ts | 5 +++ 5 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/features/order/constants/order-error-messages.ts b/src/features/order/constants/order-error-messages.ts index 6b81019..bdd5747 100644 --- a/src/features/order/constants/order-error-messages.ts +++ b/src/features/order/constants/order-error-messages.ts @@ -6,6 +6,7 @@ export const ORDER_CHECKOUT_ERRORS = { OPTION_GROUP_RULE_VIOLATION: '옵션 그룹의 선택 규칙을 충족하지 않습니다.', PICKUP_NOT_AVAILABLE: '선택한 픽업 일시는 예약할 수 없습니다.', ORDER_AMOUNT_OUT_OF_RANGE: '주문 금액이 처리 가능한 범위를 벗어났습니다.', + UNSUPPORTED_CURRENCY: 'KRW 상품만 주문할 수 있습니다.', BUYER_NAME_REQUIRED: '주문자 이름이 필요합니다.', BUYER_PHONE_REQUIRED: '주문자 연락처가 필요합니다. 프로필에 전화번호를 등록하거나 입력해 주세요.', diff --git a/src/features/order/services/order-checkout.service.spec.ts b/src/features/order/services/order-checkout.service.spec.ts index bda4750..becbe54 100644 --- a/src/features/order/services/order-checkout.service.spec.ts +++ b/src/features/order/services/order-checkout.service.spec.ts @@ -439,6 +439,48 @@ describe('OrderCheckoutService (real DB)', () => { expect(ok.status).toBe('SUBMITTED'); }); + it('상품 제작 소요시간 이전 픽업 일시는 거절한다', async () => { + const store = await makeOpenStore(); + // 제작 26시간 — 현재(9/16 16:00) 기준 9/17 14:00(22h)은 미달, 9/18 14:00(46h)은 충족 + const product = await createProduct(prisma, { + store_id: store.id, + preparation_time_minutes: 26 * 60, + }); + const buyer = await makeBuyer(); + + await expect( + service.createOrder( + buyer.id, + baseInput({ + productId: product.id.toString(), + pickupAt: new Date('2026-09-17T05:00:00.000Z'), + }), + ), + ).rejects.toThrow(BadRequestException); + + const ok = await service.createOrder( + buyer.id, + baseInput({ productId: product.id.toString() }), + ); + expect(ok.status).toBe('SUBMITTED'); + }); + + it('KRW가 아닌 통화 상품은 거절한다', async () => { + const store = await makeOpenStore(); + const product = await createProduct(prisma, { + store_id: store.id, + currency: 'USD', + }); + const buyer = await makeBuyer(); + + await expect( + service.createOrder( + buyer.id, + baseInput({ productId: product.id.toString() }), + ), + ).rejects.toThrow(BadRequestException); + }); + it('32비트 초과·음수 금액은 커밋 전에 거절한다', async () => { const store = await makeOpenStore(); const buyer = await makeBuyer(); diff --git a/src/features/order/services/order-checkout.service.ts b/src/features/order/services/order-checkout.service.ts index 425e737..a192ef8 100644 --- a/src/features/order/services/order-checkout.service.ts +++ b/src/features/order/services/order-checkout.service.ts @@ -67,15 +67,26 @@ export class OrderCheckoutService { if (!product) { throw new NotFoundException(ORDER_CHECKOUT_ERRORS.PRODUCT_NOT_FOUND); } + // Order/OrderItem에 통화 스냅샷 컬럼이 없어 비 KRW 금액은 통화 정보가 + // 소실된다 — 다국통화 스냅샷 설계 전까지 KRW만 허용(명세 외 정책 결정) + if (product.currency !== 'KRW') { + throw new BadRequestException(ORDER_CHECKOUT_ERRORS.UNSUPPORTED_CURRENCY); + } const selections = this.resolveOptionSelections(product, optionItemIds); const buyer = await this.resolveBuyer(accountId, input); - const pickupAvailable = await this.pickupSchedule.isPickupSlotAvailable({ - storeId: product.store_id, - pickupAt: input.pickupAt, - additionalQuantity: quantity, - }); + const now = this.clock.now(); + // 상품별 제작 소요시간은 매장 리드타임과 별개 조건 — 둘 다 충족해야 한다 + const preparationDeadlineMs = + now.getTime() + product.preparation_time_minutes * 60_000; + const pickupAvailable = + input.pickupAt.getTime() >= preparationDeadlineMs && + (await this.pickupSchedule.isPickupSlotAvailable({ + storeId: product.store_id, + pickupAt: input.pickupAt, + additionalQuantity: quantity, + })); if (!pickupAvailable) { throw new BadRequestException(ORDER_CHECKOUT_ERRORS.PICKUP_NOT_AVAILABLE); } @@ -104,7 +115,7 @@ export class OrderCheckoutService { } } - const submittedAt = this.clock.now(); + const submittedAt = now; const created = await this.createWithOrderNumberRetry({ accountId, pickupAt: input.pickupAt, diff --git a/src/features/product/repositories/product.repository.ts b/src/features/product/repositories/product.repository.ts index 363294f..3c300be 100644 --- a/src/features/product/repositories/product.repository.ts +++ b/src/features/product/repositories/product.repository.ts @@ -69,6 +69,8 @@ export interface ProductDetailRow { regular_price: number; sale_price: number | null; currency: string; + // 주문 생성(checkout)의 제작 소요시간 검증용 + preparation_time_minutes: number; images: { image_url: string }[]; option_groups: { id: bigint; @@ -905,6 +907,7 @@ export class ProductRepository { regular_price: true, sale_price: true, currency: true, + preparation_time_minutes: true, images: { where: { deleted_at: null }, orderBy: { sort_order: 'asc' }, diff --git a/src/test/factories/product.factory.ts b/src/test/factories/product.factory.ts index e397acc..da84140 100644 --- a/src/test/factories/product.factory.ts +++ b/src/test/factories/product.factory.ts @@ -9,6 +9,8 @@ export interface ProductOverrides { description?: string | null; regular_price?: number; sale_price?: number | null; + currency?: string; + preparation_time_minutes?: number; is_active?: boolean; } @@ -26,6 +28,9 @@ export async function createProduct( description: overrides.description ?? null, regular_price: overrides.regular_price ?? 10000, sale_price: overrides.sale_price ?? null, + currency: overrides.currency ?? 'KRW', + // 스키마 기본값 180분은 당일 픽업 테스트를 방해하므로 팩토리 기본은 0 + preparation_time_minutes: overrides.preparation_time_minutes ?? 0, is_active: overrides.is_active ?? true, }, }); From bb21992ff96b2b9407318ee84a5bc6ed67e506c8 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Tue, 25 Aug 2026 06:02:13 +0900 Subject: [PATCH 4/7] =?UTF-8?q?fix(order):=20=ED=99=9C=EC=84=B1=20USER=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=20=EA=B0=95=EC=A0=9C=20+=20=EC=A3=BC?= =?UTF-8?q?=EB=AC=B8=EC=9E=90=20=EC=A0=84=ED=99=94=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=ED=98=95=EC=8B=9D=20=EA=B2=80=EC=A6=9D=20(Codex=20=EB=B0=98?= =?UTF-8?q?=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 체크아웃 진입 시 활성 USER 계정+활성 프로필을 강제한다 (requireActiveUser와 동일 의미론 — SELLER/ADMIN이 구매자 mutation으로 주문을 만들어 capacity·랭킹을 오염시키는 경로 차단) - buyerPhone에 프로필 전화번호와 동일한 010-XXXX-XXXX 고정 형식 강제 (임의 문자열이 검증된 프로필 값을 덮어쓰지 못하게) - 프로필 닉네임이 NOT NULL 최종 fallback이라 도달 불가가 된 BUYER_NAME_REQUIRED 분기 제거 - 회귀 테스트 3건 추가 (SELLER 거절/프로필 없음 거절/전화 형식) --- .../order/constants/order-error-messages.ts | 3 +- .../order/constants/order.constants.ts | 6 +++ .../dto/inputs/create-order.input.spec.ts | 13 +++++ .../order/dto/inputs/create-order.input.ts | 8 ++- .../order/repositories/order.repository.ts | 28 ++++++++--- .../services/order-checkout.service.spec.ts | 22 ++++++++ .../order/services/order-checkout.service.ts | 50 ++++++++++++++----- 7 files changed, 107 insertions(+), 23 deletions(-) create mode 100644 src/features/order/constants/order.constants.ts diff --git a/src/features/order/constants/order-error-messages.ts b/src/features/order/constants/order-error-messages.ts index bdd5747..bde6a43 100644 --- a/src/features/order/constants/order-error-messages.ts +++ b/src/features/order/constants/order-error-messages.ts @@ -1,5 +1,7 @@ /** 주문 생성(체크아웃) 에러 메시지. */ export const ORDER_CHECKOUT_ERRORS = { + BUYER_ACCOUNT_NOT_ACTIVE: '유효한 사용자 계정이 아닙니다.', + BUYER_NOT_USER: 'USER 계정만 주문할 수 있습니다.', PRODUCT_NOT_FOUND: '상품을 찾을 수 없습니다.', DUPLICATE_OPTION_ITEM: '중복된 옵션 선택입니다.', INVALID_OPTION_ITEM: '해당 상품의 옵션이 아닙니다.', @@ -7,7 +9,6 @@ export const ORDER_CHECKOUT_ERRORS = { PICKUP_NOT_AVAILABLE: '선택한 픽업 일시는 예약할 수 없습니다.', ORDER_AMOUNT_OUT_OF_RANGE: '주문 금액이 처리 가능한 범위를 벗어났습니다.', UNSUPPORTED_CURRENCY: 'KRW 상품만 주문할 수 있습니다.', - BUYER_NAME_REQUIRED: '주문자 이름이 필요합니다.', BUYER_PHONE_REQUIRED: '주문자 연락처가 필요합니다. 프로필에 전화번호를 등록하거나 입력해 주세요.', ORDER_NUMBER_GENERATION_FAILED: diff --git a/src/features/order/constants/order.constants.ts b/src/features/order/constants/order.constants.ts new file mode 100644 index 0000000..87b8544 --- /dev/null +++ b/src/features/order/constants/order.constants.ts @@ -0,0 +1,6 @@ +/** + * 주문자 전화번호 형식. 프로필 전화번호 정책(user feature `PHONE_REGEX`, + * 010-XXXX-XXXX 고정 13자)과 동일해야 한다 — user는 배럴 없는 feature라 + * cross-feature import 대신 정책을 복제하고 출처를 명시한다. + */ +export const ORDER_BUYER_PHONE_REGEX = /^010-\d{4}-\d{4}$/; diff --git a/src/features/order/dto/inputs/create-order.input.spec.ts b/src/features/order/dto/inputs/create-order.input.spec.ts index 11bec6f..b62f072 100644 --- a/src/features/order/dto/inputs/create-order.input.spec.ts +++ b/src/features/order/dto/inputs/create-order.input.spec.ts @@ -55,4 +55,17 @@ describe('CreateOrderInput', () => { const errors = await validate(build({ ...VALID, buyerName: '' })); expect(errors[0].property).toBe('buyerName'); }); + + it('buyerPhone은 010-XXXX-XXXX 형식만 허용한다', async () => { + expect( + (await validate(build({ ...VALID, buyerPhone: 'abc' })))[0].property, + ).toBe('buyerPhone'); + expect( + (await validate(build({ ...VALID, buyerPhone: '01000001111' })))[0] + .property, + ).toBe('buyerPhone'); + expect( + await validate(build({ ...VALID, buyerPhone: '010-0000-1111' })), + ).toHaveLength(0); + }); }); diff --git a/src/features/order/dto/inputs/create-order.input.ts b/src/features/order/dto/inputs/create-order.input.ts index c1ffdd5..4aac0de 100644 --- a/src/features/order/dto/inputs/create-order.input.ts +++ b/src/features/order/dto/inputs/create-order.input.ts @@ -5,11 +5,14 @@ import { IsNotEmpty, IsOptional, IsString, + Matches, Max, MaxLength, Min, } from 'class-validator'; +import { ORDER_BUYER_PHONE_REGEX } from '@/features/order/constants/order.constants'; + export class CreateOrderInput { @IsString() @IsNotEmpty() @@ -36,7 +39,8 @@ export class CreateOrderInput { @IsOptional() @IsString() - @IsNotEmpty() - @MaxLength(30) + // 프로필 전화번호와 동일 정책(010-XXXX-XXXX 고정) — 임의 문자열이 + // 검증된 프로필 값을 덮어쓰지 못하게 형식을 강제한다 + @Matches(ORDER_BUYER_PHONE_REGEX) buyerPhone?: string; } diff --git a/src/features/order/repositories/order.repository.ts b/src/features/order/repositories/order.repository.ts index a8992b0..9629935 100644 --- a/src/features/order/repositories/order.repository.ts +++ b/src/features/order/repositories/order.repository.ts @@ -6,6 +6,7 @@ import { NotificationType, OrderStatus, Prisma, + type AccountType, } from '@prisma/client'; import { PrismaService } from '@/prisma'; @@ -114,13 +115,26 @@ export interface ReviewableOrderItemRow { export class OrderRepository { constructor(private readonly prisma: PrismaService) {} - /** 주문자 정보 fallback용 프로필 조회(닉네임·전화번호). */ - async findBuyerProfile( - accountId: bigint, - ): Promise<{ nickname: string; phone_number: string | null } | null> { - return this.prisma.userProfile.findFirst({ - where: { account_id: accountId, deleted_at: null }, - select: { nickname: true, phone_number: true }, + /** + * 구매자 검증·주문자 fallback용 계정+프로필 조회. + * USER 여부·프로필 활성 판정은 서비스가 한다(requireActiveUser와 동일 의미론). + */ + async findAccountWithProfileForCheckout(accountId: bigint): Promise<{ + account_type: AccountType; + user_profile: { + nickname: string; + phone_number: string | null; + deleted_at: Date | null; + } | null; + } | null> { + return this.prisma.account.findFirst({ + where: { id: accountId, deleted_at: null }, + select: { + account_type: true, + user_profile: { + select: { nickname: true, phone_number: true, deleted_at: true }, + }, + }, }); } diff --git a/src/features/order/services/order-checkout.service.spec.ts b/src/features/order/services/order-checkout.service.spec.ts index becbe54..365a1cb 100644 --- a/src/features/order/services/order-checkout.service.spec.ts +++ b/src/features/order/services/order-checkout.service.spec.ts @@ -1,7 +1,9 @@ import { BadRequestException, + ForbiddenException, InternalServerErrorException, NotFoundException, + UnauthorizedException, } from '@nestjs/common'; import type { Account, PrismaClient, Product, Store } from '@prisma/client'; @@ -439,6 +441,26 @@ describe('OrderCheckoutService (real DB)', () => { expect(ok.status).toBe('SUBMITTED'); }); + it('SELLER 계정·프로필 없는 계정은 주문할 수 없다', async () => { + const store = await makeOpenStore(); + const product = await createProduct(prisma, { store_id: store.id }); + const seller = await createAccount(prisma, { account_type: 'SELLER' }); + const profileless = await createAccount(prisma, { account_type: 'USER' }); + + await expect( + service.createOrder( + seller.id, + baseInput({ productId: product.id.toString() }), + ), + ).rejects.toThrow(ForbiddenException); + await expect( + service.createOrder( + profileless.id, + baseInput({ productId: product.id.toString() }), + ), + ).rejects.toThrow(UnauthorizedException); + }); + it('상품 제작 소요시간 이전 픽업 일시는 거절한다', async () => { const store = await makeOpenStore(); // 제작 26시간 — 현재(9/16 16:00) 기준 9/17 14:00(22h)은 미달, 9/18 14:00(46h)은 충족 diff --git a/src/features/order/services/order-checkout.service.ts b/src/features/order/services/order-checkout.service.ts index a192ef8..e1e5f78 100644 --- a/src/features/order/services/order-checkout.service.ts +++ b/src/features/order/services/order-checkout.service.ts @@ -1,10 +1,12 @@ import { BadRequestException, + ForbiddenException, Injectable, InternalServerErrorException, NotFoundException, + UnauthorizedException, } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; +import { AccountType, Prisma } from '@prisma/client'; import { ClockService } from '@/common/providers/clock.service'; import { RandomService } from '@/common/providers/random.service'; @@ -63,6 +65,8 @@ export class OrderCheckoutService { const optionItemIds = input.optionItemIds.map((id) => parseId(id)); const quantity = input.quantity ?? 1; + const buyerProfile = await this.requireActiveBuyer(accountId); + const product = await this.productRepo.findProductDetailById(productId); if (!product) { throw new NotFoundException(ORDER_CHECKOUT_ERRORS.PRODUCT_NOT_FOUND); @@ -74,7 +78,7 @@ export class OrderCheckoutService { } const selections = this.resolveOptionSelections(product, optionItemIds); - const buyer = await this.resolveBuyer(accountId, input); + const buyer = this.resolveBuyerInfo(buyerProfile, input); const now = this.clock.now(); // 상품별 제작 소요시간은 매장 리드타임과 별개 조건 — 둘 다 충족해야 한다 @@ -220,20 +224,40 @@ export class OrderCheckoutService { return selections; } - /** 주문자 정보: input 우선, 없으면 프로필(닉네임·전화번호) fallback. */ - private async resolveBuyer( + /** + * 활성 USER 계정 + 활성 프로필 강제(user feature requireActiveUser와 + * 동일 의미론 — user는 배럴 없는 feature라 checkout 경로에 재구현). + * SELLER/ADMIN이 구매자 mutation으로 주문을 만드는 것을 차단한다. + */ + private async requireActiveBuyer( accountId: bigint, - input: CreateOrderInput, - ): Promise<{ name: string; phone: string }> { - if (input.buyerName && input.buyerPhone) { - return { name: input.buyerName, phone: input.buyerPhone }; + ): Promise<{ nickname: string; phone_number: string | null }> { + const account = + await this.orderRepo.findAccountWithProfileForCheckout(accountId); + if (!account) { + throw new UnauthorizedException( + ORDER_CHECKOUT_ERRORS.BUYER_ACCOUNT_NOT_ACTIVE, + ); } - const profile = await this.orderRepo.findBuyerProfile(accountId); - const name = input.buyerName ?? profile?.nickname; - const phone = input.buyerPhone ?? profile?.phone_number ?? undefined; - if (!name) { - throw new BadRequestException(ORDER_CHECKOUT_ERRORS.BUYER_NAME_REQUIRED); + if (account.account_type !== AccountType.USER) { + throw new ForbiddenException(ORDER_CHECKOUT_ERRORS.BUYER_NOT_USER); } + if (!account.user_profile || account.user_profile.deleted_at) { + throw new UnauthorizedException( + ORDER_CHECKOUT_ERRORS.BUYER_ACCOUNT_NOT_ACTIVE, + ); + } + return account.user_profile; + } + + /** 주문자 정보: input 우선, 없으면 프로필(닉네임·전화번호) fallback. */ + private resolveBuyerInfo( + profile: { nickname: string; phone_number: string | null }, + input: CreateOrderInput, + ): { name: string; phone: string } { + // 이름은 프로필 닉네임(NOT NULL)이 최종 fallback이라 항상 존재한다 + const name = input.buyerName ?? profile.nickname; + const phone = input.buyerPhone ?? profile.phone_number ?? undefined; if (!phone) { throw new BadRequestException(ORDER_CHECKOUT_ERRORS.BUYER_PHONE_REQUIRED); } From dee7b8f2d8a04ac2ef23e067d2d92ad66648b9d1 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Tue, 25 Aug 2026 06:07:59 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix(order):=20=EA=B3=B5=EB=B0=B1=EB=A7=8C?= =?UTF-8?q?=20=EC=9E=88=EB=8A=94=20=EC=A3=BC=EB=AC=B8=EC=9E=90=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=EC=9D=80=20=EB=AF=B8=EC=9E=85=EB=A0=A5=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=B7=A8=EA=B8=89=20(Codex=20P2=20=EB=B0=98?= =?UTF-8?q?=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buyerName을 trim해 빈 값이면 프로필 닉네임 fallback으로 처리한다 — 공백 이름이 검증된 닉네임을 덮어써 판매자에게 빈 표기로 보이는 것 방지. 회귀 케이스 1건 추가. --- .../order/services/order-checkout.service.spec.ts | 10 ++++++++++ src/features/order/services/order-checkout.service.ts | 6 ++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/features/order/services/order-checkout.service.spec.ts b/src/features/order/services/order-checkout.service.spec.ts index 365a1cb..00fc208 100644 --- a/src/features/order/services/order-checkout.service.spec.ts +++ b/src/features/order/services/order-checkout.service.spec.ts @@ -353,6 +353,16 @@ describe('OrderCheckoutService (real DB)', () => { expect(saved.buyer_name).toBe('주문자닉네임'); expect(saved.buyer_phone).toBe('010-9999-8888'); + // 공백만 입력된 이름은 미입력으로 취급해 닉네임 fallback + const whitespaceName = await service.createOrder( + buyer.id, + baseInput({ productId: product.id.toString(), buyerName: ' ' }), + ); + const savedWhitespace = await prisma.order.findUniqueOrThrow({ + where: { id: BigInt(whitespaceName.orderId) }, + }); + expect(savedWhitespace.buyer_name).toBe('주문자닉네임'); + const phonelessBuyer = await makeBuyer(null); await expect( service.createOrder( diff --git a/src/features/order/services/order-checkout.service.ts b/src/features/order/services/order-checkout.service.ts index e1e5f78..6f2a04d 100644 --- a/src/features/order/services/order-checkout.service.ts +++ b/src/features/order/services/order-checkout.service.ts @@ -255,8 +255,10 @@ export class OrderCheckoutService { profile: { nickname: string; phone_number: string | null }, input: CreateOrderInput, ): { name: string; phone: string } { - // 이름은 프로필 닉네임(NOT NULL)이 최종 fallback이라 항상 존재한다 - const name = input.buyerName ?? profile.nickname; + // 이름은 프로필 닉네임(NOT NULL)이 최종 fallback이라 항상 존재한다. + // 공백만 입력된 이름은 빈 표기로 커밋되지 않게 미입력으로 취급한다. + const trimmedName = input.buyerName?.trim(); + const name = trimmedName || profile.nickname; const phone = input.buyerPhone ?? profile.phone_number ?? undefined; if (!phone) { throw new BadRequestException(ORDER_CHECKOUT_ERRORS.BUYER_PHONE_REQUIRED); From 1f4be4ca4ed241906286b7f933da1682674fd7d0 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Tue, 25 Aug 2026 06:19:17 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20(=EC=84=A4=EB=AA=85/?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=ED=95=84=EC=88=98=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=20=EC=84=A0=ED=83=9D=20=EC=8B=9C=20=EC=A3=BC=EB=AC=B8?= =?UTF-8?q?=20=EA=B1=B0=EC=A0=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 릴리즈 PR #209 Codex 지적 반영. option_requires_description/image가 켜진 옵션 그룹은 판매자가 요구한 커스텀 정보 없이는 주문이 완성되지 않는데, 체크아웃이 플래그를 읽지 않아 스냅샷만으로 SUBMITTED가 됐다. - findProductDetailById에 두 플래그 로드 추가 - 해당 그룹 옵션을 선택한 주문은 커스텀 체크아웃 확장 전까지 거절 (미선택이면 기존대로 허용) - 회귀 테스트 1건 추가 --- .../order/constants/order-error-messages.ts | 2 ++ .../services/order-checkout.service.spec.ts | 36 +++++++++++++++++++ .../order/services/order-checkout.service.ts | 10 ++++++ .../repositories/product.repository.ts | 5 +++ 4 files changed, 53 insertions(+) diff --git a/src/features/order/constants/order-error-messages.ts b/src/features/order/constants/order-error-messages.ts index bde6a43..6714e25 100644 --- a/src/features/order/constants/order-error-messages.ts +++ b/src/features/order/constants/order-error-messages.ts @@ -6,6 +6,8 @@ export const ORDER_CHECKOUT_ERRORS = { DUPLICATE_OPTION_ITEM: '중복된 옵션 선택입니다.', INVALID_OPTION_ITEM: '해당 상품의 옵션이 아닙니다.', OPTION_GROUP_RULE_VIOLATION: '옵션 그룹의 선택 규칙을 충족하지 않습니다.', + OPTION_CUSTOMIZATION_REQUIRED: + '커스텀 정보가 필요한 옵션은 아직 주문할 수 없습니다.', PICKUP_NOT_AVAILABLE: '선택한 픽업 일시는 예약할 수 없습니다.', ORDER_AMOUNT_OUT_OF_RANGE: '주문 금액이 처리 가능한 범위를 벗어났습니다.', UNSUPPORTED_CURRENCY: 'KRW 상품만 주문할 수 있습니다.', diff --git a/src/features/order/services/order-checkout.service.spec.ts b/src/features/order/services/order-checkout.service.spec.ts index 00fc208..b69e8d8 100644 --- a/src/features/order/services/order-checkout.service.spec.ts +++ b/src/features/order/services/order-checkout.service.spec.ts @@ -313,6 +313,42 @@ describe('OrderCheckoutService (real DB)', () => { ).rejects.toThrow(BadRequestException); }); + it('설명/이미지 필수 옵션 선택은 커스텀 확장 전까지 거절한다', async () => { + const store = await makeOpenStore(); + const product = await createProduct(prisma, { store_id: store.id }); + const group = await prisma.productOptionGroup.create({ + data: { + product_id: product.id, + name: '레터링', + is_required: false, + min_select: 1, + max_select: 1, + option_requires_description: true, + }, + }); + const item = await prisma.productOptionItem.create({ + data: { option_group_id: group.id, title: '문구 입력', price_delta: 0 }, + }); + const buyer = await makeBuyer(); + + await expect( + service.createOrder( + buyer.id, + baseInput({ + productId: product.id.toString(), + optionItemIds: [item.id.toString()], + }), + ), + ).rejects.toThrow(BadRequestException); + + // 해당 그룹을 선택하지 않으면 주문 가능(선택 그룹이므로) + const ok = await service.createOrder( + buyer.id, + baseInput({ productId: product.id.toString() }), + ); + expect(ok.status).toBe('SUBMITTED'); + }); + it('없거나 비활성 상품·비활성 매장 상품은 NOT_FOUND다', async () => { const buyer = await makeBuyer(); const inactiveStore = await createStore(prisma, { is_active: false }); diff --git a/src/features/order/services/order-checkout.service.ts b/src/features/order/services/order-checkout.service.ts index 6f2a04d..814c857 100644 --- a/src/features/order/services/order-checkout.service.ts +++ b/src/features/order/services/order-checkout.service.ts @@ -220,6 +220,16 @@ export class OrderCheckoutService { ORDER_CHECKOUT_ERRORS.OPTION_GROUP_RULE_VIOLATION, ); } + // 설명/이미지 필수 옵션은 커스텀 입력 없이는 판매자 요구 정보가 빠진 채 + // 주문된다 — 커스텀 체크아웃 확장 전까지 해당 옵션 선택은 거절한다 + if ( + count > 0 && + (group.option_requires_description || group.option_requires_image) + ) { + throw new BadRequestException( + ORDER_CHECKOUT_ERRORS.OPTION_CUSTOMIZATION_REQUIRED, + ); + } } return selections; } diff --git a/src/features/product/repositories/product.repository.ts b/src/features/product/repositories/product.repository.ts index 3c300be..bd94260 100644 --- a/src/features/product/repositories/product.repository.ts +++ b/src/features/product/repositories/product.repository.ts @@ -79,6 +79,9 @@ export interface ProductDetailRow { is_required: boolean; min_select: number; max_select: number; + // 주문 생성(checkout)의 커스텀 필수 옵션 가드용 + option_requires_description: boolean; + option_requires_image: boolean; sort_order: number; option_items: { id: bigint; @@ -923,6 +926,8 @@ export class ProductRepository { is_required: true, min_select: true, max_select: true, + option_requires_description: true, + option_requires_image: true, sort_order: true, option_items: { where: { is_active: true, deleted_at: null }, From 8211999ce1c9f7b6bd39a684d79de990800d8585 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Tue, 25 Aug 2026 06:31:30 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20(=ED=95=98=EB=A3=A8?= =?UTF-8?q?=20=EB=84=98=EB=8A=94=20=EB=A6=AC=EB=93=9C=ED=83=80=EC=9E=84?= =?UTF-8?q?=EC=9D=84=20=EB=AF=B8=EB=9E=98=20=EB=82=A0=EC=A7=9C=EC=97=90?= =?UTF-8?q?=EB=8F=84=20=EC=A0=81=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 릴리즈 PR #209 Codex 지적 반영. 리드타임 컷오프를 당일(isToday)에만 적용해, 판매자 설정 리드타임이 하루를 넘으면(최대 7일) 미래 날짜에서 무시됐다 — 7일 리드타임 매장의 내일 슬롯이 예약 가능으로 보이고 createOrder도 통과하는 문제. - buildDaySlots 컷오프를 절대 시각(now + 리드타임) 기준으로 변경 (해당 날짜 자정 대비 경과 분이 미래일에 음수가 되어 자연 반영) - 달력 판정도 동일 규칙: 리드타임에 완전히 덮인 미래일은 CLOSED - 회귀 테스트 1건 추가 (3일 리드: 슬롯 판정·달력·시간 슬롯 3면 검증) --- .../store-pickup-schedule.service.spec.ts | 46 ++++++++++++++++ .../services/store-pickup-schedule.service.ts | 52 +++++++++---------- 2 files changed, 72 insertions(+), 26 deletions(-) diff --git a/src/features/store/services/store-pickup-schedule.service.spec.ts b/src/features/store/services/store-pickup-schedule.service.spec.ts index b574d40..ac4a1de 100644 --- a/src/features/store/services/store-pickup-schedule.service.spec.ts +++ b/src/features/store/services/store-pickup-schedule.service.spec.ts @@ -523,6 +523,52 @@ describe('StorePickupScheduleService (real DB)', () => { ).resolves.toBe(false); }); + it('하루를 넘는 리드타임은 미래 날짜에도 적용된다', async () => { + // 리드 3일 → 현재(9/16 16:00) 기준 9/19 16:00 이전 슬롯은 전부 마감 + const store = await createStore(prisma, { + min_lead_time_minutes: 3 * 24 * 60, + }); + await openAllWeek(store); + + // 9/18 14:00 — 리드타임 미달 + await expect( + service.isPickupSlotAvailable({ + storeId: store.id, + pickupAt: new Date('2026-09-18T05:00:00.000Z'), + }), + ).resolves.toBe(false); + // 9/19 14:00 — 여전히 미달, 9/19 16:00 — 충족 + await expect( + service.isPickupSlotAvailable({ + storeId: store.id, + pickupAt: new Date('2026-09-19T05:00:00.000Z'), + }), + ).resolves.toBe(false); + await expect( + service.isPickupSlotAvailable({ + storeId: store.id, + pickupAt: new Date('2026-09-19T07:00:00.000Z'), + }), + ).resolves.toBe(true); + + // 달력도 동일: 리드타임에 완전히 덮인 날은 CLOSED, 부분 가용 날은 선택 가능 + const calendar = await service.storePickupCalendar(store.id, '2026-09'); + expect(dayOf(calendar, '2026-09-18')).toMatchObject({ + selectable: false, + reason: 'CLOSED', + }); + expect(dayOf(calendar, '2026-09-19').selectable).toBe(true); + + // 슬롯 조회도 9/19 16:00 이전은 마감 표기 + const slots = await service.storePickupTimeSlots(store.id, '2026-09-19'); + expect( + slots.afternoon.find((slot) => slot.time === '15:30')?.available, + ).toBe(false); + expect( + slots.afternoon.find((slot) => slot.time === '16:00')?.available, + ).toBe(true); + }); + it('capacity 잔여가 additionalQuantity보다 작으면 불가하다', async () => { const store = await createStore(prisma); await openAllWeek(store); diff --git a/src/features/store/services/store-pickup-schedule.service.ts b/src/features/store/services/store-pickup-schedule.service.ts index 1d3ec5c..367d8c7 100644 --- a/src/features/store/services/store-pickup-schedule.service.ts +++ b/src/features/store/services/store-pickup-schedule.service.ts @@ -156,12 +156,11 @@ export class StorePickupScheduleService { } const reason = this.evaluateDay(store, ctx, now, year, month, day); - const isToday = kstDayDiff(now, parsed) === 0; let slots = this.buildDaySlots( store, hour.open_time, hour.close_time, - isToday, + kstMidnightUtc(year, month, day), now, ); if (reason !== null) { @@ -231,12 +230,11 @@ export class StorePickupScheduleService { return false; } - const isToday = kstDayDiff(now, args.pickupAt) === 0; const slots = this.buildDaySlots( store, hour.open_time, hour.close_time, - isToday, + kstMidnightUtc(year, month, day), now, ); const pickupMinutes = kstMinutesOfDay(args.pickupAt); @@ -277,7 +275,7 @@ export class StorePickupScheduleService { /** * 해당 KST 달력일의 선택 불가 사유(null이면 선택 가능). * 판정 순서: 과거 → 범위 초과 → 특별휴무 → 요일 휴무/영업시간 미설정 - * → capacity 소진 → 당일 잔여 가용 슬롯 없음. + * → capacity 소진 → 리드타임 반영 잔여 가용 슬롯 없음. */ private evaluateDay( store: StorePickupPolicyRow, @@ -309,37 +307,39 @@ export class StorePickupScheduleService { return STORE_PICKUP_DAY_REASON.CAPACITY_FULL; } - // 당일은 리드타임 반영 잔여 슬롯이 있어야 선택 가능(전역 pickupCalendar 선례와 일치) - if (diff === 0) { - const slots = this.buildDaySlots( - store, - hour.open_time, - hour.close_time, - true, - now, - ); - if (!slots.some((slot) => slot.available)) { - return STORE_PICKUP_DAY_REASON.CLOSED; - } + // 리드타임 반영 잔여 슬롯이 없는 날은 선택 불가(전역 pickupCalendar 선례 확장). + // 리드타임이 하루를 넘으면 미래 날짜도 여기서 마감된다. + const slots = this.buildDaySlots( + store, + hour.open_time, + hour.close_time, + kstMidnightUtc(year, month, day), + now, + ); + if (!slots.some((slot) => slot.available)) { + return STORE_PICKUP_DAY_REASON.CLOSED; } return null; } - /** 영업시간·매장 슬롯 간격으로 슬롯 생성. 당일만 리드타임 컷오프를 적용한다. */ + /** + * 영업시간·매장 슬롯 간격으로 슬롯 생성. 리드타임 컷오프는 절대 시각 + * (now + 리드타임) 기준이라 하루를 넘는 리드타임(최대 7일)도 미래 날짜에 + * 올바르게 적용된다 — 당일만 컷오프하던 방식의 릴리즈 리뷰 반영. + */ private buildDaySlots( store: StorePickupPolicyRow, openTime: Date, closeTime: Date, - isToday: boolean, + dayStartUtc: Date, now: Date, ): StorePickupSlot[] { - // 분 단위 절삭은 리드타임을 최대 59초 짧게 만들므로, 초가 남으면 다음 분으로 올린다 - const hasSubMinute = - now.getUTCSeconds() > 0 || now.getUTCMilliseconds() > 0; - // 미래일은 컷오프 무력화(-Infinity + 리드타임 = -Infinity → 전 슬롯 가용) - const nowMinutes = isToday - ? kstMinutesOfDay(now) + (hasSubMinute ? 1 : 0) - : Number.NEGATIVE_INFINITY; + // 해당 날짜 자정 기준 현재 시각의 경과 분. 미래 날짜면 음수가 되어 + // 컷오프(nowMinutes + 리드타임)가 그만큼 앞당겨진다. 분수 분은 다음 분으로 + // 올림(분 절삭이 리드타임을 최대 59초 짧게 만들지 않도록 보수적 처리). + const nowMinutes = Math.ceil( + (now.getTime() - dayStartUtc.getTime()) / 60_000, + ); return buildTodaySlots({ openMinutes: timeColumnToMinutes(openTime), closeMinutes: timeColumnToMinutes(closeTime),