임동욱 임동욱 09-17
feat: 가맹점 관리의 업종을 공통코드 FRCS_TPBIZ_CD의 이름으로 보인다
업종 값은 BizPlay FRCS_TPBIZ_CD(CHAR라 뒤 공백 제거)이고 그동안 구분코드(FRCS_DV_CD)를 읽어
하드코딩 목록과 맞추고 있었다. 공통코드 FRCS_TPBIZ_CD를 받아 목록 열·상세 모달 선택지·엑셀이
같은 표로 이름을 찾는다(사용자 확정).

Co-Authored-By: Claude Opus 5 
@b4b2212add25982b9d18920150cf0d229b695c11
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,8 +17,8 @@
 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 {
-  MERCHANT_CATEGORY_OPTIONS,
   formatOptionalValue,
   type Merchant,
 } from '@/lib/domain/merchant';
@@ -43,10 +43,12 @@
 
 interface MerchantFormModalProps {
   merchant?: Merchant;
+  /** 업종 코드표 — 선택지이자 조회 전용일 때의 이름 출처. */
+  businessCodes: CommonCode[];
   onClose: () => void;
 }
 
-export function MerchantFormModal({ merchant, onClose }: MerchantFormModalProps) {
+export function MerchantFormModal({ merchant, businessCodes, onClose }: MerchantFormModalProps) {
   const isEdit = Boolean(merchant);
   const isReadOnly = isEdit && !IS_EDIT_ENABLED;
   // 조회 전용일 때 입력칸마다 얹는다. `state="view"`는 시안의 조회 상태로, 잠긴 것(disabled)과
@@ -201,10 +203,7 @@
             size="md"
             label="업종"
             requirement="required"
-            options={MERCHANT_CATEGORY_OPTIONS.map((option) => ({
-              value: option.value,
-              label: option.label,
-            }))}
+            options={businessCodes.map((code) => ({ value: code.code, label: code.label }))}
             value={categoryCode}
             onValueChange={setCategoryCode}
             placeholder="선택"
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
@@ -9,6 +9,7 @@
 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,
@@ -32,6 +33,8 @@
 
 interface MerchantListProps {
   items: Merchant[];
+  /** 업종 코드표(`FRCS_TPBIZ_CD`) — 업종 열과 상세 모달이 이름을 찾는다. */
+  businessCodes: CommonCode[];
   query: MerchantQuery;
   currentPage: number;
   totalPages: number;
@@ -43,6 +46,7 @@
 
 export function MerchantList({
   items,
+  businessCodes,
   query,
   currentPage,
   totalPages,
@@ -73,7 +77,7 @@
       key: 'categoryCode',
       header: '업종',
       width: 100,
-      render: (row) => formatCategoryLabel(row.categoryCode),
+      render: (row) => formatCategoryLabel(businessCodes, row.categoryCode),
     },
     {
       key: 'phoneNumber',
@@ -111,7 +115,7 @@
       key: 'actions',
       header: '관리',
       width: 120,
-      render: (row) => <MerchantRowActions merchant={row} />,
+      render: (row) => <MerchantRowActions merchant={row} businessCodes={businessCodes} />,
     },
   ];
 
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,11 +4,13 @@
 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 { MerchantFormModal } from './merchant-form-modal';
 
 interface MerchantRowActionsProps {
   merchant: Merchant;
+  businessCodes: CommonCode[];
 }
 
 /**
@@ -22,7 +24,7 @@
  * 어느 행의 버튼인지는 각 버튼 라벨이 말한다 — 감싼 요소의 `aria-label`은 의미 없는
  * 요소에 붙어 읽히지 않을 수 있다(공통코드 행과 같은 방식).
  */
-export function MerchantRowActions({ merchant }: MerchantRowActionsProps) {
+export function MerchantRowActions({ merchant, businessCodes }: MerchantRowActionsProps) {
   const { showToast } = useFeedback();
   const [isDetailOpen, setIsDetailOpen] = useState(false);
 
@@ -53,6 +55,7 @@
       {isDetailOpen && (
         <MerchantFormModal
           merchant={merchant}
+          businessCodes={businessCodes}
           onClose={() => setIsDetailOpen(false)}
         />
       )}
app/(protected)/(basic)/system/merchants/excel/route.ts
--- app/(protected)/(basic)/system/merchants/excel/route.ts
+++ app/(protected)/(basic)/system/merchants/excel/route.ts
@@ -1,5 +1,7 @@
 import { verifySession } from '@/lib/auth/dal';
+import { fetchCommonCodes } from '@/lib/data/repositories/common-code-repository';
 import { fetchAllMerchants } from '@/lib/data/repositories/merchant-repository';
+import { CODE_GROUP } from '@/lib/domain/common-code';
 import { formatCategoryLabel, formatVisibilityLabel } from '@/lib/domain/merchant';
 
 /**
@@ -34,7 +36,10 @@
 export async function GET() {
   await verifySession();
 
-  const merchants = await fetchAllMerchants();
+  const [merchants, businessCodes] = await Promise.all([
+    fetchAllMerchants(),
+    fetchCommonCodes(CODE_GROUP.merchantBusiness),
+  ]);
 
   const rows = merchants.map((merchant, index) =>
     [
@@ -42,7 +47,7 @@
       merchant.code,
       merchant.brandName,
       merchant.name,
-      formatCategoryLabel(merchant.categoryCode),
+      formatCategoryLabel(businessCodes, merchant.categoryCode),
       merchant.businessNumber,
       merchant.ownerName,
       merchant.phoneNumber,
app/(protected)/(basic)/system/merchants/page.tsx
--- app/(protected)/(basic)/system/merchants/page.tsx
+++ app/(protected)/(basic)/system/merchants/page.tsx
@@ -1,6 +1,8 @@
 import type { Metadata } from 'next';
 import { verifySession } from '@/lib/auth/dal';
+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 { parseMerchantQuery } from '@/lib/domain/merchant-query';
 import { MerchantList } from './_components/merchant-list';
 
@@ -18,7 +20,10 @@
   await verifySession();
 
   const query = parseMerchantQuery(await searchParams);
-  const { items, totalCount } = await fetchMerchants(query);
+  const [{ items, totalCount }, businessCodes] = await Promise.all([
+    fetchMerchants(query),
+    fetchCommonCodes(CODE_GROUP.merchantBusiness),
+  ]);
 
   const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize));
   const currentPage = Math.min(query.page, totalPages);
@@ -26,6 +31,7 @@
   return (
     <MerchantList
       items={items}
+      businessCodes={businessCodes}
       query={query}
       currentPage={currentPage}
       totalPages={totalPages}
lib/data/repositories/merchant-repository.ts
--- lib/data/repositories/merchant-repository.ts
+++ lib/data/repositories/merchant-repository.ts
@@ -72,7 +72,8 @@
       createdAt: readString(raw, 'frstRegDtStr'),
       updatedAt: readString(raw, 'lastMdfcnDtStr'),
       name: readString(raw, 'frcsNm'),
-      categoryCode: readString(raw, MERCHANT_CATEGORY_READ_FIELD),
+      // BizPlay 값이 CHAR 컬럼이라 뒤에 공백이 붙어 온다('52 ').
+      categoryCode: readString(raw, MERCHANT_CATEGORY_READ_FIELD).trim(),
       businessNumber: readString(raw, 'brno'),
       ownerName: readString(raw, 'rprsvNm'),
       phoneNumber: readString(raw, 'rprsvTelno'),
lib/domain/common-code.ts
--- lib/domain/common-code.ts
+++ lib/domain/common-code.ts
@@ -41,6 +41,8 @@
   },
   /** 가맹점 상세의 카테고리 — 백엔드 `MngrVocDetailMapper`가 이 그룹과 조인한다. */
   merchantType: 'COM_FRCS_TYPE_CD',
+  /** 가맹점 업종 — BizPlay `FRCS_TPBIZ_CD` 값의 이름은 이 그룹에서 찾는다(사용자 확정). */
+  merchantBusiness: 'FRCS_TPBIZ_CD',
 } as const;
 
 /** 값이 없는 항목의 화면 표기. */
lib/domain/merchant.ts
--- lib/domain/merchant.ts
+++ lib/domain/merchant.ts
@@ -1,3 +1,4 @@
+import { formatCommonCode, type CommonCode } from '@/lib/domain/common-code';
 /**
  * 백엔드 `TB_COM_VOUCHER`(`/api/v1/mngr/voc/**`) 한 행. 필드 이름이 화면 용어와 달라
  * Repository가 옮긴다 — `frcsNo`=코드, `frcsNm`=가맹점명, `brno`=사업자번호,
@@ -39,32 +40,12 @@
     : String(value);
 }
 
-/**
- * 업종 — 기획 MCH_002_p ②.
- *
- * TODO(기획): "제공받을 수 없는 데이터라면 추후 삭제 필요"로 적혀 있다. 코드표가 정해지면
- * 공통코드 조회로 바꾼다.
- */
-export const MERCHANT_CATEGORY_OPTIONS: ReadonlyArray<{
-  value: string;
-  label: string;
-}> = [
-  { value: 'CONVENIENCE', label: '편의점' },
-  { value: 'CAFE', label: '카페' },
-  { value: 'RESTAURANT', label: '음식점' },
-  { value: 'BOOKSTORE', label: '서점' },
-  { value: 'STATIONERY', label: '문구점' },
-  { value: 'ETC', label: '기타' },
-];
-
-export function formatCategoryLabel(code: string): string {
+/** 업종 이름 — 공통코드 `FRCS_TPBIZ_CD`에서 찾고, 없으면 코드를 그대로 보인다. */
+export function formatCategoryLabel(codes: readonly CommonCode[], code: string): string {
   if (!code) {
     return EMPTY_FIELD_PLACEHOLDER;
   }
-  return (
-    MERCHANT_CATEGORY_OPTIONS.find((option) => option.value === code)?.label ??
-    code
-  );
+  return formatCommonCode(codes, code);
 }
 
 export function formatVisibilityLabel(isVisible: boolean | null): string {
@@ -80,10 +61,10 @@
 }
 
 /**
- * 업종 코드 ↔ 백엔드 필드.
+ * 업종 코드 ↔ 백엔드 필드 — 조회·저장 모두 `FRCS_TPBIZ_CD`(BizPlay 업종코드)다.
+ * `FRCS_DV_CD`는 가맹점 구분(온라인/일반)이라 업종이 아니다.
  *
- * 이슈: 백엔드가 저장은 `FRCS_TPBIZ_CD`로 받고 조회·검색은 `FRCS_DV_CD`로 한다. 두 컬럼을
- * 모두 select하므로 화면은 조회 쪽(`frcsDvCd`)을 읽고, 저장할 때는 요청 VO에 있는 이름을 쓴다.
+ * 이슈: 목록 검색만 `searchFrcsTpbizCd`를 `FRCS_DV_CD`와 비교한다(보고함).
  */
-export const MERCHANT_CATEGORY_READ_FIELD = 'frcsDvCd';
+export const MERCHANT_CATEGORY_READ_FIELD = 'frcsTpbizCd';
 export const MERCHANT_CATEGORY_WRITE_FIELD = 'frcsTpbizCd';
Add a comment
List