임동욱 임동욱 09-16
feat: 가맹몰 상세를 백엔드 feature/voc(d784bb6) 계약에 맞춘다
목록·단건이 소개·카테고리 코드·영업시간·휴무일·순서·추가이미지·우편번호·등록자·등록일·수정일을
돌려주고, 목록은 상세가 있는 가맹점만(INNER JOIN), 상세 없는 단건은 NOT_FOUND, 등록은 상세가
있으면 409다. 그래서 등록 진입을 다시 가맹점 선택 화면(/new)으로 두고 — 선택지는 가맹점 전체에서
상세 보유분을 뺀 차집합 — 행의 [수정]은 상세 있는 가맹점만 연다. 응답 VO에 frcsNm이 빠진 결함은
업체명 자리에 코드를 보이는 것으로 버틴다(보고함).

Co-Authored-By: Claude Opus 5 
@2acbb13fc5c058125889058bdbf1b68ad3af82f2
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
@@ -43,25 +43,26 @@
 import styles from './merchant-detail-form.module.scss';
 
 interface MerchantDetailFormProps {
-  /** 이 화면의 가맹점 — 업체명·연락처·주소의 출처. 상세 유무와 무관하게 늘 정해져 있다. */
-  merchant: MerchantDetailSummary;
-  /** 상세가 이미 있으면 그 값. 없으면 이 화면에서 처음 만든다. */
+  /** 상세가 있는 가맹점 — 고친다. 없으면 등록이다. */
   detail?: MerchantDetail;
+  /** 등록 — 아직 상세가 없는 가맹점 선택지. 업체명 자리에서 하나를 고른다. */
+  candidates?: MerchantDetailSummary[];
   products?: MerchantProduct[];
   categories: CommonCode[];
 }
 
 /**
- * 가맹몰 상세 — 기획 BO-MAL-002(등록)·003(수정)을 한 화면으로 합쳤다(사용자 확정). 목록의 행에서
- * 들어오며 `detail`이 있으면 고치고, 없으면 처음 만든다. 첫 저장 뒤에도 화면 구성은 그대로라
- * 바로 메뉴를 붙일 수 있고, 저장은 그 자리에 머문다.
+ * 가맹몰 상세 — 기획 BO-MAL-002(등록)·003(수정)을 한 화면으로 합쳤다(사용자 확정). `detail`이 있으면
+ * 고치고, 없으면 업체명 자리에서 상세가 없는 가맹점을 골라 처음 만든다 — 백엔드 목록·단건이 상세 있는
+ * 가맹점만 다루므로(feature/voc) 등록은 이 선택으로만 들어온다. 첫 저장 뒤에는 그 가맹점 주소로 옮겨
+ * 가되 화면 구성은 그대로라 바로 메뉴를 붙일 수 있고, 이후 저장은 그 자리에 머문다.
  *
- * 업체명·연락처·우편번호·주소는 가맹점 원본이라 늘 읽기 전용이다(사용자 확정). 기획의 [주소 검색]은
- * 가맹점 관리 화면의 것이라 여기서는 값을 바꾸지 않는다.
+ * 업체명·연락처·우편번호·주소는 가맹점 원본이라 읽기 전용이다. 기획의 [주소 검색]은 가맹점 관리
+ * 화면의 것이라 여기서는 값을 바꾸지 않는다.
  */
 export function MerchantDetailForm({
-  merchant,
   detail,
+  candidates = [],
   products = [],
   categories,
 }: MerchantDetailFormProps) {
@@ -75,7 +76,10 @@
   const isEdit = detail !== undefined;
   const errors = state.status === 'error' ? (state.errors ?? {}) : {};
 
-  const frcsNo = merchant.id;
+  const [frcsNo, setFrcsNo] = useState(detail?.id ?? '');
+  // 읽기 전용 칸의 출처 — 수정은 상세, 등록은 고른 가맹점.
+  const merchant: MerchantDetailSummary | null =
+    detail ?? candidates.find((row) => row.id === frcsNo) ?? null;
   const [categoryCode, setCategoryCode] = useState(detail?.categoryCode ?? '');
   const [visibility, setVisibility] = useState<string[]>([
     detail?.isVisible === false ? 'N' : 'Y',
@@ -159,7 +163,29 @@
         <FoxCard size="lg" title="기본 정보" className={styles.section}>
           <div className={styles.grid}>
             <div className={styles.fieldWide}>
-              <FoxInput size="md" label="업체명" requirement="required" value={merchant.name} readOnly state="view" />
+              {detail ? (
+                <FoxInput size="md" label="업체명" requirement="required" value={detail.name} readOnly state="view" />
+              ) : (
+                <>
+                  <FoxSelect
+                    size="md"
+                    label="업체명"
+                    requirement="required"
+                    placeholder="상세를 등록할 가맹점 선택"
+                    options={candidates.map((row) => ({ value: row.id, label: row.name }))}
+                    value={frcsNo}
+                    onValueChange={setFrcsNo}
+                    error={Boolean(errors.frcsNo)}
+                  />
+                  {errors.frcsNo && <FoxHelperText type="danger" message={errors.frcsNo} />}
+                  {candidates.length === 0 && (
+                    <FoxHelperText
+                      type="information"
+                      message="상세를 붙일 가맹점이 없습니다. 모든 가맹점에 상세가 등록돼 있습니다."
+                    />
+                  )}
+                </>
+              )}
             </div>
             <div className={styles.field}>
               <FoxSelect
@@ -180,7 +206,7 @@
                 size="md"
                 label="업체 연락처"
                 requirement="required"
-                value={formatOptionalValue(formatPhoneNumber(merchant.phoneNumber))}
+                value={formatOptionalValue(formatPhoneNumber(merchant?.phoneNumber ?? null))}
                 readOnly
                 state="view"
               />
@@ -189,13 +215,13 @@
 
           <div className={styles.grid}>
             <div className={styles.fieldNarrow}>
-              <FoxInput size="md" label="우편번호" requirement="required" value={formatOptionalValue(detail?.postalCode)} readOnly state="view" />
+              <FoxInput size="md" label="우편번호" requirement="required" value={formatOptionalValue(merchant?.postalCode)} readOnly state="view" />
             </div>
             <div className={styles.fieldWide}>
-              <FoxInput size="md" label="주소" value={formatOptionalValue(merchant.address)} readOnly state="view" />
+              <FoxInput size="md" label="주소" value={formatOptionalValue(merchant?.address)} readOnly state="view" />
             </div>
             <div className={styles.field}>
-              <FoxInput size="md" label="상세주소" value={formatOptionalValue(merchant.addressDetail)} readOnly state="view" />
+              <FoxInput size="md" label="상세주소" value={formatOptionalValue(merchant?.addressDetail)} readOnly state="view" />
             </div>
           </div>
 
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
@@ -14,28 +14,23 @@
   params: Promise<{ frcsNo: string }>;
 }
 
-/** 가맹점 하나의 상세 화면 — 상세가 아직 없으면 그 가맹점을 고정한 채 처음 만드는 화면이 된다. */
+/**
+ * 상세가 있는 가맹점의 상세 화면. 상세가 없으면 백엔드(feature/voc)가 NOT_FOUND를 주므로 404다 —
+ * develop 백엔드는 가맹점 행을 그대로 주므로 `hasDetail`로 한 번 더 거른다(수정이 빈 UPDATE가 되지 않게).
+ */
 export default async function Page({ params }: PageProps) {
   await verifySession();
 
   const { frcsNo } = await params;
   const detail = await fetchMerchantDetail(frcsNo);
-  if (!detail) {
+  if (!detail || !detail.hasDetail) {
     notFound();
   }
 
-  // 상세가 없으면 백엔드 메뉴 목록도 비어 온다(상세와 INNER JOIN) — 부르지 않는다.
   const [products, categories] = await Promise.all([
-    detail.hasDetail ? fetchMerchantProducts(frcsNo) : Promise.resolve([]),
+    fetchMerchantProducts(frcsNo),
     fetchCommonCodes(CODE_GROUP.merchantType),
   ]);
 
-  return (
-    <MerchantDetailForm
-      merchant={detail}
-      detail={detail.hasDetail ? detail : undefined}
-      products={products}
-      categories={categories}
-    />
-  );
+  return <MerchantDetailForm detail={detail} products={products} categories={categories} />;
 }
app/(protected)/(basic)/system/merchant-details/_actions.ts
--- app/(protected)/(basic)/system/merchant-details/_actions.ts
+++ app/(protected)/(basic)/system/merchant-details/_actions.ts
@@ -222,7 +222,7 @@
   const frcsNo = readString(formData, 'frcsNo');
   const isEdit = readString(formData, 'mode') === 'edit';
   if (!frcsNo) {
-    return { status: 'error', message: INVALID_REQUEST_MESSAGE };
+    return { status: 'error', errors: { frcsNo: '업체를 선택해 주세요.' } };
   }
 
   let failed: MerchantDetailFormState | null = null;
app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.tsx
--- app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.tsx
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-list.tsx
@@ -26,6 +26,7 @@
 import {
   FILTER_ALL,
   MERCHANT_DETAILS_PATH,
+  MERCHANT_DETAIL_NEW_PATH,
   MERCHANT_DETAIL_PAGE_SIZE_OPTIONS,
   buildMerchantDetailHref,
   clampDateRange,
@@ -55,9 +56,8 @@
 /**
  * 가맹몰 상세 목록 — 기획 BO-MAL-001을 「관리자관리 > 가맹몰 상세 관리」로 옮긴 것.
  *
- * 가맹점 전체가 서고 상세가 없는 건은 상태·등록일이 `-`다(사용자 확정). 행의 [수정]은 그 가맹점의
- * 상세 화면으로 간다 — 상세가 없으면 거기서 처음 만든다. 업체(가맹점) 자체는 BizPlay 쪽에서 오므로
- * [+ 업체 등록]은 시안 자리만 지키고 비활성이다(사용자 확정).
+ * 백엔드(feature/voc)가 상세가 있는 가맹점만 주므로 모든 행이 상세 행이다. [+ 업체 등록]은 상세 화면
+ * (등록)으로 가서 상세가 없는 가맹점을 고르고, 행의 [수정]은 그 가맹점의 상세 화면으로 간다.
  *
  * 이슈: 메뉴 건수 열은 목록 응답에 없어 `-`다(보고함). 카테고리·상태도 백엔드가 아직 주지
  *   않아 대부분 `-`로 보인다.
@@ -371,7 +371,7 @@
             size="md"
             leadingIcon={<i className="fox-ico fox-ico-Plus" aria-hidden="true" />}
             label="업체 등록"
-            disabled
+            onAction={() => router.push(MERCHANT_DETAIL_NEW_PATH)}
           />
         </>
       }
app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-row-actions.tsx
--- app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-row-actions.tsx
+++ app/(protected)/(basic)/system/merchant-details/_components/merchant-detail-row-actions.tsx
@@ -16,11 +16,7 @@
   row: MerchantDetailSummary;
 }
 
-/**
- * 기획 BO-MAL-001 관리 열 — 모든 행에 [수정] [비활성|노출] [삭제](시안대로, 사용자 확정).
- * [수정]은 그 가맹점의 상세 화면으로 가고(상세가 없으면 거기서 처음 만든다), 상세가 없는 행의
- * [비활성]·[삭제]는 백엔드가 NOT_FOUND로 거절해 사유가 토스트로 올라온다.
- */
+/** 기획 BO-MAL-001 관리 열 — [수정] [비활성|노출] [삭제]. 목록 행은 모두 상세가 있는 가맹점이다. */
 export function MerchantDetailRowActions({ row }: MerchantDetailRowActionsProps) {
   const router = useRouter();
   const { showAlert, hideAlert, showToast } = useFeedback();
 
app/(protected)/(basic)/system/merchant-details/new/page.tsx (added)
+++ app/(protected)/(basic)/system/merchant-details/new/page.tsx
@@ -0,0 +1,21 @@
+import type { Metadata } from 'next';
+import { verifySession } from '@/lib/auth/dal';
+import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
+import { fetchMerchantDetailCandidates } from '@/lib/data/repositories/merchant-detail-repository';
+import { CODE_GROUP } from '@/lib/domain/common-code';
+import { MerchantDetailForm } from '../[frcsNo]/_components/merchant-detail-form';
+
+export const metadata: Metadata = { title: '가맹몰 상세 정보' };
+export const dynamic = 'force-dynamic';
+
+/** 등록 진입 — 아직 상세가 없는 가맹점 중 하나를 골라 상세를 붙인다. */
+export default async function Page() {
+  await verifySession();
+
+  const [candidates, categories] = await Promise.all([
+    fetchMerchantDetailCandidates(),
+    fetchCommonCodes(CODE_GROUP.merchantType),
+  ]);
+
+  return <MerchantDetailForm candidates={candidates} categories={categories} />;
+}
lib/data/repositories/merchant-detail-repository.ts
--- lib/data/repositories/merchant-detail-repository.ts
+++ lib/data/repositories/merchant-detail-repository.ts
@@ -11,29 +11,34 @@
 import type { MerchantDetailQuery } from '@/lib/domain/merchant-detail-query';
 
 /**
- * 가맹몰 상세 Repository — edupay-backend origin/develop 15d409a·22f67cb 기준.
+ * 가맹몰 상세 Repository — edupay-backend origin/feature/voc d784bb6(2026-09-16) 기준.
  *
  * ```
- * GET    /api/v1/mngr/voc/detail/pagination   TB_COM_VOUCHER ⟕ TB_COM_VOUCHER_DTL — 가맹점 전체
- *                                             (상세 없는 건은 hasDetail=false로 목록에도 선다)
- * GET    /api/v1/mngr/voc/detail/{frcsNo}
- * POST   /api/v1/mngr/voc/detail              @RequestBody · 가맹점이 있어야 붙는다
- * PUT    /api/v1/mngr/voc/detail/{frcsNo}     @RequestBody
+ * GET    /api/v1/mngr/voc/detail/pagination   상세가 있는 가맹점만(TB_COM_VOUCHER ⋈ TB_COM_VOUCHER_DTL ⋈ 코드)
+ * GET    /api/v1/mngr/voc/detail/{frcsNo}     상세가 없으면 NOT_FOUND 봉투
+ * POST   /api/v1/mngr/voc/detail              @RequestBody · 상세가 있으면 409, 가맹점이 없으면 404
+ * PUT    /api/v1/mngr/voc/detail/{frcsNo}     @RequestBody · 전 컬럼 덮어씀
  * DELETE /api/v1/mngr/voc/{frcsNo}            논리 삭제(DEL_YN)
+ * GET    /api/v1/mngr/voc/pagination          가맹점 전체 — 등록 화면의 선택지에만 쓴다
  * ```
  *
- * ⚠️ 백엔드 결함(보고함, 미수정) — 화면은 시안대로 두고 값을 그대로 보낸다(사용자 확정):
- * 1. 목록·단건 SELECT(`includeSql`)에 소개·카테고리 코드·영업시간·휴무일·추가이미지·순서가
- *    없어 수정 프리필이 빈다. 카테고리 이름(`CD_NM`)은 SELECT엔 있으나 VO에 담을 곳이 없다.
- * 2. `searchStartDt/EndDt`를 VO만 받고 WHERE에 쓰지 않는다.
- * 3. 「바우처 사용」을 담을 필드가 없다 — `voucherUseYn`으로 보내되 버려진다.
+ * 목록·단건이 소개·카테고리 코드·영업시간·휴무일·추가이미지·순서·우편번호·등록자(`frstRgtrNmStr`)·
+ * 등록일(`frstRegDtStr`)·수정일(`lastMdfcnDtStr`)을 돌려준다.
  *
- * 노출 상태(`useYn`)는 `MngrVocDetailVo`가 `MngrVocVo`에서 상속받아 등록·수정·조회 모두 실린다
- * (처음엔 없다고 잘못 보고했다 — 2026-09-14 정정).
+ * ⚠️ 백엔드 결함(보고함, 미수정):
+ * 1. 응답 VO(`MngrVocDetailVo`)에 `frcsNm`이 없어 SELECT의 업체명이 버려진다 — 그동안 업체명 자리에
+ *    가맹점 코드를 보인다.
+ * 2. 건수 쿼리(`selectPaginationCount`)만 LEFT JOIN이라 상세 없는 가맹점까지 세어 페이지 수가 부풀 수 있다.
+ * 3. `selectOne`이 DEL_YN을 거르지 않아 삭제한 상세가 있는 가맹점은 다시 등록하면 409가 난다.
+ * 4. `searchStartDt/EndDt`를 VO만 받고 WHERE에 쓰지 않는다.
+ * 5. 「바우처 사용」을 담을 필드가 없다 — `voucherUseYn`으로 보내되 버려진다.
  */
 
 const DETAIL_PATH = '/api/v1/mngr/voc/detail';
 const VOC_PATH = '/api/v1/mngr/voc';
+// 등록 선택지·상세 보유 목록을 한 번에 훑는 크기. 가맹점은 많아야 수백 건이다.
+const SCAN_PAGE_SIZE = 500;
+const MAX_SCAN_PAGES = 20;
 
 function isRecord(value: unknown): value is Record<string, unknown> {
   return value !== null && typeof value === 'object';
@@ -80,13 +85,41 @@
   return date.toISOString().slice(0, 10);
 }
 
+/** 상세 목록·단건의 한 행. 가맹점 API의 행은 `toCandidate`가 따로 맡는다. */
 function toSummary(raw: unknown): MerchantDetailSummary[] {
+  if (!isRecord(raw)) return [];
+  const id = readString(raw, 'frcsNo');
+  if (!id) return [];
+
+  const registeredAt = readDate(raw, 'frstRegDt');
+  return [
+    {
+      id,
+      // 결함 1 — 업체명이 안 오면 코드라도 보인다.
+      name: readString(raw, 'frcsNm') ?? id,
+      phoneNumber: readString(raw, 'rprsvTelno'),
+      address: readString(raw, 'frcsPostAddr'),
+      addressDetail: readString(raw, 'frcsDaddr'),
+      postalCode: readString(raw, 'frcsPostZip'),
+      thumbnailFileId: readString(raw, 'thumbAtchFileId'),
+      thumbnailUrl: buildFileImageUrl(readString(raw, 'thumbAtchFileId')),
+      categoryCode: readString(raw, 'frcsTypeCd'),
+      isVisible: readYesNo(raw, 'useYn'),
+      registeredAt,
+      hasDetail:
+        registeredAt !== null ||
+        readString(raw, 'thumbAtchFileId') !== null ||
+        readString(raw, 'frcsTypeCd') !== null,
+    },
+  ];
+}
+
+/** 가맹점 API(`/mngr/voc/pagination`)의 한 행 — 아직 상세가 없는 가맹점의 선택지. */
+function toCandidate(raw: unknown): MerchantDetailSummary[] {
   if (!isRecord(raw)) return [];
   const id = readString(raw, 'frcsNo');
   const name = readString(raw, 'frcsNm');
   if (!id || !name) return [];
-
-  const registeredAt = readDate(raw, 'frstRegDt');
   return [
     {
       id,
@@ -94,13 +127,13 @@
       phoneNumber: readString(raw, 'rprsvTelno'),
       address: readString(raw, 'frcsPostAddr'),
       addressDetail: readString(raw, 'frcsDaddr'),
-      thumbnailFileId: readString(raw, 'thumbAtchFileId'),
-      thumbnailUrl: buildFileImageUrl(readString(raw, 'thumbAtchFileId')),
-      categoryCode: readString(raw, 'frcsTypeCd'),
-      isVisible: readYesNo(raw, 'useYn'),
-      registeredAt,
-      // 상세 행은 LEFT JOIN이라 없으면 상세 컬럼이 전부 null이다 — 등록일이 그 표지다.
-      hasDetail: registeredAt !== null || readString(raw, 'thumbAtchFileId') !== null,
+      postalCode: readString(raw, 'frcsPostZip'),
+      thumbnailFileId: null,
+      thumbnailUrl: null,
+      categoryCode: null,
+      isVisible: null,
+      registeredAt: null,
+      hasDetail: false,
     },
   ];
 }
@@ -134,15 +167,15 @@
   return {
     ...summary,
     imageUrls: await probeImageUrls(imageFileId),
-    postalCode: readString(raw, 'frcsPostZip'),
     description: readString(raw, 'frcsCn'),
     openTime: readString(raw, 'startHour'),
     closeTime: readString(raw, 'endHour'),
     closedDays: readString(raw, 'offDayCn'),
     imageFileId,
     sortOrder: readNumber(raw, 'sortOrdr'),
-    registeredBy: readString(raw, 'frstRgtrNm'),
+    registeredBy: readString(raw, 'frstRgtrNmStr') ?? readString(raw, 'frstRgtrNm'),
     modifiedAt: readDate(raw, 'lastMdfcnDt'),
+    // 수정자는 `lastMdfrNm`이 JSON에서 빠지고 *Str 별칭도 없어 아직 안 온다.
     modifiedBy: readString(raw, 'lastMdfrNm'),
   };
 }
@@ -203,11 +236,67 @@
   );
 
   if (!result.ok) {
+    // 상세가 없는 가맹점은 NOT_FOUND 봉투로 온다 — 화면이 notFound()로 넘기게 null을 준다.
+    if (result.code === 404) return null;
     throw new BackendRequestError(result);
   }
   return toDetail(result.data);
 }
 
+async function fetchPage<T>(
+  path: string,
+  pageIndex: number,
+  pageSize: number,
+  toRow: (raw: unknown) => T[]
+): Promise<{ rows: T[]; rowCount: number; totalCount: number }> {
+  const accessToken = await getSessionAccessToken();
+  const result = await backendFetch<unknown>(path, {
+    method: 'GET',
+    query: { pageIndex, recordCountPerPage: pageSize },
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+    canHaveNullData: true,
+  });
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+  const data = result.data;
+  if (!isRecord(data) || !Array.isArray(data.list)) {
+    return { rows: [], rowCount: 0, totalCount: 0 };
+  }
+  return {
+    rows: data.list.flatMap(toRow),
+    rowCount: data.list.length,
+    totalCount: typeof data.totalCount === 'number' ? data.totalCount : data.list.length,
+  };
+}
+
+async function fetchAllPages<T>(path: string, toRow: (raw: unknown) => T[]): Promise<T[]> {
+  const rows: T[] = [];
+  let seen = 0;
+  for (let pageIndex = 1; pageIndex <= MAX_SCAN_PAGES; pageIndex += 1) {
+    const page = await fetchPage(path, pageIndex, SCAN_PAGE_SIZE, toRow);
+    rows.push(...page.rows);
+    seen += page.rowCount;
+    if (page.rowCount < SCAN_PAGE_SIZE || seen >= page.totalCount) break;
+  }
+  return rows;
+}
+
+/**
+ * 등록 화면의 업체 선택지 — 가맹점 전체에서 이미 상세가 있는 것을 뺀다. 백엔드에 "상세 없는 가맹점"
+ * 조회가 없어 두 목록을 훑어 차집합을 만든다.
+ */
+export async function fetchMerchantDetailCandidates(): Promise<MerchantDetailSummary[]> {
+  const [merchants, registered] = await Promise.all([
+    fetchAllPages(`${VOC_PATH}/pagination`, toCandidate),
+    fetchAllPages(`${DETAIL_PATH}/pagination`, toSummary),
+  ]);
+  // develop 백엔드(LEFT JOIN)는 상세 없는 가맹점도 목록에 섞어 주므로 상세가 있는 것만 뺀다.
+  const registeredIds = new Set(registered.filter((row) => row.hasDetail).map((row) => row.id));
+  return merchants.filter((row) => !registeredIds.has(row.id));
+}
+
 function toWritePayload(values: MerchantDetailValues): Record<string, unknown> {
   return {
     frcsNo: values.frcsNo,
lib/data/repositories/merchant-product-repository.ts
--- lib/data/repositories/merchant-product-repository.ts
+++ lib/data/repositories/merchant-product-repository.ts
@@ -10,11 +10,11 @@
 import type { MerchantProductValues } from '@/lib/domain/merchant-product-form';
 
 /**
- * 가맹몰 메뉴 Repository — edupay-backend origin/develop 22f67cb 기준(2026-09-14 재확인).
+ * 가맹몰 메뉴 Repository — edupay-backend origin/feature/voc d784bb6 기준(2026-09-16).
  *
  * ```
  * GET    /api/v1/mngr/voc/product/list/{frcsNo}   메뉴 전체(페이징 없음) — 상세와 INNER JOIN
- * GET    /api/v1/mngr/voc/product/{frcsProductSn}  없으면 NOT_FOUND 봉투
+ * GET    /api/v1/mngr/voc/product/{frcsProductSn}  없으면 성공 봉투에 data: null (develop은 NOT_FOUND)
  * POST   /api/v1/mngr/voc/product                  @RequestBody · 가맹점만 있으면 받는다
  * PUT    /api/v1/mngr/voc/product/{frcsProductSn}  @RequestBody · 전 컬럼 덮어씀
  * DELETE /api/v1/mngr/voc/product/{frcsProductSn}  논리 삭제(DEL_YN)
lib/domain/merchant-detail-form.ts
--- lib/domain/merchant-detail-form.ts
+++ lib/domain/merchant-detail-form.ts
@@ -29,6 +29,7 @@
 
 export type MerchantDetailFormErrors = Partial<
   Record<
+    | 'frcsNo'
     | 'categoryCode'
     | 'description'
     | 'openTime'
lib/domain/merchant-detail-query.ts
--- lib/domain/merchant-detail-query.ts
+++ lib/domain/merchant-detail-query.ts
@@ -1,6 +1,7 @@
 import { MERCHANT_DETAIL_VISIBILITY_OPTIONS } from '@/lib/domain/merchant-detail';
 
 export const MERCHANT_DETAILS_PATH = '/system/merchant-details';
+export const MERCHANT_DETAIL_NEW_PATH = `${MERCHANT_DETAILS_PATH}/new`;
 
 export function merchantDetailPath(frcsNo: string): string {
   return `${MERCHANT_DETAILS_PATH}/${encodeURIComponent(frcsNo)}`;
lib/domain/merchant-detail.ts
--- lib/domain/merchant-detail.ts
+++ lib/domain/merchant-detail.ts
@@ -7,7 +7,10 @@
  * 가져오고, 상세 항목만 등록·수정한다(사용자 확정).
  */
 
-/** 목록 한 행 — 가맹점 전체가 오며 상세가 없는 건은 `hasDetail`이 false다(그대로 목록에 선다). */
+/**
+ * 가맹점 한 건 + 상세 요약. 백엔드 목록(feature/voc)은 상세가 있는 가맹점만 주므로 거기서 온 행은
+ * `hasDetail`이 참이고, 등록 화면의 선택지(가맹점 API에서 온 것)만 거짓이다.
+ */
 export type MerchantDetailSummary = {
   /** 백엔드 `frcsNo` — 가맹점 코드이자 상세의 키. */
   id: string;
@@ -15,23 +18,23 @@
   phoneNumber: string | null;
   address: string | null;
   addressDetail: string | null;
+  postalCode: string | null;
   /** 백엔드 `thumbAtchFileId` — 대표 이미지. */
   thumbnailFileId: string | null;
   /** Repository가 만든 이미지 URL. 화면은 파일 id를 URL로 바꾸는 법을 모른다. */
   thumbnailUrl: string | null;
-  /** 백엔드 `frcsTypeCd` — 카테고리 코드. 목록 SQL에 아직 없어 늘 null이다(보고함). */
+  /** 백엔드 `frcsTypeCd` — 카테고리 코드. */
   categoryCode: string | null;
   /** 백엔드 상세 `useYn`. 상세가 없는 가맹점은 null이다. */
   isVisible: boolean | null;
-  /** 상세 등록일(`d.FRST_REG_DT`). 없으면 상세가 아직 없는 가맹점이다. */
+  /** 상세 등록일(`frstRegDtStr`, YYYY-MM-DD). */
   registeredAt: string | null;
-  /** 상세 행이 있는지 — 일괄 비활성·삭제 대상인지, 메뉴를 붙일 수 있는지를 가른다. */
+  /** 상세 행이 있는지 — 목록 행은 참, 등록 선택지는 거짓. */
   hasDetail: boolean;
 };
 
-/** 등록·수정 화면이 받는 한 건. 상세가 없으면 상세 항목이 전부 비어 있다. */
+/** 상세 화면이 받는 한 건. */
 export type MerchantDetail = MerchantDetailSummary & {
-  postalCode: string | null;
   /** 백엔드 `frcsCn` — 한줄 소개. */
   description: string | null;
   /** 백엔드 `startHour` / `endHour` — "HH:mm". */
Add a comment
List