diff --git a/prisma/migrations/20261102144339_order_item_region_label_snapshot/migration.sql b/prisma/migrations/20261102144339_order_item_region_label_snapshot/migration.sql new file mode 100644 index 00000000..1cc59b9f --- /dev/null +++ b/prisma/migrations/20261102144339_order_item_region_label_snapshot/migration.sql @@ -0,0 +1,16 @@ +-- 주문 시점 매장 지역 표기 스냅샷. 내 주문 목록이 store·region을 조인하지 않게 한다. +ALTER TABLE `order_item` ADD COLUMN `store_region_label_snapshot` VARCHAR(200) NULL; + +-- backfill: buildRegionLabel과 같은 규칙 — 현재 매장의 시·동(빈 문자열 제외), 둘 다 없으면 2차 지역명. +-- NULLIF(x, '')는 PAD SPACE 콜레이션에서 공백만 있는 값도 비우므로 CHAR_LENGTH로 판정한다. +UPDATE `order_item` oi + JOIN `store` s ON s.id = oi.store_id + LEFT JOIN `region` r ON r.id = s.region_id + SET oi.store_region_label_snapshot = CASE + WHEN CHAR_LENGTH(s.address_city) > 0 AND CHAR_LENGTH(s.address_neighborhood) > 0 + THEN CONCAT(s.address_city, ' ', s.address_neighborhood) + WHEN CHAR_LENGTH(s.address_city) > 0 THEN s.address_city + WHEN CHAR_LENGTH(s.address_neighborhood) > 0 THEN s.address_neighborhood + ELSE r.name + END; +-- end backfill diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f3aa0aba..6cb89998 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -884,6 +884,8 @@ model OrderItem { sale_price_snapshot Int? @db.UnsignedInt // 주문 시점 표시 스냅샷. 목록·상세·리뷰 가능 목록이 store·product_image를 조인하지 않게 한다. store_name_snapshot String @db.VarChar(200) + // 매장 지역 표기(buildRegionLabel). 매장에 주소·지역이 모두 없으면 null. + store_region_label_snapshot String? @db.VarChar(200) product_thumbnail_url_snapshot String? @db.VarChar(2048) quantity Int @default(1) @db.UnsignedSmallInt diff --git a/prisma/seed/orders.ts b/prisma/seed/orders.ts index a82c47d4..d95cf347 100644 --- a/prisma/seed/orders.ts +++ b/prisma/seed/orders.ts @@ -12,6 +12,7 @@ import type { SeededStores } from './stores'; import type { SeededUser } from './users'; +import { buildRegionLabel } from '@/common/utils/region-label'; import type { OrderStatus, PrismaClient } from '@/generated/prisma/client'; import { activeWhere } from '@/prisma'; @@ -27,6 +28,18 @@ async function firstImageUrl( return image?.image_url ?? null; } +/** 주문 시점 지역 표기 스냅샷 — 주문 생성 경로와 같은 규칙(buildRegionLabel). */ +async function regionLabelOf( + prisma: PrismaClient, + storeId: bigint, +): Promise { + const store = await prisma.store.findUniqueOrThrow({ + where: { id: storeId }, + include: { region: { select: { name: true } } }, + }); + return buildRegionLabel(store); +} + export interface SeededOrders { o1Submitted: bigint; o2Confirmed: bigint; @@ -45,6 +58,8 @@ export async function seedOrders( if (!user1) throw new Error('seedUsers must run before seedOrders'); const [storeA, storeB] = ctx.stores.stores; const [p1, p2, p3, p4] = ctx.stores.products; + const regionA = await regionLabelOf(prisma, storeA.id); + const regionB = await regionLabelOf(prisma, storeB.id); const { p2GroupId, p2OptionItemId } = ctx.stores.optionGroupIds; const day = 24 * 60 * 60 * 1000; @@ -61,8 +76,8 @@ export async function seedOrders( pickup_at: new Date(now.getTime() + 3 * day), buyer_name: buyerName, buyer_phone: buyerPhone, - subtotal_price: 35000, - discount_price: 0, + subtotal_price: 40000, + discount_price: 5000, total_price: 35000, submitted_at: now, items: { @@ -72,6 +87,7 @@ export async function seedOrders( product_id: p1.id, product_name_snapshot: p1.name, store_name_snapshot: storeA.store_name, + store_region_label_snapshot: regionA, product_thumbnail_url_snapshot: await firstImageUrl(prisma, p1.id), regular_price_snapshot: p1.regular_price, sale_price_snapshot: p1.sale_price, @@ -127,6 +143,7 @@ export async function seedOrders( product_id: p2.id, product_name_snapshot: p2.name, store_name_snapshot: storeA.store_name, + store_region_label_snapshot: regionA, product_thumbnail_url_snapshot: await firstImageUrl(prisma, p2.id), regular_price_snapshot: p2.regular_price, sale_price_snapshot: p2.sale_price, @@ -195,6 +212,7 @@ export async function seedOrders( product_id: p3.id, product_name_snapshot: p3.name, store_name_snapshot: storeA.store_name, + store_region_label_snapshot: regionA, product_thumbnail_url_snapshot: await firstImageUrl(prisma, p3.id), regular_price_snapshot: p3.regular_price, sale_price_snapshot: p3.sale_price, @@ -234,8 +252,8 @@ export async function seedOrders( pickup_at: o4Picked, buyer_name: buyerName, buyer_phone: buyerPhone, - subtotal_price: 35000, - discount_price: 0, + subtotal_price: 40000, + discount_price: 5000, total_price: 35000, submitted_at: o4Sub, confirmed_at: o4Conf, @@ -262,6 +280,7 @@ export async function seedOrders( product_id: p1.id, product_name_snapshot: p1.name, store_name_snapshot: storeA.store_name, + store_region_label_snapshot: regionA, product_thumbnail_url_snapshot: await firstImageUrl(prisma, p1.id), regular_price_snapshot: p1.regular_price, sale_price_snapshot: p1.sale_price, @@ -304,6 +323,7 @@ export async function seedOrders( product_id: p4.id, product_name_snapshot: p4.name, store_name_snapshot: storeB.store_name, + store_region_label_snapshot: regionB, product_thumbnail_url_snapshot: await firstImageUrl(prisma, p4.id), regular_price_snapshot: p4.regular_price, sale_price_snapshot: null, @@ -361,6 +381,7 @@ export async function seedOrders( product_id: p2.id, product_name_snapshot: p2.name, store_name_snapshot: storeA.store_name, + store_region_label_snapshot: regionA, product_thumbnail_url_snapshot: await firstImageUrl(prisma, p2.id), regular_price_snapshot: p2.regular_price, sale_price_snapshot: null, diff --git a/src/common/utils/discount-rate.spec.ts b/src/common/utils/discount-rate.spec.ts new file mode 100644 index 00000000..01c1715b --- /dev/null +++ b/src/common/utils/discount-rate.spec.ts @@ -0,0 +1,15 @@ +import { calcDiscountRate } from '@/common/utils/discount-rate'; + +describe('calcDiscountRate', () => { + it.each<[string, number, number | null, number]>([ + ['정수로 반올림(12.5→13)', 40000, 35000, 13], + ['정상 할인', 33000, 31350, 5], + ['판매가 null', 40000, null, 0], + ['판매가 = 정가', 40000, 40000, 0], + ['판매가 > 정가', 40000, 45000, 0], + ['정가 0', 0, 0, 0], + ['음수 판매가는 100에 clamp', 40000, -10000, 100], + ])('%s', (_case, regular, sale, expected) => { + expect(calcDiscountRate(regular, sale)).toBe(expected); + }); +}); diff --git a/src/common/utils/discount-rate.ts b/src/common/utils/discount-rate.ts new file mode 100644 index 00000000..0d8d60e2 --- /dev/null +++ b/src/common/utils/discount-rate.ts @@ -0,0 +1,12 @@ +/** 할인율(0~100, 반올림). 상품 카드·상세·리뷰·주문 목록이 같은 규칙을 쓴다. DI-free. */ +export function calcDiscountRate( + regularPrice: number, + salePrice: number | null, +): number { + if (salePrice === null || regularPrice <= 0 || salePrice >= regularPrice) { + return 0; + } + // salePrice가 음수 등 비정상 값이어도 공개 계약(0~100)을 지키도록 clamp + const rate = Math.round((1 - salePrice / regularPrice) * 100); + return Math.min(100, Math.max(0, rate)); +} diff --git a/src/features/order/order-my.graphql b/src/features/order/order-my.graphql index 8ffbd288..a8ea08d8 100644 --- a/src/features/order/order-my.graphql +++ b/src/features/order/order-my.graphql @@ -79,6 +79,11 @@ type MyOrderSummary { """ additionalItemCount: Int! """ + 주문 할인율(0~100, 반올림) = 할인 금액 ÷ 할인 전 합계. 할인이 없으면 0. + 옵션 추가금은 할인 대상이 아니라서, 옵션이 붙은 주문은 상품 카드의 할인율보다 낮을 수 있다. + """ + discountRate: Int! + """ 총 가격 """ totalPrice: Int! @@ -87,6 +92,10 @@ type MyOrderSummary { """ storeName: String! """ + 매장 위치 표기(예: 인천 청라동). 주문 시점 스냅샷이며, 그때 매장 주소·지역이 없었으면 null. + """ + regionLabel: String + """ 주문 내 OrderItem 중 리뷰 작성 가능한 것이 1건이라도 있는지 (PICKED_UP + 활성 리뷰 미존재) """ hasReviewableItem: Boolean! diff --git a/src/features/order/repositories/order-item-region-label-snapshot.migration.spec.ts b/src/features/order/repositories/order-item-region-label-snapshot.migration.spec.ts new file mode 100644 index 00000000..8beddfe1 --- /dev/null +++ b/src/features/order/repositories/order-item-region-label-snapshot.migration.spec.ts @@ -0,0 +1,115 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { buildRegionLabel } from '@/common/utils/region-label'; +import type { PrismaClient } from '@/generated/prisma/client'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { createOrderItem, createRegion, createStore } from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +// 백필 SQL을 그대로 실행해, 주문 생성 경로(buildRegionLabel)와 같은 표기가 채워지는지 본다. +// 마이그레이션은 빈 DB에 적용되므로 여기서만 검증된다. +const MIGRATION = join( + __dirname, + '../../../../prisma/migrations/20261102144339_order_item_region_label_snapshot/migration.sql', +); + +function backfillStatements(): string[] { + const sql = readFileSync(MIGRATION, 'utf8'); + const body = sql.slice( + sql.indexOf('-- backfill'), + sql.indexOf('-- end backfill'), + ); + return body + .split(';') + .map((stmt) => stmt.replace(/^\s*--[^\n]*$/gm, '').trim()) + .filter((stmt) => stmt.length > 0); +} + +interface StoreAddress { + city: string | null; + dong: string | null; + region?: { name: string; deleted?: boolean }; +} + +describe('order_item 지역 표기 스냅샷 백필 (real DB)', () => { + let prisma: PrismaClient; + + beforeAll(async () => { + ({ prisma } = await createTestingModuleWithRealDb({ providers: [] })); + }); + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + beforeEach(async () => { + await truncateAll(); + }); + + it.each<[string, StoreAddress, string | null]>([ + [ + '시+동', + { city: '인천', dong: '청라동', region: { name: '서구' } }, + '인천 청라동', + ], + ['시만', { city: '인천', dong: null }, '인천'], + ['동만', { city: null, dong: '청라동' }, '청라동'], + [ + '주소 없이 지역만', + { city: null, dong: null, region: { name: '서구' } }, + '서구', + ], + [ + '빈 문자열 시·동은 건너뛰고 지역명', + { city: '', dong: '', region: { name: '서구' } }, + '서구', + ], + // NULLIF(x, '')였다면 PAD SPACE 비교로 비워져 JS 규칙과 어긋난다 + [ + '공백만 있는 시도 JS 규칙처럼 쓴다', + { city: ' ', dong: '청라동' }, + ' 청라동', + ], + [ + 'soft-delete된 지역도 조회 경로처럼 쓴다', + { city: null, dong: null, region: { name: '서구', deleted: true } }, + '서구', + ], + ['주소·지역 모두 없으면 null', { city: null, dong: null }, null], + ])('%s', async (_case, address, expected) => { + const region = address.region + ? await createRegion(prisma, { name: address.region.name, level: 2 }) + : null; + if (address.region?.deleted) { + await prisma.region.update({ + where: { id: region!.id }, + data: { deleted_at: new Date() }, + }); + } + const store = await createStore(prisma, { region_id: region?.id ?? null }); + await prisma.store.update({ + where: { id: store.id }, + data: { address_city: address.city, address_neighborhood: address.dong }, + }); + // 백필 대상 재현: 스냅샷이 비어 있는(=마이그레이션 전) 품목 + const item = await createOrderItem(prisma, { + store_id: store.id, + store_region_label_snapshot: null, + }); + + const statements = backfillStatements(); + expect(statements).toHaveLength(1); + await prisma.$executeRawUnsafe(statements[0]); + + const row = await prisma.orderItem.findUniqueOrThrow({ + where: { id: item.id }, + }); + const current = await prisma.store.findUniqueOrThrow({ + where: { id: store.id }, + include: { region: { select: { name: true } } }, + }); + expect(row.store_region_label_snapshot).toBe(expected); + expect(row.store_region_label_snapshot).toBe(buildRegionLabel(current)); + }); +}); diff --git a/src/features/order/repositories/order.repository.spec.ts b/src/features/order/repositories/order.repository.spec.ts index 4d3537cd..cc41ae3c 100644 --- a/src/features/order/repositories/order.repository.spec.ts +++ b/src/features/order/repositories/order.repository.spec.ts @@ -891,6 +891,7 @@ describe('OrderRepository (real DB)', () => { productId: args.product.id, productNameSnapshot: args.product.name, storeNameSnapshot: args.store.store_name, + storeRegionLabelSnapshot: '서울 성수동', productThumbnailUrlSnapshot: null, regularPriceSnapshot: 30000, salePriceSnapshot: 25000, diff --git a/src/features/order/repositories/order.repository.ts b/src/features/order/repositories/order.repository.ts index 303f3e0c..78199b2f 100644 --- a/src/features/order/repositories/order.repository.ts +++ b/src/features/order/repositories/order.repository.ts @@ -88,10 +88,13 @@ export interface MyOrderRow { status: OrderStatus; created_at: Date; pickup_at: Date; + subtotal_price: number; + discount_price: number; total_price: number; items: { product_name_snapshot: string; store_name_snapshot: string; + store_region_label_snapshot: string | null; product_thumbnail_url_snapshot: string | null; }[]; _count: { items: number }; @@ -136,6 +139,7 @@ export interface CreateSubmittedOrderArgs { productId: bigint; productNameSnapshot: string; storeNameSnapshot: string; + storeRegionLabelSnapshot: string | null; productThumbnailUrlSnapshot: string | null; regularPriceSnapshot: number; salePriceSnapshot: number | null; @@ -303,6 +307,7 @@ export class OrderRepository { product_id: args.item.productId, product_name_snapshot: args.item.productNameSnapshot, store_name_snapshot: args.item.storeNameSnapshot, + store_region_label_snapshot: args.item.storeRegionLabelSnapshot, product_thumbnail_url_snapshot: args.item.productThumbnailUrlSnapshot, regular_price_snapshot: args.item.regularPriceSnapshot, diff --git a/src/features/order/resolvers/order-my.resolver.spec.ts b/src/features/order/resolvers/order-my.resolver.spec.ts index 9a3643be..7bfba62c 100644 --- a/src/features/order/resolvers/order-my.resolver.spec.ts +++ b/src/features/order/resolvers/order-my.resolver.spec.ts @@ -48,11 +48,17 @@ describe('User Order Resolver (real DB)', () => { it('Query.myOrders: DB에서 본인 주문 목록을 반환한다', async () => { const account = await createAccount(prisma, { account_type: 'USER' }); await createUserProfile(prisma, { account_id: account.id }); - const store = await createStore(prisma); + const store = await createStore(prisma, { + address_city: '인천', + address_neighborhood: '청라동', + }); const product = await createProduct(prisma, { store_id: store.id }); const order = await createOrder(prisma, { account_id: account.id, status: 'SUBMITTED', + subtotal_price: 40000, + discount_price: 5000, + total_price: 35000, }); await createOrderItem(prisma, { order_id: order.id, @@ -65,7 +71,11 @@ describe('User Order Resolver (real DB)', () => { ); expect(result.totalCount).toBe(1); - expect(result.items[0].orderId).toBe(order.id.toString()); + expect(result.items[0]).toMatchObject({ + orderId: order.id.toString(), + discountRate: 13, + regionLabel: '인천 청라동', + }); }); it('Query.myOrder: 타 계정 주문 접근은 404가 전파된다', async () => { diff --git a/src/features/order/services/order-checkout.service.spec.ts b/src/features/order/services/order-checkout.service.spec.ts index fec7a419..7fd3612c 100644 --- a/src/features/order/services/order-checkout.service.spec.ts +++ b/src/features/order/services/order-checkout.service.spec.ts @@ -30,6 +30,7 @@ import { createOrder as createOrderRow, createOrderItem, createProduct, + createRegion, createStore, createStoreDailyCapacity, createUserProfile, @@ -291,6 +292,54 @@ describe('OrderCheckoutService (real DB)', () => { expect(saved.discount_price).toBe(0); }); + it.each< + [ + string, + { city: string | null; dong: string | null; regionName?: string }, + string | null, + ] + >([ + [ + '시·동이 있으면 "시 동"', + { city: '인천', dong: '청라동' }, + '인천 청라동', + ], + [ + '시·동이 비어 있으면 2차 지역명', + { city: '', dong: null, regionName: '서구' }, + '서구', + ], + ['주소·지역이 모두 없으면 null', { city: null, dong: null }, null], + ])( + '매장 지역 표기를 품목에 스냅샷한다 — %s', + async (_case, { city, dong, regionName }, expected) => { + const store = await makeOpenStore(); + const region = regionName + ? await createRegion(prisma, { name: regionName, level: 2 }) + : null; + await prisma.store.update({ + where: { id: store.id }, + data: { + address_city: city, + address_neighborhood: dong, + region_id: region?.id ?? null, + }, + }); + const product = await createProduct(prisma, { store_id: store.id }); + const buyer = await makeBuyer(); + + const result = await service.createOrder( + buyer.id, + baseInput({ productId: product.id.toString() }), + ); + + const item = await prisma.orderItem.findFirstOrThrow({ + where: { order_id: BigInt(result.orderId) }, + }); + expect(item.store_region_label_snapshot).toBe(expected); + }, + ); + it('필수 그룹 누락·max 초과는 그룹 규칙 위반으로 거절한다', async () => { const store = await makeOpenStore(); const { product, sizeSmallId, sizeLargeId } = diff --git a/src/features/order/services/order-checkout.service.ts b/src/features/order/services/order-checkout.service.ts index cff5ca2d..7e2fac25 100644 --- a/src/features/order/services/order-checkout.service.ts +++ b/src/features/order/services/order-checkout.service.ts @@ -6,6 +6,7 @@ import { RandomService } from '@/common/providers/random.service'; import { parseId } from '@/common/utils/id-parser'; import { formatKstDate, kstDayBoundaries } from '@/common/utils/kst-time'; import { uniqueConstraintName } from '@/common/utils/prisma-error'; +import { buildRegionLabel } from '@/common/utils/region-label'; import { evaluateActiveUserAccount } from '@/features/auth'; import type { CreateOrderInput } from '@/features/order/dto/inputs/create-order.input'; import { OrderRepository } from '@/features/order/repositories/order.repository'; @@ -143,6 +144,7 @@ export class OrderCheckoutService { productNameSnapshot: product.name, // 주문 시점 표시값 — 이후 매장명·이미지가 바뀌어도 주문 화면은 이 값을 유지한다 storeNameSnapshot: product.store.store_name, + storeRegionLabelSnapshot: buildRegionLabel(product.store), productThumbnailUrlSnapshot: product.images[0]?.image_url ?? null, regularPriceSnapshot: product.regular_price, salePriceSnapshot: product.sale_price, diff --git a/src/features/order/services/order-my.service.spec.ts b/src/features/order/services/order-my.service.spec.ts index ac5afdb8..b3fed3a3 100644 --- a/src/features/order/services/order-my.service.spec.ts +++ b/src/features/order/services/order-my.service.spec.ts @@ -102,10 +102,65 @@ describe('UserOrderService (real DB)', () => { representativeProductName: '상품 정보 없음', representativeProductImageUrl: null, storeName: '매장 정보 없음', + regionLabel: null, additionalItemCount: 0, }); }); + it.each<[string, number, number, number]>([ + ['할인이 없으면 0', 10000, 0, 0], + ['할인 금액 ÷ 할인 전 합계를 반올림한다', 40000, 2000, 5], + // 정가 40,000·판매가 35,000(상품 카드 13%) + 옵션 5,000 — 옵션은 할인 대상이 아니다 + ['옵션 추가금이 붙으면 상품 할인율보다 낮다', 45000, 5000, 11], + ['할인 전 합계가 0이면 0(0으로 나누지 않는다)', 0, 0, 0], + ])( + 'discountRate는 주문 기준이다 — %s', + async (_case, subtotal, discount, expected) => { + const account = await setupUser(); + await createOrder(prisma, { + account_id: account.id, + subtotal_price: subtotal, + discount_price: discount, + total_price: subtotal - discount, + }); + + const result = await service.listMyOrders(account.id); + + expect(result.items[0].discountRate).toBe(expected); + }, + ); + + it('regionLabel은 주문 시점 스냅샷이라 이후 매장 주소가 바뀌어도 그대로다', async () => { + const account = await setupUser(); + const store = await createStore(prisma, { + address_city: '인천', + address_neighborhood: '청라동', + }); + const order = await createOrder(prisma, { account_id: account.id }); + await createOrderItem(prisma, { order_id: order.id, store_id: store.id }); + await prisma.store.update({ + where: { id: store.id }, + data: { address_city: '서울특별시', address_neighborhood: '역삼동' }, + }); + + const result = await service.listMyOrders(account.id); + + expect(result.items[0].regionLabel).toBe('인천 청라동'); + }); + + it('대표 품목의 지역 스냅샷이 null이면 regionLabel도 null이다', async () => { + const account = await setupUser(); + const order = await createOrder(prisma, { account_id: account.id }); + await createOrderItem(prisma, { + order_id: order.id, + store_region_label_snapshot: null, + }); + + const result = await service.listMyOrders(account.id); + + expect(result.items[0].regionLabel).toBeNull(); + }); + it('status 필터를 전달하면 해당 상태의 주문만 포함한다', async () => { const account = await setupUser(); await createOrder(prisma, { diff --git a/src/features/order/services/order-my.service.ts b/src/features/order/services/order-my.service.ts index cb728409..bcb34591 100644 --- a/src/features/order/services/order-my.service.ts +++ b/src/features/order/services/order-my.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { DomainException } from '@/common/errors/error-catalog'; +import { calcDiscountRate } from '@/common/utils/discount-rate'; import { sliceOverfetched } from '@/common/utils/pagination'; import type { MyOrdersInput } from '@/features/order/dto/inputs/my-orders.input'; import { OrderRepository } from '@/features/order/repositories/order.repository'; @@ -65,8 +66,14 @@ export class UserOrderService { representativeProductImageUrl: firstItem?.product_thumbnail_url_snapshot ?? null, additionalItemCount: Math.max(0, itemCount - 1), + // 주문 기준: 할인 전 합계를 정가, 할인 뒤 금액을 판매가로 본다(옵션 추가금은 할인 대상 아님) + discountRate: calcDiscountRate( + order.subtotal_price, + order.subtotal_price - order.discount_price, + ), totalPrice: order.total_price, storeName: firstItem?.store_name_snapshot ?? '매장 정보 없음', + regionLabel: firstItem?.store_region_label_snapshot ?? null, hasReviewableItem: reviewableOrderIds.has(order.id.toString()), }; }), diff --git a/src/features/order/types/order-my-output.type.ts b/src/features/order/types/order-my-output.type.ts index 64022fa9..10df934b 100644 --- a/src/features/order/types/order-my-output.type.ts +++ b/src/features/order/types/order-my-output.type.ts @@ -14,8 +14,10 @@ export interface MyOrderSummary { representativeProductName: string; representativeProductImageUrl: string | null; additionalItemCount: number; + discountRate: number; totalPrice: number; storeName: string; + regionLabel: string | null; hasReviewableItem: boolean; } diff --git a/src/features/product/repositories/product.repository.ts b/src/features/product/repositories/product.repository.ts index fcc704c7..08600b2f 100644 --- a/src/features/product/repositories/product.repository.ts +++ b/src/features/product/repositories/product.repository.ts @@ -119,8 +119,13 @@ export interface ProductDetailRow { sale_price: number | null; currency: string; preparation_time_minutes: number; - /** 주문 생성 시 매장명 스냅샷용 */ - store: { store_name: string }; + /** 주문 생성 시 매장명·지역 표기 스냅샷용 */ + store: { + store_name: string; + address_city: string | null; + address_neighborhood: string | null; + region: { name: string } | null; + }; images: { image_url: string }[]; option_groups: { id: bigint; @@ -1256,7 +1261,15 @@ export class ProductRepository { sale_price: true, currency: true, preparation_time_minutes: true, - store: { select: { store_name: true } }, + store: { + select: { + store_name: true, + // 주문 품목 지역 스냅샷(buildRegionLabel) 원천 + address_city: true, + address_neighborhood: true, + region: { select: { name: true } }, + }, + }, images: { where: activeWhere, orderBy: { sort_order: 'asc' }, diff --git a/src/features/product/services/product-card.service.ts b/src/features/product/services/product-card.service.ts index 3ba3eb73..311b2332 100644 --- a/src/features/product/services/product-card.service.ts +++ b/src/features/product/services/product-card.service.ts @@ -1,9 +1,9 @@ import { Injectable } from '@nestjs/common'; +import { calcDiscountRate } from '@/common/utils/discount-rate'; import { roundRatingAverage } from '@/common/utils/rating'; import { buildRegionLabel } from '@/common/utils/region-label'; import { ProductRepository } from '@/features/product/repositories/product.repository'; -import { calcDiscountRate } from '@/features/product/services/product-storefront-mappers.helper'; import type { ProductCardOutput } from '@/features/product/types/product-card-output.type'; import { ReviewReadRepository, diff --git a/src/features/product/services/product-detail-mappers.helper.ts b/src/features/product/services/product-detail-mappers.helper.ts index 1f837fea..ca12a4ec 100644 --- a/src/features/product/services/product-detail-mappers.helper.ts +++ b/src/features/product/services/product-detail-mappers.helper.ts @@ -1,5 +1,5 @@ +import { calcDiscountRate } from '@/common/utils/discount-rate'; import type { ProductDetailRow } from '@/features/product/repositories/product.repository'; -import { calcDiscountRate } from '@/features/product/services/product-storefront-mappers.helper'; import type { ProductDetail } from '@/features/product/types/product-detail-output.type'; export function toProductDetail( diff --git a/src/features/product/services/product-review-mappers.helper.ts b/src/features/product/services/product-review-mappers.helper.ts index ca8f1ffc..84e6ad39 100644 --- a/src/features/product/services/product-review-mappers.helper.ts +++ b/src/features/product/services/product-review-mappers.helper.ts @@ -1,6 +1,6 @@ +import { calcDiscountRate } from '@/common/utils/discount-rate'; import { buildRegionLabel } from '@/common/utils/region-label'; import { anonymizeReviewAuthor } from '@/common/utils/review-author'; -import { calcDiscountRate } from '@/features/product/services/product-storefront-mappers.helper'; import type { ReviewCommentItem, ReviewDetailProduct, diff --git a/src/features/product/services/product-storefront-mappers.helper.spec.ts b/src/features/product/services/product-storefront-mappers.helper.spec.ts index c428dc80..3730821b 100644 --- a/src/features/product/services/product-storefront-mappers.helper.spec.ts +++ b/src/features/product/services/product-storefront-mappers.helper.spec.ts @@ -1,32 +1,5 @@ import type { StoreProductCategoryRow } from '@/features/product/repositories/product.repository'; -import { - calcDiscountRate, - toStoreProductCategory, -} from '@/features/product/services/product-storefront-mappers.helper'; - -describe('calcDiscountRate', () => { - it('정상 할인율을 정수로 반올림한다', () => { - expect(calcDiscountRate(40000, 35000)).toBe(13); // 12.5 → 13 - expect(calcDiscountRate(33000, 31350)).toBe(5); - }); - - it('salePrice가 null이면 0', () => { - expect(calcDiscountRate(40000, null)).toBe(0); - }); - - it('salePrice가 정가 이상이면 0', () => { - expect(calcDiscountRate(40000, 40000)).toBe(0); - expect(calcDiscountRate(40000, 45000)).toBe(0); - }); - - it('정가가 0 이하이면 0', () => { - expect(calcDiscountRate(0, 0)).toBe(0); - }); - - it('salePrice가 음수 등 비정상이면 0~100으로 clamp한다', () => { - expect(calcDiscountRate(40000, -10000)).toBe(100); - }); -}); +import { toStoreProductCategory } from '@/features/product/services/product-storefront-mappers.helper'; describe('toStoreProductCategory', () => { it('카테고리 row를 매핑한다', () => { diff --git a/src/features/product/services/product-storefront-mappers.helper.ts b/src/features/product/services/product-storefront-mappers.helper.ts index 5a0a175c..d4915c02 100644 --- a/src/features/product/services/product-storefront-mappers.helper.ts +++ b/src/features/product/services/product-storefront-mappers.helper.ts @@ -1,18 +1,6 @@ import type { StoreProductCategoryRow } from '@/features/product/repositories/product.repository'; import type { StoreProductCategory } from '@/features/product/types/product-storefront-output.type'; -export function calcDiscountRate( - regularPrice: number, - salePrice: number | null, -): number { - if (salePrice === null || regularPrice <= 0 || salePrice >= regularPrice) { - return 0; - } - // salePrice가 음수 등 비정상 값이어도 공개 계약(0~100)을 지키도록 clamp - const rate = Math.round((1 - salePrice / regularPrice) * 100); - return Math.min(100, Math.max(0, rate)); -} - export function toStoreProductCategory( row: StoreProductCategoryRow, ): StoreProductCategory { diff --git a/src/test/factories/order.factory.ts b/src/test/factories/order.factory.ts index 7c3d87d9..0c81f5ca 100644 --- a/src/test/factories/order.factory.ts +++ b/src/test/factories/order.factory.ts @@ -1,4 +1,5 @@ import { DAY_MS } from '@/common/utils/kst-time'; +import { buildRegionLabel } from '@/common/utils/region-label'; import type { Order, OrderItem, @@ -59,8 +60,9 @@ export interface OrderItemOverrides { store_id?: bigint; product_id?: bigint; product_name_snapshot?: string; - /** 미지정 시 주문 생성 경로와 같은 규칙(현재 매장명·활성 첫 이미지)으로 채운다. */ + /** 미지정 시 주문 생성 경로와 같은 규칙(현재 매장명·지역 표기·활성 첫 이미지)으로 채운다. */ store_name_snapshot?: string; + store_region_label_snapshot?: string | null; product_thumbnail_url_snapshot?: string | null; regular_price_snapshot?: number; sale_price_snapshot?: number | null; @@ -90,10 +92,14 @@ export async function createOrderItem( } const orderId = overrides.order_id ?? (await createOrder(prisma)).id; - const storeName = - overrides.store_name_snapshot ?? - (await prisma.store.findUniqueOrThrow({ where: { id: storeId! } })) - .store_name; + const store = await prisma.store.findUniqueOrThrow({ + where: { id: storeId! }, + include: { region: { select: { name: true } } }, + }); + const regionLabel = + overrides.store_region_label_snapshot === undefined + ? buildRegionLabel(store) + : overrides.store_region_label_snapshot; const thumbnail = overrides.product_thumbnail_url_snapshot === undefined ? (( @@ -111,7 +117,8 @@ export async function createOrderItem( product_id: productId, product_name_snapshot: overrides.product_name_snapshot ?? 'Product snapshot', - store_name_snapshot: storeName, + store_name_snapshot: overrides.store_name_snapshot ?? store.store_name, + store_region_label_snapshot: regionLabel, product_thumbnail_url_snapshot: thumbnail, regular_price_snapshot: overrides.regular_price_snapshot ?? 10000, sale_price_snapshot: overrides.sale_price_snapshot ?? null,