diff --git a/backend/src/shared/utils/opening-hours.spec.ts b/backend/src/shared/utils/opening-hours.spec.ts new file mode 100644 index 0000000..814a2b6 --- /dev/null +++ b/backend/src/shared/utils/opening-hours.spec.ts @@ -0,0 +1,141 @@ +import { isOpenAt, parseOpeningHours } from './opening-hours'; + +// 요일 번호: 0=일요일 … 6=토요일 +const SUN = 0; +const MON = 1; +const WED = 3; +const FRI = 5; +const SAT = 6; +const at = (h: number, m = 0) => h * 60 + m; + +// 사례는 2026-10-29 공공데이터 점검(서울·대구)에서 실제로 받은 영업시간·쉬는날 문장이다 +describe('parseOpeningHours', () => { + it('단순한 시간 구간과 연중무휴를 매일 같은 시간표로 읽는다', () => { + const p = parseOpeningHours('11:00~22:00', '연중무휴'); + expect(p.status).toBe('parsed'); + expect( + p.week.every((d) => d?.[0]?.start === at(11) && d?.[0]?.end === at(22)), + ).toBe(true); + expect(p.closedWeekdays).toEqual([]); + }); + + it('글머리표와 준비시간(브레이크)을 읽는다', () => { + const p = parseOpeningHours( + '- 11:00~21:00 / - 준비시간 15:00~17:00', + '연중무휴', + ); + expect(p.breaks).toEqual([{ start: at(15), end: at(17) }]); + expect(isOpenAt(p, WED, at(16))).toBe(false); + expect(isOpenAt(p, WED, at(14))).toBe(true); + }); + + it('괄호 안의 준비 시간과 마지막 주문을 읽는다', () => { + const a = parseOpeningHours( + '11:00~21:00 (준비 시간 15:30~17:00)', + '연중무휴', + ); + expect(a.breaks).toEqual([{ start: at(15, 30), end: at(17) }]); + const b = parseOpeningHours('10:00~21:30 (마지막 주문 21:00)', '연중무휴'); + expect(b.lastOrder).toBe(at(21)); + expect(isOpenAt(b, FRI, at(21, 15))).toBe(true); + }); + + it('평일에만 있는 준비시간은 주말에 적용하지 않는다', () => { + const p = parseOpeningHours( + '- 11:30~21:00 / - 준비시간(평일) 15:30~16:30 / - 마지막 주문 20:30', + '1월 1일 / 설·추석 연휴', + ); + expect(p.breaksWeekdaysOnly).toBe(true); + expect(p.lastOrder).toBe(at(20, 30)); + expect(isOpenAt(p, WED, at(16))).toBe(false); + expect(isOpenAt(p, SAT, at(16))).toBe(true); + expect(p.holidayClosures).toEqual( + expect.arrayContaining(['설', '추석', '1월 1일']), + ); + }); + + it('정기 휴무 요일은 문을 닫은 것으로 본다', () => { + const p = parseOpeningHours( + '11:30~18:00 / ※ 재료 소진 시 조기 마감', + '매주 일요일', + ); + expect(p.closedWeekdays).toEqual([SUN]); + expect(p.week[SUN]).toEqual([]); + expect(isOpenAt(p, SUN, at(12))).toBe(false); + expect(p.notes).toContain('재료 소진 시 조기 마감'); + }); + + it('평일·주말 시간이 다르면 요일별로 나눈다', () => { + const p = parseOpeningHours('평일 11:00~22:00 / 주말 10:00~22:00', ''); + expect(isOpenAt(p, SAT, at(10, 30))).toBe(true); + expect(isOpenAt(p, MON, at(10, 30))).toBe(false); + }); + + it('요일 범위(화~일)와 월요일 휴관을 읽는다', () => { + const p = parseOpeningHours('화~일 10:00~18:00 (월요일 휴관)', ''); + expect(p.closedWeekdays).toEqual([MON]); + expect(isOpenAt(p, SUN, at(17))).toBe(true); + expect(isOpenAt(p, MON, at(12))).toBe(false); + }); + + it('자정을 넘기는 영업은 다음 날 새벽까지 이어진다', () => { + const p = parseOpeningHours('18:00~02:00', '매주 월요일'); + expect(p.week[SUN]).toEqual([{ start: at(18), end: at(26) }]); + // 일요일 밤에 열어 월요일 새벽 1시까지 — 월요일이 휴무여도 전날 영업은 이어진다 + expect(isOpenAt(p, MON, at(1))).toBe(true); + expect(isOpenAt(p, MON, at(19))).toBe(false); + }); + + it('상시 개방과 24시간은 항상 연 것으로 본다', () => { + expect(parseOpeningHours('상시 개방').status).toBe('always_open'); + const p = parseOpeningHours('24시간', '연중무휴'); + expect(p.status).toBe('always_open'); + expect(isOpenAt(p, WED, at(3))).toBe(true); + }); + + it('점포별 상이는 정할 수 없다고 표시한다(지어내지 않는다)', () => { + expect(parseOpeningHours('점포별 상이').status).toBe('varies'); + const p = parseOpeningHours('※ 점포별 상이하므로 홈페이지 참고'); + expect(p.status).toBe('varies'); + expect(isOpenAt(p, SAT, at(12))).toBeNull(); + }); + + it('값이 없으면 미확인으로 둔다', () => { + expect(parseOpeningHours(null).status).toBe('unknown'); + expect(parseOpeningHours('').status).toBe('unknown'); + expect(isOpenAt(parseOpeningHours(undefined), SAT, at(12))).toBeNull(); + }); + + it('계절마다 다른 시간은 어느 계절인지 몰라 시각 판단을 하지 않는다', () => { + const p = parseOpeningHours( + '하절기 화~일 10:00~17:00 / 동절기 화~일 11:00~16:00', + ); + expect(p.seasonal).toBe(true); + expect(isOpenAt(p, WED, at(12))).toBeNull(); + }); + + it('줄바꿈 태그와 안내 문구를 정리한다', () => { + const p = parseOpeningHours('10:00~17:00
\n※ 방문 전 연락 필수'); + expect(p.status).toBe('parsed'); + expect(p.notes).toContain('방문 전 연락 필수'); + }); + + it('쉬는날 앞의 보이지 않는 문자(BOM)를 지운다', () => { + const p = parseOpeningHours( + '16:00~23:00', + `${String.fromCharCode(0xfeff)}설·추석 당일`, + ); + expect(p.holidayClosures).toEqual(['설', '추석']); + }); + + it('"시설 점검" 같은 말은 설 휴무로 보지 않는다', () => { + const p = parseOpeningHours('09:00~18:00', '시설 점검일 별도 공지'); + expect(p.holidayClosures).toEqual([]); + }); + + it('매월 몇째 주 휴무는 요일을 막지 않고 안내로만 남긴다', () => { + const p = parseOpeningHours('10:00~20:00', '매월 둘째·넷째 주 월요일'); + expect(p.closedWeekdays).toEqual([]); + expect(p.notes[0]).toContain('매월'); + }); +}); diff --git a/backend/src/shared/utils/opening-hours.ts b/backend/src/shared/utils/opening-hours.ts new file mode 100644 index 0000000..a09a2e7 --- /dev/null +++ b/backend/src/shared/utils/opening-hours.ts @@ -0,0 +1,262 @@ +/** + * 영업시간 문장 파서 (순수 함수). + * + * 관광공사 TourAPI 의 영업시간·쉬는날 필드(usetime, opentimefood, restdatefood 등)는 + * 자유 문장이다. 예: "11:00~21:00 (준비 시간 15:30~17:00)", "평일 11:00~22:00 / 주말 10:00~22:00", + * "상시 개방", "점포별 상이", 쉬는날 "매주 화요일", "설·추석 당일". + * AI 코스 검증("도착 시각에 영업 중인가")에 쓰도록 요일별 시간표로 바꾼다. + * 읽을 수 없는 값은 지어내지 않고 'unknown'·'varies' 로 남긴다 — 화면에서는 '영업시간 미확인'으로 보여 준다. + */ + +/** 분 단위 시간 구간(00:00 기준). 자정을 넘기면 end 가 1440 보다 크다 */ +export interface TimeRange { + start: number; + end: number; +} + +/** + * - parsed: 시간표를 읽었다 + * - always_open: 상시 개방·24시간 + * - varies: 점포별 상이 등 정할 수 없다 + * - unknown: 값이 없거나 읽지 못했다 + */ +export type OpeningHoursStatus = + | 'parsed' + | 'always_open' + | 'varies' + | 'unknown'; + +export interface ParsedOpeningHours { + status: OpeningHoursStatus; + /** 요일별 영업 구간. 인덱스 0=일요일 … 6=토요일. 빈 배열은 휴무, null 은 정보 없음 */ + week: (TimeRange[] | null)[]; + /** 준비시간(브레이크 타임) */ + breaks: TimeRange[]; + /** "준비시간(평일)" 처럼 평일에만 적용되는 브레이크인지 */ + breaksWeekdaysOnly: boolean; + /** 마지막 주문 시각(분). 자정을 넘기면 1440 보다 크다 */ + lastOrder: number | null; + /** 정기 휴무 요일(0=일요일) */ + closedWeekdays: number[]; + /** 명절·공휴일 휴무 표시(날짜 계산은 하지 않는다) */ + holidayClosures: string[]; + /** 하절기·동절기처럼 계절마다 다르다 — 어느 계절인지 알 수 없어 시각 판단을 하지 않는다 */ + seasonal: boolean; + /** 시간 외 안내 문구(조기 마감, 방문 전 연락 등) */ + notes: string[]; +} + +const DAY_CHARS = '일월화수목금토'; +const MINUTES_PER_DAY = 1440; +/** 문서 앞에 붙는 보이지 않는 문자(BOM). 소스에 직접 쓰지 않으려고 코드값으로 만든다 */ +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*분)?)/; + +/** 줄바꿈 태그·글머리표·물결표 변형을 하나로 맞춘다 */ +function normalize(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(); +} + +function toMinutes(hour: string, minute?: string, minuteAlt?: string): number { + return Number(hour) * 60 + Number(minute ?? minuteAlt ?? 0); +} + +function parseRanges(segment: string): TimeRange[] { + const ranges: 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]); + // 닫는 시각이 여는 시각보다 이르거나 '익일'이면 다음 날로 넘긴다 + if (m[4] || end <= start) end += MINUTES_PER_DAY; + ranges.push({ start, end }); + } + return ranges; +} + +/** 시간 앞에 붙은 요일 표현을 요일 번호 목록으로 바꾼다. 요일 표현이 없으면 null(매일) */ +function parseDays(prefix: string): number[] | null { + if (/매일|연중/.test(prefix)) return null; + const days = new Set(); + if (/평일|주중/.test(prefix)) [1, 2, 3, 4, 5].forEach((d) => days.add(d)); + if (/주말/.test(prefix)) [0, 6].forEach((d) => days.add(d)); + const rest = prefix.replace(/평일|주중|주말|공휴일|휴일/g, ' '); + for (const m of rest.matchAll( + /([일월화수목금토])(?:요일)?\s*~\s*([일월화수목금토])(?:요일)?/g, + )) { + let d = DAY_CHARS.indexOf(m[1]); + const to = DAY_CHARS.indexOf(m[2]); + days.add(d); + while (d !== to) { + d = (d + 1) % 7; + days.add(d); + } + } + const withoutRanges = rest.replace( + /[일월화수목금토](?:요일)?\s*~\s*[일월화수목금토](?:요일)?/g, + ' ', + ); + for (const m of withoutRanges.matchAll( + /([일월화수목금토])(?:요일)?(?=[\s·,/)]|$)/g, + )) { + days.add(DAY_CHARS.indexOf(m[1])); + } + return days.size > 0 ? [...days].sort((a, b) => a - b) : null; +} + +/** 쉬는날 문장에서 정기 휴무 요일·명절 휴무·기타 안내를 뽑는다 */ +function parseRestDays(text: string, out: ParsedOpeningHours): void { + if (!text || /연중\s*무휴/.test(text)) return; + // '시설 점검' 같은 말에 걸리지 않게 명절 '설'만 잡는다 + if (/설(?:날|\s*[·,]|\s*연휴|\s*당일|$)/.test(text)) + out.holidayClosures.push('설'); + if (/추석/.test(text)) out.holidayClosures.push('추석'); + if (/1월\s*1일|신정/.test(text)) out.holidayClosures.push('1월 1일'); + if (/공휴일/.test(text)) out.holidayClosures.push('공휴일'); + // "매월 둘째·넷째 일요일" 처럼 매주가 아닌 휴무는 요일을 막지 않고 안내로만 남긴다 + if (/매월|째\s*주|째/.test(text)) { + out.notes.push(`휴무: ${text}`); + return; + } + for (const m of text.matchAll(/([일월화수목금토])요일/g)) { + const d = DAY_CHARS.indexOf(m[1]); + if (!out.closedWeekdays.includes(d)) out.closedWeekdays.push(d); + } + out.closedWeekdays.sort((a, b) => a - b); +} + +function emptyResult(status: OpeningHoursStatus): ParsedOpeningHours { + return { + status, + week: Array.from({ length: 7 }, () => null), + breaks: [], + breaksWeekdaysOnly: false, + lastOrder: null, + closedWeekdays: [], + holidayClosures: [], + seasonal: false, + notes: [], + }; +} + +/** + * 영업시간·쉬는날 문장을 요일별 시간표로 바꾼다. + * @param hours 영업시간 원문(예: opentimefood) + * @param restDays 쉬는날 원문(예: restdatefood) + */ +export function parseOpeningHours( + hours: string | null | undefined, + restDays?: string | null, +): ParsedOpeningHours { + const text = normalize(hours ?? ''); + const rest = normalize(restDays ?? ''); + const hasTime = TIME_RANGE.test(text); + TIME_RANGE.lastIndex = 0; + + let result: ParsedOpeningHours; + if (!text) { + result = emptyResult('unknown'); + } else if (!hasTime && /상시|24\s*시간|항시/.test(text)) { + result = emptyResult('always_open'); + result.week = result.week.map(() => [{ start: 0, end: MINUTES_PER_DAY }]); + } else if (!hasTime) { + result = emptyResult( + /상이|다름|다르|문의/.test(text) ? 'varies' : 'unknown', + ); + } else { + result = emptyResult('parsed'); + result.seasonal = /하절기|동절기|여름|겨울|성수기|비수기/.test(text); + const general: TimeRange[] = []; + const specific: { days: number[]; ranges: TimeRange[] }[] = []; + + 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 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" 처럼 '평일'이 괄호로 붙은 경우도 평일 전용으로 본다 + const weekdaysOnly = + /평일/.test(part) || + (part === body && /\(\s*평일\s*\)/.test(segment)); + if (weekdaysOnly) result.breaksWeekdaysOnly = true; + continue; + } + // 괄호 안에 요일 구분만 있는 경우(예: "(평일)")는 앞의 항목을 꾸미는 말이라 안내로 남기지 않는다 + if (part !== body && /^\s*(평일|주말|주중)\s*$/.test(part)) continue; + if (/마지막\s*주문|라스트\s*오더|L\.?\s*O/i.test(part)) { + const m = SINGLE_TIME.exec(part); + if (m) result.lastOrder = toMinutes(m[1], m[2], m[3]); + continue; + } + const closedInHours = + /([일월화수목금토])요일\s*(?:휴무|휴관|정기\s*휴무)/.exec(part); + if (closedInHours) parseRestDays(part, result); + const ranges = parseRanges(part); + if (ranges.length === 0) { + if (part !== body || !closedInHours) result.notes.push(part.trim()); + continue; + } + const prefix = part.slice(0, part.search(/\d/)); + const days = parseDays(prefix); + if (days) specific.push({ days, ranges }); + else 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.notes = result.notes.filter((n) => n.length > 0); + } + + parseRestDays(rest, result); + for (const d of result.closedWeekdays) result.week[d] = []; + return result; +} + +/** + * 해당 요일·시각에 영업 중인지 판단한다. + * @param weekday 0=일요일 … 6=토요일 + * @param minutes 00:00 부터의 분 + * @returns 영업 중이면 true, 아니면 false, 판단할 수 없으면 null(화면에 '영업시간 미확인') + */ +export function isOpenAt( + parsed: ParsedOpeningHours, + weekday: number, + minutes: number, +): boolean | null { + if (parsed.status === 'unknown' || parsed.status === 'varies') return null; + if (parsed.seasonal) return null; + + // 전날 밤에 시작해 자정을 넘긴 영업 구간은 오늘이 휴무일이어도 이어진다 + const yesterday = parsed.week[(weekday + 6) % 7]; + const fromYesterday = (yesterday ?? []).some( + (r) => r.end > MINUTES_PER_DAY && minutes + MINUTES_PER_DAY < r.end, + ); + if (fromYesterday) return true; + if (parsed.closedWeekdays.includes(weekday)) return false; + + const inBreak = parsed.breaks.some( + (b) => minutes >= b.start && minutes < b.end, + ); + const breakApplies = + !parsed.breaksWeekdaysOnly || (weekday >= 1 && weekday <= 5); + if (inBreak && breakApplies) return false; + + const today = parsed.week[weekday]; + if (today === null) return null; + return today.some((r) => minutes >= r.start && minutes < r.end); +}