From 9608fc2cd8ba634746eae278d9484c05d8882eb2 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 2 Nov 2026 23:42:34 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20=ED=95=A0=EC=9D=B8=EC=9C=A8=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=EC=9D=84=20=EA=B3=B5=EC=9A=A9=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - calcDiscountRate를 product-storefront-mappers.helper에서 common/utils/discount-rate로 옮김. 주문 목록도 같은 규칙을 쓰게 돼 buildRegionLabel처럼 공용 표시 규칙으로 둠. - product 쪽 import 3곳 변경, spec은 it.each 표로 옮김(7케이스). --- src/common/utils/discount-rate.spec.ts | 15 ++++++++++ src/common/utils/discount-rate.ts | 12 ++++++++ .../product/services/product-card.service.ts | 2 +- .../services/product-detail-mappers.helper.ts | 2 +- .../services/product-review-mappers.helper.ts | 2 +- .../product-storefront-mappers.helper.spec.ts | 29 +------------------ .../product-storefront-mappers.helper.ts | 12 -------- 7 files changed, 31 insertions(+), 43 deletions(-) create mode 100644 src/common/utils/discount-rate.spec.ts create mode 100644 src/common/utils/discount-rate.ts 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/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 { From 0bdc9be43ef75f7c7277091639784308af439c59 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 2 Nov 2026 23:54:07 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=A3=BC=EB=AC=B8=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=EC=97=90=20=ED=95=A0=EC=9D=B8=EC=9C=A8=C2=B7?= =?UTF-8?q?=EB=A7=A4=EC=9E=A5=20=EC=A7=80=EC=97=AD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FE 마이페이지 주문상황 카드("매장명 · 지역", "할인율 % 금액")에 쓸 값이 MyOrderSummary에 없었음. - 할인율: 주문 기준 discount_price ÷ subtotal_price 반올림(0~100, 할인 없으면 0). 옆에 붙는 금액이 주문 총액이라 숫자를 맞춤. 옵션 추가금은 할인 대상이 아니라 옵션 주문은 상품 카드 할인율보다 낮게 나옴. - 지역: order_item.store_region_label_snapshot(nullable) 추가, 주문 생성 때 buildRegionLabel로 저장. 매장 실시간 조인은 read-boundary 허용 목록(Order.items.store.region)을 늘려 스냅샷으로 감. - 마이그레이션 백필은 현재 매장 기준 같은 규칙. 빈 값 판정은 CHAR_LENGTH(NULLIF는 PAD SPACE로 공백값까지 비움). - ProductRepository.findProductDetailById의 store select에 주소·지역 추가(Product·Store·Region 모두 catalog라 경계 항목 증가 없음). - SDL: MyOrderSummary.discountRate: Int!, regionLabel: String (필드 추가만, FE 파괴적 변경 없음). - 팩토리 createOrderItem은 미지정 시 생성 경로 규칙으로 지역 스냅샷을 채움. - 시드 o1·o4: p1(정가 40,000·판매가 35,000)인데 subtotal 35,000/discount 0이던 것을 생성 경로 규칙(40,000/5,000)으로 맞춤. 테스트 17건 추가 - order-my.service: discountRate 4(옵션 포함·subtotal 0 반증), regionLabel 2(주문 뒤 매장 주소 변경 반증) - order-checkout.service: 지역 스냅샷 3(시·동 / 지역명 / null) - 백필 migration spec 8: buildRegionLabel과 결과 비교, 공백값 케이스는 NULLIF 버전에서 실패하는 것 확인 - order-my.resolver: 전체 경로 단언에 두 필드 --- .../migration.sql | 16 +++ prisma/schema.prisma | 2 + prisma/seed/orders.ts | 29 ++++- src/features/order/order-my.graphql | 9 ++ ...em-region-label-snapshot.migration.spec.ts | 115 ++++++++++++++++++ .../repositories/order.repository.spec.ts | 1 + .../order/repositories/order.repository.ts | 5 + .../order/resolvers/order-my.resolver.spec.ts | 14 ++- .../services/order-checkout.service.spec.ts | 49 ++++++++ .../order/services/order-checkout.service.ts | 2 + .../order/services/order-my.service.spec.ts | 55 +++++++++ .../order/services/order-my.service.ts | 7 ++ .../order/types/order-my-output.type.ts | 2 + .../repositories/product.repository.ts | 19 ++- src/test/factories/order.factory.ts | 19 ++- 15 files changed, 329 insertions(+), 15 deletions(-) create mode 100644 prisma/migrations/20261102144339_order_item_region_label_snapshot/migration.sql create mode 100644 src/features/order/repositories/order-item-region-label-snapshot.migration.spec.ts 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/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/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,