임동욱 임동욱 09-18
feat: 가맹몰 등록·수정을 열고 가맹몰 상세에서 업체 항목을 직접 입력하게 한다
가맹몰 관리 — [등록] 버튼과 수정 모달을 활성화해 백엔드 VO(MngrVocInsertReqVo/UpdateReqVo)
대로 JSON으로 보낸다. 예금주·입금은행(COM_BANK_CD)·계좌·업태(FRCS_BZSTAT_CD)·구분(FRCS_DV_CD)
항목을 더하고 주소·우편번호도 직접 고칠 수 있다. 백엔드는 아직 BizPlay(웹캐시) 위임이고
우리 DB 저장으로 바뀔 예정(사용자 공지)이라 응답의 가맹점 코드는 어느 모양이든 읽고, 없으면
목록에서 이름·연락처로 찾는다.

가맹몰 상세 — 업체명·연락처·우편번호([주소 검색])·주소·상세주소를 읽기 전용에서 입력으로
바꾼다(사용자 확정). 저장하면 가맹점을 만들거나(코드 없음) 바뀐 값만 고친(코드 있음) 뒤 상세를
붙인다. 가맹점 선택 화면은 빈 등록 폼으로 바뀐다. 우편번호 검색 도우미는 두 화면이 쓰므로
app/_components/postcode.ts로 옮긴다.

Co-Authored-By: Claude Fable 5.1 
@94ca85ee6dd51f97b2bc47ae27f2066b196fa1ed
app/(protected)/(basic)/system/merchant-details/[frcsNo]/_components/merchant-detail-form.module.scss
--- app/(protected)/(basic)/system/merchant-details/[frcsNo]/_components/merchant-detail-form.module.scss
+++ app/(protected)/(basic)/system/merchant-details/[frcsNo]/_components/merchant-detail-form.module.scss
@@ -48,3 +48,9 @@
   flex-direction: column;
   gap: fox.gap(2);
 }
+
+// 기획 BO-MAL-002의 [주소 검색] — 라벨 줄 없이 입력칸 아래선에 맞춰 선다.
+.searchButton {
+  display: flex;
+  align-self: flex-end;
+}
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
@@ -1,7 +1,7 @@
 'use client';
 
 import { useRouter } from 'next/navigation';
-import { useActionState, useEffect, useState } from 'react';
+import { useActionState, useEffect, useState, useTransition } from 'react';
 import { FoxButton } from '@fox/core/components/fox-button';
 import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
 import { FoxButtonPanel } from '@fox/core/components/fox-button-panel';
@@ -11,6 +11,7 @@
 import { FoxInput } from '@fox/core/components/fox-input';
 import { FoxPageHeader } from '@fox/core/components/fox-page-header';
 import { FoxSelect } from '@fox/core/components/fox-select';
+import { openPostcodeSearch } from '@/app/_components/postcode';
 import { submitFormAction } from '@/app/_hooks/submit-form-action';
 import { useBlockingAction } from '@/app/_hooks/use-blocking-action';
 import { useFeedback } from '@/app/_hooks/use-feedback';
@@ -24,14 +25,15 @@
   type MerchantDetailSummary,
 } from '@/lib/domain/merchant-detail';
 import {
+  ADDRESS_MAX_LENGTH,
   CLOSED_DAYS_MAX_LENGTH,
   DESCRIPTION_MAX_LENGTH,
   INITIAL_MERCHANT_DETAIL_FORM_STATE,
+  MERCHANT_NAME_MAX_LENGTH,
   SORT_ORDER_MAX,
 } from '@/lib/domain/merchant-detail-form';
 import { MERCHANT_DETAILS_PATH, merchantDetailPath } from '@/lib/domain/merchant-detail-query';
 import type { MerchantProduct } from '@/lib/domain/merchant-product';
-import { formatPhoneNumber } from '@/lib/domain/phone-number';
 import {
   deleteMerchantDetailAction,
   saveMerchantDetailAction,
@@ -45,10 +47,8 @@
 interface MerchantDetailFormProps {
   /** 상세가 있는 가맹점 — 고친다. */
   detail?: MerchantDetail;
-  /** 상세가 아직 없는데 가맹점은 정해진 경우(행의 [수정]) — 그 가맹점에 처음 만든다. */
+  /** 상세가 아직 없는데 가맹점은 있는 경우(행의 [수정]) — 그 가맹점에 처음 만든다. */
   merchant?: MerchantDetailSummary;
-  /** 가맹점도 정해지지 않은 경우([+ 업체 등록]) — 업체명 자리에서 하나를 고른다. */
-  candidates?: MerchantDetailSummary[];
   products?: MerchantProduct[];
   categories: CommonCode[];
   /** 메뉴 표의 배지 코드표(`FRCS_BADGE_CD`). */
@@ -57,17 +57,16 @@
 
 /**
  * 가맹몰 상세 — 기획 BO-MAL-002(등록)·003(수정)을 한 화면으로 합쳤다(사용자 확정). `detail`이 있으면
- * 고치고, `merchant`면 그 가맹점에 처음 만들고, 둘 다 없으면 업체명 자리에서 가맹점을 골라 만든다.
+ * 고치고, `merchant`면 그 가맹점에 처음 만들고, 둘 다 없으면 업체(가맹점)부터 새로 만든다.
  * 첫 저장 뒤에는 그 가맹점 주소로 옮겨 가되 화면 구성은 그대로라 바로 메뉴를 붙일 수 있고, 이후
  * 저장은 그 자리에 머문다.
  *
- * 업체명·연락처·우편번호·주소는 가맹점 원본이라 읽기 전용이다. 기획의 [주소 검색]은 가맹몰 관리
- * 화면의 것이라 여기서는 값을 바꾸지 않는다.
+ * 업체명·연락처·우편번호·주소는 여기서 직접 입력한다(사용자 확정) — 저장하면 액션이 가맹점을
+ * 만들거나(코드 없음) 바뀐 값을 고친(코드 있음) 뒤 상세를 붙인다. 바뀌었는지는 숨은 원본 값과 비교한다.
  */
 export function MerchantDetailForm({
   detail,
   merchant: fixedMerchant,
-  candidates = [],
   products = [],
   categories,
   badgeCodes = [],
@@ -82,11 +81,20 @@
   const isEdit = detail !== undefined;
   const errors = state.status === 'error' ? (state.errors ?? {}) : {};
 
-  // 가맹점이 정해져 있으면(상세가 있든 없든) 업체명은 읽기 전용이고, 아니면 여기서 고른다.
-  const pinned: MerchantDetailSummary | null = detail ?? fixedMerchant ?? null;
-  const [frcsNo, setFrcsNo] = useState(pinned?.id ?? '');
-  const merchant: MerchantDetailSummary | null =
-    pinned ?? candidates.find((row) => row.id === frcsNo) ?? null;
+  // 가맹점이 있으면(상세가 있든 없든) 그 코드로 고치고, 없으면 빈 코드로 보내 새로 만든다.
+  const merchant: MerchantDetailSummary | null = detail ?? fixedMerchant ?? null;
+  const frcsNo = merchant?.id ?? '';
+  const original = {
+    name: merchant?.name ?? '',
+    phoneNumber: merchant?.phoneNumber ?? '',
+    postalCode: merchant?.postalCode ?? '',
+    address: merchant?.address ?? '',
+    addressDetail: merchant?.addressDetail ?? '',
+  };
+  // 우편번호·주소는 검색으로 채우거나 직접 고친다.
+  const [postalCode, setPostalCode] = useState(original.postalCode);
+  const [address, setAddress] = useState(original.address);
+  const [isSearching, startSearching] = useTransition();
   const [categoryCode, setCategoryCode] = useState(detail?.categoryCode ?? '');
   const [visibility, setVisibility] = useState<string[]>([
     detail?.isVisible === false ? 'N' : 'Y',
@@ -103,6 +111,22 @@
       router.refresh();
     }
   }, [state, router, showToast]);
+
+  /** 기획 BO-MAL-002 [주소 검색] — 고른 주소로 우편번호·주소를 채운다. */
+  function searchAddress() {
+    startSearching(async () => {
+      let picked;
+      try {
+        picked = await openPostcodeSearch();
+      } catch {
+        showToast({ variant: 'danger', message: '주소 검색을 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.' });
+        return;
+      }
+      if (!picked) return;
+      setPostalCode(picked.zonecode);
+      setAddress(picked.address);
+    });
+  }
 
   function toggleVisibility() {
     if (!detail) return;
@@ -166,33 +190,27 @@
       >
         <input type="hidden" name="frcsNo" value={frcsNo} />
         <input type="hidden" name="mode" value={isEdit ? 'edit' : 'create'} />
+        {/* 저장 시 업체 값이 바뀌었는지 비교할 원본 — 안 바뀌었으면 가맹점 수정 요청을 보내지 않는다. */}
+        <input type="hidden" name="originalname" value={original.name} />
+        <input type="hidden" name="originalphoneNumber" value={original.phoneNumber} />
+        <input type="hidden" name="originalpostalCode" value={original.postalCode} />
+        <input type="hidden" name="originaladdress" value={original.address} />
+        <input type="hidden" name="originaladdressDetail" value={original.addressDetail} />
 
         <FoxCard size="lg" title="기본 정보" className={styles.section}>
           <div className={styles.grid}>
             <div className={styles.fieldWide}>
-              {pinned ? (
-                <FoxInput size="md" label="업체명" requirement="required" value={pinned.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="상세를 붙일 가맹점이 없습니다. 모든 가맹점에 상세가 등록돼 있습니다."
-                    />
-                  )}
-                </>
-              )}
+              <FoxInput
+                size="md"
+                name="name"
+                label="업체명"
+                requirement="required"
+                placeholder="예) 타볼라타"
+                defaultValue={original.name}
+                maxLength={MERCHANT_NAME_MAX_LENGTH}
+                invalid={Boolean(errors.name)}
+                message={errors.name}
+              />
             </div>
             <div className={styles.field}>
               <FoxSelect
@@ -211,24 +229,62 @@
             <div className={styles.field}>
               <FoxInput
                 size="md"
+                name="phoneNumber"
                 label="업체 연락처"
                 requirement="required"
-                value={formatOptionalValue(formatPhoneNumber(merchant?.phoneNumber ?? null))}
-                readOnly
-                state="view"
+                placeholder="0507-1413-6625"
+                defaultValue={original.phoneNumber}
+                invalid={Boolean(errors.phoneNumber)}
+                message={errors.phoneNumber}
               />
             </div>
           </div>
 
           <div className={styles.grid}>
             <div className={styles.fieldNarrow}>
-              <FoxInput size="md" label="우편번호" requirement="required" value={formatOptionalValue(merchant?.postalCode)} readOnly state="view" />
+              <FoxInput
+                size="md"
+                name="postalCode"
+                label="우편번호"
+                requirement="required"
+                placeholder="00000"
+                value={postalCode}
+                onChange={setPostalCode}
+                invalid={Boolean(errors.postalCode)}
+                message={errors.postalCode}
+              />
+            </div>
+            <div className={styles.searchButton}>
+              <FoxButton
+                type="default"
+                size="md"
+                label={isSearching ? '검색 중...' : '주소 검색'}
+                disabled={isSearching}
+                onAction={searchAddress}
+              />
             </div>
             <div className={styles.fieldWide}>
-              <FoxInput size="md" label="주소" value={formatOptionalValue(merchant?.address)} readOnly state="view" />
+              <FoxInput
+                size="md"
+                name="address"
+                label="주소"
+                placeholder="도로명 주소 (검색 결과 자동 입력)"
+                value={address}
+                onChange={setAddress}
+                maxLength={ADDRESS_MAX_LENGTH}
+                invalid={Boolean(errors.address)}
+                message={errors.address}
+              />
             </div>
             <div className={styles.field}>
-              <FoxInput size="md" label="상세주소" value={formatOptionalValue(merchant?.addressDetail)} readOnly state="view" />
+              <FoxInput
+                size="md"
+                name="addressDetail"
+                label="상세주소"
+                placeholder="건물명 · 층 · 호"
+                defaultValue={original.addressDetail}
+                maxLength={ADDRESS_MAX_LENGTH}
+              />
             </div>
           </div>
 
app/(protected)/(basic)/system/merchant-details/_actions.ts
--- app/(protected)/(basic)/system/merchant-details/_actions.ts
+++ app/(protected)/(basic)/system/merchant-details/_actions.ts
@@ -14,8 +14,10 @@
   createMerchantDetail,
   deleteMerchantDetail,
   fetchMerchantDetail,
+  findMerchantByName,
   updateMerchantDetail,
 } from '@/lib/data/repositories/merchant-detail-repository';
+import { createMerchant, updateMerchant } from '@/lib/data/repositories/merchant-repository';
 import {
   createMerchantProduct,
   deleteMerchantProduct,
@@ -33,8 +35,11 @@
 import {
   validateMerchantDetail,
   type MerchantDetailFormState,
+  type MerchantDetailMerchantValues,
   type MerchantDetailValues,
 } from '@/lib/domain/merchant-detail-form';
+import { DEFAULT_MERCHANT_DIVISION_CODE } from '@/lib/domain/merchant';
+import type { MerchantValues } from '@/lib/domain/merchant-form';
 import {
   MERCHANT_DETAILS_PATH,
   merchantDetailPath,
@@ -60,6 +65,8 @@
 // 백엔드가 삭제된 상세도 "있는 것"으로 보는 결함(보고함) — 409는 대개 이 경우다.
 const DETAIL_CONFLICT_MESSAGE =
   '이 가맹점에는 이미 상세가 있습니다. 삭제했던 상세라면 백엔드에서 정리해야 다시 등록할 수 있습니다.';
+const MERCHANT_NOT_FOUND_AFTER_CREATE_MESSAGE =
+  '업체는 등록됐지만 코드를 받지 못해 상세를 붙이지 못했습니다. 목록에 업체가 나타나면 [수정]으로 상세를 등록해 주세요.';
 const IMAGE_OVERSIZE_MESSAGE = `이미지는 장당 ${Math.floor(MAX_IMAGE_BYTES / (1024 * 1024))}MB까지 올릴 수 있습니다.`;
 const IMAGES_TOO_MANY_MESSAGE = `추가 이미지는 최대 ${MAX_EXTRA_IMAGES}장까지입니다.`;
 const REQUEST_OVERSIZE_MESSAGE = `한 번에 올리는 이미지의 합이 ${Math.floor(MAX_ATTACHMENT_BYTES / (1024 * 1024))}MB를 넘습니다. 나눠서 저장해 주세요.`;
@@ -238,12 +245,83 @@
   return uploadAttachments(bundle, FILE_MODULE.merchantImage.id);
 }
 
+function readMerchantValues(formData: FormData, prefix = ''): MerchantDetailMerchantValues {
+  return {
+    name: readString(formData, `${prefix}name`),
+    phoneNumber: readString(formData, `${prefix}phoneNumber`),
+    postalCode: readString(formData, `${prefix}postalCode`),
+    address: readString(formData, `${prefix}address`),
+    addressDetail: readString(formData, `${prefix}addressDetail`),
+  };
+}
+
+function isSameMerchant(a: MerchantDetailMerchantValues, b: MerchantDetailMerchantValues): boolean {
+  return (
+    a.name.trim() === b.name.trim() &&
+    a.phoneNumber.trim() === b.phoneNumber.trim() &&
+    a.postalCode.trim() === b.postalCode.trim() &&
+    a.address.trim() === b.address.trim() &&
+    a.addressDetail.trim() === b.addressDetail.trim()
+  );
+}
+
+/** 상세 화면이 받는 업체 항목을 가맹점 등록·수정 요청 값으로 — 나머지는 이 화면에 없어 비운다. */
+function toMerchantValues(frcsNo: string, values: MerchantDetailMerchantValues): MerchantValues {
+  return {
+    id: frcsNo,
+    brandName: '',
+    memo: '',
+    name: values.name,
+    categoryCode: '',
+    businessNumber: '',
+    ownerName: '',
+    phoneNumber: values.phoneNumber,
+    address: values.address,
+    addressDetail: values.addressDetail,
+    postalCode: values.postalCode,
+    latitude: '',
+    longitude: '',
+    bankHolderName: '',
+    bankCode: '',
+    accountNumber: '',
+    businessStatusCode: '',
+    divisionCode: DEFAULT_MERCHANT_DIVISION_CODE,
+    isVisible: true,
+  };
+}
+
+/**
+ * 업체(가맹점)를 만들거나 고치고 코드를 돌려준다.
+ * - 코드가 없으면 새로 만든다 — 응답에 코드가 없으면(BizPlay 위임 응답) 목록에서 이름으로 찾는다.
+ * - 코드가 있으면 값이 바뀌었을 때만 고친다 — BizPlay 위임 상태에선 불필요한 외부 호출을 피한다.
+ */
+async function saveMerchant(
+  frcsNo: string,
+  values: MerchantDetailMerchantValues,
+  original: MerchantDetailMerchantValues
+): Promise<string> {
+  if (frcsNo) {
+    if (!isSameMerchant(values, original)) {
+      await updateMerchant(frcsNo, toMerchantValues(frcsNo, values));
+    }
+    return frcsNo;
+  }
+  const created = await createMerchant(toMerchantValues('', values));
+  if (created) return created;
+  const found = await findMerchantByName(values.name, values.phoneNumber);
+  if (!found) {
+    throw new InputError(MERCHANT_NOT_FOUND_AFTER_CREATE_MESSAGE);
+  }
+  return found.id;
+}
+
 function readDetailValues(
   formData: FormData,
   thumbnailFileId: string,
   imageFileId: string
 ): MerchantDetailValues {
   return {
+    ...readMerchantValues(formData),
     frcsNo: readString(formData, 'frcsNo'),
     categoryCode: readString(formData, 'categoryCode'),
     description: readString(formData, 'description'),
@@ -264,11 +342,8 @@
 ): Promise<MerchantDetailFormState> {
   await verifySession();
 
-  const frcsNo = readString(formData, 'frcsNo');
   const isEdit = readString(formData, 'mode') === 'edit';
-  if (!frcsNo) {
-    return { status: 'error', errors: { frcsNo: '업체를 선택해 주세요.' } };
-  }
+  let frcsNo = readString(formData, 'frcsNo');
 
   let failed: MerchantDetailFormState | null = null;
   try {
@@ -286,10 +361,13 @@
       return { status: 'error', errors: validation.errors };
     }
 
+    // 업체 먼저(만들거나 고치고), 그 코드로 상세를 붙인다.
+    frcsNo = await saveMerchant(frcsNo, validation.values, readMerchantValues(formData, 'original'));
+    const values = { ...validation.values, frcsNo };
     if (isEdit) {
-      await updateMerchantDetail(validation.values);
+      await updateMerchantDetail(values);
     } else {
-      await createMerchantDetail(validation.values);
+      await createMerchantDetail(values);
     }
   } catch (error) {
     unstable_rethrow(error);
@@ -356,6 +434,11 @@
   }
   await updateMerchantDetail({
     frcsNo,
+    name: detail.name,
+    phoneNumber: detail.phoneNumber ?? '',
+    postalCode: detail.postalCode ?? '',
+    address: detail.address ?? '',
+    addressDetail: detail.addressDetail ?? '',
     categoryCode: detail.categoryCode,
     description: detail.description ?? '',
     openTime: detail.openTime ?? '',
app/(protected)/(basic)/system/merchant-details/new/page.tsx
--- app/(protected)/(basic)/system/merchant-details/new/page.tsx
+++ app/(protected)/(basic)/system/merchant-details/new/page.tsx
@@ -1,21 +1,16 @@
 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} />;
+  const categories = await fetchCommonCodes(CODE_GROUP.merchantType);
+  return <MerchantDetailForm categories={categories} />;
 }
app/(protected)/(basic)/system/merchants/_actions.ts
--- app/(protected)/(basic)/system/merchants/_actions.ts
+++ app/(protected)/(basic)/system/merchants/_actions.ts
@@ -1,22 +1,14 @@
 'use server';
 
+import { revalidatePath } from 'next/cache';
+import { unstable_rethrow } from 'next/navigation';
 import { verifySession } from '@/lib/auth/dal';
-import {
-  validateMerchant,
-  type MerchantFormState,
-} from '@/lib/domain/merchant-form';
+import { BackendRequestError } from '@/lib/http/backend-fetch';
+import { createMerchant, updateMerchant } from '@/lib/data/repositories/merchant-repository';
+import { validateMerchant, type MerchantFormState } from '@/lib/domain/merchant-form';
+import { MERCHANTS_PATH } from '@/lib/domain/merchant-query';
 
-/**
- * TODO(백엔드): 등록이 자체 저장에서 BizPlay 외부 가맹점 등록 API 위임으로 바뀌었다 —
- *   `POST /api/v1/mngr/voc`가 @RequestBody(JSON)가 되고 `bizPlayVocApiService.insertVoc`를
- *   부른다. 예금주명·입금은행코드·입금계좌번호·업태코드가 새로 필수인데 이 폼은 받지 않는다.
- *   항목이 정해질 때까지 등록을 막는다.
- *
- *   화면의 [등록] 버튼만 비활성화하면 막은 것이 아니다 — Server Action은 UI를 거치지 않고도
- *   직접 호출된다. 그래서 저장 직전에서 끊는다.
- */
-const DISABLED_MESSAGE =
-  '가맹점 등록은 현재 사용할 수 없습니다. 등록 항목이 확정되면 다시 열립니다.';
+const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.';
 
 function readString(formData: FormData, key: string): string {
   const value = formData.get(key);
@@ -43,11 +35,31 @@
     postalCode: readString(formData, 'postalCode'),
     latitude: readString(formData, 'latitude'),
     longitude: readString(formData, 'longitude'),
+    bankHolderName: readString(formData, 'bankHolderName'),
+    bankCode: readString(formData, 'bankCode'),
+    accountNumber: readString(formData, 'accountNumber'),
+    businessStatusCode: readString(formData, 'businessStatusCode'),
+    divisionCode: readString(formData, 'divisionCode'),
     isVisible: readString(formData, 'isVisible') === 'Y',
   });
   if (!validation.ok) {
     return { status: 'error', errors: validation.errors };
   }
 
-  return { status: 'error', message: DISABLED_MESSAGE };
+  try {
+    if (validation.values.id) {
+      await updateMerchant(validation.values.id, validation.values);
+    } else {
+      await createMerchant(validation.values);
+    }
+  } catch (error) {
+    unstable_rethrow(error);
+    console.error('[merchants] 저장 실패', error);
+    const message =
+      error instanceof BackendRequestError && error.message ? error.message : SAVE_FAILED_MESSAGE;
+    return { status: 'error', message };
+  }
+
+  revalidatePath(MERCHANTS_PATH);
+  return { status: 'success' };
 }
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,10 +17,11 @@
 import { FoxSelect } from '@fox/core/components/fox-select';
 import { submitFormAction } from '@/app/_hooks/submit-form-action';
 import { useFeedback } from '@/app/_hooks/use-feedback';
-import type { CommonCode } from '@/lib/domain/common-code';
 import {
+  DEFAULT_MERCHANT_DIVISION_CODE,
   formatOptionalValue,
   type Merchant,
+  type MerchantCodeTables,
 } from '@/lib/domain/merchant';
 import {
   ADDRESS_DETAIL_MAX_LENGTH,
@@ -28,32 +29,27 @@
   INITIAL_MERCHANT_FORM_STATE,
   MEMO_MAX_LENGTH,
   NAME_MAX_LENGTH,
+  ACCOUNT_NUMBER_MAX_LENGTH,
   OWNER_MAX_LENGTH,
 } from '@/lib/domain/merchant-form';
 import { saveMerchantAction } from '../_actions';
-import { openPostcodeSearch } from './postcode';
+import { openPostcodeSearch } from '@/app/_components/postcode';
 import styles from './merchant-form.module.scss';
-
-/**
- * 백엔드에 가맹점 수정 API가 없어(`/api/v1/mngr/voc`는 목록·등록뿐) 수정 모달을 조회
- * 전용으로 연다. **API가 생기면 이 한 줄만 `true`로 바꾸면 된다** — 입력칸·저장 버튼이
- * 전부 이 값에 걸려 있고 폼 자체는 그대로 살아 있다.
- */
-const IS_EDIT_ENABLED = false;
 
 interface MerchantFormModalProps {
   merchant?: Merchant;
-  /** 업종 코드표 — 선택지이자 조회 전용일 때의 이름 출처. */
-  businessCodes: CommonCode[];
+  /** 업종·업태·구분·은행 코드표 — 선택지. */
+  codeTables: MerchantCodeTables;
   onClose: () => void;
 }
 
-export function MerchantFormModal({ merchant, businessCodes, onClose }: MerchantFormModalProps) {
+/**
+ * 가맹몰(가맹점) 등록·수정 모달 — 기획 MCH_002_p·MCH_003_p. 모든 항목을 고칠 수 있다(사용자 확정).
+ * 요청은 백엔드 `MngrVocInsertReqVo`/`MngrVocUpdateReqVo`대로 보낸다 — 지금은 BizPlay(웹캐시)로
+ * 위임되고 곧 우리 DB 저장으로 바뀐다.
+ */
+export function MerchantFormModal({ merchant, codeTables, onClose }: MerchantFormModalProps) {
   const isEdit = Boolean(merchant);
-  const isReadOnly = isEdit && !IS_EDIT_ENABLED;
-  // 조회 전용일 때 입력칸마다 얹는다. `state="view"`는 시안의 조회 상태로, 잠긴 것(disabled)과
-  // 달리 "볼 수만 있는 값"으로 보이게 한다.
-  const viewProps = isReadOnly ? ({ readOnly: true, state: 'view' } as const) : null;
   const { showToast } = useFeedback();
   const formRef = useRef<HTMLFormElement>(null);
   const [state, formAction, isPending] = useActionState(
@@ -62,12 +58,17 @@
   );
 
   const [categoryCode, setCategoryCode] = useState(merchant?.categoryCode ?? '');
+  const [businessStatusCode, setBusinessStatusCode] = useState(merchant?.businessStatusCode ?? '');
+  const [divisionCode, setDivisionCode] = useState(
+    merchant?.divisionCode || DEFAULT_MERCHANT_DIVISION_CODE
+  );
+  const [bankCode, setBankCode] = useState('');
   // 조회 응답에 useYn이 없어 기존 가맹점은 값을 모른다 — 그때는 어느 쪽도 고르지 않는다.
   // 신규 등록만 「노출」로 시작한다.
   const [isVisible, setIsVisible] = useState<boolean | null>(
     merchant === undefined ? true : merchant.isVisible
   );
-  // 주소·좌표는 검색 결과가 채운다(기획 ③ Read Only).
+  // 주소·우편번호는 검색으로 채우거나 직접 고친다. 좌표는 검색 결과만 채운다.
   const [address, setAddress] = useState(merchant?.address ?? '');
   const [postalCode, setPostalCode] = useState(merchant?.postalCode ?? '');
   const [latitude, setLatitude] = useState(merchant?.latitude ?? '');
@@ -123,23 +124,19 @@
       dismissible={false}
       open
       size="md"
-      title={isReadOnly ? '가맹점 정보' : isEdit ? '가맹점 수정' : '가맹점 등록'}
+      title={isEdit ? '가맹몰 수정' : '가맹몰 등록'}
       onClose={onClose}
       actions={
-        isReadOnly ? (
-          <FoxButton type="default" size="md" label="닫기" onAction={onClose} />
-        ) : (
-          <>
-            <FoxButton type="default" size="md" label="취소" onAction={onClose} />
-            <FoxButton
-              type="primary"
-              size="md"
-              label={isPending ? '저장 중...' : isEdit ? '수정' : '등록'}
-              disabled={isPending}
-              onAction={() => formRef.current?.requestSubmit()}
-            />
-          </>
-        )
+        <>
+          <FoxButton type="default" size="md" label="취소" onAction={onClose} />
+          <FoxButton
+            type="primary"
+            size="md"
+            label={isPending ? '저장 중...' : isEdit ? '수정' : '등록'}
+            disabled={isPending}
+            onAction={() => formRef.current?.requestSubmit()}
+          />
+        </>
       }
     >
       <form
@@ -149,9 +146,10 @@
       >
         <input type="hidden" name="id" value={merchant?.id ?? ''} />
         <input type="hidden" name="categoryCode" value={categoryCode} />
+        <input type="hidden" name="businessStatusCode" value={businessStatusCode} />
+        <input type="hidden" name="divisionCode" value={divisionCode} />
+        <input type="hidden" name="bankCode" value={bankCode} />
         <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} />
 
@@ -172,7 +170,6 @@
             항목이라 화면에서는 지우지 않고, 값이 없으면 `-`로 보여준다(사용자 확정). */}
         <div className={styles.field}>
           <FoxInput
-            {...viewProps}
             size="md"
             name="brandName"
             label="브랜드명"
@@ -184,7 +181,6 @@
 
         <div className={styles.field}>
           <FoxInput
-            {...viewProps}
             size="md"
             name="name"
             label="가맹점명"
@@ -198,25 +194,44 @@
         </div>
 
         <div className={styles.field}>
+          <div className={styles.pair}>
+            <FoxSelect
+              size="md"
+              label="업종"
+              requirement="required"
+              options={codeTables.business.map((code) => ({ value: code.code, label: code.label }))}
+              value={categoryCode}
+              onValueChange={setCategoryCode}
+              placeholder="선택"
+              error={Boolean(errors.categoryCode)}
+              hint={errors.categoryCode}
+            />
+            <FoxSelect
+              size="md"
+              label="업태"
+              options={codeTables.businessStatus.map((code) => ({ value: code.code, label: code.label }))}
+              value={businessStatusCode}
+              onValueChange={setBusinessStatusCode}
+              placeholder="선택"
+            />
+          </div>
+        </div>
+
+        <div className={styles.field}>
           <FoxSelect
-            readOnly={isReadOnly}
             size="md"
-            label="업종"
-            requirement="required"
-            options={businessCodes.map((code) => ({ value: code.code, label: code.label }))}
-            value={categoryCode}
-            onValueChange={setCategoryCode}
+            label="가맹점 구분"
+            options={codeTables.division.map((code) => ({ value: code.code, label: code.label }))}
+            value={divisionCode}
+            onValueChange={setDivisionCode}
             placeholder="선택"
-            error={Boolean(errors.categoryCode)}
-            hint={errors.categoryCode}
           />
         </div>
 
         <div className={styles.field}>
           <div className={styles.pair}>
             <FoxInput
-              {...viewProps}
-              size="md"
+                size="md"
               name="businessNumber"
               label="사업자등록번호"
               defaultValue={merchant?.businessNumber ?? ''}
@@ -225,8 +240,7 @@
               message={errors.businessNumber}
             />
             <FoxInput
-              {...viewProps}
-              size="md"
+                size="md"
               name="ownerName"
               label="대표자명"
               defaultValue={merchant?.ownerName ?? ''}
@@ -238,7 +252,6 @@
 
         <div className={styles.field}>
           <FoxInput
-            {...viewProps}
             size="md"
             name="phoneNumber"
             label="전화번호"
@@ -257,24 +270,31 @@
           <div className={styles.addressRow}>
             <FoxInput
               size="md"
-              aria-label="주소"
-              value={address}
-              readOnly
-              state="view"
-              placeholder="주소 검색을 진행해주세요."
+              name="postalCode"
+              aria-label="우편번호"
+              value={postalCode}
+              onChange={setPostalCode}
+              placeholder="우편번호"
+              invalid={Boolean(errors.postalCode)}
+              message={errors.postalCode}
             />
-            {!isReadOnly && (
-              <FoxButton
-                type="secondary"
-                size="md"
-                label={isSearching ? '검색 중...' : '주소 검색'}
-                disabled={isSearching}
-                onAction={searchAddress}
-              />
-            )}
+            <FoxButton
+              type="secondary"
+              size="md"
+              label={isSearching ? '검색 중...' : '주소 검색'}
+              disabled={isSearching}
+              onAction={searchAddress}
+            />
           </div>
           <FoxInput
-            {...viewProps}
+            size="md"
+            name="address"
+            aria-label="주소"
+            value={address}
+            onChange={setAddress}
+            placeholder="주소 검색으로 채우거나 직접 입력하세요."
+          />
+          <FoxInput
             size="md"
             name="addressDetail"
             aria-label="상세주소"
@@ -310,19 +330,47 @@
         </div>
 
         <div className={styles.field}>
+          <FoxFormLabel as="span">정산 계좌</FoxFormLabel>
+          <div className={styles.pair}>
+            <FoxSelect
+              size="md"
+              aria-label="입금 은행"
+              options={codeTables.bank.map((code) => ({ value: code.code, label: code.label }))}
+              value={bankCode}
+              onValueChange={setBankCode}
+              placeholder="은행 선택"
+            />
+            <FoxInput
+              size="md"
+              name="bankHolderName"
+              aria-label="예금주명"
+              placeholder="예금주명"
+              maxLength={OWNER_MAX_LENGTH}
+            />
+          </div>
+          <FoxInput
+            size="md"
+            name="accountNumber"
+            aria-label="입금계좌번호"
+            placeholder="입금계좌번호 (숫자와 -)"
+            maxLength={ACCOUNT_NUMBER_MAX_LENGTH}
+            invalid={Boolean(errors.accountNumber)}
+            message={errors.accountNumber}
+          />
+        </div>
+
+        <div className={styles.field}>
           <FoxFormLabel as="span" requirement="required">
             사용여부
           </FoxFormLabel>
           <FoxRadioGroup name="visibility" size="md" label="사용여부">
             <FoxRadio
-              disabled={isReadOnly}
               value="Y"
               label="노출"
               checked={isVisible === true}
               onChange={() => setIsVisible(true)}
             />
             <FoxRadio
-              disabled={isReadOnly}
               value="N"
               label="미노출"
               checked={isVisible === false}
@@ -333,7 +381,6 @@
 
         <div className={styles.field}>
           <FoxInput
-            {...viewProps}
             size="md"
             name="memo"
             label="메모"
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
@@ -1,6 +1,7 @@
 'use client';
 
 import { useRouter } from 'next/navigation';
+import { useState } from 'react';
 import { FoxButton } from '@fox/core/components/fox-button';
 import {
   FoxListContainer,
@@ -9,13 +10,13 @@
 import { FoxSelect } from '@fox/core/components/fox-select';
 import { FoxSelectText } from '@fox/core/components/fox-select-text';
 import { FoxStatusIndicator } from '@fox/core/components/fox-status-indicator';
-import type { CommonCode } from '@/lib/domain/common-code';
 import {
   formatAddressSummary,
   formatCategoryLabel,
   formatOptionalValue,
   formatVisibilityLabel,
   type Merchant,
+  type MerchantCodeTables,
 } from '@/lib/domain/merchant';
 import {
   MERCHANTS_EXCEL_PATH,
@@ -27,13 +28,14 @@
   type MerchantQuery,
   type MerchantSearchField,
 } from '@/lib/domain/merchant-query';
+import { MerchantFormModal } from './merchant-form-modal';
 import { MerchantRowActions } from './merchant-row-actions';
 import styles from './merchant-filter.module.scss';
 
 interface MerchantListProps {
   items: Merchant[];
-  /** 업종 코드표(`FRCS_TPBIZ_CD` 다음 `FRCS_CD`) — 업종 열과 상세 모달이 이름을 찾는다. */
-  businessCodes: CommonCode[];
+  /** 업종(열·모달)·업태·구분·은행 코드표 — 등록·수정 모달의 선택지. */
+  codeTables: MerchantCodeTables;
   query: MerchantQuery;
   currentPage: number;
   totalPages: number;
@@ -45,13 +47,14 @@
 
 export function MerchantList({
   items,
-  businessCodes,
+  codeTables,
   query,
   currentPage,
   totalPages,
   totalCount,
 }: MerchantListProps) {
   const router = useRouter();
+  const [isCreateOpen, setIsCreateOpen] = useState(false);
 
   function go(patch: Partial<MerchantQuery>) {
     router.replace(buildMerchantHref(query, { ...patch, page: 1 }));
@@ -76,7 +79,7 @@
       key: 'categoryCode',
       header: '업종',
       width: 100,
-      render: (row) => formatCategoryLabel(businessCodes, row.categoryCode),
+      render: (row) => formatCategoryLabel(codeTables.business, row.categoryCode),
     },
     {
       key: 'phoneNumber',
@@ -114,7 +117,7 @@
       key: 'actions',
       header: '관리',
       width: 120,
-      render: (row) => <MerchantRowActions merchant={row} businessCodes={businessCodes} />,
+      render: (row) => <MerchantRowActions merchant={row} codeTables={codeTables} />,
     },
   ];
 
@@ -203,10 +206,6 @@
                 label="엑셀다운로드"
               />
             </form>
-            {/* TODO(백엔드): 등록이 자체 저장에서 BizPlay 외부 가맹점 등록 API 위임으로 바뀌었다
-                (`POST /api/v1/mngr/voc`가 @RequestBody JSON이 되고 bizPlayVocApiService.insertVoc를
-                부른다). 예금주명·입금은행코드·입금계좌번호·업태코드가 새로 필요한데 이 폼은 받지
-                않는다. 항목이 정해지면 MerchantFormModal과 함께 다시 연다. */}
             <FoxButton
               type="primary"
               size="md"
@@ -214,7 +213,7 @@
                 <i className="fox-ico fox-ico-Plus" aria-hidden="true" />
               }
               label="등록"
-              disabled
+              onAction={() => setIsCreateOpen(true)}
             />
           </>
         }
@@ -233,6 +232,10 @@
           }
         }}
       />
+
+      {isCreateOpen && (
+        <MerchantFormModal codeTables={codeTables} onClose={() => setIsCreateOpen(false)} />
+      )}
     </>
   );
 }
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
@@ -4,27 +4,24 @@
 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 { CommonCode } from '@/lib/domain/common-code';
-import type { Merchant } from '@/lib/domain/merchant';
+import type { Merchant, MerchantCodeTables } from '@/lib/domain/merchant';
 import { MerchantFormModal } from './merchant-form-modal';
 
 interface MerchantRowActionsProps {
   merchant: Merchant;
-  businessCodes: CommonCode[];
+  codeTables: MerchantCodeTables;
 }
 
 /**
- * 수정은 모달을 열어 등록된 내용을 보여 준다 — 백엔드에 수정 API가 없어 모달이 조회
- * 전용으로 뜬다(`merchant-form-modal.tsx`의 `IS_EDIT_ENABLED`). API가 생기면 그 한 줄만
- * 바꾸면 여기는 손대지 않아도 된다.
+ * 수정은 모달을 열어 고친다(`PUT /api/v1/mngr/voc`).
  *
- * TODO(백엔드): 삭제 API도 없다(`/api/v1/mngr/voc`는 목록·등록뿐). 시안의 자리는 두되
- * 눌러도 할 수 있는 일이 없어 사유를 알린다.
+ * TODO(백엔드): 삭제 API가 없다(`/api/v1/mngr/voc/{frcsNo}` DELETE는 가맹몰 상세를 지운다). 시안의
+ * 자리는 두되 눌러도 할 수 있는 일이 없어 사유를 알린다.
  *
  * 어느 행의 버튼인지는 각 버튼 라벨이 말한다 — 감싼 요소의 `aria-label`은 의미 없는
  * 요소에 붙어 읽히지 않을 수 있다(공통코드 행과 같은 방식).
  */
-export function MerchantRowActions({ merchant, businessCodes }: MerchantRowActionsProps) {
+export function MerchantRowActions({ merchant, codeTables }: MerchantRowActionsProps) {
   const { showToast } = useFeedback();
   const [isDetailOpen, setIsDetailOpen] = useState(false);
 
@@ -55,7 +52,7 @@
       {isDetailOpen && (
         <MerchantFormModal
           merchant={merchant}
-          businessCodes={businessCodes}
+          codeTables={codeTables}
           onClose={() => setIsDetailOpen(false)}
         />
       )}
app/(protected)/(basic)/system/merchants/page.tsx
--- app/(protected)/(basic)/system/merchants/page.tsx
+++ app/(protected)/(basic)/system/merchants/page.tsx
@@ -3,6 +3,7 @@
 import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
 import { fetchMerchants } from '@/lib/data/repositories/merchant-repository';
 import { CODE_GROUP } from '@/lib/domain/common-code';
+import type { MerchantCodeTables } from '@/lib/domain/merchant';
 import { parseMerchantQuery } from '@/lib/domain/merchant-query';
 import { MerchantList } from './_components/merchant-list';
 
@@ -20,13 +21,22 @@
   await verifySession();
 
   const query = parseMerchantQuery(await searchParams);
-  const [{ items, totalCount }, business, businessDetail] = await Promise.all([
-    fetchMerchants(query),
-    fetchCommonCodes(CODE_GROUP.merchantBusiness),
-    fetchCommonCodes(CODE_GROUP.merchantBusinessDetail),
-  ]);
-  // 앞쪽 표가 먼저 맞는다 — 2자리 값은 FRCS_TPBIZ_CD에서, 4자리 값은 FRCS_CD에서 이름을 찾는다.
-  const businessCodes = [...business, ...businessDetail];
+  const [{ items, totalCount }, business, businessDetail, businessStatus, division, bank] =
+    await Promise.all([
+      fetchMerchants(query),
+      fetchCommonCodes(CODE_GROUP.merchantBusiness),
+      fetchCommonCodes(CODE_GROUP.merchantBusinessDetail),
+      fetchCommonCodes(CODE_GROUP.merchantBusinessStatus),
+      fetchCommonCodes(CODE_GROUP.merchantDivision),
+      fetchCommonCodes(CODE_GROUP.bank),
+    ]);
+  const codeTables: MerchantCodeTables = {
+    // 앞쪽 표가 먼저 맞는다 — 2자리 값은 FRCS_TPBIZ_CD에서, 4자리 값은 FRCS_CD에서 이름을 찾는다.
+    business: [...business, ...businessDetail],
+    businessStatus,
+    division,
+    bank,
+  };
 
   const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize));
   const currentPage = Math.min(query.page, totalPages);
@@ -34,7 +44,7 @@
   return (
     <MerchantList
       items={items}
-      businessCodes={businessCodes}
+      codeTables={codeTables}
       query={query}
       currentPage={currentPage}
       totalPages={totalPages}
app/_components/postcode.ts (Renamed from app/(protected)/(basic)/system/merchants/_components/postcode.ts)
--- app/(protected)/(basic)/system/merchants/_components/postcode.ts
+++ app/_components/postcode.ts
No changes
lib/data/repositories/merchant-detail-repository.ts
--- lib/data/repositories/merchant-detail-repository.ts
+++ lib/data/repositories/merchant-detail-repository.ts
@@ -297,9 +297,23 @@
   return rows;
 }
 
-/** 등록 화면의 업체 선택지 — 아직 상세가 없는 가맹점 전부. */
-export async function fetchMerchantDetailCandidates(): Promise<MerchantDetailSummary[]> {
-  return (await fetchMergedSummaries()).filter((row) => !row.hasDetail);
+/**
+ * 방금 만든 가맹점의 코드 — 등록 응답에 코드가 없을 때 가맹점 목록에서 이름·연락처로 찾는다.
+ * 같은 이름이 여럿이면 가장 최근 것(목록이 등록일 내림차순).
+ */
+export async function findMerchantByName(
+  name: string,
+  phoneNumber: string
+): Promise<MerchantDetailSummary | null> {
+  const merchants = await fetchAllPages(`${VOC_PATH}/pagination`, toCandidate);
+  const digits = phoneNumber.replace(/\D/g, '');
+  return (
+    merchants.find(
+      (row) => row.name === name && (row.phoneNumber ?? '').replace(/\D/g, '') === digits
+    ) ??
+    merchants.find((row) => row.name === name) ??
+    null
+  );
 }
 
 /** 가맹점 한 건(상세 없이) — 가맹점 단건 API가 없어 목록에서 찾는다. */
lib/data/repositories/merchant-repository.ts
--- lib/data/repositories/merchant-repository.ts
+++ lib/data/repositories/merchant-repository.ts
@@ -14,13 +14,15 @@
  *
  * ```
  * GET  /api/v1/mngr/voc/pagination   searchCondition 1=가맹점명 2=사업자번호 3=상세주소
- * POST /api/v1/mngr/voc              어노테이션 없음 → form
+ * POST /api/v1/mngr/voc              @RequestBody MngrVocInsertReqVo
+ * PUT  /api/v1/mngr/voc              @RequestBody MngrVocUpdateReqVo (BaseVo + chgDvCd)
  * ```
  *
  * ⚠️ 백엔드 현황(보고함)
- * 1. **등록이 아직 동작하지 않는다.** 컨트롤러 `insert`의 본문이 전부 주석 처리돼 있고
- *    `return null;`이다 — 봉투 없는 빈 200이 온다. 그래서 저장해도 아무 일도 일어나지 않는다.
- * 2. **수정·삭제·단건 조회 API가 없다.** 목록과 등록 두 개뿐이다.
+ * 1. 등록·수정은 아직 BizPlay(웹캐시) 가맹점 API로 위임된다(1c7b0e0). 우리 DB에 저장하도록
+ *    바뀔 예정(사용자 공지)이라 요청은 그 VO 그대로 보내고, 응답에서 `frcsNo`가 오면 읽고 없으면
+ *    가맹점 목록에서 찾는다. 위임 상태에선 새 가맹점이 다음 날 동기화 뒤에야 목록에 나타난다.
+ * 2. **삭제·단건 조회 API가 없다.**
  * 3. 검색 필터가 어긋난다 — 업종 검색 파라미터는 `searchFrcsTpbizCd`인데 SQL은 `FRCS_DV_CD`를
  *    비교하고, 저장은 `frcsTpbizCd`로 받는다. 저장한 업종으로 검색이 걸리지 않는다.
  * 4. 목록 정렬이 이중 역순이다 — rnum이 등록일 내림차순인데 `ORDER BY RNUM DESC`로 다시
@@ -83,6 +85,8 @@
       postalCode: readString(raw, 'frcsPostZip'),
       latitude: readString(raw, 'lat'),
       longitude: readString(raw, 'lng'),
+      businessStatusCode: readString(raw, 'frcsBzstatCd').trim(),
+      divisionCode: readString(raw, 'frcsDvCd').trim(),
       // 조회 SQL이 USE_YN을 select하지 않는다(⚠️ 5). 값이 없으면 판단하지 않는다 —
       // 종전처럼 노출로 단정하면 「미노출」로 걸러 낸 행까지 전부 「노출」로 보인다.
       isVisible: readUseYn(raw),
@@ -163,35 +167,72 @@
     : [];
 }
 
-/**
- * TODO(백엔드): 아래 form 전송은 옛 계약이다. 지금 `POST /api/v1/mngr/voc`는 @RequestBody(JSON)
- *   `MngrVocInsertReqVo`를 받고 BizPlay 외부 API로 위임하며, 예금주명(bankDpstrNm)·입금은행코드
- *   (dpstBankCd)·입금계좌번호(dpstActNo)·업태코드(frcsBzstatCd)를 새로 요구한다. lat·lng는 요청
- *   VO에서 빠졌다. 등록 화면이 그 항목을 받게 되면 이 함수를 JSON으로 바꿔 다시 연결한다
- *   (현재 호출부 없음 — `_actions.ts` 참고).
- */
-export async function createMerchant(values: MerchantValues): Promise<void> {
+/** 백엔드 `MngrVocBaseVo` — 등록·수정 요청이 함께 쓰는 가맹점 필드. */
+function toMerchantPayload(values: MerchantValues): Record<string, unknown> {
+  return {
+    frcsNm: values.name,
+    rprsvNm: values.ownerName,
+    rprsvTelno: values.phoneNumber,
+    brno: values.businessNumber.replace(/-/g, ''),
+    bankDpstrNm: values.bankHolderName,
+    dpstBankCd: values.bankCode,
+    // 등록 VO(BizPlayVocInsertVo)는 dpstActno, 수정 VO(MngrVocBaseVo)는 dpstActNo다 — 둘 다 싣는다.
+    dpstActno: values.accountNumber,
+    dpstActNo: values.accountNumber,
+    frcsDvCd: values.divisionCode,
+    frcsPostAddr: values.address,
+    frcsDaddr: values.addressDetail,
+    frcsPostZip: values.postalCode,
+    [MERCHANT_CATEGORY_WRITE_FIELD]: values.categoryCode,
+    frcsBzstatCd: values.businessStatusCode,
+    // 요청 VO에 없어 무시된다. 컬럼이 생기면 그대로 저장되도록 미리 싣는다.
+    useYn: values.isVisible ? 'Y' : 'N',
+    brandNm: values.brandName,
+    memo: values.memo,
+    lat: values.latitude,
+    lng: values.longitude,
+  };
+}
+
+/** 등록 응답에서 가맹점 코드를 찾는다 — 문자열, `frcsNo`, BizPlay 응답(`repRec.FRCS_NO`) 어느 모양이든. */
+function readCreatedFrcsNo(data: unknown): string | null {
+  if (typeof data === 'string' && data !== '') return data;
+  if (!isRecord(data)) return null;
+  const direct = readString(data, 'frcsNo') || readString(data, 'FRCS_NO');
+  if (direct) return direct;
+  const repRec = data.repRec;
+  return isRecord(repRec) ? readString(repRec, 'FRCS_NO') || readString(repRec, 'frcsNo') || null : null;
+}
+
+/** 가맹점 등록. 백엔드가 코드를 돌려주면 그 값, 아니면 null이다. */
+export async function createMerchant(values: MerchantValues): Promise<string | null> {
   const accessToken = await getSessionAccessToken();
 
   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',
-      // 요청 VO에 없어 무시된다. 컬럼이 생기면 그대로 저장되도록 미리 싣는다.
-      brandNm: values.brandName,
-      memo: values.memo,
-    },
+    body: toMerchantPayload(values),
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
+    canHaveNullData: true,
+    canHaveEmptyBody: true,
+  });
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+  return readCreatedFrcsNo(result.data);
+}
+
+/**
+ * 가맹점 수정. 수정 VO에는 `frcsNo`가 없다(BizPlay가 사업자번호로 식별) — 우리 DB 저장으로 바뀔 때를
+ * 위해 `frcsNo`도 싣는다. `chgDvCd`(변경구분)는 BizPlay 전용이라 비워 보낸다.
+ */
+export async function updateMerchant(frcsNo: string, values: MerchantValues): Promise<void> {
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(VOC_PATH, {
+    method: 'PUT',
+    body: { frcsNo, chgDvCd: '', ...toMerchantPayload(values) },
     accessToken: accessToken ?? undefined,
     cache: 'no-store',
     canHaveNullData: true,
lib/domain/common-code.ts
--- lib/domain/common-code.ts
+++ lib/domain/common-code.ts
@@ -51,6 +51,12 @@
   merchantBusinessDetail: 'FRCS_CD',
   /** 가맹몰 메뉴 배지 — 앱 API가 `frcsBadge`의 쉼표 구분 코드를 이 그룹 이름으로 풀어 준다(백엔드 40f85c4). */
   merchantProductBadge: 'FRCS_BADGE_CD',
+  /** 가맹점 업태(`FRCS_BZSTAT_CD`) — 등록 요청 VO의 `frcsBzstatCd`. */
+  merchantBusinessStatus: 'FRCS_BZSTAT_CD',
+  /** 가맹점 구분(`FRCS_DV_CD`: 60 온라인 · 62 일반) — 등록 요청 VO의 `frcsDvCd`. */
+  merchantDivision: 'FRCS_DV_CD',
+  /** 입금 은행 — 등록 요청 VO의 `dpstBankCd`. */
+  bank: 'COM_BANK_CD',
 } as const;
 
 /** 값이 없는 항목의 화면 표기. */
lib/domain/merchant-detail-form.ts
--- lib/domain/merchant-detail-form.ts
+++ lib/domain/merchant-detail-form.ts
@@ -3,16 +3,31 @@
 /**
  * 가맹몰 상세 등록·수정 입력 규칙 — 순수 검증만 담는다.
  *
- * 기획 BO-MAL-002 ①의 필수 항목 중 업체명·연락처·우편번호는 가맹점 원본에서 오는 읽기 전용
- * 값이라 여기서 검사하지 않는다. 남는 필수는 카테고리·노출 상태·바우처 사용·대표이미지다.
+ * 기획 BO-MAL-002 ①의 필수 항목은 업체명·카테고리·업체 연락처·우편번호·노출 상태·바우처 사용·
+ * 대표이미지다. 업체 항목(업체명·연락처·우편번호·주소)은 이 화면에서 직접 입력한다(사용자 확정) —
+ * 가맹점이 없으면 새로 만들고, 있으면 함께 고친다.
  */
 
 export const DESCRIPTION_MAX_LENGTH = 200;
 export const CLOSED_DAYS_MAX_LENGTH = 100;
 export const SORT_ORDER_MAX = 9999;
+export const MERCHANT_NAME_MAX_LENGTH = 100;
+export const ADDRESS_MAX_LENGTH = 200;
 const TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
+const POSTAL_CODE_PATTERN = /^\d{5}$/;
+const PHONE_PATTERN = /^[\d-]+$/;
 
-export type MerchantDetailValues = {
+/** 상세 화면이 함께 다루는 업체(가맹점) 항목 — 백엔드 `MngrVocBaseVo`의 일부. */
+export type MerchantDetailMerchantValues = {
+  name: string;
+  phoneNumber: string;
+  postalCode: string;
+  address: string;
+  addressDetail: string;
+};
+
+export type MerchantDetailValues = MerchantDetailMerchantValues & {
+  /** 비어 있으면 가맹점을 새로 만든다. */
   frcsNo: string;
   categoryCode: string;
   description: string;
@@ -29,7 +44,10 @@
 
 export type MerchantDetailFormErrors = Partial<
   Record<
-    | 'frcsNo'
+    | 'name'
+    | 'phoneNumber'
+    | 'postalCode'
+    | 'address'
     | 'categoryCode'
     | 'description'
     | 'openTime'
@@ -61,6 +79,33 @@
   categories: readonly CommonCode[]
 ): ValidationResult {
   const errors: MerchantDetailFormErrors = {};
+
+  const name = values.name.trim();
+  if (!name) {
+    errors.name = '업체명을 입력해 주세요.';
+  } else if (name.length > MERCHANT_NAME_MAX_LENGTH) {
+    errors.name = `업체명은 ${MERCHANT_NAME_MAX_LENGTH}자 이내로 입력해 주세요.`;
+  }
+
+  const phoneNumber = values.phoneNumber.trim();
+  if (!phoneNumber) {
+    errors.phoneNumber = '업체 연락처를 입력해 주세요.';
+  } else if (!PHONE_PATTERN.test(phoneNumber)) {
+    errors.phoneNumber = '연락처는 숫자와 -만 입력할 수 있습니다.';
+  }
+
+  const postalCode = values.postalCode.trim();
+  if (!postalCode) {
+    errors.postalCode = '우편번호를 입력해 주세요.';
+  } else if (!POSTAL_CODE_PATTERN.test(postalCode)) {
+    errors.postalCode = '우편번호는 숫자 5자리로 입력해 주세요.';
+  }
+
+  const address = values.address.trim();
+  const addressDetail = values.addressDetail.trim();
+  if (address.length > ADDRESS_MAX_LENGTH || addressDetail.length > ADDRESS_MAX_LENGTH) {
+    errors.address = `주소는 ${ADDRESS_MAX_LENGTH}자 이내로 입력해 주세요.`;
+  }
 
   if (!categories.some((code) => code.code === values.categoryCode)) {
     errors.categoryCode = '카테고리를 선택해 주세요.';
@@ -103,6 +148,17 @@
   }
   return {
     ok: true,
-    values: { ...values, description, openTime, closeTime, closedDays },
+    values: {
+      ...values,
+      name,
+      phoneNumber,
+      postalCode,
+      address,
+      addressDetail,
+      description,
+      openTime,
+      closeTime,
+      closedDays,
+    },
   };
 }
lib/domain/merchant-form.ts
--- lib/domain/merchant-form.ts
+++ lib/domain/merchant-form.ts
@@ -3,6 +3,7 @@
 export const OWNER_MAX_LENGTH = 50;
 export const ADDRESS_DETAIL_MAX_LENGTH = 200;
 export const MEMO_MAX_LENGTH = 500;
+export const ACCOUNT_NUMBER_MAX_LENGTH = 30;
 
 export interface MerchantValues {
   id: string;
@@ -19,11 +20,20 @@
   postalCode: string;
   latitude: string;
   longitude: string;
+  /** 백엔드 등록 요청 VO(`MngrVocInsertReqVo`)의 예금주명·입금은행코드·입금계좌번호·업태·구분. */
+  bankHolderName: string;
+  bankCode: string;
+  accountNumber: string;
+  businessStatusCode: string;
+  divisionCode: string;
   isVisible: boolean;
 }
 
 export type MerchantFormErrors = Partial<
-  Record<'name' | 'categoryCode' | 'phoneNumber' | 'address' | 'businessNumber', string>
+  Record<
+    'name' | 'categoryCode' | 'phoneNumber' | 'address' | 'postalCode' | 'businessNumber' | 'accountNumber',
+    string
+  >
 >;
 
 export type MerchantFormState =
@@ -64,12 +74,22 @@
   }
 
   if (!values.address.trim()) {
-    errors.address = '주소 검색을 진행해 주세요.';
+    errors.address = '주소를 검색하거나 입력해 주세요.';
+  }
+
+  const postalCode = values.postalCode.trim();
+  if (postalCode && !/^\d{5}$/.test(postalCode)) {
+    errors.postalCode = '우편번호는 숫자 5자리로 입력해 주세요.';
   }
 
   const businessNumber = values.businessNumber.trim();
   if (businessNumber && !BUSINESS_NUMBER_PATTERN.test(businessNumber)) {
     errors.businessNumber = '사업자등록번호는 000-00-00000 형식으로 입력해 주세요.';
+  }
+
+  const accountNumber = values.accountNumber.trim();
+  if (accountNumber && !/^[\d-]+$/.test(accountNumber)) {
+    errors.accountNumber = '계좌번호는 숫자와 -만 입력할 수 있습니다.';
   }
 
   if (Object.keys(errors).length > 0) {
@@ -88,6 +108,12 @@
       businessNumber,
       address: values.address.trim(),
       addressDetail: values.addressDetail.trim().slice(0, ADDRESS_DETAIL_MAX_LENGTH),
+      postalCode,
+      bankHolderName: values.bankHolderName.trim().slice(0, OWNER_MAX_LENGTH),
+      bankCode: values.bankCode.trim(),
+      accountNumber: accountNumber.slice(0, ACCOUNT_NUMBER_MAX_LENGTH),
+      businessStatusCode: values.businessStatusCode.trim(),
+      divisionCode: values.divisionCode.trim(),
     },
   };
 }
lib/domain/merchant.ts
--- lib/domain/merchant.ts
+++ lib/domain/merchant.ts
@@ -26,10 +26,26 @@
   postalCode: string;
   latitude: string;
   longitude: string;
+  /** 백엔드 `frcsBzstatCd` — 업태 코드(`FRCS_BZSTAT_CD`). */
+  businessStatusCode: string;
+  /** 백엔드 `frcsDvCd` — 가맹점 구분(`FRCS_DV_CD`). */
+  divisionCode: string;
   /** 백엔드 `useYn`. develop 목록엔 없어 `null`(모름)이고 feature/voc부터 온다. */
   isVisible: boolean | null;
 }
 
+/** 가맹몰 등록·수정 폼이 쓰는 코드표 넷 — 화면이 한 번에 받아 모달까지 내려보낸다. */
+export type MerchantCodeTables = {
+  /** 업종(`FRCS_TPBIZ_CD` 다음 `FRCS_CD`). */
+  business: CommonCode[];
+  businessStatus: CommonCode[];
+  division: CommonCode[];
+  bank: CommonCode[];
+};
+
+/** 가맹점 구분 기본값 — 관리자가 만드는 가맹점은 일반가맹점이다. */
+export const DEFAULT_MERCHANT_DIVISION_CODE = '62';
+
 export const EMPTY_FIELD_PLACEHOLDER = '-';
 
 export function formatOptionalValue(
Add a comment
List