임동욱 임동욱 08-21
feat: 가맹점 관리를 /api/v1/mngr/voc API로 이식
mock을 걷어내고 TB_COM_VOUCHER를 보는 실제 API에 붙였다. 필드 이름이 화면 용어와
달라 Repository가 옮긴다 — frcsNo=코드, frcsNm=가맹점명, brno=사업자번호,
rprsvNm/rprsvTelno=대표자/전화, frcsPostAddr/frcsDaddr/frcsPostZip=주소 3종.

백엔드에 자리가 없는 것은 화면에서 뺐다 — 브랜드명·메모·등록일 컬럼이 없고,
수정·삭제·단건조회 API가 없다(목록과 등록 둘뿐). 행의 수정·삭제 버튼은 시안 자리를
두되 누르면 준비 중임을 알린다.

우편번호(frcsPostZip)를 새로 싣는다 — 주소 검색이 함께 채우는 값이다.

Co-Authored-By: Claude Opus 5 
@48276ee4d9a407350f1b93550415b40b6bb920ed
app/(protected)/(basic)/system/merchants/_actions.ts
--- app/(protected)/(basic)/system/merchants/_actions.ts
+++ app/(protected)/(basic)/system/merchants/_actions.ts
@@ -2,11 +2,7 @@
 
 import { revalidatePath } from 'next/cache';
 import { verifySession } from '@/lib/auth/dal';
-import {
-  createMerchant,
-  deleteMerchant,
-  updateMerchant,
-} from '@/lib/data/repositories/merchant-repository';
+import { createMerchant } from '@/lib/data/repositories/merchant-repository';
 import {
   validateMerchant,
   type MerchantFormState,
@@ -14,7 +10,6 @@
 import { MERCHANTS_PATH } from '@/lib/domain/merchant-query';
 
 const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.';
-const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.';
 
 function readString(formData: FormData, key: string): string {
   const value = formData.get(key);
@@ -29,7 +24,6 @@
 
   const validation = validateMerchant({
     id: readString(formData, 'id'),
-    brandName: readString(formData, 'brandName'),
     name: readString(formData, 'name'),
     categoryCode: readString(formData, 'categoryCode'),
     businessNumber: readString(formData, 'businessNumber'),
@@ -37,38 +31,20 @@
     phoneNumber: readString(formData, 'phoneNumber'),
     address: readString(formData, 'address'),
     addressDetail: readString(formData, 'addressDetail'),
+    postalCode: readString(formData, 'postalCode'),
     latitude: readString(formData, 'latitude'),
     longitude: readString(formData, 'longitude'),
     isVisible: readString(formData, 'isVisible') === 'Y',
-    memo: readString(formData, 'memo'),
   });
   if (!validation.ok) {
     return { status: 'error', errors: validation.errors };
   }
 
+  // TODO(백엔드): 수정 API가 없다(`/api/v1/mngr/voc`는 목록·등록 둘뿐). 생기면 여기서 가른다.
   try {
-    if (validation.values.id) {
-      await updateMerchant(validation.values);
-    } else {
-      await createMerchant(validation.values);
-    }
+    await createMerchant(validation.values);
   } catch {
     return { status: 'error', message: SAVE_FAILED_MESSAGE };
-  }
-
-  revalidatePath(MERCHANTS_PATH);
-  return { status: 'success' };
-}
-
-export async function deleteMerchantAction(
-  id: string
-): Promise<{ status: 'success' } | { status: 'error'; message: string }> {
-  await verifySession();
-
-  try {
-    await deleteMerchant(id);
-  } catch {
-    return { status: 'error', message: DELETE_FAILED_MESSAGE };
   }
 
   revalidatePath(MERCHANTS_PATH);
app/(protected)/(basic)/system/merchants/_components/merchant-form-modal.tsx
--- app/(protected)/(basic)/system/merchants/_components/merchant-form-modal.tsx
+++ app/(protected)/(basic)/system/merchants/_components/merchant-form-modal.tsx
@@ -17,9 +17,7 @@
 } from '@/lib/domain/merchant';
 import {
   ADDRESS_DETAIL_MAX_LENGTH,
-  BRAND_MAX_LENGTH,
   INITIAL_MERCHANT_FORM_STATE,
-  MEMO_MAX_LENGTH,
   NAME_MAX_LENGTH,
   OWNER_MAX_LENGTH,
 } from '@/lib/domain/merchant-form';
@@ -44,6 +42,7 @@
   const [isVisible, setIsVisible] = useState(merchant?.isVisible ?? true);
   // 주소·좌표는 검색 결과가 채운다(기획 ③ Read Only).
   const [address, setAddress] = useState(merchant?.address ?? '');
+  const [postalCode, setPostalCode] = useState(merchant?.postalCode ?? '');
   const [latitude, setLatitude] = useState(merchant?.latitude ?? '');
   const [longitude, setLongitude] = useState(merchant?.longitude ?? '');
 
@@ -66,6 +65,7 @@
       return;
     }
     setAddress(picked.trim());
+    setPostalCode('');
     setLatitude('');
     setLongitude('');
   }
@@ -98,6 +98,7 @@
         <input type="hidden" name="categoryCode" value={categoryCode} />
         <input type="hidden" name="isVisible" value={isVisible ? 'Y' : 'N'} />
         <input type="hidden" name="address" value={address} />
+        <input type="hidden" name="postalCode" value={postalCode} />
         <input type="hidden" name="latitude" value={latitude} />
         <input type="hidden" name="longitude" value={longitude} />
 
@@ -113,17 +114,6 @@
             />
           </div>
         )}
-
-        <div className={styles.field}>
-          <FoxInput
-            size="md"
-            name="brandName"
-            label="브랜드명"
-            defaultValue={merchant?.brandName ?? ''}
-            placeholder="브랜드명을 입력하세요."
-            maxLength={BRAND_MAX_LENGTH}
-          />
-        </div>
 
         <div className={styles.field}>
           <FoxInput
@@ -265,39 +255,6 @@
             />
           </FoxRadioGroup>
         </div>
-
-        <div className={styles.field}>
-          <FoxInput
-            size="md"
-            name="memo"
-            label="메모"
-            defaultValue={merchant?.memo ?? ''}
-            placeholder="관리자 메모 (사용자에게 노출되지 않음)"
-            maxLength={MEMO_MAX_LENGTH}
-          />
-        </div>
-
-        {isEdit && (
-          <div className={styles.field}>
-            <FoxFormLabel as="span">등록일 / 최종수정일</FoxFormLabel>
-            <div className={styles.pair}>
-              <FoxInput
-                size="md"
-                aria-label="등록일"
-                value={merchant?.createdAt ?? ''}
-                readOnly
-                state="view"
-              />
-              <FoxInput
-                size="md"
-                aria-label="최종수정일"
-                value={merchant?.updatedAt ?? ''}
-                readOnly
-                state="view"
-              />
-            </div>
-          </div>
-        )}
 
         {state.status === 'error' && state.message && (
           <FoxHelperText type="danger" message={state.message} />
app/(protected)/(basic)/system/merchants/_components/merchant-list.tsx
--- app/(protected)/(basic)/system/merchants/_components/merchant-list.tsx
+++ app/(protected)/(basic)/system/merchants/_components/merchant-list.tsx
@@ -66,12 +66,6 @@
       render: (_row, index) => (currentPage - 1) * query.pageSize + index + 1,
     },
     { key: 'code', header: '가맹점코드', width: 140 },
-    {
-      key: 'brandName',
-      header: '브랜드명',
-      width: 120,
-      render: (row) => formatOptionalValue(row.brandName),
-    },
     { key: 'name', header: '가맹점명', width: 240, emphasis: true },
     {
       key: 'categoryCode',
@@ -101,12 +95,6 @@
           label={formatVisibilityLabel(row.isVisible)}
         />
       ),
-    },
-    {
-      key: 'createdAt',
-      header: '등록일',
-      width: 140,
-      render: (row) => formatOptionalValue(row.createdAt),
     },
     {
       key: 'actions',
app/(protected)/(basic)/system/merchants/_components/merchant-row-actions.tsx
--- app/(protected)/(basic)/system/merchants/_components/merchant-row-actions.tsx
+++ app/(protected)/(basic)/system/merchants/_components/merchant-row-actions.tsx
@@ -1,73 +1,43 @@
 'use client';
 
-import { useState, useTransition } from 'react';
-import { FoxButton } from '@fox/core/components/fox-button';
-import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
 import { FoxIconButton } from '@fox/core/components/fox-icon-button';
 import { useFeedback } from '@/app/_hooks/use-feedback';
 import type { Merchant } from '@/lib/domain/merchant';
-import { deleteMerchantAction } from '../_actions';
-import { MerchantFormModal } from './merchant-form-modal';
 
 interface MerchantRowActionsProps {
   merchant: Merchant;
 }
 
+/**
+ * TODO(백엔드): 수정·삭제 API가 없다 — `/api/v1/mngr/voc`는 목록(`/pagination`)과 등록뿐이다.
+ * 시안의 자리는 두되 눌러도 할 수 있는 일이 없어, 비활성 대신 사유를 알리도록 했다.
+ */
 export function MerchantRowActions({ merchant }: MerchantRowActionsProps) {
-  const { showAlert, hideAlert, showToast } = useFeedback();
-  const [isEditOpen, setIsEditOpen] = useState(false);
-  const [isDeleting, startDeleting] = useTransition();
+  const { showToast } = useFeedback();
 
-  function runDelete() {
-    hideAlert();
-    startDeleting(async () => {
-      const result = await deleteMerchantAction(merchant.id);
-      if (result.status === 'error') {
-        showToast({ variant: 'danger', message: result.message });
-        return;
-      }
-      showToast({ variant: 'success', message: '가맹점을 삭제했습니다.' });
-    });
-  }
-
-  function confirmDelete() {
-    showAlert({
-      variant: 'danger',
-      title: '가맹점을 삭제하시겠습니까?',
-      message: `"${merchant.name}"을(를) 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
-      actions: (
-        <FoxButtonGroup size="md">
-          <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
-          <FoxButton type="primary" size="md" label="삭제" onAction={runDelete} />
-        </FoxButtonGroup>
-      ),
+  function notReady() {
+    showToast({
+      variant: 'warning',
+      message: '가맹점 수정·삭제 기능은 백엔드 준비 중입니다.',
     });
   }
 
   return (
-    <FoxButtonGroup size="sm" align="center">
+    <span aria-label={`${merchant.name} 관리`}>
       <FoxIconButton
         type="default"
         size="sm"
         icon={<i className="fox-ico fox-ico-PencilSimple" aria-hidden="true" />}
         label="수정"
-        onAction={() => setIsEditOpen(true)}
+        onAction={notReady}
       />
       <FoxIconButton
         type="default"
         size="sm"
         icon={<i className="fox-ico fox-ico-Trash" aria-hidden="true" />}
-        label={isDeleting ? '삭제 중' : '삭제'}
-        disabled={isDeleting}
-        onAction={confirmDelete}
+        label="삭제"
+        onAction={notReady}
       />
-
-      {isEditOpen && (
-        <MerchantFormModal
-          merchant={merchant}
-          onClose={() => setIsEditOpen(false)}
-        />
-      )}
-    </FoxButtonGroup>
+    </span>
   );
 }
app/(protected)/(basic)/system/merchants/excel/route.ts
--- app/(protected)/(basic)/system/merchants/excel/route.ts
+++ app/(protected)/(basic)/system/merchants/excel/route.ts
@@ -12,7 +12,6 @@
 const HEADERS = [
   '번호',
   '가맹점코드',
-  '브랜드명',
   '가맹점명',
   '업종',
   '사업자등록번호',
@@ -20,8 +19,8 @@
   '전화번호',
   '주소',
   '상세주소',
+  '우편번호',
   '사용여부',
-  '등록일',
 ];
 
 /** 쉼표·따옴표·줄바꿈이 든 값은 따옴표로 감싸고 내부 따옴표는 두 번 쓴다(RFC 4180). */
@@ -38,7 +37,6 @@
     [
       String(index + 1),
       merchant.code,
-      merchant.brandName,
       merchant.name,
       formatCategoryLabel(merchant.categoryCode),
       merchant.businessNumber,
@@ -46,8 +44,8 @@
       merchant.phoneNumber,
       merchant.address,
       merchant.addressDetail,
+      merchant.postalCode,
       formatVisibilityLabel(merchant.isVisible),
-      merchant.createdAt,
     ]
       .map(toCsvCell)
       .join(',')
lib/data/repositories/merchant-repository.ts
--- lib/data/repositories/merchant-repository.ts
+++ lib/data/repositories/merchant-repository.ts
@@ -1,162 +1,180 @@
 import 'server-only';
-import type { Merchant } from '@/lib/domain/merchant';
-import { MERCHANT_CATEGORY_OPTIONS } from '@/lib/domain/merchant';
+import { getSessionAccessToken } from '@/lib/auth/dal';
+import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
+import {
+  MERCHANT_CATEGORY_READ_FIELD,
+  MERCHANT_CATEGORY_WRITE_FIELD,
+  type Merchant,
+} from '@/lib/domain/merchant';
 import type { MerchantQuery } from '@/lib/domain/merchant-query';
 import type { MerchantValues } from '@/lib/domain/merchant-form';
 
 /**
- * 가맹점 Repository — 기획 MCH_001/002_p/003_p.
+ * 가맹점 Repository — `TB_COM_VOUCHER`.
  *
- * TODO(백엔드): 관리자용 가맹점 API가 아직 없다(`mngr` 도메인에 해당 컨트롤러가 없고,
- * `common/qrauth`의 `CmmPaymentMerchantVo`는 결제 조회용이라 id·name·type·addr·telNo뿐이다).
- * 아래 mock 저장소를 지우고 다른 Repository처럼 backendFetch + getSessionAccessToken으로
- * 교체하면 호출부는 그대로 둘 수 있다.
+ * ```
+ * GET  /api/v1/mngr/voc/pagination   searchCondition 1=가맹점명 2=사업자번호 3=상세주소
+ * POST /api/v1/mngr/voc              어노테이션 없음 → form
+ * ```
  *
- * 이슈: 저장소가 모듈 메모리라 서버를 다시 띄우면 등록·수정·삭제가 초기값으로 되돌아간다.
+ * ⚠️ 백엔드 현황(보고함)
+ * 1. **등록이 아직 동작하지 않는다.** 컨트롤러 `insert`의 본문이 전부 주석 처리돼 있고
+ *    `return null;`이다 — 봉투 없는 빈 200이 온다. 그래서 저장해도 아무 일도 일어나지 않는다.
+ * 2. **수정·삭제·단건 조회 API가 없다.** 목록과 등록 두 개뿐이다.
+ * 3. 검색 필터가 어긋난다 — 업종 검색 파라미터는 `searchFrcsTpbizCd`인데 SQL은 `FRCS_DV_CD`를
+ *    비교하고, 저장은 `frcsTpbizCd`로 받는다. 저장한 업종으로 검색이 걸리지 않는다.
+ * 4. 목록 정렬이 이중 역순이다 — rnum이 등록일 내림차순인데 `ORDER BY RNUM DESC`로 다시
+ *    뒤집어 오래된 가맹점부터 나온다.
+ * 5. `USE_YN`을 select하지 않는다 — 검색 조건에는 쓰면서 응답에는 없어 사용여부를 알 수 없다.
+ *    화면은 노출로 보인다.
+ * 6. 브랜드명·메모·등록일 컬럼이 없다 — 기획에 있으나 담을 자리가 없어 화면에서 뺐다.
  */
 
-const TOTAL = 128;
-const BRANDS = ['GS25', 'CU', '', '스타벅스', '', '교보문고'];
-const CATEGORY_CODES = MERCHANT_CATEGORY_OPTIONS.map((option) => option.value);
-const CITIES = [
-  '경기도 평택시 송탄1로',
-  '경상남도 양산시 배움터길',
-  '경기도 용인시 기흥구 죽전로',
-  '서울특별시 마포구 양화로',
-  '부산광역시 해운대구 센텀중앙로',
-];
-const BASE_DATE = Date.UTC(2026, 0, 5);
-const DAY_MS = 24 * 60 * 60 * 1000;
+const VOC_PATH = '/api/v1/mngr/voc';
 
-function dateAt(offsetDays: number): string {
-  return new Date(BASE_DATE + offsetDays * DAY_MS).toISOString().slice(0, 10);
-}
+/** 엑셀은 전체를 담는다 — 이 상한을 넘으면 조용히 잘린다. */
+const EXCEL_ROW_LIMIT = 10_000;
 
-/** 인덱스로만 만든다 — 난수를 쓰면 새로고침마다 목록이 흔들린다. */
-function buildMerchant(index: number): Merchant {
-  const serial = index + 1;
-  const brand = BRANDS[index % BRANDS.length];
-  const city = CITIES[index % CITIES.length];
-
-  return {
-    id: `MCH-${String(serial).padStart(6, '0')}`,
-    code: `MCH-${String(serial).padStart(6, '0')}`,
-    brandName: brand,
-    name: brand ? `${brand} ${city.split(' ').at(-1)}점` : `${city.split(' ').at(-1)}점`,
-    categoryCode: CATEGORY_CODES[index % CATEGORY_CODES.length],
-    businessNumber: `${100 + (index % 800)}-${10 + (index % 80)}-${String(10000 + index).slice(0, 5)}`,
-    ownerName: `대표${serial}`,
-    phoneNumber: `0${2 + (index % 6)}${String(10000000 + index * 7).slice(0, 8)}`,
-    address: `${city} ${(index % 90) + 1}`,
-    addressDetail: brand ? `1층 ${brand}` : '',
-    latitude: (37.0987 + (index % 50) * 0.01).toFixed(4),
-    longitude: (127.0736 + (index % 50) * 0.01).toFixed(4),
-    // 몇 곳은 미노출로 둬 필터가 실제로 걸리는지 보이게 한다.
-    isVisible: index % 7 !== 0,
-    memo: '',
-    createdAt: dateAt(index % 60),
-    updatedAt: dateAt((index % 60) + 15),
-  };
-}
-
-let MERCHANTS: Merchant[] = Array.from({ length: TOTAL }, (_, index) =>
-  buildMerchant(index)
-);
-
-const SEARCH_VALUE: Record<string, (merchant: Merchant) => string> = {
-  name: (merchant) => merchant.name,
-  businessNumber: (merchant) => merchant.businessNumber,
-  address: (merchant) => `${merchant.address} ${merchant.addressDetail}`,
+/** 화면 검색 대상 → 백엔드 `searchCondition`. */
+const SEARCH_CONDITION: Record<string, string> = {
+  name: '1',
+  businessNumber: '2',
+  address: '3',
 };
 
-function matchesQuery(merchant: Merchant, query: MerchantQuery): boolean {
-  if (query.category && merchant.categoryCode !== query.category) {
-    return false;
-  }
-  if (query.visibility === 'visible' && !merchant.isVisible) {
-    return false;
-  }
-  if (query.visibility === 'hidden' && merchant.isVisible) {
-    return false;
-  }
-
-  const keyword = query.keyword.trim().toLowerCase();
-  if (!keyword) {
-    return true;
-  }
-  const read = SEARCH_VALUE[query.searchField];
-  return read ? read(merchant).toLowerCase().includes(keyword) : true;
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return value !== null && typeof value === 'object';
 }
 
-/** 기획 MCH_001 ④ — 최근등록순 정렬. */
-function byNewestFirst(a: Merchant, b: Merchant): number {
-  return Number(b.code.slice(4)) - Number(a.code.slice(4));
+function readString(source: Record<string, unknown>, key: string): string {
+  const value = source[key];
+  return typeof value === 'string' ? value : '';
+}
+
+function toMerchant(raw: unknown): Merchant[] {
+  if (!isRecord(raw)) {
+    return [];
+  }
+
+  const code = readString(raw, 'frcsNo');
+  if (!code) {
+    return [];
+  }
+
+  return [
+    {
+      id: code,
+      code,
+      name: readString(raw, 'frcsNm'),
+      categoryCode: readString(raw, MERCHANT_CATEGORY_READ_FIELD),
+      businessNumber: readString(raw, 'brno'),
+      ownerName: readString(raw, 'rprsvNm'),
+      phoneNumber: readString(raw, 'rprsvTelno'),
+      address: readString(raw, 'frcsPostAddr'),
+      addressDetail: readString(raw, 'frcsDaddr'),
+      postalCode: readString(raw, 'frcsPostZip'),
+      latitude: readString(raw, 'lat'),
+      longitude: readString(raw, 'lng'),
+      // 조회 SQL이 USE_YN을 select하지 않는다(⚠️ 5) — 값이 없으면 노출로 본다.
+      isVisible: readString(raw, 'useYn') !== 'N',
+    },
+  ];
 }
 
 export async function fetchMerchants(
   query: MerchantQuery
 ): Promise<{ items: Merchant[]; totalCount: number }> {
-  const matched = MERCHANTS.filter((merchant) => matchesQuery(merchant, query));
-  matched.sort(byNewestFirst);
+  const accessToken = await getSessionAccessToken();
+  const keyword = query.keyword.trim();
 
-  const start = (query.page - 1) * query.pageSize;
+  const result = await backendFetch<unknown>(`${VOC_PATH}/pagination`, {
+    method: 'GET',
+    query: {
+      ...(keyword
+        ? {
+            searchCondition: SEARCH_CONDITION[query.searchField] ?? '1',
+            searchKeyword: keyword,
+          }
+        : {}),
+      ...(query.visibility
+        ? { searchUseYn: query.visibility === 'visible' ? 'Y' : 'N' }
+        : {}),
+      ...(query.category ? { searchFrcsTpbizCd: query.category } : {}),
+      pageIndex: query.page,
+      recordCountPerPage: query.pageSize,
+    },
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+    canHaveNullData: true,
+  });
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+
+  const data = result.data;
+  if (data === null || data === undefined) {
+    return { items: [], totalCount: 0 };
+  }
+  if (!isRecord(data) || !Array.isArray(data.list)) {
+    throw new Error('가맹점 목록 응답의 형식이 올바르지 않습니다.');
+  }
+
   return {
-    items: matched.slice(start, start + query.pageSize),
-    totalCount: matched.length,
+    items: data.list.flatMap(toMerchant),
+    totalCount:
+      typeof data.totalCount === 'number' ? data.totalCount : data.list.length,
   };
 }
 
 /** 엑셀 다운로드는 검색 조건과 무관하게 전체다(기획 MCH_001 ②). */
 export async function fetchAllMerchants(): Promise<Merchant[]> {
-  return [...MERCHANTS].sort(byNewestFirst);
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(`${VOC_PATH}/pagination`, {
+    method: 'GET',
+    query: { pageIndex: 1, recordCountPerPage: EXCEL_ROW_LIMIT },
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+    canHaveNullData: true,
+  });
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+
+  const data = result.data;
+  return isRecord(data) && Array.isArray(data.list)
+    ? data.list.flatMap(toMerchant)
+    : [];
 }
 
-function nowDate(): string {
-  return new Date().toISOString().slice(0, 10);
-}
-
-/** 가맹점코드는 저장 시 자동 생성한다(기획 MCH_002_p ⑤). */
 export async function createMerchant(values: MerchantValues): Promise<void> {
-  const nextSerial =
-    MERCHANTS.reduce(
-      (max, merchant) => Math.max(max, Number(merchant.code.slice(4)) || 0),
-      0
-    ) + 1;
-  const code = `MCH-${String(nextSerial).padStart(6, '0')}`;
+  const accessToken = await getSessionAccessToken();
 
-  MERCHANTS = [
-    ...MERCHANTS,
-    {
-      ...values,
-      id: code,
-      code,
-      createdAt: nowDate(),
-      updatedAt: nowDate(),
+  const result = await backendFetch<unknown>(VOC_PATH, {
+    method: 'POST',
+    form: {
+      frcsNm: values.name,
+      [MERCHANT_CATEGORY_WRITE_FIELD]: values.categoryCode,
+      // 요청 VO의 필드 이름이 대문자다(`private String BRNO`) — Spring이 그 이름으로 바인딩한다.
+      BRNO: values.businessNumber,
+      rprsvNm: values.ownerName,
+      rprsvTelno: values.phoneNumber,
+      frcsPostAddr: values.address,
+      frcsDaddr: values.addressDetail,
+      frcsPostZip: values.postalCode,
+      lat: values.latitude,
+      lng: values.longitude,
+      useYn: values.isVisible ? 'Y' : 'N',
     },
-  ];
-}
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+    canHaveNullData: true,
+    canHaveEmptyBody: true,
+  });
 
-export async function updateMerchant(values: MerchantValues): Promise<void> {
-  const index = MERCHANTS.findIndex((merchant) => merchant.id === values.id);
-  if (index < 0) {
-    throw new Error('가맹점을 찾을 수 없습니다.');
+  if (!result.ok) {
+    throw new BackendRequestError(result);
   }
-
-  MERCHANTS[index] = {
-    ...MERCHANTS[index],
-    ...values,
-    // 코드·등록일은 바뀌지 않는다.
-    id: MERCHANTS[index].id,
-    code: MERCHANTS[index].code,
-    createdAt: MERCHANTS[index].createdAt,
-    updatedAt: nowDate(),
-  };
-}
-
-export async function deleteMerchant(id: string): Promise<void> {
-  const next = MERCHANTS.filter((merchant) => merchant.id !== id);
-  if (next.length === MERCHANTS.length) {
-    throw new Error('가맹점을 찾을 수 없습니다.');
-  }
-  MERCHANTS = next;
 }
lib/domain/merchant-form.ts
--- lib/domain/merchant-form.ts
+++ lib/domain/merchant-form.ts
@@ -1,12 +1,10 @@
+// 이슈: 브랜드명·메모는 백엔드 TB_COM_VOUCHER에 컬럼이 없어 화면에서 뺐다(기획에는 있다).
 export const NAME_MAX_LENGTH = 100;
-export const BRAND_MAX_LENGTH = 100;
 export const OWNER_MAX_LENGTH = 50;
 export const ADDRESS_DETAIL_MAX_LENGTH = 200;
-export const MEMO_MAX_LENGTH = 500;
 
 export interface MerchantValues {
   id: string;
-  brandName: string;
   name: string;
   categoryCode: string;
   businessNumber: string;
@@ -14,10 +12,10 @@
   phoneNumber: string;
   address: string;
   addressDetail: string;
+  postalCode: string;
   latitude: string;
   longitude: string;
   isVisible: boolean;
-  memo: string;
 }
 
 export type MerchantFormErrors = Partial<
@@ -79,13 +77,11 @@
     values: {
       ...values,
       name,
-      brandName: values.brandName.trim().slice(0, BRAND_MAX_LENGTH),
       ownerName: values.ownerName.trim().slice(0, OWNER_MAX_LENGTH),
       phoneNumber,
       businessNumber,
       address: values.address.trim(),
       addressDetail: values.addressDetail.trim().slice(0, ADDRESS_DETAIL_MAX_LENGTH),
-      memo: values.memo.trim().slice(0, MEMO_MAX_LENGTH),
     },
   };
 }
lib/domain/merchant.ts
--- lib/domain/merchant.ts
+++ lib/domain/merchant.ts
@@ -1,8 +1,12 @@
+/**
+ * 백엔드 `TB_COM_VOUCHER`(`/api/v1/mngr/voc/**`) 한 행. 필드 이름이 화면 용어와 달라
+ * Repository가 옮긴다 — `frcsNo`=코드, `frcsNm`=가맹점명, `brno`=사업자번호,
+ * `rprsvNm`=대표자명, `rprsvTelno`=전화번호, `frcsPostAddr`/`frcsDaddr`=주소/상세주소.
+ */
 export interface Merchant {
   id: string;
-  /** 등록 시 자동 생성 — 수정 불가(기획 MCH_003_p ①). */
+  /** `frcsNo` — 서버가 만든다. 수정 불가(기획 MCH_003_p ①). */
   code: string;
-  brandName: string;
   name: string;
   categoryCode: string;
   businessNumber: string;
@@ -10,12 +14,10 @@
   phoneNumber: string;
   address: string;
   addressDetail: string;
+  postalCode: string;
   latitude: string;
   longitude: string;
   isVisible: boolean;
-  memo: string;
-  createdAt: string;
-  updatedAt: string;
 }
 
 export const EMPTY_FIELD_PLACEHOLDER = '-';
@@ -64,3 +66,12 @@
 export function formatAddressSummary(merchant: Merchant): string {
   return formatOptionalValue(merchant.address);
 }
+
+/**
+ * 업종 코드 ↔ 백엔드 필드.
+ *
+ * 이슈: 백엔드가 저장은 `FRCS_TPBIZ_CD`로 받고 조회·검색은 `FRCS_DV_CD`로 한다. 두 컬럼을
+ * 모두 select하므로 화면은 조회 쪽(`frcsDvCd`)을 읽고, 저장할 때는 요청 VO에 있는 이름을 쓴다.
+ */
+export const MERCHANT_CATEGORY_READ_FIELD = 'frcsDvCd';
+export const MERCHANT_CATEGORY_WRITE_FIELD = 'frcsTpbizCd';
Add a comment
List