diff --git a/backend/src/commands/place-sync.ts b/backend/src/commands/place-sync.ts index 4e13d21..e01c1ab 100644 --- a/backend/src/commands/place-sync.ts +++ b/backend/src/commands/place-sync.ts @@ -4,6 +4,7 @@ import { NestFactory } from '@nestjs/core'; import { WinstonModule } from 'nest-winston'; import { PlaceDedupe } from '@/modules/place/sync/place-dedupe'; +import { PlaceHoursReparse } from '@/modules/place/sync/place-hours-reparse'; import { PlaceSyncStatus } from '@/modules/place/sync/place-sync-status'; import { DEFAULT_STORE_SIGNGU, @@ -32,10 +33,18 @@ const USAGE = `사용법: place:sync <작업> [옵션] store 상가정보 데이트 업종(카페·빵·디저트·요리주점·생맥주) 적재. 분기마다 다시 실행하면 사라진 업소를 폐업 추정으로 숨긴다 --signgu=27110,27200,27260 시군구 코드 5자리 (기본: 대구 중구·남구·수성구) dedupe 관광공사·상가정보에 함께 있는 같은 가게를 병합(키 불필요, tour-kor·store 가 끝날 때도 자동 실행) + reparse-hours 저장된 영업시간 원문을 지금의 파서로 다시 해석(키 불필요, 파서를 고친 뒤 한 번) status 지역별 적재 현황과 오늘 쓴 호출 수 --region=27`; -const JOBS = ['tour-kor', 'tour-pet', 'store', 'dedupe', 'status'] as const; +const JOBS = [ + 'tour-kor', + 'tour-pet', + 'store', + 'dedupe', + 'reparse-hours', + 'status', +] as const; type Job = (typeof JOBS)[number]; function parseArgs(argv: string[]): { @@ -88,6 +97,8 @@ async function main(): Promise { await app.get(StoreSync).run({ signguCodes: signgu }); } else if (job === 'dedupe') { await app.get(PlaceDedupe).run(); + } else if (job === 'reparse-hours') { + await app.get(PlaceHoursReparse).run(); } else { await app.get(PlaceSyncStatus).report(region); } diff --git a/backend/src/modules/place/sync/place-hours-reparse.ts b/backend/src/modules/place/sync/place-hours-reparse.ts new file mode 100644 index 0000000..523cb73 --- /dev/null +++ b/backend/src/modules/place/sync/place-hours-reparse.ts @@ -0,0 +1,88 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { parseOpeningHours } from '@/shared/utils/opening-hours'; +import { stableStringify } from '@/shared/utils/stable-json'; +import { PlaceCuration } from '../entities/place-curation.entity'; +import { Place } from '../entities/place.entity'; + +export interface ReparseResult { + places: number; + placesChanged: number; + curations: number; + curationsChanged: number; + byStatus: Record; +} + +/** + * 저장된 영업시간 원문을 지금의 파서로 다시 해석한다(공공데이터 API 를 다시 부르지 않는다). + * 파서를 고친 뒤 한 번 실행한다. 원본 갱신 시각(updated_at)은 건드리지 않는다. + */ +@Injectable() +export class PlaceHoursReparse { + private readonly logger = new Logger(PlaceHoursReparse.name); + + constructor( + @InjectRepository(Place) private readonly places: Repository, + @InjectRepository(PlaceCuration) + private readonly curations: Repository, + ) {} + + async run(): Promise { + const result: ReparseResult = { + places: 0, + placesChanged: 0, + curations: 0, + curationsChanged: 0, + byStatus: {}, + }; + + const places = await this.places + .createQueryBuilder('p') + .select([ + 'p.id', + 'p.publicHoursText', + 'p.publicRestText', + 'p.publicHours', + ]) + .where( + 'p.public_hours_text IS NOT NULL OR p.public_rest_text IS NOT NULL', + ) + .getMany(); + const curations = await this.curations + .createQueryBuilder('c') + .select(['c.placeId', 'c.hoursText', 'c.restText', 'c.hours']) + .where('c.hours_text IS NOT NULL OR c.rest_text IS NOT NULL') + .getMany(); + + await this.places.manager.transaction(async (tx) => { + for (const p of places) { + const next = parseOpeningHours(p.publicHoursText, p.publicRestText); + result.places += 1; + result.byStatus[next.status] = (result.byStatus[next.status] ?? 0) + 1; + if (stableStringify(next) === stableStringify(p.publicHours)) continue; + await tx.query(`UPDATE place SET public_hours = $1 WHERE id = $2`, [ + JSON.stringify(next), + p.id, + ]); + result.placesChanged += 1; + } + for (const c of curations) { + const next = parseOpeningHours(c.hoursText, c.restText); + result.curations += 1; + if (stableStringify(next) === stableStringify(c.hours)) continue; + await tx.query( + `UPDATE place_curation SET hours = $1 WHERE place_id = $2`, + [JSON.stringify(next), c.placeId], + ); + result.curationsChanged += 1; + } + }); + + this.logger.log( + `영업시간 다시 해석: 장소 ${result.places}곳 중 ${result.placesChanged}곳 바뀜 · 큐레이션 ${result.curations}건 중 ${result.curationsChanged}건 바뀜 · 해석 결과 ${JSON.stringify(result.byStatus)}`, + ); + return result; + } +} diff --git a/backend/src/modules/place/sync/place-sync.module.ts b/backend/src/modules/place/sync/place-sync.module.ts index 6e77911..00d6746 100644 --- a/backend/src/modules/place/sync/place-sync.module.ts +++ b/backend/src/modules/place/sync/place-sync.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { PlaceCuration } from '../entities/place-curation.entity'; import { Place } from '../entities/place.entity'; import { PlaceSyncRun } from '../entities/place-sync-run.entity'; import { PlaceDedupe } from './place-dedupe'; +import { PlaceHoursReparse } from './place-hours-reparse'; import { PlaceSyncStatus } from './place-sync-status'; import { SyncRunRecorder } from './sync-run.recorder'; import { PublicDataApiFactory } from './public-data.factory'; @@ -16,7 +18,7 @@ import { TourPetSync } from './tour-pet.sync'; * API 서버(AppModule)에는 넣지 않는다. CLI 명령(src/commands/place-sync.ts)에서만 쓴다. */ @Module({ - imports: [TypeOrmModule.forFeature([Place, PlaceSyncRun])], + imports: [TypeOrmModule.forFeature([Place, PlaceCuration, PlaceSyncRun])], providers: [ PublicDataApiFactory, SyncRunRecorder, @@ -24,8 +26,16 @@ import { TourPetSync } from './tour-pet.sync'; TourPetSync, StoreSync, PlaceDedupe, + PlaceHoursReparse, + PlaceSyncStatus, + ], + exports: [ + TourKorSync, + TourPetSync, + StoreSync, + PlaceDedupe, + PlaceHoursReparse, PlaceSyncStatus, ], - exports: [TourKorSync, TourPetSync, StoreSync, PlaceDedupe, PlaceSyncStatus], }) export class PlaceSyncModule {} diff --git a/backend/src/shared/utils/html-text.spec.ts b/backend/src/shared/utils/html-text.spec.ts index 00e06f8..abfc64a 100644 --- a/backend/src/shared/utils/html-text.spec.ts +++ b/backend/src/shared/utils/html-text.spec.ts @@ -8,6 +8,12 @@ describe('htmlToText', () => { expect(htmlToText('무료 입장')).toBe('무료 입장'); }); + it('닫는 형태로 잘못 쓴 줄바꿈 태그(
)도 줄바꿈으로 바꾼다', () => { + expect(htmlToText('평일 09:00~18:00
주말 09:00~17:00')).toBe( + '평일 09:00~18:00\n주말 09:00~17:00', + ); + }); + it('자주 쓰는 HTML 엔티티를 되돌린다', () => { expect(htmlToText('A & B <안내>')).toBe('A & B <안내>'); }); diff --git a/backend/src/shared/utils/html-text.ts b/backend/src/shared/utils/html-text.ts index 06bc163..2eb80b0 100644 --- a/backend/src/shared/utils/html-text.ts +++ b/backend/src/shared/utils/html-text.ts @@ -15,12 +15,12 @@ const ENTITIES: Record = { /** * HTML 흔적을 지운 일반 텍스트를 돌려준다. - * `
`·`

` 는 줄바꿈으로, 나머지 태그는 지우고, 자주 쓰는 엔티티를 되돌린다. 비면 null. + * `
`·`
`·`

` 는 줄바꿈으로, 나머지 태그는 지우고, 자주 쓰는 엔티티를 되돌린다. 비면 null. */ export function htmlToText(value: unknown): string | null { if (typeof value !== 'string' && typeof value !== 'number') return null; const text = String(value) - .replace(//gi, '\n') + .replace(/<\/?br\s*\/?>/gi, '\n') .replace(/<\/p>/gi, '\n') .replace(/<[^>]+>/g, '') .replace(/&(amp|lt|gt|quot|#39|apos|nbsp);/g, (m) => ENTITIES[m] ?? m) diff --git a/backend/src/shared/utils/opening-hours.spec.ts b/backend/src/shared/utils/opening-hours.spec.ts index 814a2b6..269f3f5 100644 --- a/backend/src/shared/utils/opening-hours.spec.ts +++ b/backend/src/shared/utils/opening-hours.spec.ts @@ -133,6 +133,78 @@ describe('parseOpeningHours', () => { expect(p.holidayClosures).toEqual([]); }); + // 2026-11-03 대구 관광공사 원문 — 구간이 구분 없이 붙어 오거나 줄바꿈으로만 나뉜다 + it('구분 없이 이어 붙은 요일별 구간을 나눠 읽는다(더현대 대구)', () => { + const p = parseOpeningHours( + '월요일~목요일 10:30~20:00금요일~일요일 10:30~20:30', + ); + expect(p.week[MON]).toEqual([{ start: at(10, 30), end: at(20) }]); + expect(p.week[SAT]).toEqual([{ start: at(10, 30), end: at(20, 30) }]); + expect(isOpenAt(p, MON, at(20, 15))).toBe(false); + expect(isOpenAt(p, SAT, at(20, 15))).toBe(true); + }); + + it('"평일 …주말 …" 처럼 붙은 구간도 나눈다', () => { + const p = parseOpeningHours('평일 09:00~18:00주말 09:00~17:00'); + expect(isOpenAt(p, MON, at(17, 30))).toBe(true); + expect(isOpenAt(p, SAT, at(17, 30))).toBe(false); + }); + + it('줄바꿈은 구간 구분으로 본다', () => { + const p = parseOpeningHours( + '월요일~목요일 10:30~20:00\n금요일~일요일 10:30~20:30', + ); + expect(p.week[FRI]).toEqual([{ start: at(10, 30), end: at(20, 30) }]); + expect(p.week[WED]).toEqual([{ start: at(10, 30), end: at(20) }]); + }); + + it('줄을 바꿔 쓴 괄호 안 준비 시간도 준비 시간으로 읽는다', () => { + const p = parseOpeningHours('11:00~21:00\n(준비 시간 15:30~17:00)'); + expect(p.breaks).toEqual([{ start: at(15, 30), end: at(17) }]); + expect(isOpenAt(p, WED, at(16))).toBe(false); + expect(isOpenAt(p, WED, at(12))).toBe(true); + }); + + it('대괄호 요일 머리말은 다음 줄들의 시간에 적용한다(곳 카페)', () => { + const p = parseOpeningHours( + '[평일 / 일요일]\n- 11:00~17:30\n[토요일]\n- 11:00~19:00', + ); + expect(p.week[MON]).toEqual([{ start: at(11), end: at(17, 30) }]); + expect(p.week[SUN]).toEqual([{ start: at(11), end: at(17, 30) }]); + expect(p.week[SAT]).toEqual([{ start: at(11), end: at(19) }]); + }); + + it('괄호 안의 / 로 구간을 자르지 않고, 준비 시간·마지막 주문을 각각 읽는다(갈비만)', () => { + const p = parseOpeningHours( + '주중 11:30 ~ 22:30 (브레이크타임 14:30~16:30 / 라스트오더 21:30)', + ); + expect(p.week[MON]).toEqual([{ start: at(11, 30), end: at(22, 30) }]); + expect(p.breaks).toEqual([{ start: at(14, 30), end: at(16, 30) }]); + expect(p.lastOrder).toBe(at(21, 30)); + }); + + it('주중 줄의 브레이크타임은 주말에 적용하지 않는다(갈비만 전체 원문)', () => { + const p = parseOpeningHours( + '주중 11:30 ~ 22:30 (브레이크타임 14:30~16:30 / 라스트오더 21:40)\n주말 11:30 ~ 22:30 (브레이크타임 없음 / 라스트오더 21:40)', + '연중무휴', + ); + expect(p.breaksWeekdaysOnly).toBe(true); + expect(isOpenAt(p, WED, at(15))).toBe(false); + expect(isOpenAt(p, SAT, at(15))).toBe(true); + expect(p.lastOrder).toBe(at(21, 40)); + }); + + it('달 머리말("[1월]")은 월요일로 읽지 않고, 달마다 다르면 시각 판단을 하지 않는다', () => { + const p = parseOpeningHours('[1월]\n- 10:30~18:30\n[2월]\n- 10:30~19:00'); + expect(p.seasonal).toBe(true); + expect(isOpenAt(p, MON, at(12))).toBeNull(); + }); + + it('하이픈으로 쓴 시간 범위는 나누지 않는다', () => { + const p = parseOpeningHours('10:30-20:00'); + expect(p.week[MON]).toEqual([{ start: at(10, 30), end: at(20) }]); + }); + it('매월 몇째 주 휴무는 요일을 막지 않고 안내로만 남긴다', () => { const p = parseOpeningHours('10:00~20:00', '매월 둘째·넷째 주 월요일'); expect(p.closedWeekdays).toEqual([]); diff --git a/backend/src/shared/utils/opening-hours.ts b/backend/src/shared/utils/opening-hours.ts index a09a2e7..9c6df15 100644 --- a/backend/src/shared/utils/opening-hours.ts +++ b/backend/src/shared/utils/opening-hours.ts @@ -53,17 +53,35 @@ const BOM = String.fromCharCode(0xfeff); const TIME_RANGE = /(\d{1,2})\s*(?::\s*(\d{2})|시\s*(?:(\d{1,2})\s*분)?)\s*~\s*(익일\s*)?(\d{1,2})\s*(?::\s*(\d{2})|시\s*(?:(\d{1,2})\s*분)?)/g; const SINGLE_TIME = /(\d{1,2})\s*(?::\s*(\d{2})|시\s*(?:(\d{1,2})\s*분)?)/; +/** + * 구분 없이 이어 붙은 구간 — 시각 바로 뒤(공백 없이)에 요일·평일·주말·'['가 오는 곳. + * 2026-11-03 대구 관광공사 원문 33곳: "월요일~목요일 10:30~20:00금요일~일요일 10:30~20:30", "평일 09:00~18:00주말 09:00~17:00" + */ +const SEGMENT_AFTER_TIME = + /(\d{1,2}:\d{2})(?=[월화수목금토일]요일|[월화수목금토일]\s*[~,·]\s*[월화수목금토일]|평일|주말|주중|매일|공휴일|\[)/g; -/** 줄바꿈 태그·글머리표·물결표 변형을 하나로 맞춘다 */ -function normalize(text: string): string { +/** 괄호·대괄호 안의 '/' 는 구간 구분이 아니다 — ';' 로 바꿔 둔다("[평일 / 일요일]", "(브레이크 … / 라스트오더 …)") */ +function protectInnerSlashes(text: string): string { return text - .replaceAll(BOM, '') - .replace(//gi, ' / ') - .replace(/[∼~〜]/g, '~') - .replace(/(\d)\s*[-–—]\s*(\d{1,2}\s*[:시])/g, '$1~$2') - .replace(/(^|\/|\n)\s*-\s*/g, '$1') - .replace(/\s+/g, ' ') - .trim(); + .replace(/\(([^()]*)\)/g, (_, s: string) => `(${s.replace(/\//g, ';')})`) + .replace(/\[([^[\]]*)\]/g, (_, s: string) => `[${s.replace(/\//g, ';')}]`); +} + +/** 줄바꿈 태그·줄바꿈·글머리표·물결표 변형을 하나로 맞추고, 구간 사이에 ' / ' 를 넣는다 */ +function normalize(text: string): string { + return ( + protectInnerSlashes(text) + .replaceAll(BOM, '') + .replace(/<\/?br\s*\/?>/gi, ' / ') + // 줄바꿈은 구간 구분이다(동기화가
을 줄바꿈으로 바꿔 저장한다). 공백을 합치기 전에 바꾼다 + .replace(/\s*\n+\s*/g, ' / ') + .replace(/[∼~〜]/g, '~') + .replace(/(\d)\s*[-–—]\s*(\d{1,2}\s*[:시])/g, '$1~$2') + .replace(SEGMENT_AFTER_TIME, '$1 / ') + .replace(/(^|\/)\s*-\s*/g, '$1') + .replace(/\s+/g, ' ') + .trim() + ); } function toMinutes(hour: string, minute?: string, minuteAlt?: string): number { @@ -105,7 +123,7 @@ function parseDays(prefix: string): number[] | null { ' ', ); for (const m of withoutRanges.matchAll( - /([일월화수목금토])(?:요일)?(?=[\s·,/)]|$)/g, + /([일월화수목금토])(?:요일)?(?=[\s·,;/)\]]|$)/g, )) { days.add(DAY_CHARS.indexOf(m[1])); } @@ -173,22 +191,36 @@ export function parseOpeningHours( ); } else { result = emptyResult('parsed'); - result.seasonal = /하절기|동절기|여름|겨울|성수기|비수기/.test(text); + // 계절·달마다 시간이 다르면("[1월] … [2월] …") 오늘이 어느 쪽인지 몰라 시각 판단을 하지 않는다 + result.seasonal = + /하절기|동절기|여름|겨울|성수기|비수기|\[\s*\d{1,2}\s*월\s*\]/.test(text); const general: TimeRange[] = []; const specific: { days: number[]; ranges: TimeRange[] }[] = []; + // "[토요일]" 처럼 대괄호 요일 머리말만 있는 줄 — 다음 머리말이 나올 때까지 이어지는 시간에 적용한다 + let headerDays: number[] | null = null; for (const raw of text.split(/\s*\/\s*|\s*※\s*/)) { const segment = raw.trim(); if (!segment) continue; - // 괄호 안의 준비시간·마지막 주문은 따로 읽고 본문에서 뺀다 - const inner = [...segment.matchAll(/\(([^)]*)\)/g)].map((m) => m[1]); + const header = /^\[([^\]]*)\]$/.exec(segment); + if (header && !/\d{1,2}\s*[:시]/.test(header[1])) { + // 달 머리말("[1월]")의 '월'을 월요일로 읽지 않게 숫자+월/일을 지운다 + headerDays = parseDays(header[1].replace(/\d{1,2}\s*[월일]/g, ' ')); + continue; + } + // 괄호 안의 준비시간·마지막 주문은 따로 읽고 본문에서 뺀다(괄호 안 '/' 는 ';' 로 바뀌어 있다) + const inner = [...segment.matchAll(/\(([^)]*)\)/g)].flatMap((m) => + m[1].split(/\s*;\s*/), + ); const body = segment.replace(/\([^)]*\)/g, ' '); for (const part of [body, ...inner]) { if (/준비\s*시간|브레이크|휴식\s*시간|break/i.test(part)) { result.breaks.push(...parseRanges(part)); - // "준비시간(평일) 15:30~16:30" 처럼 '평일'이 괄호로 붙은 경우도 평일 전용으로 본다 + // "준비시간(평일) 15:30~16:30" 처럼 '평일'이 괄호로 붙은 경우, + // "주중 11:30~22:30 (브레이크타임 …)" 처럼 평일·주중 줄의 괄호 안에 있는 경우도 평일 전용으로 본다 const weekdaysOnly = - /평일/.test(part) || + /평일|주중/.test(part) || + /^(평일|주중)/.test(segment) || (part === body && /\(\s*평일\s*\)/.test(segment)); if (weekdaysOnly) result.breaksWeekdaysOnly = true; continue; @@ -209,7 +241,7 @@ export function parseOpeningHours( continue; } const prefix = part.slice(0, part.search(/\d/)); - const days = parseDays(prefix); + const days = parseDays(prefix) ?? headerDays; if (days) specific.push({ days, ranges }); else general.push(...ranges); } diff --git a/backend/src/shared/utils/stable-json.spec.ts b/backend/src/shared/utils/stable-json.spec.ts new file mode 100644 index 0000000..a173716 --- /dev/null +++ b/backend/src/shared/utils/stable-json.spec.ts @@ -0,0 +1,14 @@ +import { stableStringify } from './stable-json'; + +describe('stableStringify', () => { + it('키 순서가 달라도 같은 문자열을 만든다(중첩·배열 포함)', () => { + const a = { status: 'parsed', week: [{ start: 1, end: 2 }], notes: [] }; + const b = { notes: [], week: [{ end: 2, start: 1 }], status: 'parsed' }; + expect(stableStringify(a)).toBe(stableStringify(b)); + }); + + it('값이 다르면 다른 문자열이다', () => { + expect(stableStringify({ a: 1 })).not.toBe(stableStringify({ a: 2 })); + expect(stableStringify(null)).toBe('null'); + }); +}); diff --git a/backend/src/shared/utils/stable-json.ts b/backend/src/shared/utils/stable-json.ts new file mode 100644 index 0000000..e7c4bfb --- /dev/null +++ b/backend/src/shared/utils/stable-json.ts @@ -0,0 +1,20 @@ +/** + * 키 순서와 무관한 JSON 문자열 (순수 함수). + * PostgreSQL jsonb 는 저장할 때 키 순서를 바꾸므로, 값이 같은지 비교할 때 이 함수로 맞춘다. + */ +export function stableStringify(value: unknown): string { + return JSON.stringify(sortKeys(value)); +} + +function sortKeys(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortKeys); + if (value !== null && typeof value === 'object') { + const obj = value as Record; + return Object.fromEntries( + Object.keys(obj) + .sort() + .map((k) => [k, sortKeys(obj[k])]), + ); + } + return value; +} diff --git a/docs/design/place-db.md b/docs/design/place-db.md index ed1ea6f..633d1af 100644 --- a/docs/design/place-db.md +++ b/docs/design/place-db.md @@ -249,6 +249,7 @@ dc exec backend npm run place:sync -- tour-kor # 대구(27) 목록 dc exec backend npm run place:sync -- tour-pet # 반려동물 동반 정보(약 10회) dc exec backend npm run place:sync -- store # 상가정보 데이트 업종(대구 중구·남구·수성구, 30회 안팎). 분기마다 dc exec backend npm run place:sync -- dedupe # 같은 가게 병합(키 불필요). tour-kor·store 가 끝날 때도 자동으로 돈다 +dc exec backend npm run place:sync -- reparse-hours # 저장된 영업시간 원문을 지금의 파서로 다시 해석(키 불필요). 파서를 고친 뒤 한 번 dc exec backend npm run place:sync -- status # 적재 현황·오늘 쓴 호출 수 # 다음 날 같은 tour-kor 명령을 다시 실행하면 남은 상세를 이어서 받는다 @@ -261,6 +262,9 @@ node dist/commands/place-sync.js tour-kor - 상세는 nature → attraction → culture → cafe → food … 순서로 받는다(이틀에 나뉘어도 앱에 먼저 필요한 것부터). - 목록을 끝까지 받았을 때만 사라진 장소를 숨기고, 목록이 기존 노출 장소의 80% 미만이면 원본 장애로 보고 숨기지 않는다. - 원본 수정일(modifiedtime)이 바뀐 장소는 `detail_synced_at` 을 비워 상세를 다시 받는다. +- 영업시간 해석(2026-11-03 대구 관광공사 1,174곳): 시간표 809 · 상시 개방 210 · 점포별 상이 75 · 영업시간 아닌 문장 20 · 원문 없음(숙소) 60. + 원문은 구간이 구분 없이 붙거나("…20:00금요일~일요일 …"), 줄바꿈으로만 나뉘거나, "[토요일]" 머리말 다음 줄에 시간이 오는 등 형식이 제각각이다. + 파서를 고친 뒤 `reparse-hours` 로 124곳을 바로잡았다(API 재호출 없음). - 상가정보는 `storeListInDong`(divId=signguCd, 소분류 업종 필터)로 시군구 × 데이트 업종 6개를 받는다(`--signgu=` 로 범위 변경). 끝까지 받은 (시군구, 업종) 묶음에서만 이번에 보이지 않은 업소를 `gone`(폐업 추정)으로 바꾸고, 결과가 기존의 80% 미만이면 건너뛴다. 업종이 바뀐 업소(예: 카페 → 빵집)는 같은 bizesId 로 갱신되어 그대로 노출된다. 기준년월(stdrYm)은 `source_snapshot` 에 남긴다.