import 'server-only'; import { getSessionAccessToken } from '@/lib/auth/dal'; import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch'; import { buildFileImageUrl, fetchAttachmentFiles, } from '@/lib/data/repositories/file-repository'; import type { MerchantDetail, 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 1c7b0e0(2026-09-17) 기준. * * ``` * GET /api/v1/mngr/voc/detail/pagination 상세가 있는 가맹점만(TB_COM_VOUCHER ⋈ TB_COM_VOUCHER_DTL) + 메뉴 건수 * GET /api/v1/mngr/voc/detail/{frcsNo} 상세가 없으면 성공 봉투에 data: null (구 버전은 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 가맹점 전체 — 목록 합치기·등록 선택지·상세 없는 단건에 쓴다 * ``` * * 목록·단건이 소개·카테고리 코드·이름(`frcsTypeCdNm`)·영업시간·휴무일·추가이미지·순서·우편번호· * 등록자(`frstRgtrNmStr`)·등록일(`frstRegDtStr`)·수정일(`lastMdfcnDtStr`)·메뉴 건수(`productCnt`)를 * 돌려준다. 업종·업태 이름(`frcsTpbizCdNm`·`frcsBzstatCdNm`·`frcsCdNm`)도 오지만 시안에 자리가 없어 * 읽지 않는다. 카테고리 코드표 조인은 1c7b0e0에서 빠져 어느 그룹의 코드든 목록에 남는다. * 추가 이미지 장수는 묶음 파일 목록 API로 알아낸다(더듬기 없음). * * ⚠️ 백엔드 결함(보고함, 미수정): * 1. 응답 VO(`MngrVocDetailVo`)에 `frcsNm`이 없어 SELECT의 업체명이 버려진다 — 목록은 가맹점 목록의 * 이름으로 메우고, 단건은 코드만 오면 가맹점 목록을 한 번 더 읽는다. * 2. `selectOne`이 DEL_YN을 거르지 않는다. 삭제한 상세가 남은 가맹점은 목록·앱에서는 빠지는데 단건은 * 삭제된 값을 돌려주고(수정 화면이 그 값을 보인다) 등록은 409로 막힌다 — 삭제 뒤 재등록 불가. * 응답에 delYn이 없어(@JsonIgnore) 프론트가 가려낼 수도 없다. * 3. 건수 쿼리(`selectPaginationCount`)만 LEFT JOIN이라 부풀어 온다 — 목록을 여기서 합쳐 세므로 쓰지 않는다. * 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'), categoryLabel: readString(raw, 'frcsTypeCdNm'), isVisible: readYesNo(raw, 'useYn'), productCount: readNumber(raw, 'productCnt'), 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, categoryLabel: null, isVisible: null, productCount: null, registeredAt: null, hasDetail: false, }, ]; } /** 추가 이미지 묶음의 URL을 `fileSn` 순으로 — 묶음 파일 목록 API(백엔드 1c7b0e0)로 장수를 안다. */ async function resolveImageUrls(fileId: string | null): Promise { if (!fileId) return []; const files = await fetchAttachmentFiles(fileId); return files .map((file) => buildFileImageUrl(fileId, file.fileSn)) .filter((url): url is string => url !== null); } async function toDetail(raw: unknown, withImages: boolean): Promise { const [summary] = toSummary(raw); if (!summary || !isRecord(raw)) return null; const imageFileId = readString(raw, 'atchFileId'); return { ...summary, imageUrls: withImages ? await resolveImageUrls(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'), }; } /** * 목록 — 가맹점 전체에 상세를 덧입혀 상세가 없는 가맹점도 세운다(사용자 확정). 백엔드 목록은 상세가 * 있는 것만 주므로 가맹점 목록과 상세 목록을 모두 받아 합치고, 검색·기간·페이지는 여기서 처리한다. * 부수 효과로 백엔드가 쓰지 않던 등록일 기간 조건과 응답에 빠진 업체명(결함 1)이 여기서 채워진다. */ export async function fetchMerchantDetails( query: MerchantDetailQuery ): Promise<{ items: MerchantDetailSummary[]; totalCount: number }> { const merged = await fetchMergedSummaries(); const keyword = query.keyword.trim().toLowerCase(); const filtered = merged.filter((row) => { if (keyword && !row.name.toLowerCase().includes(keyword)) return false; if (query.category && row.categoryCode !== query.category) return false; if (query.visibility && row.isVisible !== (query.visibility === 'Y')) return false; if (query.startDate && (row.registeredAt === null || row.registeredAt < query.startDate)) return false; if (query.endDate && (row.registeredAt === null || row.registeredAt > query.endDate)) return false; return true; }); const start = (query.page - 1) * query.pageSize; return { items: filtered.slice(start, start + query.pageSize), totalCount: filtered.length, }; } /** 상세 있는 가맹점을 등록일 내림차순으로 먼저, 나머지 가맹점을 코드 내림차순으로 뒤에 둔다. */ function compareSummaryOrder(a: MerchantDetailSummary, b: MerchantDetailSummary): number { if (a.hasDetail !== b.hasDetail) return a.hasDetail ? -1 : 1; const dateA = a.registeredAt ?? ''; const dateB = b.registeredAt ?? ''; if (dateA !== dateB) return dateA < dateB ? 1 : -1; return b.id.localeCompare(a.id, undefined, { numeric: true }); } async function fetchMergedSummaries(): Promise { const [merchants, details] = await Promise.all([ fetchAllPages(`${VOC_PATH}/pagination`, toCandidate), fetchAllPages(`${DETAIL_PATH}/pagination`, toSummary), ]); const byId = new Map(merchants.map((row) => [row.id, row])); for (const detail of details) { // develop 백엔드(LEFT JOIN)는 상세 없는 가맹점도 섞어 준다 — 상세 있는 행만 덧입힌다. if (!detail.hasDetail) continue; const merchant = byId.get(detail.id); byId.set(detail.id, { ...detail, // 결함 1 — 상세 응답에 업체명이 없으면 가맹점 목록의 이름을 쓴다. name: detail.name === detail.id && merchant ? merchant.name : detail.name, }); } return [...byId.values()].sort(compareSummaryOrder); } /** * 단건. `withImages`를 끄면 추가 이미지 목록 조회를 건너뛴다 — 노출 토글처럼 값만 다시 보내는 곳은 * URL이 필요 없다. */ export async function fetchMerchantDetail( frcsNo: string, { withImages = true }: { withImages?: boolean } = {} ): 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 봉투였다. 지금은 성공 봉투에 data: null이라 아래 toDetail이 null을 준다. if (result.code === 404) return null; throw new BackendRequestError(result); } return toDetail(result.data, withImages); } 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 findMerchantByName( name: string, phoneNumber: string ): Promise { const merchants = await fetchAllPages(`${VOC_PATH}/pagination`, toCandidate); const digits = phoneNumber.replace(/\D/g, ''); return ( merchants.find( (row) => row.name === name && (row.phoneNumber ?? '').replace(/\D/g, '') === digits ) ?? merchants.find((row) => row.name === name) ?? null ); } /** 가맹점 한 건(상세 없이) — 가맹점 단건 API가 없어 목록에서 찾는다. */ export async function fetchMerchantSummary( frcsNo: string ): Promise { const merchants = await fetchAllPages(`${VOC_PATH}/pagination`, toCandidate); return merchants.find((row) => row.id === frcsNo) ?? null; } 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'); }