diff --git a/resources/tester/test_est_schedule_scale.py b/resources/tester/test_est_schedule_scale.py new file mode 100644 index 000000000..8a7fde018 --- /dev/null +++ b/resources/tester/test_est_schedule_scale.py @@ -0,0 +1,39 @@ +"""EST 테스트 앱 예정공정표 눈금 단위(원본 1·5·10·15·20일, 1주, 1개월).""" + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def run_js(code: str): + runner = f""" + const fs = require('fs'), vm = require('vm'); + const ctx = vm.createContext({{ structuredClone, console }}); + vm.runInContext(fs.readFileSync('tools/est-test/native-schedule.js', 'utf8'), ctx); + process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx))); + """ + return json.loads(subprocess.check_output(["node", "-e", runner], cwd=ROOT).decode("utf-8")) + + +def test_day_week_month_ticks(): + result = run_js( + """(() => { + const t = (s, e, k, n) => EstSchedule.ticks(s, e, k, n); + const week = t('2026-01-01', '2026-01-28', '1w'); + const month = t('2026-01-15', '2026-04-10', '1m'); + const tenth = t('2026-01-01', '2026-01-30', '10d'); + const many = t('2026-01-01', '2026-12-31', '1d', 60); + let bad = ''; try { t('2026-01-01', '2026-01-02', 'x'); } catch (e) { bad = e.message; } + return { week: week.ticks.map((x) => [x.label, Math.round(x.left)]), + month: month.ticks.map((x) => x.label), tenth: tenth.ticks.map((x) => x.label), + cut: many.cut, count: many.ticks.length, empty: t('', '', '1d').ticks.length, bad }; + })()""" + ) + assert result["week"] == [["01-01", 0], ["01-08", 25], ["01-15", 50], ["01-22", 75]] + assert result["month"] == ["2026-01", "2026-02", "2026-03", "2026-04"] + assert result["tenth"] == ["01-01", "01-11", "01-21"] + assert result["cut"] is True and result["count"] == 60 + assert result["empty"] == 0 + assert result["bad"] == "눈금 단위를 고르세요." diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 51e613391..84f2a61ba 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -452,6 +452,8 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - [x] 낙찰률 적용 범위(원본 TFRMBIMOGBIUL 내역서낙찰·공종별) — 전체낙찰율/비목별, 전체·선택한 줄·선택한 공종 하위, 낙찰 적용된 줄 제외. 기존 계산(`EstBusiness.bid`)에 행 묶음 지정만 더함. 편집 메뉴에 낙찰률 적용·당초단가 복원. - 검증완료: `test_est_bid.py` 2개(공종 하위만 변경·밖 불변, 적용된 줄 제외·빈 선택·제목 없음·음수 거부), 낙찰 관련 업무·설계변경 시험 포함 41개 통과. ORCA 브라우저에서 「구조물공」 선택 → 전체 80% 공종별 → 하위 4줄만 변경·순공사비 45,293,066 → 42,598,790, 되돌리기 원상. - 남음: 근거자료낙찰 탭(공종별 낙찰율 목록), 「연금·건강보험료·안전관리비 고정」. + - [x] 예정공정표 눈금 단위(원본 TFRMSCHEDULE mnu1Day·5Day·10Day·15Day·20Day·1Week·1Month) — 기간 칸 머리에 눈금 날짜, 막대 뒤 눈금선, 60개 초과 시 앞 60개. + - 검증완료: `test_est_schedule_scale.py`(1주·1개월·10일 눈금 위치·날짜, 60개 자르기, 빈 기간, 잘못된 단위 거부) 통과. ORCA 브라우저에서 1개월 → 1주 전환 시 머리 날짜 03-01·03-08… 11개, 글자 간격 47px 이상 확인 후 샘플 복구. - 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133. - [ ] 남은 핵심(실무 빈도순): 1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기. diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 6bc535cfc..0d0e74cea 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -22,6 +22,8 @@ 내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다. +예정공정표의 `눈금`은 원본처럼 1일·5일·10일·15일·20일·1주·1개월 단위로 기간 칸 위에 날짜를 적고 막대 뒤에 세로 눈금선을 그립니다. 눈금이 60개를 넘으면 앞 60개만 그립니다. + `편집 → 낙찰률 적용`은 원본 낙찰율 창처럼 전체낙찰율(한 비율) 또는 비목별낙찰율을 고르고, 전체 줄·선택한 줄·선택한 공종(하위 줄 모두)에 적용합니다. 이미 낙찰이 적용된 줄은 제외할 수 있고, `당초단가(100%) 복원`으로 되돌립니다. `파일 → 프로젝트 추가`는 다른 공사 파일(ESTX·웹 JSON)의 순공사비 위 내역 행 전체를 하위 자료와 함께 이 공사의 순공사비 위에 합칩니다. 같은 코드 자료는 이 공사의 것을 그대로 씁니다. diff --git a/tools/est-test/native-schedule.js b/tools/est-test/native-schedule.js index 8b7ae6dc2..a1c733e2b 100644 --- a/tools/est-test/native-schedule.js +++ b/tools/est-test/native-schedule.js @@ -152,12 +152,55 @@ const EstSchedule = (() => { function clearSchedule(project) { project.schedule = []; } + // 원본 예정공정표 눈금(mnu1Day·mnu5Day·mnu10Day·mnu15Day·mnu20Day·mnu1Week·mnu1Month). + const scales = [ + ["1d", "1일", 1], + ["5d", "5일", 5], + ["10d", "10일", 10], + ["15d", "15일", 15], + ["20d", "20일", 20], + ["1w", "1주", 7], + ["1m", "1개월", 0], + ]; + function ticks(start, end, scale, limit = 60) { + if (!start || !end) return { ticks: [], cut: false }; + const first = day(start), + span = day(end) - first + 1, + step = scales.find(([key]) => key === scale)?.[2]; + if (step == null) throw Error("눈금 단위를 고르세요."); + const out = []; + if (step) for (let d = first; d < first + span; d += step) out.push(d); + else { + const date = new Date(first * 86400000); + for (let y = date.getUTCFullYear(), m = date.getUTCMonth(); ; m++) { + const t = Date.UTC(y, m, 1) / 86400000; + if (t >= first + span) break; + out.push(Math.max(t, first)); + } + } + const cut = out.length > limit; + return { + cut, + ticks: out.slice(0, limit).map((d) => ({ + left: ((d - first) / span) * 100, + label: new Date(d * 86400000).toISOString().slice(step ? 5 : 0, step ? 10 : 7), + })), + }; + } function render() { const { esc, number, state } = EstApp, output = calculate(state.project); + state.scheduleScale ||= "1m"; + const grid = ticks(output.start, output.end, state.scheduleScale), + lines = grid.ticks + .map((t) => `linear-gradient(#999,#999) ${t.left}% 0/1px 100% no-repeat`) + .join(","), + width = Math.max(180, grid.ticks.length * 44), + header = `
${grid.ticks.map((t) => `${esc(t.label)}`).join("")}
`, + scaleSelect = `${grid.cut ? "눈금이 많아 앞 60개만 표시" : ""}`; const rowsCount = output.rows.length; EstApp.byId("work-content").innerHTML = - `
시작 ${esc(output.start)} · 종료 ${esc(output.end)} · 가중치 합계 ${number(output.weight)} · 입력 진도 가중평균 ${number(output.progress)}%

기간과 진도는 직접 입력합니다. 가중치는 공종 금액 또는 사용자가 정한 값을 사용합니다.

${["코드", "공정명", "시작일", "종료일", "가중치", "진도(%)", "기간", "작업"].map((h) => ``).join("")}${output.rows.map((r, i) => `${["Code", "Name", "start", "end", "weight", "progress"].map((k) => ``).join("")}`).join("")}
${h}
`; + `
${scaleSelect}시작 ${esc(output.start)} · 종료 ${esc(output.end)} · 가중치 합계 ${number(output.weight)} · 입력 진도 가중평균 ${number(output.progress)}%

기간과 진도는 직접 입력합니다. 가중치는 공종 금액 또는 사용자가 정한 값을 사용합니다.

${["코드", "공정명", "시작일", "종료일", "가중치", "진도(%)", "기간", "작업"].map((h) => ``).join("")}${output.rows.map((r, i) => `${["Code", "Name", "start", "end", "weight", "progress"].map((k) => ``).join("")}`).join("")}
${h === "기간" ? `기간${header}` : h}
`; } function install() { document.addEventListener("click", (event) => { @@ -213,6 +256,11 @@ const EstSchedule = (() => { }); document.addEventListener("change", (event) => { const node = event.target; + if (node.dataset.scheduleScale !== undefined) { + EstApp.state.scheduleScale = node.value; + EstApp.render(); + return; + } if (!node.dataset.schedule) return; const p = structuredClone(EstApp.state.project), row = p.schedule[Number(node.dataset.index)], @@ -231,6 +279,7 @@ const EstSchedule = (() => { return { validate, calculate, + ticks, fromEstimate, refreshWeights, refreshProgress,