o2o-site-AEO/solution/frontend/src/features/builder/placeAdapter.ts
김성경 67a1db3cb9 feat(frontend): 캔버스 소개 요약값 배선(placeAdapter, common)
factField()가 FactData.summary를 InfoField.summary로 옮기고,
common.ts에 introSummary()를 추가한다. 아직 어느 변형도 이 값을
쓰지 않는다 — IntroSideBySide/IntroStory 연결은 다음 커밋.
2026-09-14 17:12:30 +09:00

242 lines
9.2 KiB
TypeScript

/**
* 백엔드 응답 → 캔버스 데이터.
*
* ★ 순수 함수만 둔다. 스토어도 네트워크도 모른다 — 입력(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<number, IndustryType> = {
[PlaceCategory.LODGING]: 'stay',
[PlaceCategory.CAFE]: 'cafe',
[PlaceCategory.RESTAURANT]: 'restaurant',
[PlaceCategory.CLINIC]: 'clinic',
};
/** facts.source_type — 사장님이 [맞아요] 를 누를 판단 근거. 출처 없는 값은 보여주지 않는다. */
const SOURCE_LABEL: Record<number, string> = {
[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<string, FactData> {
const byKey = new Map<string, FactData>();
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<string, FactRef> {
const specByKey = new Map(specs.map((spec) => [spec.key, spec]));
const refs: Record<string, FactRef> = {};
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<string>();
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),
};
}