Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 25 additions & 4 deletions prisma/seed/orders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -27,6 +28,18 @@ async function firstImageUrl(
return image?.image_url ?? null;
}

/** 주문 시점 지역 표기 스냅샷 — 주문 생성 경로와 같은 규칙(buildRegionLabel). */
async function regionLabelOf(
prisma: PrismaClient,
storeId: bigint,
): Promise<string | null> {
const store = await prisma.store.findUniqueOrThrow({
where: { id: storeId },
include: { region: { select: { name: true } } },
});
return buildRegionLabel(store);
}

export interface SeededOrders {
o1Submitted: bigint;
o2Confirmed: bigint;
Expand All @@ -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;
Expand All @@ -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: {
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions src/common/utils/discount-rate.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
12 changes: 12 additions & 0 deletions src/common/utils/discount-rate.ts
Original file line number Diff line number Diff line change
@@ -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));
}
9 changes: 9 additions & 0 deletions src/features/order/order-my.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ type MyOrderSummary {
"""
additionalItemCount: Int!
"""
주문 할인율(0~100, 반올림) = 할인 금액 ÷ 할인 전 합계. 할인이 없으면 0.
옵션 추가금은 할인 대상이 아니라서, 옵션이 붙은 주문은 상품 카드의 할인율보다 낮을 수 있다.
"""
discountRate: Int!
"""
총 가격
"""
totalPrice: Int!
Expand All @@ -87,6 +92,10 @@ type MyOrderSummary {
"""
storeName: String!
"""
매장 위치 표기(예: 인천 청라동). 주문 시점 스냅샷이며, 그때 매장 주소·지역이 없었으면 null.
"""
regionLabel: String
"""
주문 내 OrderItem 중 리뷰 작성 가능한 것이 1건이라도 있는지 (PICKED_UP + 활성 리뷰 미존재)
"""
hasReviewableItem: Boolean!
Expand Down
Original file line number Diff line number Diff line change
@@ -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));
});
});
1 change: 1 addition & 0 deletions src/features/order/repositories/order.repository.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/features/order/repositories/order.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -136,6 +139,7 @@ export interface CreateSubmittedOrderArgs {
productId: bigint;
productNameSnapshot: string;
storeNameSnapshot: string;
storeRegionLabelSnapshot: string | null;
productThumbnailUrlSnapshot: string | null;
regularPriceSnapshot: number;
salePriceSnapshot: number | null;
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 12 additions & 2 deletions src/features/order/resolvers/order-my.resolver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 () => {
Expand Down
Loading
Loading