import 'server-only'; import { getSessionAccessToken } from '@/lib/auth/dal'; import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch'; import { buildFileImageUrl } from '@/lib/data/repositories/file-repository'; import { MAX_EXTRA_IMAGES, type MerchantDetail, type MerchantDetailSummary, } from '@/lib/domain/merchant-detail'; import type { MerchantDetailValues } from '@/lib/domain/merchant-detail-form'; import type { MerchantDetailQuery } from '@/lib/domain/merchant-detail-query'; /** * 가맹몰 상세 Repository — edupay-backend origin/feature/voc d784bb6(2026-09-16) 기준. * * ``` * GET /api/v1/mngr/voc/detail/pagination 상세가 있는 가맹점만(TB_COM_VOUCHER ⋈ TB_COM_VOUCHER_DTL ⋈ 코드) * GET /api/v1/mngr/voc/detail/{frcsNo} 상세가 없으면 NOT_FOUND 봉투 * POST /api/v1/mngr/voc/detail @RequestBody · 상세가 있으면 409, 가맹점이 없으면 404 * PUT /api/v1/mngr/voc/detail/{frcsNo} @RequestBody · 전 컬럼 덮어씀 * DELETE /api/v1/mngr/voc/{frcsNo} 논리 삭제(DEL_YN) * GET /api/v1/mngr/voc/pagination 가맹점 전체 — 등록 화면의 선택지에만 쓴다 * ``` * * 목록·단건이 소개·카테고리 코드·영업시간·휴무일·추가이미지·순서·우편번호·등록자(`frstRgtrNmStr`)· * 등록일(`frstRegDtStr`)·수정일(`lastMdfcnDtStr`)을 돌려준다. * * ⚠️ 백엔드 결함(보고함, 미수정): * 1. 응답 VO(`MngrVocDetailVo`)에 `frcsNm`이 없어 SELECT의 업체명이 버려진다 — 그동안 업체명 자리에 * 가맹점 코드를 보인다. * 2. 건수 쿼리(`selectPaginationCount`)만 LEFT JOIN이라 상세 없는 가맹점까지 세어 페이지 수가 부풀 수 있다. * 3. `selectOne`이 DEL_YN을 거르지 않아 삭제한 상세가 있는 가맹점은 다시 등록하면 409가 난다. * 4. `searchStartDt/EndDt`를 VO만 받고 WHERE에 쓰지 않는다. * 5. 「바우처 사용」을 담을 필드가 없다 — `voucherUseYn`으로 보내되 버려진다. */ const DETAIL_PATH = '/api/v1/mngr/voc/detail'; const VOC_PATH = '/api/v1/mngr/voc'; // 등록 선택지·상세 보유 목록을 한 번에 훑는 크기. 가맹점은 많아야 수백 건이다. const SCAN_PAGE_SIZE = 500; const MAX_SCAN_PAGES = 20; function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object'; } function readString(source: Record, key: string): string | null { const value = source[key]; if (typeof value === 'string' && value !== '') return value; if (typeof value === 'number') return String(value); return null; } function readNumber(source: Record, key: string): number | null { const value = source[key]; if (typeof value === 'number' && Number.isFinite(value)) return value; if (typeof value === 'string' && value.trim() !== '') { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } return null; } function readYesNo(source: Record, key: string): boolean | null { const value = readString(source, key); return value === null ? null : value.toUpperCase() === 'Y'; } /** * 등록일은 `d.FRST_REG_DT`가 별칭 없이 오므로 `frstRegDt`(Date → ISO 문자열 또는 epoch)로 * 받는다. 다른 화면의 `frstRegDtStr`가 있으면 그것을 우선한다. */ function readDate(source: Record, key: string): string | null { const formatted = readString(source, `${key}Str`); if (formatted) return formatted.slice(0, 10); const raw = source[key]; const date = typeof raw === 'number' ? new Date(raw) : typeof raw === 'string' && raw !== '' ? new Date(raw) : null; if (date === null || Number.isNaN(date.getTime())) return null; return date.toISOString().slice(0, 10); } /** 상세 목록·단건의 한 행. 가맹점 API의 행은 `toCandidate`가 따로 맡는다. */ function toSummary(raw: unknown): MerchantDetailSummary[] { if (!isRecord(raw)) return []; const id = readString(raw, 'frcsNo'); if (!id) return []; const registeredAt = readDate(raw, 'frstRegDt'); return [ { id, // 결함 1 — 업체명이 안 오면 코드라도 보인다. name: readString(raw, 'frcsNm') ?? id, phoneNumber: readString(raw, 'rprsvTelno'), address: readString(raw, 'frcsPostAddr'), addressDetail: readString(raw, 'frcsDaddr'), postalCode: readString(raw, 'frcsPostZip'), thumbnailFileId: readString(raw, 'thumbAtchFileId'), thumbnailUrl: buildFileImageUrl(readString(raw, 'thumbAtchFileId')), categoryCode: readString(raw, 'frcsTypeCd'), isVisible: readYesNo(raw, 'useYn'), registeredAt, hasDetail: registeredAt !== null || readString(raw, 'thumbAtchFileId') !== null || readString(raw, 'frcsTypeCd') !== null, }, ]; } /** 가맹점 API(`/mngr/voc/pagination`)의 한 행 — 아직 상세가 없는 가맹점의 선택지. */ function toCandidate(raw: unknown): MerchantDetailSummary[] { if (!isRecord(raw)) return []; const id = readString(raw, 'frcsNo'); const name = readString(raw, 'frcsNm'); if (!id || !name) return []; return [ { id, name, phoneNumber: readString(raw, 'rprsvTelno'), address: readString(raw, 'frcsPostAddr'), addressDetail: readString(raw, 'frcsDaddr'), postalCode: readString(raw, 'frcsPostZip'), thumbnailFileId: null, thumbnailUrl: null, categoryCode: null, isVisible: null, registeredAt: null, hasDetail: false, }, ]; } /** * 묶음 안의 이미지 수를 알려 주는 API가 없다(보고함). fileSn이 1부터 빈틈없이 붙으므로 * 없는 번호가 나올 때까지 HEAD로 더듬는다 — 없는 번호는 백엔드가 500으로 답한다. */ async function probeImageUrls(fileId: string | null): Promise { if (!fileId) return []; const urls: string[] = []; for (let fileSn = 1; fileSn <= MAX_EXTRA_IMAGES; fileSn += 1) { const url = buildFileImageUrl(fileId, fileSn); if (!url) break; try { const response = await fetch(url, { method: 'HEAD', cache: 'no-store' }); if (!response.ok) break; } catch { break; } urls.push(url); } return urls; } async function toDetail(raw: unknown): Promise { const [summary] = toSummary(raw); if (!summary || !isRecord(raw)) return null; const imageFileId = readString(raw, 'atchFileId'); return { ...summary, imageUrls: await probeImageUrls(imageFileId), description: readString(raw, 'frcsCn'), openTime: readString(raw, 'startHour'), closeTime: readString(raw, 'endHour'), closedDays: readString(raw, 'offDayCn'), imageFileId, sortOrder: readNumber(raw, 'sortOrdr'), registeredBy: readString(raw, 'frstRgtrNmStr') ?? readString(raw, 'frstRgtrNm'), modifiedAt: readDate(raw, 'lastMdfcnDt'), // 수정자는 `lastMdfrNm`이 JSON에서 빠지고 *Str 별칭도 없어 아직 안 온다. modifiedBy: readString(raw, 'lastMdfrNm'), }; } export async function fetchMerchantDetails( query: MerchantDetailQuery ): Promise<{ items: MerchantDetailSummary[]; totalCount: number }> { const accessToken = await getSessionAccessToken(); const result = await backendFetch(`${DETAIL_PATH}/pagination`, { method: 'GET', query: { ...(query.keyword ? { searchFrcsNm: query.keyword } : {}), ...(query.category ? { searchFrcsTypeCd: query.category } : {}), ...(query.visibility ? { searchUseYn: query.visibility } : {}), ...(query.startDate ? { searchStartDt: query.startDate } : {}), ...(query.endDate ? { searchEndDt: query.endDate } : {}), 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(toSummary), totalCount: typeof data.totalCount === 'number' ? data.totalCount : data.list.length, }; } export async function fetchMerchantDetail( frcsNo: string ): Promise { const accessToken = await getSessionAccessToken(); const result = await backendFetch( `${DETAIL_PATH}/${encodeURIComponent(frcsNo)}`, { method: 'GET', accessToken: accessToken ?? undefined, cache: 'no-store', canHaveNullData: true, } ); if (!result.ok) { // 상세가 없는 가맹점은 NOT_FOUND 봉투로 온다 — 화면이 notFound()로 넘기게 null을 준다. if (result.code === 404) return null; throw new BackendRequestError(result); } return toDetail(result.data); } async function fetchPage( path: string, pageIndex: number, pageSize: number, toRow: (raw: unknown) => T[] ): Promise<{ rows: T[]; rowCount: number; totalCount: number }> { const accessToken = await getSessionAccessToken(); const result = await backendFetch(path, { method: 'GET', query: { pageIndex, recordCountPerPage: pageSize }, accessToken: accessToken ?? undefined, cache: 'no-store', canHaveNullData: true, }); if (!result.ok) { throw new BackendRequestError(result); } const data = result.data; if (!isRecord(data) || !Array.isArray(data.list)) { return { rows: [], rowCount: 0, totalCount: 0 }; } return { rows: data.list.flatMap(toRow), rowCount: data.list.length, totalCount: typeof data.totalCount === 'number' ? data.totalCount : data.list.length, }; } async function fetchAllPages(path: string, toRow: (raw: unknown) => T[]): Promise { const rows: T[] = []; let seen = 0; for (let pageIndex = 1; pageIndex <= MAX_SCAN_PAGES; pageIndex += 1) { const page = await fetchPage(path, pageIndex, SCAN_PAGE_SIZE, toRow); rows.push(...page.rows); seen += page.rowCount; if (page.rowCount < SCAN_PAGE_SIZE || seen >= page.totalCount) break; } return rows; } /** * 등록 화면의 업체 선택지 — 가맹점 전체에서 이미 상세가 있는 것을 뺀다. 백엔드에 "상세 없는 가맹점" * 조회가 없어 두 목록을 훑어 차집합을 만든다. */ export async function fetchMerchantDetailCandidates(): Promise { const [merchants, registered] = await Promise.all([ fetchAllPages(`${VOC_PATH}/pagination`, toCandidate), fetchAllPages(`${DETAIL_PATH}/pagination`, toSummary), ]); // develop 백엔드(LEFT JOIN)는 상세 없는 가맹점도 목록에 섞어 주므로 상세가 있는 것만 뺀다. const registeredIds = new Set(registered.filter((row) => row.hasDetail).map((row) => row.id)); return merchants.filter((row) => !registeredIds.has(row.id)); } function toWritePayload(values: MerchantDetailValues): Record { return { frcsNo: values.frcsNo, frcsTypeCd: values.categoryCode, frcsCn: values.description, startHour: values.openTime, endHour: values.closeTime, offDayCn: values.closedDays, thumbAtchFileId: values.thumbnailFileId, atchFileId: values.imageFileId || null, sortOrdr: values.sortOrder, useYn: values.isVisible ? 'Y' : 'N', // 요청 VO에 없어 버려진다(⚠️ 3). 자리가 생기면 그대로 저장된다. voucherUseYn: values.isVoucherUsable ? 'Y' : 'N', }; } async function sendWrite( path: string, method: 'POST' | 'PUT' | 'DELETE', body?: unknown ): Promise { const accessToken = await getSessionAccessToken(); const result = await backendFetch(path, { method, ...(body === undefined ? {} : { body }), accessToken: accessToken ?? undefined, cache: 'no-store', canHaveNullData: true, canHaveEmptyBody: true, }); if (!result.ok) { throw new BackendRequestError(result); } } export async function createMerchantDetail( values: MerchantDetailValues ): Promise { await sendWrite(DETAIL_PATH, 'POST', toWritePayload(values)); } export async function updateMerchantDetail( values: MerchantDetailValues ): Promise { await sendWrite( `${DETAIL_PATH}/${encodeURIComponent(values.frcsNo)}`, 'PUT', toWritePayload(values) ); } export async function deleteMerchantDetail(frcsNo: string): Promise { await sendWrite(`${VOC_PATH}/${encodeURIComponent(frcsNo)}`, 'DELETE'); }