diff --git a/backend/src/modules/place/dto/place-search-query.dto.ts b/backend/src/modules/place/dto/place-search-query.dto.ts index f536532..6254c22 100644 --- a/backend/src/modules/place/dto/place-search-query.dto.ts +++ b/backend/src/modules/place/dto/place-search-query.dto.ts @@ -123,4 +123,14 @@ export class PlaceSearchQueryDto extends CursorPaginationQueryDto { @Transform(toBoolean) @IsBoolean({ message: 'indoor 는 true 또는 false 로 입력해 주세요.' }) indoor?: boolean; + + @ApiPropertyOptional({ + description: + '관광공사 장소와 운영자가 확인한 장소만 — 확인 안 된 상가정보 매장과 운영자가 추천하지 않기로 한 곳을 뺀다(장소 상세의 함께 가기 좋은 곳)', + example: true, + }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean({ message: 'trusted 는 true 또는 false 로 입력해 주세요.' }) + trusted?: boolean; } diff --git a/backend/src/modules/place/festival.service.ts b/backend/src/modules/place/festival.service.ts index 93e6c7b..4d05384 100644 --- a/backend/src/modules/place/festival.service.ts +++ b/backend/src/modules/place/festival.service.ts @@ -7,6 +7,7 @@ import { toPaginatedResult, } from '@/common/dto/pagination.dto'; import { BusinessException } from '@/common/errors/business.exception'; +import { addDaysIso, daysBetweenIso } from '@/shared/utils/iso-date'; import { kstDateString } from '@/shared/utils/kst'; import { EventDetailDto, EventListItemDto } from './dto/event-list-item.dto'; import { @@ -21,7 +22,6 @@ import type { EventSource } from './place.constants'; import { decodePlaceCursor, encodePlaceCursor } from './place-cursor'; import { boundingBox, sqlDistanceM } from './place.sql'; -const DAY_MS = 86_400_000; const KST_OFFSET_MS = 9 * 60 * 60 * 1000; /** 출처 표기(행사 상세 하단·앱 정보 화면) */ @@ -37,23 +37,6 @@ const MAX_SPAN_SQL = `CASE e.kind ${Object.entries(EVENT_MAX_SPAN_DAYS) .map(([kind, days]) => `WHEN '${kind}' THEN ${days}`) .join(' ')} END`; -/** 'YYYY-MM-DD' 두 날짜 사이 일수(b − a). 형식이 틀리거나 없는 날짜면 NaN */ -function daysBetween(a: string, b: string): number { - const ta = Date.parse(`${a}T00:00:00Z`); - const tb = Date.parse(`${b}T00:00:00Z`); - // '2030-13-01' 처럼 해석이 안 되면 NaN — toISOString 이 예외를 던지지 않게 먼저 거른다 - if (Number.isNaN(ta) || Number.isNaN(tb)) return Number.NaN; - const validA = new Date(ta).toISOString().startsWith(a); - const validB = new Date(tb).toISOString().startsWith(b); - return validA && validB ? Math.round((tb - ta) / DAY_MS) : Number.NaN; -} - -function addDays(iso: string, days: number): string { - return new Date(Date.parse(`${iso}T00:00:00Z`) + days * DAY_MS) - .toISOString() - .slice(0, 10); -} - /** * 축제·공연·행사 조회 — 홈 '이번 주말 축제·행사', 장소 상세 '근처 축제·행사'. * 60일 넘는 상시 프로그램과 숨김 행사는 목록에서 뺀다. @@ -71,9 +54,10 @@ export class FestivalService { ): Promise> { const today = kstDateString(now); const from = query.from ?? today; - const to = query.to ?? addDays(from, EVENT_RANGE_DEFAULT_DAYS); - const span = daysBetween(from, to); - if (!(span >= 0 && span <= EVENT_RANGE_MAX_DAYS)) { + // 끝을 생략하면 시작일 + 기본 일수. 시작일이 없는 날짜("2030-13-01")면 null 이 되어 아래에서 VAL_001 로 거른다 + const to = query.to ?? addDaysIso(from, EVENT_RANGE_DEFAULT_DAYS); + const span = to === null ? Number.NaN : daysBetweenIso(from, to); + if (to === null || !(span >= 0 && span <= EVENT_RANGE_MAX_DAYS)) { throw new BusinessException('VAL_001', { context: { reason: '기간 오류', from, to }, }); diff --git a/backend/src/modules/place/place.service.ts b/backend/src/modules/place/place.service.ts index 7adbb43..c2f6712 100644 --- a/backend/src/modules/place/place.service.ts +++ b/backend/src/modules/place/place.service.ts @@ -88,6 +88,15 @@ export class PlaceService { settings: ['indoor', 'both'], }); } + if (query.trusted) { + // 확인 안 된 상가정보 매장이 추천처럼 보이지 않게 뺀다(2026-11-02 합의). 앱이 받아서 거르면 + // 도심처럼 상가가 빽빽한 곳에서는 가까운 몇 곳이 모두 상가라 칸이 비었다(2026-11-04 회귀 점검) + qb.andWhere('(v.source = :trustedSource OR v.verified_at IS NOT NULL)', { + trustedSource: 'tour', + }).andWhere('(v.verdict IS NULL OR v.verdict <> :notRecommend)', { + notRecommend: 'not_recommend', + }); + } // 괄호로 감싸야 TypeORM 이 엔티티 속성 선택으로 해석하지 않는다(그러면 결과 객체가 비어 버린다) let sortKey = '(v.name)'; diff --git a/backend/src/shared/utils/iso-date.spec.ts b/backend/src/shared/utils/iso-date.spec.ts new file mode 100644 index 0000000..284e1fd --- /dev/null +++ b/backend/src/shared/utils/iso-date.spec.ts @@ -0,0 +1,34 @@ +import { addDaysIso, daysBetweenIso } from './iso-date'; + +describe('daysBetweenIso', () => { + it('두 날짜 사이 일수를 센다(달·윤년 경계 포함)', () => { + expect(daysBetweenIso('2026-11-04', '2026-12-04')).toBe(30); + expect(daysBetweenIso('2026-02-28', '2026-03-01')).toBe(1); + expect(daysBetweenIso('2028-02-28', '2028-03-01')).toBe(2); + expect(daysBetweenIso('2026-11-08', '2026-11-07')).toBe(-1); + }); + + it.each(['2026-13-01', '2026-00-10', '2026-02-30', '2026-1-1', ''])( + '없는 날짜 "%s" 가 끼면 NaN', + (bad) => { + expect(daysBetweenIso(bad, '2026-11-04')).toBeNaN(); + expect(daysBetweenIso('2026-11-04', bad)).toBeNaN(); + }, + ); +}); + +describe('addDaysIso', () => { + it('일수를 더한 날짜를 돌려준다', () => { + expect(addDaysIso('2026-11-04', 30)).toBe('2026-12-04'); + expect(addDaysIso('2026-12-31', 1)).toBe('2027-01-01'); + }); + + it('없는 날짜는 예외 없이 null', () => { + expect(addDaysIso('2026-13-01', 30)).toBeNull(); + expect(addDaysIso('2026-02-30', 1)).toBeNull(); + }); + + it('결과가 네 자리 연도를 넘으면 null — DB 가 읽지 못하는 날짜를 만들지 않는다', () => { + expect(addDaysIso('9999-12-31', 30)).toBeNull(); + }); +}); diff --git a/backend/src/shared/utils/iso-date.ts b/backend/src/shared/utils/iso-date.ts new file mode 100644 index 0000000..60fd21a --- /dev/null +++ b/backend/src/shared/utils/iso-date.ts @@ -0,0 +1,32 @@ +/** + * 'YYYY-MM-DD' 날짜 계산 (순수 함수) — 행사 기간 조건에 쓴다. + * 없는 날짜("2030-13-01", "2030-02-30")는 예외를 던지지 않고 null·NaN 으로 알린다. 호출부가 VAL_001 로 응답한다. + */ + +const DAY_MS = 86_400_000; +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; + +/** 실제로 있는 날짜면 그날 0시(UTC) 시각, 아니면 null */ +function utcMidnight(iso: string): number | null { + if (!ISO_DATE.test(iso)) return null; + const time = Date.parse(`${iso}T00:00:00Z`); + // '2030-02-30' 처럼 다른 날로 넘어가거나 해석이 안 되는 날짜를 거른다 + if (Number.isNaN(time)) return null; + return new Date(time).toISOString().slice(0, 10) === iso ? time : null; +} + +/** 두 날짜 사이 일수(b − a). 어느 하나라도 없는 날짜면 NaN */ +export function daysBetweenIso(a: string, b: string): number { + const ta = utcMidnight(a); + const tb = utcMidnight(b); + if (ta === null || tb === null) return Number.NaN; + return Math.round((tb - ta) / DAY_MS); +} + +/** 날짜에 일수를 더한 'YYYY-MM-DD'. 없는 날짜이거나 결과가 네 자리 연도를 벗어나면 null */ +export function addDaysIso(iso: string, days: number): string | null { + const time = utcMidnight(iso); + if (time === null) return null; + const next = new Date(time + days * DAY_MS).toISOString().slice(0, 10); + return ISO_DATE.test(next) ? next : null; +} diff --git a/backend/src/shared/utils/opening-hours.spec.ts b/backend/src/shared/utils/opening-hours.spec.ts index 269f3f5..586daa8 100644 --- a/backend/src/shared/utils/opening-hours.spec.ts +++ b/backend/src/shared/utils/opening-hours.spec.ts @@ -210,4 +210,107 @@ describe('parseOpeningHours', () => { expect(p.closedWeekdays).toEqual([]); expect(p.notes[0]).toContain('매월'); }); + + // 2026-11-04 회귀 점검: 관광공사가 줄바꿈을 빼고 준 원문 — 영업시간이 준비시간으로 들어가 낮에 '영업 종료'로 보였다 + it('줄바꿈 없이 붙은 글머리표를 나눠 영업시간·준비시간·마지막 주문을 각각 읽는다(거송갈비찜)', () => { + const p = parseOpeningHours( + '- 11:00~21:00- 준비시간 16:00~17:00- 마지막 주문 20:20', + '1월 1일', + ); + expect(p.week[WED]).toEqual([{ start: at(11), end: at(21) }]); + expect(p.breaks).toEqual([{ start: at(16), end: at(17) }]); + expect(p.lastOrder).toBe(at(20, 20)); + expect(isOpenAt(p, WED, at(12, 30))).toBe(true); + expect(isOpenAt(p, WED, at(16, 30))).toBe(false); + }); + + it('붙은 글머리표 뒤의 마지막 주문을 여는 시각으로 읽지 않는다(서민갈비)', () => { + const p = parseOpeningHours('- 11:30~23:00- 마지막 주문 22:30'); + expect(p.week[MON]).toEqual([{ start: at(11, 30), end: at(23) }]); + expect(p.lastOrder).toBe(at(22, 30)); + }); + + it('대괄호 요일 머리말 뒤에 붙은 글머리표도 요일별로 읽는다(까꾸리웰빙손칼국수)', () => { + const p = parseOpeningHours( + '[목요일~화요일]- 11:30~20:30- 준비시간 15:00~17:00- 마지막 주문 19:30[수요일]- 11:30~16:00- 마지막 주문 15:30', + ); + expect(p.week[MON]).toEqual([{ start: at(11, 30), end: at(20, 30) }]); + expect(p.week[WED]).toEqual([{ start: at(11, 30), end: at(16) }]); + expect(p.breaks).toEqual([{ start: at(15), end: at(17) }]); + }); + + it('하이픈 뒤에 숫자가 오면 글머리표가 아니라 시간 범위다', () => { + const p = parseOpeningHours('10:30- 20:00'); + expect(p.week[MON]).toEqual([{ start: at(10, 30), end: at(20) }]); + }); + + it('시설 머리말마다 시간을 따로 읽고 요일별로 합친다 — 뒤 시설의 요일 시간이 앞 시설을 지우지 않는다(칠성시장)', () => { + const p = parseOpeningHours( + '[칠성시장]- 05:00~20:00 (단, 점포 별로 상이할 수 있음)[칠성야시장]- 일요일 19:00~22:30- 금요일 / 토요일 19:00~23:30', + ); + expect(p.week[SUN]).toEqual([ + { start: at(5), end: at(20) }, + { start: at(19), end: at(22, 30) }, + ]); + expect(p.week[WED]).toEqual([{ start: at(5), end: at(20) }]); + expect(p.week[FRI]).toEqual([ + { start: at(5), end: at(20) }, + { start: at(19), end: at(23, 30) }, + ]); + expect(isOpenAt(p, SUN, at(12))).toBe(true); + expect(isOpenAt(p, SAT, at(22))).toBe(true); + }); + + it('시설마다 평일·주말 시간이 다르면 어느 시설이든 여는 시간을 영업으로 본다(스파밸리)', () => { + const p = parseOpeningHours( + '[실내]- 09:00~18:00[야외]- 평일 09:00~17:00- 주말 09:30~17:30', + ); + expect(isOpenAt(p, WED, at(17, 45))).toBe(true); + expect(isOpenAt(p, SAT, at(9, 15))).toBe(true); + expect(isOpenAt(p, SAT, at(18, 30))).toBe(false); + }); + + it('요일 사이의 / 는 구간 구분이 아니라 나열이다(영생곱창)', () => { + const p = parseOpeningHours( + '- 수요일~목요일 / 일요일 16:00~24:00 (마지막 주문 23:00)- 금요일~토요일 16:00~01:00 (마지막 주문 24:00)', + ); + expect(p.week[WED]).toEqual([{ start: at(16), end: at(24) }]); + expect(p.week[SUN]).toEqual([{ start: at(16), end: at(24) }]); + expect(p.week[FRI]).toEqual([{ start: at(16), end: at(25) }]); + expect(p.week[MON]).toBeNull(); + }); + + it('휴게시간은 쉬는 시간이다 — 요일 머리말의 영업시간을 덮지 않는다(대덕승마장)', () => { + const p = parseOpeningHours( + '[화요일~금요일] - 09:00~20:00 (휴게시간 12:00~13:00) [토요일] - 09:00~18:00 (휴게시간 12:00~13:00)', + ); + expect(p.week[WED]).toEqual([{ start: at(9), end: at(20) }]); + expect(p.week[SAT]).toEqual([{ start: at(9), end: at(18) }]); + expect(p.week[MON]).toBeNull(); + expect(isOpenAt(p, WED, at(12, 30))).toBe(false); + expect(isOpenAt(p, WED, at(14))).toBe(true); + }); + + it('2시간 이하의 점심시간은 쉬는 시간, 그보다 길면 점심 영업으로 읽는다', () => { + const office = parseOpeningHours('09:00~18:00 (점심시간 13:00~14:00)'); + expect(office.breaks).toEqual([{ start: at(13), end: at(14) }]); + expect(isOpenAt(office, WED, at(13, 30))).toBe(false); + const restaurant = parseOpeningHours( + '점심시간 11:30~14:30 / 저녁시간 17:00~21:00', + ); + expect(restaurant.breaks).toEqual([]); + expect(restaurant.week[WED]).toEqual([ + { start: at(11, 30), end: at(14, 30) }, + { start: at(17), end: at(21) }, + ]); + }); + + it('여는 시각과 닫는 시각이 같은 구간은 24시간으로 읽지 않고 버린다(대구창의융합교육원)', () => { + const p = parseOpeningHours( + '[학생과학관]- 10:00~17:00- 준비시간 13:00~13:00', + ); + expect(p.breaks).toEqual([]); + expect(p.week[WED]).toEqual([{ start: at(10), end: at(17) }]); + expect(isOpenAt(p, WED, at(14))).toBe(true); + }); }); diff --git a/backend/src/shared/utils/opening-hours.ts b/backend/src/shared/utils/opening-hours.ts index 9c6df15..e6c075c 100644 --- a/backend/src/shared/utils/opening-hours.ts +++ b/backend/src/shared/utils/opening-hours.ts @@ -59,12 +59,33 @@ const SINGLE_TIME = /(\d{1,2})\s*(?::\s*(\d{2})|시\s*(?:(\d{1,2})\s*분)?)/; */ const SEGMENT_AFTER_TIME = /(\d{1,2}:\d{2})(?=[월화수목금토일]요일|[월화수목금토일]\s*[~,·]\s*[월화수목금토일]|평일|주말|주중|매일|공휴일|\[)/g; +/** + * 줄바꿈 없이 이어 붙은 글머리표 — 시각·닫는 괄호 바로 뒤의 '- ' 다음에 숫자가 아닌 말이 오는 곳. + * 2026-11-04 대구 관광공사 원문 107곳: "- 11:00~21:00- 준비시간 16:00~17:00- 마지막 주문 20:20". + * 나누지 않으면 영업시간이 준비시간·마지막 주문 구간에 섞여 사라진다(낮에 '영업 종료'로 보였다). + * "11:00- 21:00" 처럼 뒤에 숫자가 오면 시간 범위라 나누지 않는다. + */ +const BULLET_AFTER_TIME = /([\d)\]])\s*-\s+(?=[^\d\s~\-–—])/g; +/** 쉬는 시간(브레이크)을 뜻하는 말 */ +const BREAK_WORDS = /준비\s*시간|브레이크|휴식\s*시간|휴게\s*시간|break/i; +/** + * '점심시간'은 관공서·도서관에서는 쉬는 시간("09:00~18:00 (점심시간 12:00~13:00)")이지만, + * 식당에서는 점심 영업일 수 있다 — 2시간 이하인 구간만 쉬는 시간으로 본다 + */ +const LUNCH_BREAK = /점심\s*시간/; +const LUNCH_BREAK_MAX_MINUTES = 120; +/** 대괄호 머리말 앞 — "…(단, 점포별 상이)[칠성야시장]" 처럼 앞 구간에 붙어 오는 머리말을 떼어 낸다 */ +const BEFORE_BRACKET = /(\S)\s*(?=\[)/g; +/** 요일 사이의 '/' 는 구간 구분이 아니라 나열이다 — "금요일 / 토요일 19:00~23:30" */ +const SLASH_BETWEEN_DAYS = + /([월화수목금토일](?:요일)?)\s*\/\s*(?=[월화수목금토일](?:요일)?(?:[\s,·~]|$))/g; /** 괄호·대괄호 안의 '/' 는 구간 구분이 아니다 — ';' 로 바꿔 둔다("[평일 / 일요일]", "(브레이크 … / 라스트오더 …)") */ function protectInnerSlashes(text: string): string { return text .replace(/\(([^()]*)\)/g, (_, s: string) => `(${s.replace(/\//g, ';')})`) - .replace(/\[([^[\]]*)\]/g, (_, s: string) => `[${s.replace(/\//g, ';')}]`); + .replace(/\[([^[\]]*)\]/g, (_, s: string) => `[${s.replace(/\//g, ';')}]`) + .replace(SLASH_BETWEEN_DAYS, '$1, '); } /** 줄바꿈 태그·줄바꿈·글머리표·물결표 변형을 하나로 맞추고, 구간 사이에 ' / ' 를 넣는다 */ @@ -77,7 +98,9 @@ function normalize(text: string): string { .replace(/\s*\n+\s*/g, ' / ') .replace(/[∼~〜]/g, '~') .replace(/(\d)\s*[-–—]\s*(\d{1,2}\s*[:시])/g, '$1~$2') + .replace(BULLET_AFTER_TIME, '$1 / ') .replace(SEGMENT_AFTER_TIME, '$1 / ') + .replace(BEFORE_BRACKET, '$1 / ') .replace(/(^|\/)\s*-\s*/g, '$1') .replace(/\s+/g, ' ') .trim() @@ -93,6 +116,8 @@ function parseRanges(segment: string): TimeRange[] { for (const m of segment.matchAll(TIME_RANGE)) { const start = toMinutes(m[1], m[2], m[3]); let end = toMinutes(m[5], m[6], m[7]); + // 여는 시각과 닫는 시각이 같으면("준비시간 13:00~13:00") 잘못 적은 값이다 — 24시간으로 읽지 않고 버린다 + if (end === start && !m[4]) continue; // 닫는 시각이 여는 시각보다 이르거나 '익일'이면 다음 날로 넘긴다 if (m[4] || end <= start) end += MINUTES_PER_DAY; ranges.push({ start, end }); @@ -100,6 +125,16 @@ function parseRanges(segment: string): TimeRange[] { return ranges; } +/** '점심시간 12:00~13:00' 처럼 짧은(2시간 이하) 점심시간이면 쉬는 시간이다 */ +function isLunchBreak(part: string): boolean { + if (!LUNCH_BREAK.test(part)) return false; + const ranges = parseRanges(part); + return ( + ranges.length > 0 && + ranges.every((r) => r.end - r.start <= LUNCH_BREAK_MAX_MINUTES) + ); +} + /** 시간 앞에 붙은 요일 표현을 요일 번호 목록으로 바꾼다. 요일 표현이 없으면 null(매일) */ function parseDays(prefix: string): number[] | null { if (/매일|연중/.test(prefix)) return null; @@ -151,6 +186,57 @@ function parseRestDays(text: string, out: ParsedOpeningHours): void { out.closedWeekdays.sort((a, b) => a - b); } +/** 시설·계절 머리말 하나에 딸린 시간들 */ +interface Section { + /** 요일 지정이 없는(매일) 구간 */ + general: TimeRange[]; + /** 요일을 지정한 구간 — 같은 묶음의 매일 구간을 덮는다 */ + specific: { days: number[]; ranges: TimeRange[] }[]; +} + +const emptySection = (): Section => ({ general: [], specific: [] }); + +/** 시설·계절 머리말에서 새 묶음을 연다. 지금 묶음이 비어 있으면 그대로 쓴다 */ +function openSection(sections: Section[]): void { + const last = sections[sections.length - 1]; + if (last.general.length > 0 || last.specific.length > 0) { + sections.push(emptySection()); + } +} + +/** 묶음 하나의 요일별 시간표 — 매일 시간을 먼저 깔고, 요일을 지정한 시간으로 덮는다 */ +function sectionWeek(section: Section): (TimeRange[] | null)[] { + const week: (TimeRange[] | null)[] = Array.from({ length: 7 }, () => + section.general.length > 0 ? [...section.general] : null, + ); + for (const { days, ranges } of section.specific) { + for (const d of days) week[d] = [...ranges]; + } + return week; +} + +/** + * 묶음끼리는 요일별로 합친다 — "[칠성시장] 05:00~20:00 [칠성야시장] 일요일 19:00~22:30" 이면 + * 일요일은 두 구간 모두 연다(어느 시설이든 열려 있으면 영업 중). 뒤 시설의 요일 시간이 앞 시설을 지우지 않게 한다. + */ +function mergeSectionWeeks(sections: Section[]): (TimeRange[] | null)[] { + const weeks = sections.map(sectionWeek); + return Array.from({ length: 7 }, (_, d) => { + const known = weeks + .map((w) => w[d]) + .filter((ranges): ranges is TimeRange[] => ranges !== null); + if (known.length === 0) return null; + if (known.length === 1) return known[0]; + const seen = new Set(); + return known.flat().filter((r) => { + const key = `${r.start}-${r.end}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + }); +} + function emptyResult(status: OpeningHoursStatus): ParsedOpeningHours { return { status, @@ -194,27 +280,32 @@ export function parseOpeningHours( // 계절·달마다 시간이 다르면("[1월] … [2월] …") 오늘이 어느 쪽인지 몰라 시각 판단을 하지 않는다 result.seasonal = /하절기|동절기|여름|겨울|성수기|비수기|\[\s*\d{1,2}\s*월\s*\]/.test(text); - const general: TimeRange[] = []; - const specific: { days: number[]; ranges: TimeRange[] }[] = []; - // "[토요일]" 처럼 대괄호 요일 머리말만 있는 줄 — 다음 머리말이 나올 때까지 이어지는 시간에 적용한다 + // 시설·계절 머리말("[칠성시장]", "[하절기]")마다 묶음을 나눈다. 머리말이 없으면 묶음은 하나다 + const sections: Section[] = [emptySection()]; + // "[토요일]" 처럼 대괄호 요일 머리말 — 다음 머리말이 나올 때까지 이어지는 시간에 적용한다 let headerDays: number[] | null = null; for (const raw of text.split(/\s*\/\s*|\s*※\s*/)) { - const segment = raw.trim(); + let segment = raw.trim(); if (!segment) continue; - const header = /^\[([^\]]*)\]$/.exec(segment); + // 머리말 뒤에 시간이 바로 이어 붙은 줄("[칠성시장]- 05:00~20:00")도 머리말과 나머지로 나눠 읽는다 + const header = /^\[([^\]]*)\]\s*-?\s*/.exec(segment); if (header && !/\d{1,2}\s*[:시]/.test(header[1])) { // 달 머리말("[1월]")의 '월'을 월요일로 읽지 않게 숫자+월/일을 지운다 headerDays = parseDays(header[1].replace(/\d{1,2}\s*[월일]/g, ' ')); - continue; + // 요일이 아닌 머리말(시설·계절)이면 새 묶음을 연다 + if (headerDays === null) openSection(sections); + segment = segment.slice(header[0].length).trim(); + if (!segment) continue; } + const section = sections[sections.length - 1]; // 괄호 안의 준비시간·마지막 주문은 따로 읽고 본문에서 뺀다(괄호 안 '/' 는 ';' 로 바뀌어 있다) 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)) { + if (BREAK_WORDS.test(part) || isLunchBreak(part)) { result.breaks.push(...parseRanges(part)); // "준비시간(평일) 15:30~16:30" 처럼 '평일'이 괄호로 붙은 경우, // "주중 11:30~22:30 (브레이크타임 …)" 처럼 평일·주중 줄의 괄호 안에 있는 경우도 평일 전용으로 본다 @@ -242,15 +333,11 @@ export function parseOpeningHours( } const prefix = part.slice(0, part.search(/\d/)); const days = parseDays(prefix) ?? headerDays; - if (days) specific.push({ days, ranges }); - else general.push(...ranges); + if (days) section.specific.push({ days, ranges }); + else section.general.push(...ranges); } } - // 매일 적용되는 시간을 먼저 깔고, 요일을 지정한 시간으로 덮는다 - if (general.length > 0) result.week = result.week.map(() => [...general]); - for (const { days, ranges } of specific) { - for (const d of days) result.week[d] = [...ranges]; - } + result.week = mergeSectionWeeks(sections); result.notes = result.notes.filter((n) => n.length > 0); } diff --git a/backend/test/festival.e2e-spec.ts b/backend/test/festival.e2e-spec.ts index 1713fa7..78c2569 100644 --- a/backend/test/festival.e2e-spec.ts +++ b/backend/test/festival.e2e-spec.ts @@ -214,6 +214,10 @@ describe('축제·행사 조회 API (e2e)', () => { ['형식이 틀린 날짜', 'from=2030/05/01'], ['없는 날짜', 'from=2030-02-30&to=2030-03-01'], ['해석이 안 되는 날짜(13월)', 'from=2030-13-01&to=2030-12-31'], + // 2026-11-04 회귀 점검: 끝을 생략하면 시작일로 끝 날짜를 계산하다 500(SYS_001)이 났다 + ['끝을 생략한 없는 시작일(13월)', 'from=2030-13-01'], + ['끝을 생략한 없는 시작일(0월)', 'from=2030-00-10'], + ['끝을 생략해 끝 날짜가 네 자리 연도를 넘는 경우', 'from=9999-12-31'], ['없는 분류', `${may}&kind=parade`], ['lat 만 보낸 경우', `${may}&lat=36.6`], ])('%s 는 VAL_001(400)', async (_, query) => { diff --git a/backend/test/place.e2e-spec.ts b/backend/test/place.e2e-spec.ts index 882eff3..3debd6c 100644 --- a/backend/test/place.e2e-spec.ts +++ b/backend/test/place.e2e-spec.ts @@ -22,6 +22,10 @@ import { parseOpeningHours } from '@/shared/utils/opening-hours'; const C = { lat: 36.5, lng: 130.5 }; const at = (dLat: number) => ({ lat: C.lat + dLat, lng: C.lng }); const near = `lat=${C.lat}&lng=${C.lng}&radius=2000`; +/** 'trusted'(관광공사·확인 장소만) 검증용 묶음 — 위 묶음의 거리순 기대값에 섞이지 않게 5.5km 떨어뜨린다 */ +const C2 = { lat: 36.55, lng: 130.5 }; +const at2 = (dLat: number) => ({ lat: C2.lat + dLat, lng: C2.lng }); +const near2 = `lat=${C2.lat}&lng=${C2.lng}&radius=1000`; interface ListItem { id: string; @@ -186,6 +190,58 @@ describe('장소 조회 API (e2e)', () => { verifiedMethod: 'phone', verifiedAt: '2026-11-02', }); + + // trusted 묶음: 관광공사 1 · 확인 안 된 상가 1 · 확인한 상가 1 · 추천 안 함으로 판정한 관광공사·상가 각 1 + await save('R1', { + source: 'tour', + sourceId: 'e2e-R1', + category: 'attraction', + name: '이투이 바닷가 전망대', + ...at2(0.001), + }); + await save('R2', { + source: 'store', + sourceId: 'e2e-R2', + category: 'cafe', + name: '이투이 확인안한카페', + ...at2(0.0005), // 가장 가깝다 — trusted 가 아니면 첫 자리를 차지한다 + }); + await save('R3', { + source: 'store', + sourceId: 'e2e-R3', + category: 'cafe', + name: '이투이 확인한카페', + ...at2(0.002), + }); + await save('R4', { + source: 'tour', + sourceId: 'e2e-R4', + category: 'cafe', + name: '이투이 추천안함카페', + ...at2(0.0025), + }); + await save('R5', { + source: 'store', + sourceId: 'e2e-R5', + category: 'bar', + name: '이투이 추천안함바', + ...at2(0.003), + }); + const curations = ds.getRepository(PlaceCuration); + await curations.save({ + placeId: ids.R3, + verdict: 'pending', + verifiedMethod: 'official_channel', + verifiedAt: '2026-11-04', + }); + for (const key of ['R4', 'R5']) { + await curations.save({ + placeId: ids[key], + verdict: 'not_recommend', + verifiedMethod: 'visit', + verifiedAt: '2026-11-04', + }); + } }); afterAll(async () => { @@ -248,6 +304,21 @@ describe('장소 조회 API (e2e)', () => { expect(names(res)).toEqual(['이투이카페', '이투이 골목카페']); }); + it('trusted 는 관광공사·운영자 확인 장소만 주고, 추천 안 함으로 판정한 곳은 뺀다', async () => { + const all = await get(`/api/places?${near2}`).expect(200); + expect(list(all).data.pageInfo.total).toBe(5); + const res = await get(`/api/places?${near2}&trusted=true`).expect(200); + expect(names(res)).toEqual(['이투이 바닷가 전망대', '이투이 확인한카페']); + expect(list(res).data.pageInfo.total).toBe(2); + // 가장 가까운 곳이 확인 안 된 상가여도 limit 안에 관광공사·확인 장소가 채워진다 + const plain = await get(`/api/places?${near2}&limit=1`).expect(200); + expect(names(plain)).toEqual(['이투이 확인안한카페']); + const one = await get(`/api/places?${near2}&trusted=true&limit=1`).expect( + 200, + ); + expect(names(one)).toEqual(['이투이 바닷가 전망대']); + }); + it('좌표 없이 이름 검색하면 이름순으로 반경 제한 없이 준다', async () => { const res = await get( `/api/places?q=${encodeURIComponent('이투이')}`, @@ -266,6 +337,7 @@ describe('장소 조회 API (e2e)', () => { ['없는 분류', 'category=pub'], ['형식이 틀린 커서', `${near}&cursor=abc`], ['true/false 가 아닌 필터', 'pet=yes'], + ['true/false 가 아닌 trusted', 'trusted=1'], ])('%s 는 VAL_001(400)', async (_, query) => { const res = await get(`/api/places?${query}`).expect(400); expect(detail(res).error?.code).toBe('VAL_001'); diff --git a/docs/design/place-db.md b/docs/design/place-db.md index 568379d..312e330 100644 --- a/docs/design/place-db.md +++ b/docs/design/place-db.md @@ -273,6 +273,14 @@ node dist/commands/place-sync.js tour-kor - 영업시간 해석(2026-11-03 대구 관광공사 1,174곳): 시간표 809 · 상시 개방 210 · 점포별 상이 75 · 영업시간 아닌 문장 20 · 원문 없음(숙소) 60. 원문은 구간이 구분 없이 붙거나("…20:00금요일~일요일 …"), 줄바꿈으로만 나뉘거나, "[토요일]" 머리말 다음 줄에 시간이 오는 등 형식이 제각각이다. 파서를 고친 뒤 `reparse-hours` 로 124곳을 바로잡았다(API 재호출 없음). +- 2026-11-04 회귀 점검에서 더 고쳤다(`reparse-hours` 로 107곳 바뀜): + - 줄바꿈 없이 붙은 글머리표("- 11:00~21:00- 준비시간 16:00~17:00- 마지막 주문 20:20", 107곳)를 구간으로 나눈다. + 나누지 않으면 영업시간이 준비시간 구간에 섞여 낮에 '영업 종료'로 보였다(39곳). 요일 시간이 통째로 빈 곳도 41곳에서 0곳이 됐다. + - "[칠성시장] … [칠성야시장] 일요일 …" 처럼 시설 머리말이 여럿이면 시설마다 시간표를 만들고 요일별로 합친다(어느 시설이든 열려 있으면 영업 중). + 뒤 시설의 요일 시간이 앞 시설 시간을 지우지 않게 하려는 것이다. + - '휴게시간'은 쉬는 시간으로 읽고, '점심시간'은 2시간 이하일 때만 쉬는 시간으로 읽는다(식당의 점심 영업과 구분). + - "금요일 / 토요일" 처럼 요일 사이의 '/' 는 나열로 읽고, "13:00~13:00" 같은 0분 구간은 버린다(24시간으로 읽지 않는다). + - 남은 한계: 쉬는 시간은 시설·요일 구분 없이 하루 전체에 적용한다. 원문이 틀린 곳(팔공산 닭갈비 "준비시간(주말) 05:00~16:30")은 그대로다. - 상가정보는 `storeListInDong`(divId=signguCd, 소분류 업종 필터)로 시군구 × 데이트 업종 6개를 받는다(`--signgu=` 로 범위 변경). 끝까지 받은 (시군구, 업종) 묶음에서만 이번에 보이지 않은 업소를 `gone`(폐업 추정)으로 바꾼다(원본 장애 판단은 위와 같다). 업종이 바뀐 업소(예: 카페 → 빵집)는 같은 bizesId 로 갱신되어 그대로 노출된다. 기준년월(stdrYm)은 `source_snapshot` 에 남긴다. @@ -347,11 +355,15 @@ node dist/commands/place-sync.js tour-kor | 엔드포인트 | 내용 | | --- | --- | -| `GET /api/places` | `q`(이름) · `lat`+`lng`+`radius`(기본 1,500m, 100~20,000) · `category`(쉼표 구분) · `pet` · `indoor` · `cursor` · `limit`. 좌표가 있으면 거리순, 없으면 이름순. 키셋 커서(정렬값+id). 응답 `{items, pageInfo}` 에 `total` 포함 | +| `GET /api/places` | `q`(이름) · `lat`+`lng`+`radius`(기본 1,500m, 100~20,000) · `category`(쉼표 구분) · `pet` · `indoor` · `trusted` · `cursor` · `limit`. 좌표가 있으면 거리순, 없으면 이름순. 키셋 커서(정렬값+id). 응답 `{items, pageInfo}` 에 `total` 포함 | | `GET /api/places/{id}` | 상세 — 영업시간(원문·해석 상태·출처), 반려동물(크기·조건), 이용 안내(요금·주차 등), 추천 이유(추천+확인한 곳만), 출처 표기. 병합된 id 면 대상 장소, 숨김·폐업 추정이면 RES_001 | - 목록 항목의 `openNow` 는 한국 시간 기준 영업 중 여부다. 알 수 없으면 null → 화면은 "영업시간 미확인 · 방문 전 확인". - 사후면세점 브랜드 매장은 이름 검색일 때만 나온다(반려동물 필터 결과가 더현대 입점 매장으로 덮이던 문제). +- `trusted=true` 는 관광공사 장소와 운영자가 확인한 장소만 준다(확인 안 된 상가정보 매장·'추천 안 함' 판정 제외). 장소 상세 '함께 가기 좋은 곳'이 쓴다. + 앱이 가까운 30곳을 받아 거르던 때는 도심처럼 상가가 빽빽한 곳에서 받은 곳이 모두 상가라 칸이 비었다 + (2026-11-04 회귀 점검: 관광공사 장소 상세 860곳 중 30곳은 칸이 사라지고 186곳은 덜 보였다). +- 행사 목록에서 끝(`to`)을 생략하고 없는 시작일(`from=2030-13-01`)을 보내면 500 이 나던 것을 VAL_001(400)로 고쳤다(2026-11-04). - 실패 코드: 검색 조건·커서·id 형식 오류 VAL_001(400), 없음 RES_001(404). 새 코드는 만들지 않았다. - 계약 테스트: `backend/test/place.e2e-spec.ts` — 시작할 때 마이그레이션을 적용하고 동해 좌표에 테스트 장소를 만들어 검증한다(16개). - 아직 없는 것: 영어 등 다국어 응답(`place_i18n`), 지도 화면용 사각형(bbox) 조회.