/** * 백엔드 응답 → 캔버스 데이터. * * ★ 순수 함수만 둔다. 스토어도 네트워크도 모른다 — 입력(place·fact·media·스키마)이 같으면 * 출력이 항상 같다. 그래서 "화면이 왜 저 값을 보여주나"를 이 파일만 읽고 답할 수 있다. * * ★ 필드명은 지어내지 않는다. 기준은 backend/router/v1/place/protocol.py 의 PlaceData 와 * backend/router/v1/fact/protocol.py 의 FactData · FieldSpecData 다. */ import type {IndustryType, InfoField, PhotoItem} from '@o2o/shared'; import {isPublishableFact, PlaceCategory, SourceType} from '@o2o/shared'; import type {FactData, FieldSpecData, MediaData, PlaceData} from '@/api'; import {FALLBACK_INDUSTRY} from '@/data/industryData'; import type {FactRef, LivePlaceInput} from '@/stores/builderTypes'; /** places.category → 빌더 업종. 어떤 업종 시드(섹션·템플릿·문구)를 깔지가 여기서 갈린다. */ export const CATEGORY_TO_INDUSTRY: Record = { [PlaceCategory.LODGING]: 'stay', [PlaceCategory.CAFE]: 'cafe', [PlaceCategory.RESTAURANT]: 'restaurant', [PlaceCategory.CLINIC]: 'clinic', }; /** facts.source_type — 사장님이 [맞아요] 를 누를 판단 근거. 출처 없는 값은 보여주지 않는다. */ const SOURCE_LABEL: Record = { [SourceType.OWNER]: '사장님 입력', [SourceType.API]: '외부 API', [SourceType.CRAWL]: '채널 수집', [SourceType.LLM]: 'AI 초안', }; /** 출처 한 줄. URL 은 통째로 쓰면 패널을 밀어내므로 호스트만 남긴다. */ function factSource(fact: FactData): string { const label = SOURCE_LABEL[fact.source_type] ?? '출처 미상'; if (!fact.source_url) return label; try { return `${label} · ${new URL(fact.source_url).hostname}`; } catch { return label; } } /** * fact 값을 사람이 읽는 문장으로. * * bool 은 'true' 가 그대로 화면에 나가면 안 되고, number 는 천 단위 구분이 없으면 * 사장님도 AI 도 자릿수를 세야 한다(shared/lib/facts.ts 의 factText 와 같은 규칙). */ function formatFactValue(fact: FactData, spec?: FieldSpecData): string { const raw = (fact.value ?? '').trim(); if (!raw) return ''; const unit = fact.unit ?? spec?.unit ?? ''; if (spec?.type === 'bool') return raw === 'true' || raw === '1' || raw === 'Y' ? '예' : '아니오'; if (spec?.type === 'number') { const num = Number(raw.replace(/,/g, '')); return `${Number.isFinite(num) ? num.toLocaleString('ko-KR') : raw}${unit}`; } return `${raw}${unit}`; } /** place 가 소유하는 줄. fact 가 아니므로 전이시킬 대상이 없다(id 가 겹치면 안 된다). */ const IDENTITY_FIELD_IDS = new Set(['name', 'address', 'phone']); /** * place 응답의 신원 정보(상호·주소·전화). * * fact 가 아직 하나도 없어도 이건 채워진다 — 수집 전 사업장을 열어도 캔버스가 빈 껍데기가 * 되지 않게. id 는 데모 시드와 같은 이름을 쓴다(캔버스 푸터가 'address' 를 이름으로 찾는다). */ function identityFields(place: PlaceData): InfoField[] { // ★ verified_at 이 NULL 이면 동일 업소 검증 전이다 — 주소·전화가 동명 업소의 것일 수 있다. // 그래서 '확인 필요'로 올린다: 캔버스가 가리고, 발행 게이트가 잡는다(절대규칙 1). const verified = Boolean(place.verified_at); const address = place.road_address ?? place.address ?? ''; const fields: InfoField[] = [ { id: 'name', label: '상호', value: place.name, requiresVerification: false, isVerified: true, source: '사장님 등록', }, ]; if (address) { fields.push({ id: 'address', label: '주소', value: address, requiresVerification: !verified, isVerified: verified, source: '동일 업소 검증', critical: true, }); } if (place.phone) { fields.push({ id: 'phone', label: '전화', value: place.phone, requiresVerification: !verified, isVerified: verified, source: '동일 업소 검증', critical: true, }); } return fields; } /** * key 당 대표 fact 1건(scope='unit' 인 값은 뺀다). * * ★ 정보 표와 저장 배선이 반드시 같은 fact 를 가리켜야 한다 — 서로 다른 걸 고르면 * 화면에서 보고 있는 줄과 실제로 전이시키는 fact 가 어긋나, 엉뚱한 값이 '확인됨'이 된다. */ function placeFactsByKey(facts: FactData[]): Map { const byKey = new Map(); for (const fact of facts) { if (fact.unit_id) continue; if (!byKey.has(fact.key)) byKey.set(fact.key, fact); } return byKey; } /** 정보 표의 줄 id → 어떤 fact 를 전이시킬지. 화면과 같은 규칙(placeFactsByKey)으로 고른다. */ function toFactRefs(facts: FactData[], specs: FieldSpecData[]): Record { const specByKey = new Map(specs.map((spec) => [spec.key, spec])); const refs: Record = {}; for (const [key, fact] of placeFactsByKey(facts)) { // 신원 줄과 id 가 겹치면 사장님이 보는 줄과 다른 fact 를 고치게 된다. 그럴 바엔 로컬로 둔다. if (IDENTITY_FIELD_IDS.has(key)) continue; const spec = specByKey.get(key); refs[key] = { factId: fact.fact_id, status: fact.status, type: spec?.type, // 단위는 fact 가 우선이다(수집 시점의 값). 없으면 업종 스키마의 단위. unit: fact.unit ?? spec?.unit ?? undefined, }; } return refs; } /** fact 1건 → 정보 표 한 줄. */ function factField(fact: FactData, spec?: FieldSpecData): InfoField { const publishable = isPublishableFact(fact.status); return { id: fact.key, label: spec?.label ?? fact.key, value: formatFactValue(fact, spec), // ★ 절대규칙 1 — VERIFIED · CORRECTED 가 아니면 캔버스가 가린다. requiresVerification: !publishable, isVerified: publishable, source: factSource(fact), critical: spec?.critical, // 캔버스 미리보기용 축약문(intro/room_intro 가 길 때만 백엔드가 채워 보낸다). summary: fact.summary?.trim() || undefined, }; } /** * place + fact + 업종 스키마 → 캔버스의 infoFields. * * 줄 순서는 업종 스키마(FieldSpecData)가 정한다. 수집된 순서대로 늘어놓으면 재수집이 * 돌 때마다 표의 줄 순서가 바뀐다 — 사장님 눈에는 값이 바뀐 것처럼 보인다. * * ★ scope='unit' 인 fact(객실·메뉴별 값)는 넣지 않는다. 같은 key 가 단위 수만큼 들어와 * '기본 정보' 표에 같은 라벨이 여러 줄 겹친다 — 단위별 값은 객실/메뉴 섹션의 몫이다. */ function toInfoFields(place: PlaceData, facts: FactData[], specs: FieldSpecData[]): InfoField[] { const placeFacts = placeFactsByKey(facts); const ordered: InfoField[] = []; const taken = new Set(); for (const spec of specs) { if (spec.scope !== 'place') continue; const fact = placeFacts.get(spec.key); if (!fact) continue; taken.add(spec.key); ordered.push(factField(fact, spec)); } // 스키마에 없는 key 도 버리지 않는다(스키마 배포보다 수집이 앞선 경우). for (const [key, fact] of placeFacts) { if (!taken.has(key)) ordered.push(factField(fact)); } // 값이 빈 fact 는 표에 넣지 않는다 — 라벨만 있는 빈 줄은 정보가 아니라 잡음이다. return [...identityFields(place), ...ordered.filter((f) => f.value !== '')]; } /** * 서버 media → 캔버스 사진. * * ★ **발행 가능한 것만** 넘긴다(승인 + alt 있음). 미승인 사진을 캔버스에 그리면 * 사장님은 그게 사이트에 나갈 것으로 읽는데, 발행 스냅샷은 그걸 걸러낸다 — * 화면과 발행본이 갈리는 자리다. `publishable` 은 서버가 판정해 내려준다. */ function toPhotos(media: MediaData[]): PhotoItem[] { return media .filter((m) => m.publishable !== false) .map((m, index) => ({ id: m.media_id, url: m.url ?? m.origin_url ?? '', // alt 는 Vision 이 붙인 설명이다. 없으면 라벨(분류)로 떨어뜨린다. title: m.alt_text || m.label || '수집된 사진', category: m.label ?? '기타', isPrimary: index === 0, isVisible: true, })) .filter((p) => p.url); } export function toLivePlaceInput( placeId: string, place: PlaceData, facts: FactData[], specs: FieldSpecData[], media: MediaData[], ): LivePlaceInput { return { placeId, industry: CATEGORY_TO_INDUSTRY[place.category] ?? FALLBACK_INDUSTRY, storeName: place.name, location: place.road_address ?? place.address ?? '', weatherLocation: place.region_code && place.latitude != null && place.longitude != null ? { regionCode: place.region_code, latitude: Number(place.latitude), longitude: Number(place.longitude), } : undefined, infoFields: toInfoFields(place, facts, specs), factRefs: toFactRefs(facts, specs), // fact 가 하나도 없으면 아직 수집 전이다(신원 2줄은 place 가 준 것이지 수집물이 아니다). hasCollected: facts.length > 0, photos: toPhotos(media), }; }