feat(backend): 장소 조회 API와 같은 가게 중복 병합 추가

검색·지도·상세 화면이 장소 DB 를 쓰도록 조회 API 를 연다(비로그인 허용).

- GET /api/places: 이름 검색, 좌표+반경 거리순, 분류·반려동물·실내 필터,
  키셋 커서 페이지네이션(정렬값+id), 전체 개수, 항목별 "지금 영업 중"(KST)
- GET /api/places/{id}: 영업시간 원문·해석 상태·출처, 반려동물 크기·조건,
  이용 안내(요금·주차 등), 추천 이유(추천+확인한 곳만), 출처 표기.
  병합된 id 로 들어오면 병합 대상 장소를, 숨김·폐업 추정이면 RES_001 을 준다
- 같은 가게 병합: 관광공사 쪽이 카페·식당·주점이고 100m 안이며 상호가 같거나
  포함(짧은 쪽 2글자 이상)이면 상가정보 쪽을 merged_into_id 로 관광공사 쪽에 합친다.
  tour-kor·store 가 끝날 때 자동으로, place:sync -- dedupe 로 따로도 돈다
- 사후면세점 브랜드 매장(SH040300)은 목록·필터에서 빼고 이름 검색에만 낸다
  (반려동물 필터 결과가 더현대 입점 매장으로 덮이던 문제)
- 공공 반려동물 조건 원문의 글머리표·줄바꿈 정리

e2e(test/place.e2e-spec.ts)는 시작할 때 마이그레이션을 적용하고 동해 좌표에
테스트 장소를 만들어 노출 규칙·필터·커서·상세·오류 코드를 검증한다.
2026-11-02 대구 실측: 같은 가게 21곳 병합, 도심 반려동물 필터 7곳.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-03 18:57:58 +09:00
parent 71aaf0bcae
commit df48ed78eb
29 changed files with 1604 additions and 15 deletions
@@ -0,0 +1,120 @@
import { ApiProperty } from '@nestjs/swagger';
import { PET_SIZES } from '../place.constants';
import type { PetSize } from '../place.constants';
import { PlaceListItemDto } from './place-list-item.dto';
const HOURS_STATUSES = ['parsed', 'always_open', 'varies', 'unknown'] as const;
export class PlaceHoursDto {
@ApiProperty({
description:
'영업시간 원문(운영자 확인 값이 있으면 그것, 없으면 공공데이터)',
nullable: true,
example: '11:00~21:00 (준비 시간 15:30~17:00)',
})
text!: string | null;
@ApiProperty({
description: '쉬는 날 원문',
nullable: true,
example: '매주 월요일',
})
restText!: string | null;
@ApiProperty({
description:
'해석 결과 — varies(점포별 상이)·unknown 이면 시간을 단정하지 않는다',
enum: HOURS_STATUSES,
})
status!: (typeof HOURS_STATUSES)[number];
}
export class PlacePetDto {
@ApiProperty({
description: '반려견 크기(운영자 확인)',
enum: PET_SIZES,
isArray: true,
nullable: true,
})
sizes!: PetSize[] | null;
@ApiProperty({
description:
'조건 — 운영자 확인 값 또는 공공데이터 원문(예: "목줄 착용", "켄넬 필수")',
nullable: true,
example: '전 견종 동반 가능 · 목줄 착용',
})
conditions!: string | null;
}
export class PlaceCurationInfoDto {
@ApiProperty({
description: '추천 이유(운영자가 직접 쓴 글)',
nullable: true,
example: '골목 벽화를 따라 30분 산책하기 좋아요.',
})
reason!: string | null;
@ApiProperty({ description: '업체 공식 채널', nullable: true })
officialUrl!: string | null;
}
export class PlaceAttributionDto {
@ApiProperty({
description: '정보 출처 표기',
example: '한국관광공사',
})
provider!: string;
@ApiProperty({
description:
'원본 기준 시점(관광공사 수정일 YYYY-MM-DD · 상가정보 기준년월 YYYYMM)',
nullable: true,
example: '2026-10-15',
})
asOf!: string | null;
}
/** 장소 상세 — 목록 필드에 상세 정보를 더한다 */
export class PlaceDetailDto extends PlaceListItemDto {
@ApiProperty({ description: '지번 주소', nullable: true })
addressJibun!: string | null;
@ApiProperty({ description: '전화', nullable: true })
phone!: string | null;
@ApiProperty({ description: '홈페이지', nullable: true })
homepage!: string | null;
@ApiProperty({ description: '소개(공공데이터)', nullable: true })
overview!: string | null;
@ApiProperty({ description: '큰 사진 URL', nullable: true })
imageUrl!: string | null;
@ApiProperty({ type: PlaceHoursDto })
hours!: PlaceHoursDto;
@ApiProperty({ type: PlacePetDto })
pet!: PlacePetDto;
@ApiProperty({
description: '이용 안내(입장료·주차·문의처·대표 메뉴 등 공공데이터 원문)',
type: 'object',
additionalProperties: { type: 'string' },
nullable: true,
example: { fee: '무료', parking: '가능' },
})
extra!: Record<string, string> | null;
@ApiProperty({
type: PlaceCurationInfoDto,
nullable: true,
description: '운영자 추천 정보 — 추천(O)하고 확인한 장소만',
})
curation!: PlaceCurationInfoDto | null;
@ApiProperty({ type: PlaceAttributionDto })
attribution!: PlaceAttributionDto;
}
@@ -0,0 +1,128 @@
import { ApiProperty } from '@nestjs/swagger';
import { PageInfoDto } from '@/common/dto/pagination.dto';
import {
IMAGE_LICENSES,
PET_POLICIES,
PLACE_CATEGORIES,
PLACE_SETTINGS,
PLACE_SOURCES,
} from '../place.constants';
import type {
ImageLicense,
PetPolicy,
PlaceCategory,
PlaceSetting,
PlaceSource,
} from '../place.constants';
/** 값의 출처 — 화면 표기("11월 2일 확인" / "출처 한국관광공사" / "미확인")를 고른다 */
const VALUE_ORIGINS = ['curation', 'public', 'unknown'] as const;
export type ValueOriginDto = (typeof VALUE_ORIGINS)[number];
/** 검색·지도 목록의 장소 한 건 */
export class PlaceListItemDto {
@ApiProperty({
description: '장소 id',
example: '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
})
id!: string;
@ApiProperty({ description: '이름', example: '김광석다시그리기길' })
name!: string;
@ApiProperty({ description: '지점명', nullable: true, example: null })
branchName!: string | null;
@ApiProperty({
description: '분류',
enum: PLACE_CATEGORIES,
example: 'attraction',
})
category!: PlaceCategory;
@ApiProperty({
description: '데이터 출처(tour 한국관광공사 · store 소상공인 상가정보)',
enum: PLACE_SOURCES,
})
source!: PlaceSource;
@ApiProperty({
description: '주소',
nullable: true,
example: '대구광역시 중구 달구벌대로 2238',
})
address!: string | null;
@ApiProperty({ description: '위도', example: 35.8597 })
lat!: number;
@ApiProperty({ description: '경도', example: 128.6067 })
lng!: number;
@ApiProperty({
description: '기준 좌표에서의 거리(m). lat·lng 없이 조회하면 null',
nullable: true,
example: 1057,
})
distanceM!: number | null;
@ApiProperty({ description: '작은 사진 URL', nullable: true })
imageThumbUrl!: string | null;
@ApiProperty({
description:
'사진 공공누리 유형 — 화면에 출처를 표시해야 한다(Type3 은 변경 금지)',
enum: IMAGE_LICENSES,
nullable: true,
})
imageLicense!: ImageLicense | null;
@ApiProperty({
description:
'영업시간 출처(curation 운영자 확인 · public 공공데이터 · unknown 미확인)',
enum: VALUE_ORIGINS,
})
hoursOrigin!: ValueOriginDto;
@ApiProperty({
description:
'지금(한국 시간) 영업 중인지. 알 수 없으면 null — "영업시간 미확인 · 방문 전 확인"',
nullable: true,
example: true,
})
openNow!: boolean | null;
@ApiProperty({
description: '반려동물 동반',
enum: PET_POLICIES,
example: 'allowed',
})
petPolicy!: PetPolicy;
@ApiProperty({ description: '반려동물 정보 출처', enum: VALUE_ORIGINS })
petOrigin!: ValueOriginDto;
@ApiProperty({
description: '실내/실외(운영자가 확인한 장소만)',
enum: PLACE_SETTINGS,
nullable: true,
})
setting!: PlaceSetting | null;
@ApiProperty({
description: '운영자 확인일(YYYY-MM-DD). 확인하지 않았으면 null',
nullable: true,
example: null,
})
verifiedAt!: string | null;
}
/** 장소 목록 응답 본문(인터셉터가 { success, data } 로 한 번 더 감싼다) */
export class PlaceListResponseDto {
@ApiProperty({ type: [PlaceListItemDto] })
items!: PlaceListItemDto[];
@ApiProperty({ type: PageInfoDto })
pageInfo!: PageInfoDto;
}
@@ -0,0 +1,126 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsIn,
IsInt,
IsLatitude,
IsLongitude,
IsOptional,
IsString,
Length,
Max,
Min,
ValidateIf,
} from 'class-validator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import { PLACE_CATEGORIES } from '../place.constants';
import type { PlaceCategory } from '../place.constants';
/** 반경 기본값·한도(m) */
export const PLACE_RADIUS_DEFAULT_M = 1_500;
export const PLACE_RADIUS_MIN_M = 100;
export const PLACE_RADIUS_MAX_M = 20_000;
/** 쿼리 문자열 'true'/'false' → boolean. 그 밖의 값은 그대로 두어 검증에서 걸리게 한다 */
const toBoolean = ({ value }: { value: unknown }): unknown => {
if (value === 'true' || value === true) return true;
if (value === 'false' || value === false) return false;
return value;
};
/** 'cafe,food' → ['cafe', 'food'] (같은 키를 여러 번 보내도 된다) */
const toList = ({ value }: { value: unknown }): unknown =>
typeof value === 'string'
? value
.split(',')
.map((v) => v.trim())
.filter(Boolean)
: value;
/** 장소 검색·근처 목록 조건 */
export class PlaceSearchQueryDto extends CursorPaginationQueryDto {
@ApiPropertyOptional({
description:
'이름 검색어(1~50자). 있으면 사후면세점 브랜드 매장도 검색된다',
example: '김광석',
})
@IsOptional()
@IsString({ message: '검색어는 문자열로 입력해 주세요.' })
@Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value,
)
@Length(1, 50, { message: '검색어는 1~50자로 입력해 주세요.' })
q?: string;
@ApiPropertyOptional({
description: '분류(쉼표로 여러 개)',
enum: PLACE_CATEGORIES,
isArray: true,
example: 'cafe,food',
})
@IsOptional()
@Transform(toList)
@IsArray({ message: '분류 값이 올바르지 않습니다.' })
@IsIn(PLACE_CATEGORIES, {
each: true,
message: '분류 값이 올바르지 않습니다.',
})
category?: PlaceCategory[];
@ApiPropertyOptional({
description: '기준 위도 — lng 와 함께 보내면 거리순으로 반경 안만 찾는다',
example: 35.865,
})
@ValidateIf(
(o: PlaceSearchQueryDto) => o.lat !== undefined || o.lng !== undefined,
)
@Type(() => Number)
@IsLatitude({ message: '위도는 lat·lng 를 함께, 올바른 값으로 보내 주세요.' })
lat?: number;
@ApiPropertyOptional({ description: '기준 경도', example: 128.5966 })
@ValidateIf(
(o: PlaceSearchQueryDto) => o.lat !== undefined || o.lng !== undefined,
)
@Type(() => Number)
@IsLongitude({
message: '경도는 lat·lng 를 함께, 올바른 값으로 보내 주세요.',
})
lng?: number;
@ApiPropertyOptional({
description: `반경(m, ${PLACE_RADIUS_MIN_M}~${PLACE_RADIUS_MAX_M}). lat·lng 가 있을 때만 쓴다`,
default: PLACE_RADIUS_DEFAULT_M,
})
@IsOptional()
@Type(() => Number)
@IsInt({ message: '반경은 정수(m)로 입력해 주세요.' })
@Min(PLACE_RADIUS_MIN_M, {
message: `반경은 ${PLACE_RADIUS_MIN_M}m 이상이어야 합니다.`,
})
@Max(PLACE_RADIUS_MAX_M, {
message: `반경은 ${PLACE_RADIUS_MAX_M}m 이하여야 합니다.`,
})
radius?: number;
@ApiPropertyOptional({
description: '반려동물 동반 가능(전 구역·일부 구역) 장소만',
example: true,
})
@IsOptional()
@Transform(toBoolean)
@IsBoolean({ message: 'pet 은 true 또는 false 로 입력해 주세요.' })
pet?: boolean;
@ApiPropertyOptional({
description: '실내(실내·실내외) 장소만 — 운영자가 확인한 장소만 해당한다',
example: false,
})
@IsOptional()
@Transform(toBoolean)
@IsBoolean({ message: 'indoor 는 true 또는 false 로 입력해 주세요.' })
indoor?: boolean;
}
@@ -1,7 +1,13 @@
import { ViewColumn, ViewEntity } from 'typeorm';
import type { ParsedOpeningHours } from '@/shared/utils/opening-hours';
import { AI_TIER2_CATEGORIES, sqlValues } from '../place.constants';
import {
AI_TIER2_CATEGORIES,
BRAND_STORE_CATEGORY,
MALL_NAME_PREFIXES,
sqlAlternation,
sqlValues,
} from '../place.constants';
import type {
CurationVerdict,
ImageLicense,
@@ -22,12 +28,17 @@ const PUBLIC_HOURS = `p.public_hours->>'status' IN ('parsed', 'always_open')`;
const CURATED_PET = `c.verified_at IS NOT NULL AND c.pet_policy <> 'unknown'`;
const PUBLIC_PET = `p.public_pet_policy IS NOT NULL`;
const HOME_PICK = `c.verdict = 'recommend' AND c.verified_at IS NOT NULL`;
// 노출 중 = 숨김·폐업 추정이 아니고, 다른 출처의 같은 가게로 병합되지도 않은 장소
const LISTED = `p.status = 'active' AND p.merged_into_id IS NULL`;
// 사후면세점 브랜드 매장(백화점·쇼핑몰 자체는 제외) — 목록·필터에서 빼고 이름 검색에만 낸다
const BRAND_STORE = `p.source_category = ${sqlValues([BRAND_STORE_CATEGORY])} AND p.name !~ '^(${sqlAlternation(MALL_NAME_PREFIXES)})'`;
/**
* "앱이 보는 값" — 공공 값과 큐레이션 값을 합치고 노출 플래그를 계산한다.
* 우선순위(큐레이션 > 공공 > 미확인)와 노출 규칙은 **여기서만** 정한다. API·AI 는 이 뷰를 읽는다.
* 노출 규칙(2026-11-02 합의, docs/design/place-db.md §3):
* - is_searchable: 노출 중인 장소는 모두 검색·지도에 나온다(확인 안 된 상가정보 매장 포함)
* - is_searchable: 노출 중인 장소는 모두 검색·지도에 나온다(확인 안 된 상가정보 매장 포함, 병합된 중복은 제외)
* - is_brand_store: 사후면세점 브랜드 매장 — 조회 API 가 목록·필터에서는 빼고 이름 검색에만 낸다
* - is_home_pick: 추천(O) + 확인일이 있는 장소만 홈 동네 추천에 나온다
* - ai_tier: 1 = 추천(O) + 확인 / 2 = 관광공사 장소(코스 분류, X 아님) / NULL = 그 외 — 확인 안 된 상가정보 매장은 AI 후보가 아니다
*/
@@ -36,6 +47,7 @@ SELECT
p.id,
p.source,
p.source_id,
p.source_category,
p.category,
p.name,
p.branch_name,
@@ -58,10 +70,11 @@ SELECT
CASE WHEN ${CURATED_PET} THEN 'curation' WHEN ${PUBLIC_PET} THEN 'public' ELSE 'unknown' END AS pet_origin,
CASE WHEN c.verified_at IS NOT NULL THEN c.pet_sizes END AS pet_sizes,
CASE WHEN c.verified_at IS NOT NULL THEN c.pet_conditions END AS pet_conditions,
(p.status = 'active') AS is_searchable,
(p.status = 'active' AND COALESCE(${HOME_PICK}, false)) AS is_home_pick,
(${LISTED}) AS is_searchable,
COALESCE(${BRAND_STORE}, false) AS is_brand_store,
(${LISTED} AND COALESCE(${HOME_PICK}, false)) AS is_home_pick,
CASE
WHEN p.status <> 'active' THEN NULL
WHEN NOT (${LISTED}) THEN NULL
WHEN ${HOME_PICK} THEN 1
WHEN c.verdict = 'not_recommend' THEN NULL
WHEN p.source = 'tour' AND p.category IN (${sqlValues(AI_TIER2_CATEGORIES)}) THEN 2
@@ -81,6 +94,10 @@ export class PlaceListing {
@ViewColumn()
sourceId!: string;
/** 원본 분류 코드(lclsSystm3 · indsSclsCd) */
@ViewColumn()
sourceCategory!: string | null;
@ViewColumn()
category!: PlaceCategory;
@@ -151,6 +168,9 @@ export class PlaceListing {
@ViewColumn()
isSearchable!: boolean;
@ViewColumn()
isBrandStore!: boolean;
@ViewColumn()
isHomePick!: boolean;
@@ -4,6 +4,8 @@ import {
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToOne,
PrimaryGeneratedColumn,
Unique,
@@ -167,6 +169,17 @@ export class Place {
@Column({ type: 'timestamptz', nullable: true })
detailSyncedAt!: Date | null;
/**
* 같은 가게를 다른 출처가 이미 가지고 있으면 그 장소의 id(상가정보 → 관광공사).
* 값이 있으면 검색·추천에 내보내지 않고, 상세로 들어오면 병합 대상 장소를 보여 준다. 동기화가 끝날 때마다 다시 계산한다.
*/
@Column({ type: 'uuid', nullable: true })
mergedIntoId!: string | null;
@ManyToOne(() => Place, { nullable: true })
@JoinColumn({ name: 'merged_into_id' })
mergedInto?: Relation<Place> | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@@ -0,0 +1,28 @@
import { publicPetNote } from './pet-note';
describe('publicPetNote', () => {
it('글머리표·줄바꿈을 정리해 한 줄로 잇는다(김광석다시그리기길 실제 원문)', () => {
expect(
publicPetNote({
acmpyTypeCd: '전구역 동반가능',
acmpyPsblCpam: '전 견종 동반 가능',
acmpyNeedMtr: '목줄 착용',
etcAcmpyInfo:
'- 맹견의 경우, 입마개 착용 필수\n- 배변봉투 지참 및 배변처리 필수',
}),
).toBe(
'전 견종 동반 가능 · 목줄 착용 · 맹견의 경우, 입마개 착용 필수 · 배변봉투 지참 및 배변처리 필수',
);
});
it('같은 문장이 여러 필드에 반복되면 한 번만 쓴다', () => {
expect(
publicPetNote({ acmpyNeedMtr: '목줄 착용', etcAcmpyInfo: '※ 목줄 착용' }),
).toBe('목줄 착용');
});
it('조건 문장이 없으면 null', () => {
expect(publicPetNote({ acmpyTypeCd: '전구역 동반가능' })).toBeNull();
expect(publicPetNote(null)).toBeNull();
});
});
+26
View File
@@ -0,0 +1,26 @@
/**
* 관광공사 반려동물 동반 정보 원문 → 화면에 보여 줄 조건 문장 (순수 함수).
* 원문에는 글머리표('- ')와 줄바꿈이 섞여 온다(예: "- 맹견의 경우, 입마개 착용 필수\n- 배변봉투 지참").
*/
/** 조건으로 보여 줄 필드(동반 구분 acmpyTypeCd 는 petPolicy 로 따로 나간다) */
const NOTE_FIELDS = ['acmpyPsblCpam', 'acmpyNeedMtr', 'etcAcmpyInfo'];
/** 줄 앞의 글머리표·번호 표시 */
const BULLET_RE = /^\s*(?:[-*•·※]|\d+[.)])\s*/;
export function publicPetNote(
raw: Record<string, string> | null,
): string | null {
if (!raw) return null;
const parts: string[] = [];
for (const key of NOTE_FIELDS) {
const value = raw[key];
if (typeof value !== 'string') continue;
for (const line of value.split(/\n+/)) {
const text = line.replace(BULLET_RE, '').trim();
if (text !== '' && !parts.includes(text)) parts.push(text);
}
}
return parts.length > 0 ? parts.join(' · ') : null;
}
@@ -0,0 +1,36 @@
import { decodePlaceCursor, encodePlaceCursor } from './place-cursor';
const ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d';
describe('place cursor', () => {
it('거리·이름 커서를 인코딩했다가 그대로 되돌린다', () => {
const byDistance = encodePlaceCursor({ k: 812.4567, id: ID });
expect(decodePlaceCursor(byDistance, 'number')).toEqual({
k: 812.4567,
id: ID,
});
const byName = encodePlaceCursor({ k: '김광석다시그리기길', id: ID });
expect(decodePlaceCursor(byName, 'string')).toEqual({
k: '김광석다시그리기길',
id: ID,
});
});
it('정렬 방식과 맞지 않는 커서는 받지 않는다', () => {
const byName = encodePlaceCursor({ k: '가', id: ID });
expect(decodePlaceCursor(byName, 'number')).toBeNull();
});
it('형식이 틀린 커서는 받지 않는다', () => {
expect(decodePlaceCursor('아무거나', 'number')).toBeNull();
expect(
decodePlaceCursor(
Buffer.from('{"k":1,"id":"x"}').toString('base64url'),
'number',
),
).toBeNull();
expect(
decodePlaceCursor(Buffer.from('null').toString('base64url'), 'number'),
).toBeNull();
});
});
+39
View File
@@ -0,0 +1,39 @@
/**
* 장소 목록 커서 (순수 함수) — 정렬 기준값과 id 로 다음 페이지 시작점을 가리킨다(키셋 페이지네이션).
* 거리순이면 k 는 거리(m, 숫자), 이름순이면 k 는 이름(문자열)이다.
*/
export interface PlaceCursor {
k: number | string;
id: string;
}
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function encodePlaceCursor(cursor: PlaceCursor): string {
return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url');
}
/**
* 커서를 해석한다. 형식이 틀리거나 정렬 방식과 맞지 않으면(거리순인데 문자열 등) null.
* null 이면 호출하는 쪽이 VAL_001 로 응답한다.
*/
export function decodePlaceCursor(
raw: string,
kind: 'number' | 'string',
): PlaceCursor | null {
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
} catch {
return null;
}
if (parsed === null || typeof parsed !== 'object') return null;
const { k, id } = parsed as Record<string, unknown>;
if (typeof id !== 'string' || !UUID_RE.test(id)) return null;
if (kind === 'number' && typeof k === 'number' && Number.isFinite(k)) {
return { k, id };
}
if (kind === 'string' && typeof k === 'string') return { k, id };
return null;
}
@@ -105,6 +105,43 @@ export const AI_TIER2_CATEGORIES = [
'cafe',
] as const satisfies readonly PlaceCategory[];
/**
* 사후면세점(관광공사 SH040300) — 2026-11-02 대구 도심 쇼핑 135곳 대부분이 더현대 등에 입점한 브랜드 매장이었다.
* 목록·필터 결과에는 내보내지 않고 이름 검색(q)에만 나온다. 백화점·쇼핑몰 자체는 예외(아래 이름으로 시작).
*/
export const BRAND_STORE_CATEGORY = 'SH040300';
export const MALL_NAME_PREFIXES = [
'현대백화점',
'더현대',
'롯데백화점',
'대구백화점',
'신세계',
'대구신세계',
'동아백화점',
] as const;
/**
* 같은 가게를 관광공사·상가정보가 둘 다 가지고 있을 때(2026-11-02 대구 약 21곳) 상가정보 쪽을 관광공사 쪽에 병합한다.
* 관광공사 쪽이 이 분류일 때만 비교한다 — 박물관·공원 안의 카페("이디야커피 국립대구박물관점")를 박물관과 합치지 않게.
*/
export const MERGE_TARGET_CATEGORIES = [
'cafe',
'food',
'bar',
] as const satisfies readonly PlaceCategory[];
/** 같은 가게로 볼 최대 거리(m) */
export const MERGE_MAX_DISTANCE_M = 100;
/** 정규식 대안 목록 `a|b|c` — 한글·영문·숫자 단어만 허용한다(뷰 SQL 에 들어간다) */
export function sqlAlternation(words: readonly string[]): string {
for (const w of words) {
if (!/^[가-힣A-Za-z0-9]+$/.test(w)) {
throw new Error(`SQL 정규식 단어에 허용되지 않는 문자가 있습니다: ${w}`);
}
}
return words.join('|');
}
/**
* CHECK 제약·뷰에 넣을 SQL 값 목록 — `'a', 'b'`.
* 코드 상수로만 만들지만, 혹시라도 따옴표가 섞이면 SQL 이 깨지므로 즉시 실패시킨다.
@@ -0,0 +1,46 @@
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { PlaceDetailDto } from './dto/place-detail.dto';
import { PlaceListResponseDto } from './dto/place-list-item.dto';
import { PlaceSearchQueryDto } from './dto/place-search-query.dto';
import { PlaceService } from './place.service';
/**
* 장소 조회 — 로그인 없이 쓴다(둘러보기·검색은 비로그인 허용, 2026-10-28 합의).
* 라우팅과 입출력만 담당하고 조회 규칙은 PlaceService·place_listing 뷰가 정한다.
*/
@ApiTags('Place')
@Controller('places')
export class PlaceController {
constructor(private readonly placeService: PlaceService) {}
@Get()
@ApiOperation({
summary: '장소 검색·근처 목록 (커서 페이지네이션)',
description:
'lat·lng 를 주면 반경 안을 거리순으로, 없으면 이름순으로 준다. 사후면세점 브랜드 매장은 이름 검색(q)일 때만 나온다.',
})
@ApiStandardResponse(PlaceListResponseDto, { description: '장소 목록' })
@ApiErrorResponse('VAL_001', '검색 조건 오류(좌표·반경·분류·커서 형식)')
search(@Query() query: PlaceSearchQueryDto) {
return this.placeService.search(query);
}
@Get(':id')
@ApiOperation({
summary: '장소 상세',
description:
'같은 가게로 병합된 장소 id 로 들어오면 병합 대상 장소를 준다. 숨김·폐업 추정 장소는 찾을 수 없음으로 응답한다.',
})
@ApiStandardResponse(PlaceDetailDto, { description: '장소 상세' })
@ApiErrorResponse('VAL_001', 'id 형식 오류(UUID 가 아님)')
@ApiErrorResponse('RES_001', '장소를 찾을 수 없음')
findOne(@Param('id', new ParseUUIDPipe()) id: string) {
return this.placeService.findOne(id);
}
}
+6 -2
View File
@@ -7,10 +7,12 @@ import { Place } from './entities/place.entity';
import { PlaceI18n } from './entities/place-i18n.entity';
import { PlaceListing } from './entities/place-listing.entity';
import { PlaceSyncRun } from './entities/place-sync-run.entity';
import { PlaceController } from './place.controller';
import { PlaceService } from './place.service';
/**
* 장소·축제 데이터 모듈 (설계: docs/design/place-db.md).
* 지금은 스키마만 있다 — 동기화 명령·큐레이션 가져오기·조회 API 는 다음 단계에서 이 모듈에 추가한다.
* 조회 API(검색·근처 목록·상세)를 제공한다. 동기화는 CLI 전용 PlaceSyncModule(sync/)이 맡는다.
*/
@Module({
imports: [
@@ -23,6 +25,8 @@ import { PlaceSyncRun } from './entities/place-sync-run.entity';
PlaceSyncRun,
]),
],
exports: [TypeOrmModule],
controllers: [PlaceController],
providers: [PlaceService],
exports: [TypeOrmModule, PlaceService],
})
export class PlaceModule {}
+238
View File
@@ -0,0 +1,238 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import { kstClock } from '@/shared/utils/kst';
import { isOpenAt } from '@/shared/utils/opening-hours';
import { escapeLike } from '@/shared/utils/sql-like';
import { PlaceDetailDto } from './dto/place-detail.dto';
import { PlaceListItemDto } from './dto/place-list-item.dto';
import {
PLACE_RADIUS_DEFAULT_M,
PlaceSearchQueryDto,
} from './dto/place-search-query.dto';
import { PlaceListing } from './entities/place-listing.entity';
import { Place } from './entities/place.entity';
import { publicPetNote } from './pet-note';
import { decodePlaceCursor, encodePlaceCursor } from './place-cursor';
import { boundingBox, sqlDistanceM } from './place.sql';
/** 출처 표기 */
const PROVIDER_LABEL: Record<Place['source'], string> = {
tour: '한국관광공사',
store: '소상공인시장진흥공단 상가(상권)정보',
};
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
/**
* 장소 조회 — 검색·근처 목록과 상세. 노출 규칙·값 우선순위는 place_listing 뷰가 정하고, 여기서는 뷰를 읽기만 한다.
*/
@Injectable()
export class PlaceService {
constructor(
@InjectRepository(PlaceListing)
private readonly listings: Repository<PlaceListing>,
@InjectRepository(Place) private readonly places: Repository<Place>,
) {}
/**
* 검색·근처 목록. 좌표가 있으면 반경 안을 거리순으로, 없으면 이름순으로 준다(키셋 커서).
* 사후면세점 브랜드 매장은 이름 검색(q)일 때만 나온다.
*/
async search(
query: PlaceSearchQueryDto,
now = new Date(),
): Promise<PaginatedResult<PlaceListItemDto>> {
const near =
query.lat !== undefined && query.lng !== undefined
? {
lat: query.lat,
lng: query.lng,
radius: query.radius ?? PLACE_RADIUS_DEFAULT_M,
}
: null;
const cursor = query.cursor
? decodePlaceCursor(query.cursor, near ? 'number' : 'string')
: null;
if (query.cursor && !cursor) {
throw new BusinessException('VAL_001', {
context: { reason: '커서 형식 오류' },
});
}
const qb = this.listings
.createQueryBuilder('v')
.where('v.is_searchable = true');
if (query.q) {
qb.andWhere('v.name ILIKE :q', { q: `%${escapeLike(query.q)}%` });
} else {
qb.andWhere('v.is_brand_store = false');
}
if (query.category?.length) {
qb.andWhere('v.category IN (:...categories)', {
categories: query.category,
});
}
if (query.pet) {
qb.andWhere('v.pet_policy IN (:...pets)', {
pets: ['allowed', 'partial'],
});
}
if (query.indoor) {
qb.andWhere('v.setting IN (:...settings)', {
settings: ['indoor', 'both'],
});
}
// 괄호로 감싸야 TypeORM 이 엔티티 속성 선택으로 해석하지 않는다(그러면 결과 객체가 비어 버린다)
let sortKey = '(v.name)';
if (near) {
sortKey = sqlDistanceM(':lat', ':lng', 'v.lat', 'v.lng');
const box = boundingBox(near.lat, near.lng, near.radius);
qb.andWhere('v.lat BETWEEN :latMin AND :latMax', box)
.andWhere('v.lng BETWEEN :lngMin AND :lngMax', box)
.andWhere(`${sortKey} <= :radius`)
.setParameters({ lat: near.lat, lng: near.lng, radius: near.radius });
}
const total = await qb.clone().getCount();
if (cursor) {
qb.andWhere(`(${sortKey}, v.id) > (:ck, :cid)`, {
ck: cursor.k,
cid: cursor.id,
});
}
const { entities, raw } = await qb
.addSelect(sortKey, 'sort_key')
.orderBy('sort_key', 'ASC')
.addOrderBy('v.id', 'ASC')
.limit(query.limit + 1)
.getRawAndEntities<{ sort_key: number | string }>();
const hasNext = entities.length > query.limit;
const page = entities.slice(0, query.limit);
const clock = kstClock(now);
const distanceAt = (i: number) => (near ? Number(raw[i]?.sort_key) : null);
const items = page.map((v, i) => this.toListItem(v, distanceAt(i), clock));
const last = page.at(-1);
const nextCursor =
hasNext && last
? encodePlaceCursor({
k: near ? (distanceAt(page.length - 1) ?? 0) : last.name,
id: last.id,
})
: null;
return toPaginatedResult(items, { hasNext, nextCursor, total });
}
/**
* 상세. 다른 장소로 병합된 id(같은 가게의 상가정보 쪽)로 들어오면 병합 대상 장소를 보여 준다.
* 없거나 숨김·폐업 추정이면 RES_001.
*/
async findOne(id: string, now = new Date()): Promise<PlaceDetailDto> {
const first = await this.places.findOne({
where: { id },
relations: { curation: true },
});
const place =
first?.mergedIntoId != null
? await this.places.findOne({
where: { id: first.mergedIntoId },
relations: { curation: true },
})
: first;
const listing = place
? await this.listings.findOneBy({ id: place.id })
: null;
if (!place || !listing?.isSearchable) {
throw new BusinessException('RES_001', { context: { placeId: id } });
}
const curation = place.curation ?? null;
const curatedHours = listing.hoursOrigin === 'curation';
const hoursStatus = curatedHours
? curation?.hours?.status
: place.publicHours?.status;
return {
...this.toListItem(listing, null, kstClock(now)),
addressJibun: place.addressJibun,
phone: place.phone,
homepage: place.homepage,
overview: place.overview,
imageUrl: place.imageUrl,
hours: {
text: curatedHours
? (curation?.hoursText ?? null)
: place.publicHoursText,
restText: curatedHours
? (curation?.restText ?? null)
: place.publicRestText,
status: hoursStatus ?? 'unknown',
},
pet: {
sizes: listing.petOrigin === 'curation' ? listing.petSizes : null,
conditions:
listing.petOrigin === 'curation'
? (curation?.petConditions ?? null)
: listing.petOrigin === 'public'
? publicPetNote(place.publicPet)
: null,
},
extra: place.publicExtra,
curation: listing.isHomePick
? {
reason: curation?.reasonKo ?? null,
officialUrl: curation?.officialUrl ?? null,
}
: null,
attribution: {
provider: PROVIDER_LABEL[place.source],
asOf:
place.source === 'tour'
? place.sourceModifiedAt
? new Date(place.sourceModifiedAt.getTime() + KST_OFFSET_MS)
.toISOString()
.slice(0, 10)
: null
: place.sourceSnapshot,
},
};
}
private toListItem(
v: PlaceListing,
distance: number | null,
clock: { weekday: number; minutes: number },
): PlaceListItemDto {
return {
id: v.id,
name: v.name,
branchName: v.branchName,
category: v.category,
source: v.source,
address: v.addressRoad,
lat: v.lat,
lng: v.lng,
distanceM:
distance === null || !Number.isFinite(distance)
? null
: Math.round(distance),
imageThumbUrl: v.imageThumbUrl,
imageLicense: v.imageLicense,
hoursOrigin: v.hoursOrigin,
openNow: v.hours ? isOpenAt(v.hours, clock.weekday, clock.minutes) : null,
petPolicy: v.petPolicy,
petOrigin: v.petOrigin,
setting: v.setting,
verifiedAt: v.verifiedAt,
};
}
}
@@ -0,0 +1,13 @@
import { boundingBox } from './place.sql';
describe('boundingBox', () => {
it('반경을 담는 위도·경도 범위를 만든다(대구 위도에서 경도 폭이 더 넓다)', () => {
const box = boundingBox(35.865, 128.5966, 1500);
const dLat = box.latMax - box.latMin;
const dLng = box.lngMax - box.lngMin;
expect(dLat).toBeCloseTo((2 * 1500) / 111_320, 6);
expect(dLng).toBeGreaterThan(dLat);
expect(box.latMin).toBeLessThan(35.865);
expect(box.lngMax).toBeGreaterThan(128.5966);
});
});
+41
View File
@@ -0,0 +1,41 @@
/**
* 장소 조회에 쓰는 SQL 조각. 인자는 SQL 식(컬럼 이름·바인딩 파라미터)이며 사용자 입력을 직접 넣지 않는다.
*/
/** 위도 1도의 거리(m) */
export const METERS_PER_LAT_DEGREE = 111_320;
/** 두 좌표 사이 거리(m) — 하버사인. 부동소수점 오차로 asin 범위를 넘지 않게 1 로 자른다 */
export function sqlDistanceM(
lat1: string,
lng1: string,
lat2: string,
lng2: string,
): string {
return (
`(2 * 6371000 * asin(LEAST(1, sqrt(` +
`power(sin(radians(${lat2} - ${lat1}) / 2), 2) + ` +
`cos(radians(${lat1})) * cos(radians(${lat2})) * power(sin(radians(${lng2} - ${lng1}) / 2), 2)` +
`))))`
);
}
/**
* 반경 검색 1차 거르기용 사각형(위도·경도 범위). 인덱스(lat, lng)를 타게 한 뒤 정확한 거리로 다시 거른다.
*/
export function boundingBox(
lat: number,
lng: number,
radiusM: number,
): { latMin: number; latMax: number; lngMin: number; lngMax: number } {
const dLat = radiusM / METERS_PER_LAT_DEGREE;
const dLng =
radiusM /
(METERS_PER_LAT_DEGREE * Math.max(Math.cos((lat * Math.PI) / 180), 0.01));
return {
latMin: lat - dLat,
latMax: lat + dLat,
lngMin: lng - dLng,
lngMax: lng + dLng,
};
}
@@ -0,0 +1,38 @@
import { isSameShopName, normalizeShopName } from './shop-name';
// 2026-11-02 대구 적재 데이터에서 관광공사·상가정보가 겹친 실제 사례
describe('isSameShopName', () => {
it('띄어쓰기·괄호·영문 병기가 달라도 같은 가게로 본다', () => {
expect(isSameShopName('커피명가 라핀카', '커피명가라핀카')).toBe(true);
expect(isSameShopName('아눅 앞산', '아눅앞산')).toBe(true);
expect(
isSameShopName(
'낫온리커피어반델리(NOT ONLY COFFEE URBAN DELI)',
'낫온리커피어반델리',
),
).toBe(true);
});
it('한쪽이 다른 쪽을 포함하면(두 글자 이상) 같은 가게로 본다', () => {
expect(isSameShopName('카페 접속', '접속')).toBe(true);
expect(isSameShopName('롤러커피', '롤러커피롤러커피플러스')).toBe(true);
expect(isSameShopName('원조아삼겹 본점', '원조아삼겹')).toBe(true);
});
it('한 글자 상호는 완전히 같을 때만 같은 가게다', () => {
expect(isSameShopName('류', '류')).toBe(true);
expect(isSameShopName('올리브영 대구동성로광장점', '동')).toBe(false);
expect(isSameShopName('정호승 문학관', '문')).toBe(false);
});
it('이름이 전혀 다르면 다른 가게다', () => {
expect(isSameShopName('김진수로스터리카페', '오마모리')).toBe(false);
expect(isSameShopName('', '오마모리')).toBe(false);
});
});
describe('normalizeShopName', () => {
it('소문자 한글·영문·숫자만 남긴다', () => {
expect(normalizeShopName('Cafe 1979 (교동)')).toBe('cafe1979교동');
});
});
+26
View File
@@ -0,0 +1,26 @@
/**
* 상호 비교 (순수 함수) — 같은 가게를 관광공사·상가정보가 둘 다 가지고 있는지 판단할 때 쓴다.
*/
/** 비교용 정규화: 소문자로, 한글·영문·숫자만 남긴다(공백·괄호·기호 제거) */
export function normalizeShopName(name: string): string {
return name.toLowerCase().replace(/[^0-9a-z가-힣]/g, '');
}
/** 한쪽이 다른 쪽을 포함할 때, 짧은 쪽이 이 글자 수 이상이어야 같은 가게로 본다(한 글자 상호 '동'·'문' 오탐 방지) */
const MIN_CONTAINED_LENGTH = 2;
/**
* 두 상호가 같은 가게로 보이는지.
* - 정규화한 이름이 같으면 같다 ('커피명가 라핀카' = '커피명가라핀카', '류' = '류')
* - 한쪽이 다른 쪽을 포함하고 짧은 쪽이 2글자 이상이면 같다 ('카페 접속' ⊃ '접속', '롤러커피롤러커피플러스' ⊃ '롤러커피')
* 거리·분류 조건은 호출하는 쪽에서 따로 건다(이름만으로는 '이디야커피 국립대구박물관점' ⊃ '국립대구박물관'도 같다고 나온다).
*/
export function isSameShopName(a: string, b: string): boolean {
const x = normalizeShopName(a);
const y = normalizeShopName(b);
if (x === '' || y === '') return false;
if (x === y) return true;
const [short, long] = x.length <= y.length ? [x, y] : [y, x];
return short.length >= MIN_CONTAINED_LENGTH && long.includes(short);
}
@@ -0,0 +1,89 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Place } from '../entities/place.entity';
import {
MERGE_MAX_DISTANCE_M,
MERGE_TARGET_CATEGORIES,
sqlValues,
} from '../place.constants';
import { METERS_PER_LAT_DEGREE, sqlDistanceM } from '../place.sql';
import { isSameShopName } from '../shop-name';
/** 1차 거르기 사각형 — 한국 최북단 위도(약 39.5°)에서도 반경을 담도록 경도 폭을 넉넉히 잡는다 */
const BOX_LAT = MERGE_MAX_DISTANCE_M / METERS_PER_LAT_DEGREE;
const BOX_LNG =
MERGE_MAX_DISTANCE_M /
(METERS_PER_LAT_DEGREE * Math.cos((39.5 * Math.PI) / 180));
interface CandidatePair {
store_id: string;
store_name: string;
tour_id: string;
tour_name: string;
distance_m: number;
}
export interface DedupeResult {
candidates: number;
merged: number;
}
/**
* 같은 가게를 관광공사·상가정보가 둘 다 가진 경우, 상가정보 쪽을 관광공사 쪽에 병합한다(merged_into_id).
* 관광공사 쪽에 영업시간·사진·소개가 있으므로 그쪽을 남긴다.
* 규칙: 관광공사 쪽이 카페·식당·주점이고, 100m 안이며, 상호가 같거나 한쪽이 다른 쪽을 포함(2글자 이상).
* 매번 전부 다시 계산한다(관광공사 장소가 숨겨지거나 이름이 바뀌면 병합도 풀린다).
*/
@Injectable()
export class PlaceDedupe {
private readonly logger = new Logger(PlaceDedupe.name);
constructor(
@InjectRepository(Place) private readonly places: Repository<Place>,
) {}
async run(): Promise<DedupeResult> {
const dist = sqlDistanceM('t.lat', 't.lng', 's.lat', 's.lng');
const pairs = await this.places.manager.query<CandidatePair[]>(
`SELECT s.id AS store_id, s.name AS store_name, t.id AS tour_id, t.name AS tour_name, ${dist} AS distance_m
FROM place s
JOIN place t
ON t.source = 'tour' AND t.status = 'active'
AND t.category IN (${sqlValues(MERGE_TARGET_CATEGORIES)})
AND abs(s.lat - t.lat) < $1 AND abs(s.lng - t.lng) < $2
WHERE s.source = 'store' AND s.status = 'active' AND ${dist} <= $3`,
[BOX_LAT, BOX_LNG, MERGE_MAX_DISTANCE_M],
);
// 상가정보 업소마다 상호가 같은 가장 가까운 관광공사 장소 하나
const best = new Map<string, { tourId: string; distance: number }>();
for (const p of pairs) {
if (!isSameShopName(p.store_name, p.tour_name)) continue;
const prev = best.get(p.store_id);
const distance = Number(p.distance_m);
if (!prev || distance < prev.distance) {
best.set(p.store_id, { tourId: p.tour_id, distance });
}
}
await this.places.manager.transaction(async (tx) => {
// 원본 updated_at 을 건드리지 않도록 직접 UPDATE 한다
await tx.query(
`UPDATE place SET merged_into_id = NULL WHERE source = 'store' AND merged_into_id IS NOT NULL`,
);
for (const [storeId, { tourId }] of best) {
await tx.query(`UPDATE place SET merged_into_id = $1 WHERE id = $2`, [
tourId,
storeId,
]);
}
});
this.logger.log(
`중복 병합: 후보 ${pairs.length}쌍 중 같은 가게 ${best.size}곳을 관광공사 장소에 병합`,
);
return { candidates: pairs.length, merged: best.size };
}
}
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { Place } from '../entities/place.entity';
import { PlaceSyncRun } from '../entities/place-sync-run.entity';
import { PlaceDedupe } from './place-dedupe';
import { PlaceSyncStatus } from './place-sync-status';
import { SyncRunRecorder } from './sync-run.recorder';
import { PublicDataApiFactory } from './public-data.factory';
@@ -22,8 +23,9 @@ import { TourPetSync } from './tour-pet.sync';
TourKorSync,
TourPetSync,
StoreSync,
PlaceDedupe,
PlaceSyncStatus,
],
exports: [TourKorSync, TourPetSync, StoreSync, PlaceSyncStatus],
exports: [TourKorSync, TourPetSync, StoreSync, PlaceDedupe, PlaceSyncStatus],
})
export class PlaceSyncModule {}
+7 -1
View File
@@ -4,6 +4,7 @@ import { Repository } from 'typeorm';
import { Place } from '../entities/place.entity';
import { BudgetExceededError, CallBudget } from './call-budget';
import { PlaceDedupe } from './place-dedupe';
import { PublicDataApiFactory } from './public-data.factory';
import type { StoreItem } from './store-api.client';
import {
@@ -56,6 +57,8 @@ export interface StoreSyncResult {
inserted: number;
updated: number;
gone: number;
/** 관광공사 장소에 병합된(같은 가게로 본) 업소 수 */
merged: number;
skipped: Partial<Record<StoreSkipReason, number>>;
snapshot: string | null;
groups: StoreGroupResult[];
@@ -76,6 +79,7 @@ export class StoreSync {
@InjectRepository(Place) private readonly places: Repository<Place>,
private readonly recorder: SyncRunRecorder,
private readonly apiFactory: PublicDataApiFactory,
private readonly dedupe: PlaceDedupe,
) {}
async run(options: { signguCodes: string[] }): Promise<StoreSyncResult> {
@@ -93,6 +97,7 @@ export class StoreSync {
inserted: 0,
updated: 0,
gone: 0,
merged: 0,
skipped: {},
snapshot: null,
groups: [],
@@ -185,6 +190,7 @@ export class StoreSync {
}
await this.upsert([...rows.values()]);
await this.markGone(existing, result, rowsPerGroup, runStart);
result.merged = (await this.dedupe.run()).merged;
} catch (error) {
result.errors.unshift(
error instanceof Error ? error.message : String(error),
@@ -269,7 +275,7 @@ export class StoreSync {
.map(([k, v]) => `${k} ${v}`)
.join(', ');
this.logger.log(
`상가정보(기준 ${r.snapshot ?? '?'}) ${r.fetched}건 → 새 업소 ${r.inserted} · 갱신 ${r.updated} · 폐업 추정 ${r.gone}` +
`상가정보(기준 ${r.snapshot ?? '?'}) ${r.fetched}건 → 새 업소 ${r.inserted} · 갱신 ${r.updated} · 폐업 추정 ${r.gone} · 중복 병합 ${r.merged}` +
(skipped ? ` · 건너뜀(${skipped})` : ''),
);
const incomplete = r.groups.filter((g) => !g.complete);
@@ -5,6 +5,7 @@ import { IsNull, Repository } from 'typeorm';
import { Place } from '../entities/place.entity';
import type { PlaceCategory } from '../place.constants';
import { BudgetExceededError, CallBudget } from './call-budget';
import { PlaceDedupe } from './place-dedupe';
import { SyncRunRecorder } from './sync-run.recorder';
import type { TourApi, TourItem } from './tour-api.client';
import { PublicDataApiFactory } from './public-data.factory';
@@ -79,6 +80,8 @@ export interface TourKorResult {
detailed: number;
detailFailed: number;
detailRemaining: number;
/** 이 장소들에 병합된 상가정보 업소 수 */
merged: number;
stoppedByBudget: boolean;
calls: Record<string, number>;
errors: string[];
@@ -98,6 +101,7 @@ export class TourKorSync {
@InjectRepository(Place) private readonly places: Repository<Place>,
private readonly recorder: SyncRunRecorder,
private readonly apiFactory: PublicDataApiFactory,
private readonly dedupe: PlaceDedupe,
) {}
async run(options: TourKorOptions): Promise<TourKorResult> {
@@ -118,6 +122,7 @@ export class TourKorSync {
detailed: 0,
detailFailed: 0,
detailRemaining: 0,
merged: 0,
stoppedByBudget: false,
calls: {},
errors: [],
@@ -128,6 +133,7 @@ export class TourKorSync {
if (!options.skipDetails && !result.stoppedByBudget) {
await this.syncDetails(api, budget, regionCode, result);
}
result.merged = (await this.dedupe.run()).merged;
result.detailRemaining = await this.places.countBy({
source: 'tour',
ldongRegnCd: regionCode,