import 'server-only'; 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 — `TB_COM_VOUCHER`. * * ``` * 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. (feature/voc bf17fc5에서 해소) 목록이 `USE_YN`과 `FRCS_DV_CD_NM`을 돌려주기 시작했다 — * develop에선 아직 없어 사용여부가 `-`로 보인다. * 6. 브랜드명·메모·등록일·최종수정일 컬럼이 없다 — 기획에 있는 항목이라 화면은 그대로 두고 * 값만 비워 `-`로 보인다. 등록 시 보내는 브랜드명·메모도 요청 VO에 없어 버려진다. */ const VOC_PATH = '/api/v1/mngr/voc'; /** 엑셀은 전체를 담는다 — 이 상한을 넘으면 조용히 잘린다. */ const EXCEL_ROW_LIMIT = 10_000; /** 화면 검색 대상 → 백엔드 `searchCondition`. */ const SEARCH_CONDITION: Record = { name: '1', businessNumber: '2', address: '3', }; function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object'; } function readString(source: Record, 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, // 백엔드에 컬럼이 없다(⚠️ 6) — 응답에 있으면 쓰고, 없으면 빈 값으로 두어 화면이 `-`를 낸다. brandName: readString(raw, 'brandNm'), memo: readString(raw, 'memo'), createdAt: readString(raw, 'frstRegDtStr'), updatedAt: readString(raw, 'lastMdfcnDtStr'), 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: readUseYn(raw), }, ]; } /** 백엔드 `useYn`은 'Y'/'N' 문자열이다. 응답에 없으면 판단하지 않고 null로 둔다. */ function readUseYn(raw: Record): boolean | null { const value = raw.useYn; return typeof value === 'string' && value !== '' ? value.toUpperCase() === 'Y' : null; } export async function fetchMerchants( query: MerchantQuery ): Promise<{ items: Merchant[]; totalCount: number }> { const accessToken = await getSessionAccessToken(); const keyword = query.keyword.trim(); const result = await backendFetch(`${VOC_PATH}/pagination`, { method: 'GET', query: { ...(keyword ? { searchCondition: SEARCH_CONDITION[query.searchField] ?? '1', searchKeyword: keyword, } : {}), ...(query.visibility ? { searchUseYn: query.visibility === 'visible' ? 'Y' : 'N' } : {}), 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: data.list.flatMap(toMerchant), totalCount: typeof data.totalCount === 'number' ? data.totalCount : data.list.length, }; } /** 엑셀 다운로드는 검색 조건과 무관하게 전체다(기획 MCH_001 ②). */ export async function fetchAllMerchants(): Promise { const accessToken = await getSessionAccessToken(); const result = await backendFetch(`${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) : []; } /** * 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 { const accessToken = await getSessionAccessToken(); const result = await backendFetch(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, }, accessToken: accessToken ?? undefined, cache: 'no-store', canHaveNullData: true, canHaveEmptyBody: true, }); if (!result.ok) { throw new BackendRequestError(result); } }