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..bde6a43 --- /dev/null +++ b/src/features/order/constants/order-error-messages.ts @@ -0,0 +1,16 @@ +/** 주문 생성(체크아웃) 에러 메시지. */ +export const ORDER_CHECKOUT_ERRORS = { + BUYER_ACCOUNT_NOT_ACTIVE: '유효한 사용자 계정이 아닙니다.', + BUYER_NOT_USER: 'USER 계정만 주문할 수 있습니다.', + PRODUCT_NOT_FOUND: '상품을 찾을 수 없습니다.', + DUPLICATE_OPTION_ITEM: '중복된 옵션 선택입니다.', + INVALID_OPTION_ITEM: '해당 상품의 옵션이 아닙니다.', + OPTION_GROUP_RULE_VIOLATION: '옵션 그룹의 선택 규칙을 충족하지 않습니다.', + PICKUP_NOT_AVAILABLE: '선택한 픽업 일시는 예약할 수 없습니다.', + ORDER_AMOUNT_OUT_OF_RANGE: '주문 금액이 처리 가능한 범위를 벗어났습니다.', + UNSUPPORTED_CURRENCY: 'KRW 상품만 주문할 수 있습니다.', + BUYER_PHONE_REQUIRED: + '주문자 연락처가 필요합니다. 프로필에 전화번호를 등록하거나 입력해 주세요.', + ORDER_NUMBER_GENERATION_FAILED: + '주문번호 생성에 실패했습니다. 잠시 후 다시 시도해 주세요.', +} as const; 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 new file mode 100644 index 0000000..b62f072 --- /dev/null +++ b/src/features/order/dto/inputs/create-order.input.spec.ts @@ -0,0 +1,71 @@ +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'); + }); + + 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 new file mode 100644 index 0000000..4aac0de --- /dev/null +++ b/src/features/order/dto/inputs/create-order.input.ts @@ -0,0 +1,46 @@ +import { + IsArray, + IsDate, + IsInt, + 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() + 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() + // 프로필 전화번호와 동일 정책(010-XXXX-XXXX 고정) — 임의 문자열이 + // 검증된 프로필 값을 덮어쓰지 못하게 형식을 강제한다 + @Matches(ORDER_BUYER_PHONE_REGEX) + 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..9629935 100644 --- a/src/features/order/repositories/order.repository.ts +++ b/src/features/order/repositories/order.repository.ts @@ -5,6 +5,8 @@ import { NotificationEvent, NotificationType, OrderStatus, + Prisma, + type AccountType, } from '@prisma/client'; import { PrismaService } from '@/prisma'; @@ -41,6 +43,59 @@ 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; + orderNumber: string; + pickupAt: Date; + buyerName: string; + buyerPhone: string; + subtotalPrice: number; + discountPrice: number; + totalPrice: number; + submittedAt: Date; + /** null이면 capacity 원자 검사 생략(호출부가 무제한으로 판단한 경우는 없음 — 항상 전달 권장). */ + capacityGuard: DailyCapacityGuard | null; + 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 +115,144 @@ export interface ReviewableOrderItemRow { export class OrderRepository { constructor(private readonly prisma: PrismaService) {} + /** + * 구매자 검증·주문자 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 }, + }, + }, + }); + } + + /** + * SUBMITTED 주문 생성. Order + OrderItem + 옵션 스냅샷 + 상태 히스토리를 + * 트랜잭션으로 원자 생성한다. 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 tx.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..00fc208 --- /dev/null +++ b/src/features/order/services/order-checkout.service.spec.ts @@ -0,0 +1,624 @@ +import { + BadRequestException, + ForbiddenException, + InternalServerErrorException, + NotFoundException, + UnauthorizedException, +} 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'); + + // 공백만 입력된 이름은 미입력으로 취급해 닉네임 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( + 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('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)은 충족 + 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(); + // 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 }); + 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..6f2a04d --- /dev/null +++ b/src/features/order/services/order-checkout.service.ts @@ -0,0 +1,320 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + InternalServerErrorException, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { AccountType, 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, + 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'; +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; + +// GraphQL Int는 signed 32비트. 커밋 전에 금액을 이 범위로 제한해 +// "저장은 됐는데 응답 직렬화에서 실패 → 재시도 중복 주문" 경로를 차단한다. +const MAX_ORDER_AMOUNT = 2_147_483_647; +const DAY_MS = 24 * 60 * 60 * 1000; + +/** 옵션 검증 결과(스냅샷 조립용). */ +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 buyerProfile = await this.requireActiveBuyer(accountId); + + const product = await this.productRepo.findProductDetailById(productId); + 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 = this.resolveBuyerInfo(buyerProfile, input); + + 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); + } + + // 가격 스냅샷: 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; + // 음수(과도한 음수 델타·판매가>정가 이상 데이터)나 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 = now; + const created = await this.createWithOrderNumberRetry({ + accountId, + pickupAt: input.pickupAt, + buyerName: buyer.name, + buyerPhone: buyer.phone, + subtotalPrice, + discountPrice, + totalPrice: itemSubtotalPrice, + submittedAt, + capacityGuard: this.buildCapacityGuard(product.store_id, input.pickupAt), + 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, + }; + } + + /** 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개. + */ + 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; + } + + /** + * 활성 USER 계정 + 활성 프로필 강제(user feature requireActiveUser와 + * 동일 의미론 — user는 배럴 없는 feature라 checkout 경로에 재구현). + * SELLER/ADMIN이 구매자 mutation으로 주문을 만드는 것을 차단한다. + */ + private async requireActiveBuyer( + accountId: bigint, + ): 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, + ); + } + 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 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); + } + 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 { + 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 && + 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/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/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 {} 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, }, });