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 @RequestBody MngrVocInsertReqVo * PUT /api/v1/mngr/voc @RequestBody MngrVocUpdateReqVo (BaseVo + chgDvCd) * ``` * * ⚠️ 백엔드 현황(보고함) * 1. 등록·수정은 아직 BizPlay(웹캐시) 가맹점 API로 위임된다(1c7b0e0). 우리 DB에 저장하도록 * 바뀔 예정(사용자 공지)이라 요청은 그 VO 그대로 보내고, 응답에서 `frcsNo`가 오면 읽고 없으면 * 가맹점 목록에서 찾는다. 위임 상태에선 새 가맹점이 다음 날 동기화 뒤에야 목록에 나타난다. * 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. 브랜드명·메모·최종수정일 컬럼이 없다 — 기획에 있는 항목이라 화면은 그대로 두고 값만 비워 * `-`로 보인다(등록일은 1c7b0e0부터 `frstRegDtStr`로 온다). 등록 시 보내는 브랜드명·메모도 요청 * 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 readText(source: Record, key: string): string { const value = source[key]; if (typeof value === 'number' && Number.isFinite(value)) return String(value); return readString(source, key); } 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'), // BizPlay 값이 CHAR 컬럼이라 뒤에 공백이 붙어 온다('52 '). categoryCode: readString(raw, MERCHANT_CATEGORY_READ_FIELD).trim(), 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: readText(raw, 'lat'), longitude: readText(raw, 'lng'), businessStatusCode: readString(raw, 'frcsBzstatCd').trim(), divisionCode: readString(raw, 'frcsDvCd').trim(), // 조회 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) : []; } /** 백엔드 `MngrVocBaseVo` — 등록·수정 요청이 함께 쓰는 가맹점 필드. */ function toMerchantPayload(values: MerchantValues): Record { 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 { const accessToken = await getSessionAccessToken(); const result = await backendFetch(VOC_PATH, { method: 'POST', 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 { const accessToken = await getSessionAccessToken(); const result = await backendFetch(VOC_PATH, { method: 'PUT', body: { frcsNo, chgDvCd: '', ...toMerchantPayload(values) }, accessToken: accessToken ?? undefined, cache: 'no-store', canHaveNullData: true, canHaveEmptyBody: true, }); if (!result.ok) { throw new BackendRequestError(result); } }