factField()가 FactData.summary를 InfoField.summary로 옮기고, common.ts에 introSummary()를 추가한다. 아직 어느 변형도 이 값을 쓰지 않는다 — IntroSideBySide/IntroStory 연결은 다음 커밋.
242 lines
9.2 KiB
TypeScript
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),
|
|
};
|
|
}
|