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/develop 15d409a·22f67cb 기준. * * ``` * GET /api/v1/mngr/voc/detail/pagination TB_COM_VOUCHER ⟕ TB_COM_VOUCHER_DTL — 가맹점 전체 * (화면은 상세 있는 건만 목록에, 없는 건은 등록 선택지에) * GET /api/v1/mngr/voc/detail/{frcsNo} * POST /api/v1/mngr/voc/detail @RequestBody · 가맹점이 있어야 붙는다 * PUT /api/v1/mngr/voc/detail/{frcsNo} @RequestBody * DELETE /api/v1/mngr/voc/{frcsNo} 논리 삭제(DEL_YN) * ``` * * ⚠️ 백엔드 결함(보고함, 미수정) — 화면은 시안대로 두고 값을 그대로 보낸다(사용자 확정): * 1. 목록·단건 SELECT(`includeSql`)에 소개·카테고리 코드·영업시간·휴무일·추가이미지·순서가 * 없어 수정 프리필이 빈다. 카테고리 이름(`CD_NM`)은 SELECT엔 있으나 VO에 담을 곳이 없다. * 2. `searchStartDt/EndDt`를 VO만 받고 WHERE에 쓰지 않는다. * 3. 「바우처 사용」을 담을 필드가 없다 — `voucherUseYn`으로 보내되 버려진다. * * 노출 상태(`useYn`)는 `MngrVocDetailVo`가 `MngrVocVo`에서 상속받아 등록·수정·조회 모두 실린다 * (처음엔 없다고 잘못 보고했다 — 2026-09-14 정정). */ const DETAIL_PATH = '/api/v1/mngr/voc/detail'; const VOC_PATH = '/api/v1/mngr/voc'; 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); } function toSummary(raw: unknown): MerchantDetailSummary[] { if (!isRecord(raw)) return []; const id = readString(raw, 'frcsNo'); const name = readString(raw, 'frcsNm'); if (!id || !name) return []; const registeredAt = readDate(raw, 'frstRegDt'); return [ { id, name, phoneNumber: readString(raw, 'rprsvTelno'), address: readString(raw, 'frcsPostAddr'), addressDetail: readString(raw, 'frcsDaddr'), thumbnailFileId: readString(raw, 'thumbAtchFileId'), thumbnailUrl: buildFileImageUrl(readString(raw, 'thumbAtchFileId')), categoryCode: readString(raw, 'frcsTypeCd'), isVisible: readYesNo(raw, 'useYn'), registeredAt, // 상세 행은 LEFT JOIN이라 없으면 상세 컬럼이 전부 null이다 — 등록일이 그 표지다. hasDetail: registeredAt !== null || readString(raw, 'thumbAtchFileId') !== null, }, ]; } /** * 묶음 안의 이미지 수를 알려 주는 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), postalCode: readString(raw, 'frcsPostZip'), description: readString(raw, 'frcsCn'), openTime: readString(raw, 'startHour'), closeTime: readString(raw, 'endHour'), closedDays: readString(raw, 'offDayCn'), imageFileId, sortOrder: readNumber(raw, 'sortOrdr'), registeredBy: readString(raw, 'frstRgtrNm'), modifiedAt: readDate(raw, 'lastMdfcnDt'), modifiedBy: readString(raw, 'lastMdfrNm'), }; } type SummaryFilters = Pick< MerchantDetailQuery, 'keyword' | 'category' | 'visibility' | 'startDate' | 'endDate' >; async function fetchSummaryPage( filters: Partial, pageIndex: number, pageSize: number ): Promise<{ items: MerchantDetailSummary[]; rowCount: number; totalCount: number }> { const accessToken = await getSessionAccessToken(); const result = await backendFetch(`${DETAIL_PATH}/pagination`, { method: 'GET', query: { ...(filters.keyword ? { searchFrcsNm: filters.keyword } : {}), ...(filters.category ? { searchFrcsTypeCd: filters.category } : {}), ...(filters.visibility ? { searchUseYn: filters.visibility } : {}), ...(filters.startDate ? { searchStartDt: filters.startDate } : {}), ...(filters.endDate ? { searchEndDt: filters.endDate } : {}), pageIndex, recordCountPerPage: 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: [], rowCount: 0, totalCount: 0 }; } if (!isRecord(data) || !Array.isArray(data.list)) { throw new Error('가맹몰 상세 목록 응답의 형식이 올바르지 않습니다.'); } return { items: data.list.flatMap(toSummary), // 매핑에서 걸러진 행이 있어도 페이지가 끝났는지는 원본 행 수로 판단한다. rowCount: data.list.length, totalCount: typeof data.totalCount === 'number' ? data.totalCount : data.list.length, }; } // 백엔드 목록에는 "상세가 있는 건만" 조건이 없다(보고함). 그래서 전 페이지를 받아 여기서 거른다 — // 가맹점은 많아야 수백 건이라 한두 번의 요청이다. 상한을 넘으면 그 뒤는 잘린다. // TODO(백엔드): 상세 유무 조건이 생기면 이 훑기를 없애고 페이지 하나만 받는다. const SCAN_PAGE_SIZE = 200; const MAX_SCAN_PAGES = 25; async function fetchAllSummaries( filters: Partial ): Promise { const items: MerchantDetailSummary[] = []; let seen = 0; for (let pageIndex = 1; pageIndex <= MAX_SCAN_PAGES; pageIndex += 1) { const page = await fetchSummaryPage(filters, pageIndex, SCAN_PAGE_SIZE); items.push(...page.items); seen += page.rowCount; if (page.rowCount < SCAN_PAGE_SIZE || seen >= page.totalCount) { break; } } return items; } /** 목록 — 상세가 있는 가맹점만, 화면 쪽 페이지로 잘라 준다(사용자 확정). */ export async function fetchMerchantDetails( query: MerchantDetailQuery ): Promise<{ items: MerchantDetailSummary[]; totalCount: number }> { const registered = (await fetchAllSummaries(query)).filter((row) => row.hasDetail); const start = (query.page - 1) * query.pageSize; return { items: registered.slice(start, start + query.pageSize), totalCount: registered.length, }; } /** 등록 화면의 업체 선택지 — 아직 상세가 없는 가맹점 전부. */ export async function fetchMerchantDetailCandidates(): Promise { return (await fetchAllSummaries({})).filter((row) => !row.hasDetail); } 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) { throw new BackendRequestError(result); } return toDetail(result.data); } 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'); }