Add product options and remove server-side stock management

- 상품에 재고 없는 옵션(optionLabel/options — 예: 키링 색상)을 도입하고 주문 항목에 optionId/optionName 스냅샷 저장
- 주문 생성 시 옵션 검증: 옵션 상품에 미선택이면 OPTION_REQUIRED, 무옵션 상품에 optionId가 오면 INVALID_INPUT
- 같은 상품의 색상별 항목을 지원하도록 상품 문서를 productId당 한 번만 읽게 정리
- 재고는 별도 플랫폼에서 관리하므로 서버 재고 개념 전면 제거(ProductDoc 재고 필드, availableStock, 주문 예약/차감, stock/adjust 엔드포인트, order/status restock)
- 상세 카탈로그·주문 DTO에 옵션 노출, product/upsert가 optionLabel/options 수용
- 시드/업로드 스크립트의 재고 필드 기록 제거, vitest 계약 테스트 추가
This commit is contained in:
윤정민 2026-07-21 13:57:07 +09:00
parent 2e4d01f985
commit fde21bf8c3
11 changed files with 192 additions and 35 deletions

View File

@ -25,9 +25,6 @@ async function main() {
name: p.name, name: p.name,
pointPrice: p.pointPrice, pointPrice: p.pointPrice,
displayOrder: p.displayOrder, displayOrder: p.displayOrder,
totalStock: existing?.totalStock ?? 0,
reservedStock: existing?.reservedStock ?? 0,
safetyStock: existing?.safetyStock ?? 0,
...draft, ...draft,
createdAt: existing?.createdAt ?? now, createdAt: existing?.createdAt ?? now,
updatedAt: now, updatedAt: now,

View File

@ -146,9 +146,6 @@ async function upsertProduct(
displayOrder: { integerValue: String(spec.displayOrder) }, displayOrder: { integerValue: String(spec.displayOrder) },
mainImages: stringArray(mainImages), mainImages: stringArray(mainImages),
detailImages: stringArray(detailImages), detailImages: stringArray(detailImages),
totalStock: existingProduct?.fields?.totalStock ?? { integerValue: "0" },
reservedStock: existingProduct?.fields?.reservedStock ?? { integerValue: "0" },
safetyStock: existingProduct?.fields?.safetyStock ?? { integerValue: "0" },
createdAt: existingProduct?.fields?.createdAt ?? { timestampValue: now }, createdAt: existingProduct?.fields?.createdAt ?? { timestampValue: now },
updatedAt: { timestampValue: now }, updatedAt: { timestampValue: now },
}); });

View File

@ -7,7 +7,6 @@ import { getWallet } from "../repositories/walletRepository";
import { listLedger } from "../repositories/pointLedgerRepository"; import { listLedger } from "../repositories/pointLedgerRepository";
import { firestore } from "../firebase"; import { firestore } from "../firebase";
import { Timestamp } from "firebase-admin/firestore"; import { Timestamp } from "firebase-admin/firestore";
import { availableStock, productRef } from "../repositories/productRepository";
import type { OrderStatus, ProductDoc } from "../types/reward"; import type { OrderStatus, ProductDoc } from "../types/reward";
import { transitionOrder } from "../services/orderService"; import { transitionOrder } from "../services/orderService";
import { invalidateRewardCatalog } from "../services/rewardCatalogService"; import { invalidateRewardCatalog } from "../services/rewardCatalogService";
@ -25,12 +24,21 @@ export const admin = onRequest(async (req, res) => {
if (tail === "product/upsert" && req.method === "POST") { if (tail === "product/upsert" && req.method === "POST") {
const { const {
id, name, pointPrice, active, redeemable, displayOrder, id, name, pointPrice, active, redeemable, displayOrder,
mainImages, detailImages, mainImages, detailImages, optionLabel, options,
} = req.body ?? {}; } = req.body ?? {};
const validUrls = (value: unknown, max: number) => const validUrls = (value: unknown, max: number) =>
Array.isArray(value) && value.length <= max && value.every((url) => Array.isArray(value) && value.length <= max && value.every((url) =>
typeof url === "string" && url.length <= 2048 && /^https:\/\//.test(url) typeof url === "string" && url.length <= 2048 && /^https:\/\//.test(url)
); );
// 옵션은 재고 없는 선택지 라벨(예: 색상)이다. 요청에서 빠지면 옵션 없는 상품으로 저장한다.
const validOptions =
options === undefined ||
(Array.isArray(options) && options.length >= 1 && options.length <= 20 &&
options.every((o) =>
typeof o?.id === "string" && /^[A-Za-z0-9_-]{1,100}$/.test(o.id) &&
typeof o?.name === "string" && o.name.length >= 1 && o.name.length <= 100
) &&
new Set(options.map((o) => o.id)).size === options.length);
const valid = const valid =
/^[A-Za-z0-9_-]{1,100}$/.test(id) && /^[A-Za-z0-9_-]{1,100}$/.test(id) &&
typeof name === "string" && name.length >= 1 && name.length <= 100 && typeof name === "string" && name.length >= 1 && name.length <= 100 &&
@ -38,7 +46,10 @@ export const admin = onRequest(async (req, res) => {
typeof active === "boolean" && typeof redeemable === "boolean" && typeof active === "boolean" && typeof redeemable === "boolean" &&
Number.isSafeInteger(displayOrder) && displayOrder >= 0 && Number.isSafeInteger(displayOrder) && displayOrder >= 0 &&
validUrls(mainImages, 10) && validUrls(detailImages, 30) && validUrls(mainImages, 10) && validUrls(detailImages, 30) &&
(!active || !redeemable || mainImages.length > 0); (!active || !redeemable || mainImages.length > 0) &&
validOptions &&
(optionLabel === undefined ||
(typeof optionLabel === "string" && optionLabel.length >= 1 && optionLabel.length <= 50));
if (!valid) throw new HttpError(400, "invalid product", "INVALID_INPUT"); if (!valid) throw new HttpError(400, "invalid product", "INVALID_INPUT");
const now = Timestamp.now(); const now = Timestamp.now();
await firestore.runTransaction(async (tx) => { await firestore.runTransaction(async (tx) => {
@ -48,9 +59,12 @@ export const admin = onRequest(async (req, res) => {
tx.set(productRef, { tx.set(productRef, {
name, pointPrice, active, redeemable, displayOrder, name, pointPrice, active, redeemable, displayOrder,
mainImages, detailImages, mainImages, detailImages,
totalStock: previous?.totalStock ?? 0, ...(options !== undefined
reservedStock: previous?.reservedStock ?? 0, ? {
safetyStock: previous?.safetyStock ?? 0, optionLabel: optionLabel ?? "옵션",
options: options.map((o: { id: string; name: string }) => ({ id: o.id, name: o.name })),
}
: {}),
createdAt: previous?.createdAt ?? now, createdAt: previous?.createdAt ?? now,
updatedAt: now, updatedAt: now,
}); });
@ -59,8 +73,7 @@ export const admin = onRequest(async (req, res) => {
res.json({ id }); res.json({ id });
return; return;
} }
if (tail === "stock/adjust" && req.method === "POST") { const { productId, deltaTotal, safetyStock } = req.body ?? {}; if (!/^[A-Za-z0-9_-]{1,100}$/.test(productId) || !Number.isSafeInteger(deltaTotal ?? 0) || (safetyStock !== undefined && (!Number.isSafeInteger(safetyStock) || safetyStock < 0))) throw new HttpError(400, "invalid stock adjustment", "INVALID_INPUT"); const result = await firestore.runTransaction(async (tx) => { const ref = productRef(productId); const snap = await tx.get(ref); if (!snap.exists) throw new HttpError(404, "product not found", "PRODUCT_NOT_FOUND"); const product = snap.data() as ProductDoc; const next = { totalStock: product.totalStock + (deltaTotal ?? 0), reservedStock: product.reservedStock, safetyStock: safetyStock ?? product.safetyStock, updatedAt: Timestamp.now() }; if (next.totalStock < 0 || availableStock(next) < 0) throw new HttpError(409, "invalid stock adjustment", "INVALID_STOCK"); tx.update(ref, next); return { ...next, availableStock: availableStock(next) }; }); res.json(result); return; } if (tail === "order/status" && req.method === "POST") { const { orderId, status } = req.body ?? {}; res.json(await transitionOrder(orderId, status as OrderStatus, adminUid, { admin: true })); return; }
if (tail === "order/status" && req.method === "POST") { const { orderId, status, restock } = req.body ?? {}; res.json(await transitionOrder(orderId, status as OrderStatus, adminUid, { admin: true, restock })); return; }
if (tail === "game/end" && req.method === "POST") { if (tail === "game/end" && req.method === "POST") {
const { gameId, winningTeamCode } = req.body ?? {}; const { gameId, winningTeamCode } = req.body ?? {};

View File

@ -1,6 +1,5 @@
import { firestore } from "../firebase"; import { firestore } from "../firebase";
import type { ProductDoc } from "../types/reward"; import type { ProductDoc } from "../types/reward";
export function productRef(id: string) { return firestore.doc(`products/${id}`); } export function productRef(id: string) { return firestore.doc(`products/${id}`); }
export function availableStock(product: Pick<ProductDoc, "totalStock" | "reservedStock" | "safetyStock">) { return product.totalStock - product.reservedStock - product.safetyStock; }
export async function listProducts(): Promise<Array<ProductDoc & { id: string }>> { const s = await firestore.collection("products").where("active", "==", true).where("redeemable", "==", true).get(); return s.docs.map((d) => ({ id: d.id, ...(d.data() as ProductDoc) })); } export async function listProducts(): Promise<Array<ProductDoc & { id: string }>> { const s = await firestore.collection("products").where("active", "==", true).where("redeemable", "==", true).get(); return s.docs.map((d) => ({ id: d.id, ...(d.data() as ProductDoc) })); }
export async function getProduct(id: string): Promise<(ProductDoc & { id: string }) | null> { const s = await productRef(id).get(); return s.exists ? { id, ...(s.data() as ProductDoc) } : null; } export async function getProduct(id: string): Promise<(ProductDoc & { id: string }) | null> { const s = await productRef(id).get(); return s.exists ? { id, ...(s.data() as ProductDoc) } : null; }

View File

@ -2,39 +2,56 @@ import { Timestamp, type DocumentSnapshot } from "firebase-admin/firestore";
import { firestore } from "../firebase"; import { firestore } from "../firebase";
import { HttpError } from "../middleware/errors"; import { HttpError } from "../middleware/errors";
import { getOrder, listOrders, orderRef } from "../repositories/orderRepository"; import { getOrder, listOrders, orderRef } from "../repositories/orderRepository";
import { availableStock, productRef } from "../repositories/productRepository"; import { productRef } from "../repositories/productRepository";
import { applyPointChangesTx } from "./pointService"; import { applyPointChangesTx } from "./pointService";
import { getWalletTx } from "../repositories/walletRepository"; import { getWalletTx } from "../repositories/walletRepository";
import { PointLedgerType } from "../types/points"; import { PointLedgerType } from "../types/points";
import { ORDER_TRANSITIONS, type OrderDoc, type OrderStatus, type ProductDoc, type Recipient } from "../types/reward"; import { ORDER_TRANSITIONS, type OrderDoc, type OrderItem, type OrderStatus, type ProductDoc, type ProductOption, type Recipient } from "../types/reward";
const KEY = /^[A-Za-z0-9_-]{1,100}$/; const KEY = /^[A-Za-z0-9_-]{1,100}$/;
export interface CreateOrderInput { clientIdempotencyKey: string; items: Array<{ productId: string; qty: number }>; recipient: Recipient } export interface CreateOrderInput { clientIdempotencyKey: string; items: Array<{ productId: string; qty: number; optionId?: string }>; recipient: Recipient }
function validateInput(i: CreateOrderInput) { if (!KEY.test(i?.clientIdempotencyKey ?? "") || !Array.isArray(i.items) || i.items.length < 1 || i.items.length > 20 || i.items.some((x) => !KEY.test(x.productId) || !Number.isSafeInteger(x.qty) || x.qty < 1 || x.qty > 100)) throw new HttpError(400, "invalid order", "INVALID_INPUT"); const r = i.recipient; if (!r || [r.name, r.phone, r.address1, r.postalCode].some((v) => typeof v !== "string" || v.length < 1 || v.length > 200)) throw new HttpError(400, "invalid recipient", "INVALID_INPUT"); } function validateInput(i: CreateOrderInput) { if (!KEY.test(i?.clientIdempotencyKey ?? "") || !Array.isArray(i.items) || i.items.length < 1 || i.items.length > 20 || i.items.some((x) => !KEY.test(x.productId) || !Number.isSafeInteger(x.qty) || x.qty < 1 || x.qty > 100 || (x.optionId !== undefined && !KEY.test(x.optionId)))) throw new HttpError(400, "invalid order", "INVALID_INPUT"); const r = i.recipient; if (!r || [r.name, r.phone, r.address1, r.postalCode].some((v) => typeof v !== "string" || v.length < 1 || v.length > 200)) throw new HttpError(400, "invalid recipient", "INVALID_INPUT"); }
/** 옵션 상품이면 주문 항목의 optionId 를 실제 옵션으로 해석하고, 무옵션 상품이면 optionId 를 거부한다. */
export function resolveOrderOption(product: Pick<ProductDoc, "options">, optionId: string | undefined): ProductOption | null {
const options = product.options ?? [];
if (options.length === 0) {
if (optionId !== undefined) throw new HttpError(400, "product has no options", "INVALID_INPUT");
return null;
}
const option = options.find((o) => o.id === optionId);
if (!option) throw new HttpError(400, "option required", "OPTION_REQUIRED");
return option;
}
export async function createOrder(uid: string, input: CreateOrderInput) { export async function createOrder(uid: string, input: CreateOrderInput) {
validateInput(input); const id = `${uid}_${input.clientIdempotencyKey}`; const now = Timestamp.now(); validateInput(input); const id = `${uid}_${input.clientIdempotencyKey}`; const now = Timestamp.now();
return firestore.runTransaction(async (tx) => { return firestore.runTransaction(async (tx) => {
const existing = await tx.get(orderRef(id)); if (existing.exists) return { order: { id, ...(existing.data() as OrderDoc) }, deduplicated: true }; const existing = await tx.get(orderRef(id)); if (existing.exists) return { order: { id, ...(existing.data() as OrderDoc) }, deduplicated: true };
const products: DocumentSnapshot[] = []; for (const item of input.items) products.push(await tx.get(productRef(item.productId))); // 같은 상품의 서로 다른 옵션이 별개 항목으로 들어올 수 있으므로 상품 문서는 productId 당 한 번만 읽는다.
// 재고는 별도 플랫폼에서 관리하므로 여기서는 검사·예약하지 않는다.
const snapById = new Map<string, DocumentSnapshot>(); for (const item of input.items) { if (!snapById.has(item.productId)) snapById.set(item.productId, await tx.get(productRef(item.productId))); }
const currentWallet = await getWalletTx(tx, uid); const currentWallet = await getWalletTx(tx, uid);
const orderItems = input.items.map((item, i) => { const p = products[i].data() as ProductDoc | undefined; if (!p || !p.active || !p.redeemable) throw new HttpError(409, "product unavailable", "PRODUCT_UNAVAILABLE"); if (availableStock(p) < item.qty) throw new HttpError(409, "out of stock", "OUT_OF_STOCK"); return { productId: item.productId, qty: item.qty, pointPrice: p.pointPrice, name: p.name }; }); const orderItems: OrderItem[] = input.items.map((item) => {
const p = snapById.get(item.productId)!.data() as ProductDoc | undefined;
if (!p || !p.active || !p.redeemable) throw new HttpError(409, "product unavailable", "PRODUCT_UNAVAILABLE");
const option = resolveOrderOption(p, item.optionId);
return { productId: item.productId, qty: item.qty, pointPrice: p.pointPrice, name: p.name, ...(option ? { optionId: option.id, optionName: option.name } : {}) };
});
const total = orderItems.reduce((n, i) => n + i.pointPrice * i.qty, 0); if ((currentWallet?.availableBalance ?? 0) < total) throw new HttpError(409, "insufficient balance", "INSUFFICIENT_BALANCE"); const total = orderItems.reduce((n, i) => n + i.pointPrice * i.qty, 0); if ((currentWallet?.availableBalance ?? 0) < total) throw new HttpError(409, "insufficient balance", "INSUFFICIENT_BALANCE");
const reserveId = `${uid}:order:${id}:reserve`; const wallet = await applyPointChangesTx(tx, uid, [{ txId: reserveId, type: PointLedgerType.OrderReserve, amount: total, orderId: id }]); const reserveId = `${uid}:order:${id}:reserve`; const wallet = await applyPointChangesTx(tx, uid, [{ txId: reserveId, type: PointLedgerType.OrderReserve, amount: total, orderId: id }]);
for (let i = 0; i < input.items.length; i++) tx.update(productRef(input.items[i].productId), { reservedStock: (products[i].data() as ProductDoc).reservedStock + input.items[i].qty, updatedAt: now });
const order: OrderDoc = { uid, items: orderItems, totalPoints: total, recipient: input.recipient, status: "reserved", clientIdempotencyKey: input.clientIdempotencyKey, reserveLedgerTxIds: [reserveId], orderedAt: now, statusHistory: [{ status: "reserved", at: now, actor: uid }], createdAt: now, updatedAt: now }; const order: OrderDoc = { uid, items: orderItems, totalPoints: total, recipient: input.recipient, status: "reserved", clientIdempotencyKey: input.clientIdempotencyKey, reserveLedgerTxIds: [reserveId], orderedAt: now, statusHistory: [{ status: "reserved", at: now, actor: uid }], createdAt: now, updatedAt: now };
tx.create(orderRef(id), order); return { order: { id, ...order }, deduplicated: false, availableBalance: wallet!.availableBalance }; tx.create(orderRef(id), order); return { order: { id, ...order }, deduplicated: false, availableBalance: wallet!.availableBalance };
}); });
} }
export async function transitionOrder(orderId: string, target: OrderStatus, actor: string, opts: { admin?: boolean; restock?: boolean; system?: boolean } = {}) { export async function transitionOrder(orderId: string, target: OrderStatus, actor: string, opts: { admin?: boolean; system?: boolean } = {}) {
return firestore.runTransaction(async (tx) => { return firestore.runTransaction(async (tx) => {
const snap = await tx.get(orderRef(orderId)); if (!snap.exists) throw new HttpError(404, "order not found", "ORDER_NOT_FOUND"); const order = snap.data() as OrderDoc; const snap = await tx.get(orderRef(orderId)); if (!snap.exists) throw new HttpError(404, "order not found", "ORDER_NOT_FOUND"); const order = snap.data() as OrderDoc;
if (!ORDER_TRANSITIONS[order.status].includes(target)) throw new HttpError(409, "invalid transition", "INVALID_ORDER_TRANSITION"); if (!opts.admin && !(order.uid === actor && order.status === "reserved" && target === "cancelled")) throw new HttpError(403, "forbidden", "FORBIDDEN"); if (!ORDER_TRANSITIONS[order.status].includes(target)) throw new HttpError(409, "invalid transition", "INVALID_ORDER_TRANSITION"); if (!opts.admin && !(order.uid === actor && order.status === "reserved" && target === "cancelled")) throw new HttpError(403, "forbidden", "FORBIDDEN");
const productSnaps: DocumentSnapshot[] = []; for (const item of order.items) productSnaps.push(await tx.get(productRef(item.productId))); const now = Timestamp.now(); const now = Timestamp.now();
let type: PointLedgerType | null = null; let suffix = ""; if (target === "cancelled") { type = PointLedgerType.OrderRelease; suffix = "release"; } else if (target === "confirmed") { type = PointLedgerType.OrderCapture; suffix = "capture"; } else if (target === "refunded") { type = PointLedgerType.OrderRefund; suffix = "refund"; } let type: PointLedgerType | null = null; let suffix = ""; if (target === "cancelled") { type = PointLedgerType.OrderRelease; suffix = "release"; } else if (target === "confirmed") { type = PointLedgerType.OrderCapture; suffix = "capture"; } else if (target === "refunded") { type = PointLedgerType.OrderRefund; suffix = "refund"; }
if (type) await applyPointChangesTx(tx, order.uid, [{ txId: `${order.uid}:order:${orderId}:${suffix}`, type, amount: order.totalPoints, orderId, reversalOf: order.reserveLedgerTxIds[0] }]); if (type) await applyPointChangesTx(tx, order.uid, [{ txId: `${order.uid}:order:${orderId}:${suffix}`, type, amount: order.totalPoints, orderId, reversalOf: order.reserveLedgerTxIds[0] }]);
order.items.forEach((item, i) => { const product = productSnaps[i].data() as ProductDoc; const patch: Partial<ProductDoc> = { updatedAt: now }; if (target === "cancelled") patch.reservedStock = product.reservedStock - item.qty; if (target === "confirmed") { patch.reservedStock = product.reservedStock - item.qty; patch.totalStock = product.totalStock - item.qty; } if (target === "refunded" && opts.restock !== false) patch.totalStock = product.totalStock + item.qty; tx.update(productRef(item.productId), patch); });
const patch: Record<string, unknown> = { status: target, updatedAt: now, statusHistory: [...order.statusHistory, { status: target, at: now, actor }] }; if (target === "cancelled") { patch.cancelledAt = now; patch.cancelledBy = actor; } if (target === "confirmed") patch.confirmedAt = now; if (target === "refunded") patch.refundedAt = now; tx.update(orderRef(orderId), patch); const patch: Record<string, unknown> = { status: target, updatedAt: now, statusHistory: [...order.statusHistory, { status: target, at: now, actor }] }; if (target === "cancelled") { patch.cancelledAt = now; patch.cancelledBy = actor; } if (target === "confirmed") patch.confirmedAt = now; if (target === "refunded") patch.refundedAt = now; tx.update(orderRef(orderId), patch);
return { id: orderId, status: target }; return { id: orderId, status: target };
}); });

View File

@ -41,6 +41,14 @@ export async function getCatalogProduct(id: string): Promise<ProductDetailDto |
return { return {
...catalogSummary(product), ...catalogSummary(product),
detailImages: product.detailImages, detailImages: product.detailImages,
// 옵션이 있는 상품만 라벨과 선택지를 내려보낸다. 필드를 하나씩 옮겨 담아
// 문서에 다른 필드가 생겨도 응답으로 새지 않게 한다.
...(product.options?.length
? {
optionLabel: product.optionLabel ?? "옵션",
options: product.options.map((o) => ({ id: o.id, name: o.name })),
}
: {}),
}; };
}); });
} }

View File

@ -51,6 +51,8 @@ export interface OrderItemDto {
qty: number; qty: number;
pointPrice: number; pointPrice: number;
name: string; name: string;
optionId?: string;
optionName?: string;
} }
export interface RecipientDto { export interface RecipientDto {
@ -113,8 +115,18 @@ export interface ProductSummaryDto {
mainImages: string[]; mainImages: string[];
} }
/** 상품 옵션(예: 색상). 재고는 별도 플랫폼 관리라 선택지 라벨만 내려간다. */
export interface ProductOptionDto {
id: string;
name: string;
}
export interface ProductDetailDto extends ProductSummaryDto { export interface ProductDetailDto extends ProductSummaryDto {
detailImages: string[]; detailImages: string[];
/** 옵션 선택 UI 라벨(예: "색상"). options 가 있을 때만 내려간다. */
optionLabel?: string;
/** 비어 있지 않으면 주문 시 옵션 선택이 필수다. */
options?: ProductOptionDto[];
} }
export interface EligibilityDto { export interface EligibilityDto {
@ -217,6 +229,8 @@ export function toOrderDto(id: string, doc: OrderDoc): OrderDto {
qty: item.qty, qty: item.qty,
pointPrice: item.pointPrice, pointPrice: item.pointPrice,
name: item.name, name: item.name,
optionId: item.optionId,
optionName: item.optionName,
})), })),
totalPoints: doc.totalPoints, totalPoints: doc.totalPoints,
recipient: { recipient: {

View File

@ -1,5 +1,14 @@
import type { Timestamp } from "firebase-admin/firestore"; import type { Timestamp } from "firebase-admin/firestore";
/**
* (: 색상).
* .
*/
export interface ProductOption {
id: string;
name: string;
}
export interface ProductDoc { export interface ProductDoc {
name: string; name: string;
pointPrice: number; pointPrice: number;
@ -10,12 +19,10 @@ export interface ProductDoc {
mainImages: string[]; mainImages: string[];
/** 상세 페이지에서 배열 순서대로 위에서 아래로 노출한다. */ /** 상세 페이지에서 배열 순서대로 위에서 아래로 노출한다. */
detailImages: string[]; detailImages: string[];
/** 실재고. 예약 확정 시 차감한다. */ /** 옵션 선택 UI 의 라벨(예: "색상"). options 가 있을 때만 의미가 있다. */
totalStock: number; optionLabel?: string;
/** 주문에 예약되어 아직 확정되지 않은 재고. */ /** 배열 순서 = 노출 순서. 비어 있지 않으면 주문 시 optionId 가 필수다. */
reservedStock: number; options?: ProductOption[];
/** 판매하지 않고 남겨 둘 안전재고. */
safetyStock: number;
createdAt: Timestamp; createdAt: Timestamp;
updatedAt: Timestamp; updatedAt: Timestamp;
} }
@ -25,7 +32,11 @@ export const ORDER_TRANSITIONS: Record<OrderStatus, readonly OrderStatus[]> = {
preparing: ["shipped", "refunded"], shipped: ["delivered", "refunded"], preparing: ["shipped", "refunded"], shipped: ["delivered", "refunded"],
delivered: ["refunded"], cancelled: [], refunded: [], delivered: ["refunded"], cancelled: [], refunded: [],
}; };
export interface OrderItem { productId: string; qty: number; pointPrice: number; name: string } export interface OrderItem {
productId: string; qty: number; pointPrice: number; name: string;
/** 옵션 상품이면 주문 시점의 옵션 스냅샷. */
optionId?: string; optionName?: string;
}
export interface Recipient { name: string; phone: string; address1: string; address2?: string; postalCode: string; deliveryMemo?: string } export interface Recipient { name: string; phone: string; address1: string; address2?: string; postalCode: string; deliveryMemo?: string }
export interface OrderDoc { export interface OrderDoc {
uid: string; items: OrderItem[]; totalPoints: number; recipient: Recipient; status: OrderStatus; uid: string; items: OrderItem[]; totalPoints: number; recipient: Recipient; status: OrderStatus;

View File

@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { resolveOrderOption } from "../../src/services/orderService";
import { HttpError } from "../../src/middleware/errors";
const options = [
{ id: "blue", name: "블루" },
{ id: "red", name: "레드" },
];
describe("resolveOrderOption", () => {
it("옵션 상품은 optionId 를 실제 옵션으로 해석한다", () => {
expect(resolveOrderOption({ options }, "blue")).toEqual({ id: "blue", name: "블루" });
});
it("옵션 상품에 optionId 가 없으면 OPTION_REQUIRED 로 거부한다", () => {
expect(() => resolveOrderOption({ options }, undefined)).toThrowError(
expect.objectContaining({ status: 400, code: "OPTION_REQUIRED" }) as HttpError,
);
});
it("옵션 상품에 존재하지 않는 optionId 도 OPTION_REQUIRED 로 거부한다", () => {
expect(() => resolveOrderOption({ options }, "green")).toThrowError(
expect.objectContaining({ status: 400, code: "OPTION_REQUIRED" }) as HttpError,
);
});
it("무옵션 상품은 optionId 없이 null 을 돌려준다", () => {
expect(resolveOrderOption({}, undefined)).toBeNull();
expect(resolveOrderOption({ options: [] }, undefined)).toBeNull();
});
it("무옵션 상품에 optionId 가 오면 INVALID_INPUT 으로 거부한다", () => {
expect(() => resolveOrderOption({}, "blue")).toThrowError(
expect.objectContaining({ status: 400, code: "INVALID_INPUT" }) as HttpError,
);
});
});

View File

@ -30,9 +30,6 @@ describe("rewardCatalogService image contract", () => {
displayOrder: 999, displayOrder: 999,
mainImages, mainImages,
detailImages, detailImages,
totalStock: 10,
reservedStock: 2,
safetyStock: 1,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
@ -45,6 +42,7 @@ describe("rewardCatalogService image contract", () => {
expect(product).not.toHaveProperty("totalStock"); expect(product).not.toHaveProperty("totalStock");
expect(product).not.toHaveProperty("reservedStock"); expect(product).not.toHaveProperty("reservedStock");
expect(product).not.toHaveProperty("safetyStock"); expect(product).not.toHaveProperty("safetyStock");
expect(product).not.toHaveProperty("options");
}); });
it("상세에는 mainImages와 detailImages를 모두 순서대로 노출한다", async () => { it("상세에는 mainImages와 detailImages를 모두 순서대로 노출한다", async () => {
@ -54,4 +52,53 @@ describe("rewardCatalogService image contract", () => {
detailImages, detailImages,
}); });
}); });
it("옵션이 없는 상품은 상세에 optionLabel/options 키가 없다", async () => {
const product = await getCatalogProduct(id);
expect(product).not.toHaveProperty("optionLabel");
expect(product).not.toHaveProperty("options");
});
});
describe("rewardCatalogService option contract", () => {
const optionId = "catalog-options-test";
beforeEach(async () => {
invalidateRewardCatalog(optionId);
await firestore.doc(`products/${optionId}`).delete();
const now = Timestamp.now();
await firestore.doc(`products/${optionId}`).set({
name: "옵션 계약 상품",
pointPrice: 2600,
active: true,
redeemable: true,
displayOrder: 998,
mainImages,
detailImages,
optionLabel: "색상",
options: [
{ id: "blue", name: "블루" },
{ id: "red", name: "레드" },
],
createdAt: now,
updatedAt: now,
});
});
it("상세에는 옵션 라벨과 선택지를 문서 순서대로 노출한다", async () => {
expect(await getCatalogProduct(optionId)).toMatchObject({
id: optionId,
optionLabel: "색상",
options: [
{ id: "blue", name: "블루" },
{ id: "red", name: "레드" },
],
});
});
it("목록에는 옵션을 노출하지 않는다", async () => {
const product = (await getCatalog()).find((item) => item.id === optionId);
expect(product).not.toHaveProperty("optionLabel");
expect(product).not.toHaveProperty("options");
});
}); });

View File

@ -196,4 +196,21 @@ describe("toOrderDto", () => {
const polluted = { ...baseOrder, internalOnly: "secret" } as OrderDoc; const polluted = { ...baseOrder, internalOnly: "secret" } as OrderDoc;
expect(wire(toOrderDto("o1", polluted))).not.toHaveProperty("internalOnly"); expect(wire(toOrderDto("o1", polluted))).not.toHaveProperty("internalOnly");
}); });
it("옵션 스냅샷이 있으면 항목에 optionId/optionName 을 그대로 내보낸다", () => {
const withOption: OrderDoc = {
...baseOrder,
items: [{ productId: "p1", qty: 1, pointPrice: 500, name: "키링", optionId: "blue", optionName: "블루" }],
};
const json = wire(toOrderDto("o1", withOption));
expect(json.items).toEqual([
{ productId: "p1", qty: 1, pointPrice: 500, name: "키링", optionId: "blue", optionName: "블루" },
]);
});
it("옵션이 없는 항목은 응답에서 optionId/optionName 키가 사라진다", () => {
const json = wire(toOrderDto("o1", baseOrder)) as { items: Array<Record<string, unknown>> };
expect(json.items[0]).not.toHaveProperty("optionId");
expect(json.items[0]).not.toHaveProperty("optionName");
});
}); });