임동욱 임동욱 09-17
feat: 추가 이미지는 기존 묶음에 덧붙여 올리고 메뉴 배지는 공통코드 FRCS_BADGE_CD를 쓴다
백엔드 40f85c4 — 묶음 업로드가 atchFileId를 받으면 그 묶음 뒤에 새 장만 덧붙인다. 저장된 장이
앞에 그대로 있고 새 장이 뒤에만 붙는 저장은 이 경로로 보내 저장된 장을 다시 읽지 않는다
(파일이 다른 서버에 있어도 됨). 순서 변경·삭제는 전처럼 통째로 다시 올린다. 앱 API가
frcsBadge의 쉼표 코드를 FRCS_BADGE_CD 이름으로 풀므로 배지 선택지·표시·검증도 그 코드표로
바꾼다(하드코딩 BEST/MAIN/NEW/SALE 제거).

Co-Authored-By: Claude Fable 5.1 
@0dd1b275235dcade59e6dde3d7a9827bf7069504
app/(protected)/(basic)/system/merchant-details/[frcsNo]/_components/merchant-detail-form.tsx
--- app/(protected)/(basic)/system/merchant-details/[frcsNo]/_components/merchant-detail-form.tsx
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/_components/merchant-detail-form.tsx
@@ -51,6 +51,8 @@
   candidates?: MerchantDetailSummary[];
   products?: MerchantProduct[];
   categories: CommonCode[];
+  /** 메뉴 표의 배지 코드표(`FRCS_BADGE_CD`). */
+  badgeCodes?: CommonCode[];
 }
 
 /**
@@ -68,6 +70,7 @@
   candidates = [],
   products = [],
   categories,
+  badgeCodes = [],
 }: MerchantDetailFormProps) {
   const router = useRouter();
   const { showAlert, hideAlert, showToast } = useFeedback();
@@ -337,7 +340,7 @@
           description="앱 상세 화면의 메뉴 목록에 노출된다"
           className={styles.section}
         >
-          <MerchantProductTable frcsNo={frcsNo} products={products} canAdd={isEdit} />
+          <MerchantProductTable frcsNo={frcsNo} badgeCodes={badgeCodes} products={products} canAdd={isEdit} />
         </FoxCard>
 
         {detail && (
app/(protected)/(basic)/system/merchant-details/[frcsNo]/page.tsx
--- app/(protected)/(basic)/system/merchant-details/[frcsNo]/page.tsx
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/page.tsx
@@ -36,9 +36,10 @@
     return <MerchantDetailForm merchant={merchant} categories={await categoriesPromise} />;
   }
 
-  const [products, categories, merchant] = await Promise.all([
+  const [products, categories, badgeCodes, merchant] = await Promise.all([
     fetchMerchantProducts(frcsNo),
     categoriesPromise,
+    fetchCommonCodes(CODE_GROUP.merchantProductBadge),
     // 상세 응답에 업체명이 빠지는 백엔드 결함 — 코드만 왔으면 가맹점 목록에서 이름을 채운다.
     detail.name === detail.id ? fetchMerchantSummary(frcsNo) : Promise.resolve(null),
   ]);
@@ -47,6 +48,7 @@
       detail={merchant ? { ...detail, name: merchant.name } : detail}
       products={products}
       categories={categories}
+      badgeCodes={badgeCodes}
     />
   );
 }
app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/[productSn]/page.tsx
--- app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/[productSn]/page.tsx
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/[productSn]/page.tsx
@@ -1,11 +1,13 @@
 import type { Metadata } from 'next';
 import { notFound } from 'next/navigation';
 import { verifySession } from '@/lib/auth/dal';
+import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
 import { fetchMerchantDetail } from '@/lib/data/repositories/merchant-detail-repository';
 import {
   fetchMerchantProduct,
   fetchMerchantProducts,
 } from '@/lib/data/repositories/merchant-product-repository';
+import { CODE_GROUP } from '@/lib/domain/common-code';
 import { MerchantProductForm } from '../_components/merchant-product-form';
 
 export const metadata: Metadata = { title: '가맹몰 메뉴 수정' };
@@ -19,14 +21,22 @@
   await verifySession();
 
   const { frcsNo, productSn } = await params;
-  const [detail, product, products] = await Promise.all([
+  const [detail, product, products, badgeCodes] = await Promise.all([
     fetchMerchantDetail(frcsNo),
     fetchMerchantProduct(productSn),
     fetchMerchantProducts(frcsNo),
+    fetchCommonCodes(CODE_GROUP.merchantProductBadge),
   ]);
   if (!detail || !product || product.frcsNo !== frcsNo) {
     notFound();
   }
 
-  return <MerchantProductForm detail={detail} product={product} productCount={products.length} />;
+  return (
+    <MerchantProductForm
+      detail={detail}
+      badgeCodes={badgeCodes}
+      product={product}
+      productCount={products.length}
+    />
+  );
 }
app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/_components/merchant-product-form.tsx
--- app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/_components/merchant-product-form.tsx
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/_components/merchant-product-form.tsx
@@ -15,6 +15,7 @@
 import { submitFormAction } from '@/app/_hooks/submit-form-action';
 import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
 import { useFeedback } from '@/app/_hooks/use-feedback';
+import type { CommonCode } from '@/lib/domain/common-code';
 import { FILE_MODULE, fileModuleAccept } from '@/lib/domain/file-module';
 import { formatOptionalValue, type MerchantDetail } from '@/lib/domain/merchant-detail';
 import {
@@ -23,7 +24,6 @@
 } from '@/lib/domain/merchant-detail-query';
 import {
   MAX_PRODUCT_BADGES,
-  MERCHANT_PRODUCT_BADGE_OPTIONS,
   MERCHANT_PRODUCT_VISIBILITY_OPTIONS,
   type MerchantProduct,
 } from '@/lib/domain/merchant-product';
@@ -40,6 +40,8 @@
 
 interface MerchantProductFormProps {
   detail: MerchantDetail;
+  /** 배지 선택지 — 공통코드 `FRCS_BADGE_CD`. */
+  badgeCodes: CommonCode[];
   product?: MerchantProduct;
   productCount: number;
 }
@@ -48,7 +50,12 @@
  * 가맹몰 메뉴 등록·수정 — 기획 BO-MAL-004. 대상 업체는 읽기 전용이고 이 화면에서 바꾸지 않는다.
  * 저장하면 업체 상세 화면으로 돌아간다.
  */
-export function MerchantProductForm({ detail, product, productCount }: MerchantProductFormProps) {
+export function MerchantProductForm({
+  detail,
+  badgeCodes,
+  product,
+  productCount,
+}: MerchantProductFormProps) {
   const router = useRouter();
   const { showAlert, hideAlert, showToast } = useFeedback();
   const [isBusy, startAction] = useBlockingAction();
@@ -213,7 +220,7 @@
               label="배지"
               name="badges"
               optionalText="복수 선택"
-              options={MERCHANT_PRODUCT_BADGE_OPTIONS}
+              options={badgeCodes.map((code) => ({ value: code.code, label: code.label }))}
               values={badges}
               onChange={setBadges}
               maxSelected={MAX_PRODUCT_BADGES}
app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/new/page.tsx
--- app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/new/page.tsx
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/products/new/page.tsx
@@ -1,8 +1,10 @@
 import type { Metadata } from 'next';
 import { notFound } from 'next/navigation';
 import { verifySession } from '@/lib/auth/dal';
+import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
 import { fetchMerchantDetail } from '@/lib/data/repositories/merchant-detail-repository';
 import { fetchMerchantProducts } from '@/lib/data/repositories/merchant-product-repository';
+import { CODE_GROUP } from '@/lib/domain/common-code';
 import { MerchantProductForm } from '../_components/merchant-product-form';
 
 export const metadata: Metadata = { title: '가맹몰 메뉴 등록' };
@@ -21,6 +23,9 @@
     notFound();
   }
 
-  const products = await fetchMerchantProducts(frcsNo);
-  return <MerchantProductForm detail={detail} productCount={products.length} />;
+  const [products, badgeCodes] = await Promise.all([
+    fetchMerchantProducts(frcsNo),
+    fetchCommonCodes(CODE_GROUP.merchantProductBadge),
+  ]);
+  return <MerchantProductForm detail={detail} badgeCodes={badgeCodes} productCount={products.length} />;
 }
app/(protected)/(basic)/system/merchant-details/_actions.ts
--- app/(protected)/(basic)/system/merchant-details/_actions.ts
+++ app/(protected)/(basic)/system/merchant-details/_actions.ts
@@ -39,10 +39,7 @@
   MERCHANT_DETAILS_PATH,
   merchantDetailPath,
 } from '@/lib/domain/merchant-detail-query';
-import {
-  MERCHANT_PRODUCT_BADGE_OPTIONS,
-  type MerchantProductBadge,
-} from '@/lib/domain/merchant-product';
+import type { MerchantProductBadge } from '@/lib/domain/merchant-product';
 import {
   validateMerchantProduct,
   type MerchantProductFormState,
@@ -167,8 +164,10 @@
 }
 
 /**
- * 추가 이미지 묶음. 백엔드가 한 `atchFileId` 아래 `fileSn` 1..n으로 채번하므로 순서·삭제·추가
- * 어느 것이든 바뀌면 묶음을 통째로 다시 올린다 — 저장된 장은 백엔드에서 다시 읽어 합친다.
+ * 추가 이미지 묶음. 백엔드가 한 `atchFileId` 아래 `fileSn` 1..n으로 채번한다.
+ * - 저장된 장이 그대로 앞에 있고 새 장이 뒤에만 붙었으면 기존 묶음에 덧붙인다(백엔드 40f85c4) —
+ *   저장된 장을 다시 읽지 않아 빠르고, 파일이 다른 서버에 있어도 된다.
+ * - 순서를 바꾸거나 저장된 장을 지웠으면 묶음을 통째로 다시 올린다 — 저장된 장은 백엔드에서 다시 읽는다.
  *
  * 폼: 칸마다 `imageSlot`(`saved:<fileSn>` | `new`)과 `imageFile`(같은 순서, 안 고른 칸은 빈 파일).
  */
@@ -199,6 +198,23 @@
   }
   if (untouched) {
     return retainedId;
+  }
+
+  const appendOnly =
+    retainedId !== '' &&
+    savedSns.length > 0 &&
+    savedSns.every((sn, index) => sn === index + 1) &&
+    slots.every((slot, index) =>
+      index < savedSns.length ? files[index] === null && slot === `saved:${index + 1}` : files[index] !== null
+    );
+  if (appendOnly) {
+    const appended = files.slice(savedSns.length).filter((file): file is File => file !== null);
+    appended.forEach(assertImage);
+    const appendedBytes = appended.reduce((total, file) => total + file.size, 0);
+    if (appendedBytes > MAX_ATTACHMENT_BYTES) {
+      throw new InputError(REQUEST_OVERSIZE_MESSAGE);
+    }
+    return uploadAttachments(appended, FILE_MODULE.merchantImage.id, retainedId);
   }
 
   let newBytes = 0;
@@ -398,10 +414,8 @@
 function readBadges(formData: FormData): MerchantProductBadge[] {
   return formData
     .getAll('badges')
-    .map((entry) => (typeof entry === 'string' ? entry : ''))
-    .filter((value): value is MerchantProductBadge =>
-      MERCHANT_PRODUCT_BADGE_OPTIONS.some((option) => option.value === value)
-    );
+    .map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
+    .filter((value) => value !== '');
 }
 
 async function resolveProductImageId(formData: FormData): Promise<string> {
@@ -427,17 +441,23 @@
 
   let failed: MerchantProductFormState | null = null;
   try {
-    const imageFileId = await resolveProductImageId(formData);
-    const validation = validateMerchantProduct({
-      frcsNo,
-      name: readString(formData, 'name'),
-      price: readOptionalInteger(formData, 'price'),
-      description: readString(formData, 'description'),
-      badges: readBadges(formData),
-      isVisible: readString(formData, 'isVisible') !== 'N',
-      sortOrder: readOptionalInteger(formData, 'sortOrder'),
-      imageFileId,
-    });
+    const [imageFileId, badgeCodes] = await Promise.all([
+      resolveProductImageId(formData),
+      fetchCommonCodes(CODE_GROUP.merchantProductBadge),
+    ]);
+    const validation = validateMerchantProduct(
+      {
+        frcsNo,
+        name: readString(formData, 'name'),
+        price: readOptionalInteger(formData, 'price'),
+        description: readString(formData, 'description'),
+        badges: readBadges(formData),
+        isVisible: readString(formData, 'isVisible') !== 'N',
+        sortOrder: readOptionalInteger(formData, 'sortOrder'),
+        imageFileId,
+      },
+      badgeCodes
+    );
     if (!validation.ok) {
       return { status: 'error', errors: validation.errors };
     }
app/(protected)/(basic)/system/merchant-details/_components/merchant-product-table.tsx
--- app/(protected)/(basic)/system/merchant-details/_components/merchant-product-table.tsx
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-product-table.tsx
@@ -7,6 +7,7 @@
 import { FoxTable } from '@fox/core/components/fox-table';
 import { FoxTableCell } from '@fox/core/components/fox-table-cell';
 import { FoxTableRow } from '@fox/core/components/fox-table-row';
+import type { CommonCode } from '@/lib/domain/common-code';
 import { EMPTY_FIELD_PLACEHOLDER } from '@/lib/domain/merchant-detail';
 import { merchantProductNewPath } from '@/lib/domain/merchant-detail-query';
 import {
@@ -21,6 +22,8 @@
 
 interface MerchantProductTableProps {
   frcsNo: string;
+  /** 배지 코드표(`FRCS_BADGE_CD`) — 배지 열의 이름. */
+  badgeCodes: CommonCode[];
   products: MerchantProduct[];
   /** 상세가 아직 저장되지 않은 가맹점 — 백엔드가 메뉴 등록을 거부하므로 추가를 막는다. */
   canAdd: boolean;
@@ -30,7 +33,7 @@
  * 기획 BO-MAL-002 ⑥ — 메뉴는 목록으로만 보이고 등록·수정은 별도 화면에서 한다.
  * 목록 화면 뼈대(FoxListContainer)는 머리말·검색까지 딸려 와 카드 안에는 표 조각만 놓는다.
  */
-export function MerchantProductTable({ frcsNo, products, canAdd }: MerchantProductTableProps) {
+export function MerchantProductTable({ frcsNo, badgeCodes, products, canAdd }: MerchantProductTableProps) {
   const router = useRouter();
 
   return (
@@ -96,7 +99,7 @@
                     <span className={styles.badges}>
                       {product.badges.map((badge) => (
                         <FoxBadge key={badge} type="pastel" color="neutral" size="sm">
-                          {formatProductBadgeLabel(badge)}
+                          {formatProductBadgeLabel(badgeCodes, badge)}
                         </FoxBadge>
                       ))}
                     </span>
lib/data/repositories/file-repository.ts
--- lib/data/repositories/file-repository.ts
+++ lib/data/repositories/file-repository.ts
@@ -70,10 +70,14 @@
 /**
  * 여러 장을 **한 `atchFileId` 아래** 올린다 — 파일마다 `fileSn`이 1,2,3…으로 붙는다.
  * 4컷만화처럼 묶음이 하나의 콘텐츠에 매달리는 자리에 쓴다.
+ *
+ * `appendTo`를 주면 그 묶음 뒤에 덧붙이고 같은 id를 돌려준다(백엔드 40f85c4). 백엔드는 원본 파일명과
+ * 크기가 같은 장을 이미 있는 것으로 보고 건너뛴다 — 같은 파일을 두 번 넣으면 한 장만 남는다.
  */
 export async function uploadAttachments(
   files: File[],
-  moduleId: string
+  moduleId: string,
+  appendTo?: string
 ): Promise<string> {
   const accessToken = await getSessionAccessToken();
 
@@ -88,6 +92,7 @@
     {
       method: 'POST',
       multipart,
+      ...(appendTo ? { query: { atchFileId: appendTo } } : {}),
       accessToken: accessToken ?? undefined,
       cache: 'no-store',
       timeoutMs: UPLOAD_TIMEOUT_MS,
lib/domain/common-code.ts
--- lib/domain/common-code.ts
+++ lib/domain/common-code.ts
@@ -45,6 +45,8 @@
   merchantBusiness: 'FRCS_TPBIZ_CD',
   /** 업종 세분류(4자리 = 업종 2자리 + 업태 2자리). 값이 위 그룹에 없을 때 여기서 찾는다(사용자 확정). */
   merchantBusinessDetail: 'FRCS_CD',
+  /** 가맹몰 메뉴 배지 — 앱 API가 `frcsBadge`의 쉼표 구분 코드를 이 그룹 이름으로 풀어 준다(백엔드 40f85c4). */
+  merchantProductBadge: 'FRCS_BADGE_CD',
 } as const;
 
 /** 값이 없는 항목의 화면 표기. */
lib/domain/merchant-product-form.ts
--- lib/domain/merchant-product-form.ts
+++ lib/domain/merchant-product-form.ts
@@ -1,8 +1,5 @@
-import {
-  MAX_PRODUCT_BADGES,
-  MERCHANT_PRODUCT_BADGE_OPTIONS,
-  type MerchantProductBadge,
-} from '@/lib/domain/merchant-product';
+import type { CommonCode } from '@/lib/domain/common-code';
+import { MAX_PRODUCT_BADGES, type MerchantProductBadge } from '@/lib/domain/merchant-product';
 
 /** 기획 BO-MAL-004 ② — 필수는 메뉴명·가격·노출 여부 셋. */
 export const PRODUCT_NAME_MAX_LENGTH = 100;
@@ -40,7 +37,8 @@
   | { ok: false; errors: MerchantProductFormErrors };
 
 export function validateMerchantProduct(
-  values: MerchantProductValues
+  values: MerchantProductValues,
+  badgeCodes: readonly CommonCode[]
 ): ValidationResult {
   const errors: MerchantProductFormErrors = {};
 
@@ -65,9 +63,7 @@
     errors.description = `설명은 ${PRODUCT_DESCRIPTION_MAX_LENGTH}자 이내로 입력해 주세요.`;
   }
 
-  const badges = values.badges.filter((badge) =>
-    MERCHANT_PRODUCT_BADGE_OPTIONS.some((option) => option.value === badge)
-  );
+  const badges = values.badges.filter((badge) => badgeCodes.some((code) => code.code === badge));
   if (badges.length > MAX_PRODUCT_BADGES) {
     errors.badges = `배지는 최대 ${MAX_PRODUCT_BADGES}개까지 고를 수 있습니다.`;
   }
lib/domain/merchant-product.ts
--- lib/domain/merchant-product.ts
+++ lib/domain/merchant-product.ts
@@ -1,3 +1,5 @@
+import { formatCommonCode, type CommonCode } from '@/lib/domain/common-code';
+
 /**
  * 가맹몰 메뉴 — 백엔드 `TB_COM_VOUCHER_PRODUCT`(`/api/v1/mngr/voc/product`).
  * 기획(BO-MAL-004)은 「메뉴」, 백엔드는 product라 부른다. 코드는 백엔드를 따르고 화면 문구만
@@ -19,15 +21,8 @@
   isVisible: boolean;
 };
 
-export const MERCHANT_PRODUCT_BADGE_OPTIONS = [
-  { value: 'BEST', label: 'BEST' },
-  { value: 'MAIN', label: '대표' },
-  { value: 'NEW', label: '신규' },
-  { value: 'SALE', label: '할인' },
-] as const;
-
-export type MerchantProductBadge =
-  (typeof MERCHANT_PRODUCT_BADGE_OPTIONS)[number]['value'];
+/** 배지 코드 — 공통코드 `FRCS_BADGE_CD`의 상세코드(예: BEST·MAIN). 기획의 4종은 코드표가 정한다. */
+export type MerchantProductBadge = string;
 
 /** 기획 BO-MAL-004 ③ [의사결정 필요] 잠정값 — 배지 동시 선택 상한. */
 export const MAX_PRODUCT_BADGES = 2;
@@ -35,8 +30,8 @@
 const BADGE_SEPARATOR = ',';
 
 /**
- * 배지 ↔ 백엔드 `frcsBadge`(문자열 하나). 앱·백엔드에 규약이 없어 여기서 정한다(사용자 확정):
- * 코드를 쉼표로 잇고 순서를 보존한다. 예) `BEST,MAIN`
+ * 배지 ↔ 백엔드 `frcsBadge`(문자열 하나) — 코드를 쉼표로 잇고 순서를 보존한다. 예) `BEST,MAIN`.
+ * 앱 API도 같은 규칙으로 잘라 `FRCS_BADGE_CD` 이름을 붙인다(백엔드 40f85c4).
  */
 export function parseProductBadges(raw: string | null): MerchantProductBadge[] {
   if (!raw) {
@@ -45,20 +40,18 @@
   return raw
     .split(BADGE_SEPARATOR)
     .map((token) => token.trim())
-    .filter((token): token is MerchantProductBadge =>
-      MERCHANT_PRODUCT_BADGE_OPTIONS.some((option) => option.value === token)
-    );
+    .filter((token) => token !== '');
 }
 
 export function joinProductBadges(badges: readonly MerchantProductBadge[]): string {
   return badges.join(BADGE_SEPARATOR);
 }
 
-export function formatProductBadgeLabel(badge: MerchantProductBadge): string {
-  return (
-    MERCHANT_PRODUCT_BADGE_OPTIONS.find((option) => option.value === badge)?.label ??
-    badge
-  );
+export function formatProductBadgeLabel(
+  codes: readonly CommonCode[],
+  badge: MerchantProductBadge
+): string {
+  return formatCommonCode(codes, badge);
 }
 
 export const MERCHANT_PRODUCT_VISIBILITY_OPTIONS = [
Add a comment
List