generated from ttipo/project-base
fix(backend): 줄바꿈 없이 붙은 영업시간 원문을 구간마다 나눠 읽음
관광공사 원문 107곳이 "- 11:00~21:00- 준비시간 16:00~17:00- 마지막 주문 20:20" 처럼
줄바꿈 없이 붙어 온다. 맨 앞 글머리표만 지우던 파서는 이를 한 구간으로 보고 영업시간까지
준비시간·마지막 주문으로 읽었다. 그래서 39곳이 영업 중인 낮에 '영업 종료'로 보였고
41곳은 요일 시간이 통째로 비었다(2026-11-04 회귀 점검).
- 시각·닫는 괄호 뒤의 '- '(다음이 숫자가 아닌 말)와 대괄호 머리말 앞에서 구간을 나눈다
- 시설 머리말("[칠성시장] … [칠성야시장] …")마다 시간표를 따로 만들고 요일별로 합친다.
나누기만 하면 뒤 시설의 요일 시간이 앞 시설 시간을 지워 일부 시간대가 빠졌다
- '휴게시간'은 쉬는 시간, '점심시간'은 2시간 이하일 때만 쉬는 시간으로 읽는다(식당의 점심 영업과 구분)
- 요일 사이의 '/' 는 나열로 읽고, 0분 구간("13:00~13:00")은 24시간으로 읽지 않고 버린다
개발 DB 는 reparse-hours 로 다시 해석했다(107곳 바뀜, 공공데이터 API 재호출 없음).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string>();
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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` 에 남긴다.
|
||||
|
||||
Reference in New Issue
Block a user