diff --git a/resources/tester/test_est_jungsang.py b/resources/tester/test_est_jungsang.py new file mode 100644 index 000000000..71650c642 --- /dev/null +++ b/resources/tester/test_est_jungsang.py @@ -0,0 +1,87 @@ +"""EST 테스트 앱 정산원가계산서(원본 당초금액 초기화·정산원가계산서 작성/해제).""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +FILES = [ + "native-math.js", + "native-formula.js", + "native-machine.js", + "native-rows.js", + "native-text.js", + "native-quantity.cjs", + "native-wonga.js", + "native-jungsang.js", + "native-engine.js", + "native-row-match.js", + "native-escalation.js", + "native-progress.js", + "native-business.js", + "native-review.js", + "native-resources.js", + "native-schedule.js", + "native-model.js", + "native-verify.js", +] + + +def run_js(code: str): + runner = f""" + const fs = require('fs'), vm = require('vm'); + const ctx = vm.createContext({{ structuredClone, console }}); + for (const f of {json.dumps(FILES)}) + vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }}); + ctx.source = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8')); + 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_difference_text_and_row_reuse(): + result = run_js( + """(() => { + const rows = [{ Name: '순공사비', Cost: 100, SC: 120 }, { Name: '', Cost: 0 }, { Name: '', Cost: 0 }]; + const less = EstJungSang.difference(rows); + rows[0].Cost = 150; + const more = EstJungSang.difference(rows); + return { less, more, at: rows.findIndex((r) => r.LinkIndex === 66), size: rows.length, row: rows[1] }; + })()""" + ) + assert result["less"] == "(감)20" and result["more"] == "(증)30" + assert result["at"] == 1 and result["size"] == 3 + assert result["row"]["Name"] == "." and result["row"]["EndLineIndex"] == 1 + + +def test_toggle_copies_original_amounts_and_recalc_rewrites_difference(): + result = run_js( + """(() => { + const calc = (p) => EstNative.calculate(EstModel.validate(p)); + let p = calc(structuredClone(source)); + const before = p.tables.Wonga.map((r) => Number(r.Cost) || 0); + const fixed = p.tables.Wonga.findIndex((r) => Number(r.LinkIndex) === 55); + EstJungSang.toggle(p); + const copied = p.tables.Wonga.every((r, i) => r.LinkIndex === 66 || Number(r.SC) === (before[i] ?? 0)); + p = calc(p); + const zero = p.tables.Wonga.find((r) => r.LinkIndex === 66).VSusic; + const row = p.tables.EstMainDat.find((r) => Number(r.GN) > 0 && Number(r.TU) > 0 && !Number(r.Level)); + row.GN = Number(row.GN) * 2; + p = calc(p); + const diff = p.tables.Wonga.find((r) => r.LinkIndex === 66).VSusic; + const errors = p.calculation.errors.length; + EstJungSang.toggle(p); + const off = { show: p.options.JungSangShow, has66: p.tables.Wonga.some((r) => r.LinkIndex === 66), + keptSC: p.tables.Wonga.some((r) => Number(r.SC) > 0) }; + return { copied, released: Number(p.tables.Wonga[fixed].LinkIndex), zero, diff, errors, off, + columns: EstJungSang.columns({ options: { JungSangShow: true } }, ['Name', 'VSusic', 'Cost']) }; + })()""" + ) + assert result["copied"] is True + assert result["released"] == 0 + assert re.match(r"^(0|\((증|감)\)\d+)$", result["zero"]) + assert result["diff"].startswith("(증)") + assert result["errors"] == 0 + assert result["off"] == {"show": False, "has66": False, "keptSC": True} + assert result["columns"] == ["Name", "VSusic", "SC", "Cost"] diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 038c2fa5a..6d36c88d8 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -457,6 +457,9 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - [x] 원가계산서 사용자 양식(원본 TFRMWONGABASE popupSaveAs·popupFormats·popupLoadFrom, TFRMFORMATFILELIST 등록·삭제·열기·파일) — 서버 보관함 `data/forms.json`(`forms-store.cjs`, 종류 wonga·document), 등록·목록·열기·삭제·파일 내려받기/불러오기. 금액 제외, 열 때 도급액 고정 해제(저장 금액을 쓰는 옵션이라 금액 없는 양식에서 0이 됨을 확인). - 검증완료: `test_est_forms_library.py` 2개(등록·같은 이름 교체·목록·삭제·잘못된 종류/이름/내용 거부, 금액 제외·잘못된 행 거부·다른 동작 무시) 통과. ORCA 브라우저에서 등록 → 목록 31행 → 원가 표를 3행으로 줄인 뒤 열기 → 31행 복구·도급액 79,603,324(고정 해제) → 삭제·보관함 비움, 샘플 복구. - 남음: 기본양식으로 저장(새 공사 기본값), 갑지·표지 문서 양식. + - [x] 정산원가계산서(원본 popJunSangCalc 0xd0eb28→0xd0f6fc, popJungSangShow 0xd0ea24, 재계산 0xca31c8 — 디스어셈블 근거) — 당초금액 초기화(SC := Cost, 55 → 0, JungSangInit), 작성/해제 토글(JungSangShow), 차액 행(LinkIndex 66, (감)/(증)) 재계산마다 갱신, 원가 화면 당초금액·정산금액 열. 엔진은 66 행 수식을 계산하지 않음. + - 검증완료: `test_est_jungsang.py` 2개(차액 글자·빈 행 재사용, 당초 복사·55 해제·재계산 차액·해제 시 66 비움·SC 유지·열 순서), 대조·업무 시험 포함 통과. ORCA 브라우저에서 작성 → 열 「당초금액, 정산금액」·차액 (증)324(도급액 고정 해제분)·오류 0, 해제 → 「원가금액」 복귀, 샘플 복구. + - 남음: 당초분·정산분 색상 옵션. - 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133. - [ ] 남은 핵심(실무 빈도순): 1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기. diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 3ccc9a25f..76c10931a 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -22,6 +22,8 @@ 내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다. +`원가계산서 → 정산원가계산서 작성/해제`는 원본처럼 원가 표에 당초금액 열을 금액 앞에 보이고 금액 열을 정산금액으로 바꿉니다. 처음 켤 때(또는 `당초금액 초기화`) 원가 행마다 현재 금액을 당초금액으로 옮기고 도급액 고정 연결(55)을 풀며, 맨 아래 금액 행의 차이를 `(감)`·`(증)` 금액 행으로 적습니다. 켜져 있는 동안 재계산마다 차액 행을 다시 씁니다. 끄면 차액 행만 비우고 당초금액은 남깁니다. + `원가계산서 → 원가계산서 양식 목록·등록`은 현재 원가계산서의 항목·수식·요율을 이름 붙여 이 앱 `data/forms.json`에 등록하고, 목록에서 열기·삭제하며, 파일로 내려받거나 불러옵니다. 금액은 남기지 않고 열 때 다시 계산하므로 도급액 고정은 풀립니다. 예정공정표의 `눈금`은 원본처럼 1일·5일·10일·15일·20일·1주·1개월 단위로 기간 칸 위에 날짜를 적고 막대 뒤에 세로 눈금선을 그립니다. 눈금이 60개를 넘으면 앞 60개만 그립니다. diff --git a/tools/est-test/index.html b/tools/est-test/index.html index a6f077424..d8fabacfe 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -12,6 +12,7 @@ + @@ -152,6 +153,8 @@ > +
diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js index 3046a4050..4b91830c9 100644 --- a/tools/est-test/native-business-actions.js +++ b/tools/est-test/native-business-actions.js @@ -606,7 +606,8 @@ const EstBusinessActions = (() => { (typeof EstExcelMap !== "undefined" && EstExcelMap.handle(name, state)) || (typeof EstTransfer !== "undefined" && EstTransfer.handle(name, state)) || (typeof EstBid !== "undefined" && EstBid.handle(name, state)) || - (typeof EstFormsLibrary !== "undefined" && EstFormsLibrary.handle(name, state, node)) + (typeof EstFormsLibrary !== "undefined" && EstFormsLibrary.handle(name, state, node)) || + (typeof EstJungSang !== "undefined" && EstJungSang.handle(name, state)) ) { EstApp.render(); } else if (name === "load-contract" || name === "load-bid") { diff --git a/tools/est-test/native-engine.js b/tools/est-test/native-engine.js index 7ef671567..79ebb5dfe 100644 --- a/tools/est-test/native-engine.js +++ b/tools/est-test/native-engine.js @@ -339,6 +339,8 @@ const EstNative = (() => { 직접공사비: num(direct.TC), }); tables.Wonga = wonga.rows; + if (options.JungSangShow && typeof EstJungSang !== "undefined") + EstJungSang.difference(tables.Wonga); errors.push(...wonga.errors); const wongaNames = new Map( wonga.rows diff --git a/tools/est-test/native-jungsang.js b/tools/est-test/native-jungsang.js new file mode 100644 index 000000000..6cdf82815 --- /dev/null +++ b/tools/est-test/native-jungsang.js @@ -0,0 +1,82 @@ +// 원본 정산원가계산서(TfrmWongaBase popJunSangCalc 0xd0eb28 → 0xd0f6fc, popJungSangShow 0xd0ea24, 재계산 0xca31c8). +// 당초금액 초기화: 원가 행마다 SC(당초금액) := Cost, LinkIndex 55 → 0, 초기화 표지(JungSangInit), 차액 행 기록. +// 정산원가계산서 작성/해제: JungSangShow 토글 — 켤 때 초기화 전이면 확인 없이 초기화, 끌 때 차액 행(66)만 비움(SC 유지). +// 켜져 있는 동안 재계산마다 차액 행을 다시 쓴다. 차액 d = 마지막 금액 행의 SC − Cost, d>0 「(감)」·d<0 「(증)」. +const EstJungSang = (() => { + const num = (value) => Number(value) || 0; + const blank = (row) => !String(row.Name ?? "").trim() && !num(row.Cost) && !row.VSusic; + + function difference(rows) { + let last = -1; + rows.forEach((row, i) => { + if (num(row.LinkIndex) !== 66 && String(row.Name ?? "").trim() && num(row.Cost)) last = i; + }); + const d = last < 0 ? 0 : num(rows[last].SC) - num(rows[last].Cost); + const text = d > 0 ? `(감)${Math.abs(d)}` : d < 0 ? `(증)${Math.abs(d)}` : "0"; + let at = rows.findIndex((row) => num(row.LinkIndex) === 66); + if (at < 0) { + for (let i = rows.length - 1; i >= 0 && blank(rows[i]); i--) at = i; + } + if (at < 0) { + rows.push({}); + at = rows.length - 1; + } + Object.assign(rows[at], { + EndLineIndex: 1, + Name: ".", + VGungu: text, + VSusic: text, + LinkIndex: 66, + }); + return text; + } + + function init(project) { + const rows = project.tables.Wonga || []; + for (const row of rows) { + row.SC = num(row.Cost); + if (num(row.LinkIndex) === 55) row.LinkIndex = 0; + } + project.options.JungSangInit = 1; + return difference(rows); + } + + function clear(project) { + const row = (project.tables.Wonga || []).find((r) => num(r.LinkIndex) === 66); + if (row) Object.assign(row, { Name: "", VGungu: "", VSusic: "", LinkIndex: 0 }); + } + + function toggle(project) { + project.options.JungSangShow = !project.options.JungSangShow; + if (project.options.JungSangShow) { + if (!num(project.options.JungSangInit)) init(project); + } else clear(project); + return project.options.JungSangShow; + } + + // 화면: 정산 모드에서는 원가 표에 당초금액 열을 금액 앞에 보이고 금액 열 이름을 정산금액으로 바꾼다. + function columns(project, base) { + if (!project.options?.JungSangShow) return base; + const out = base.filter((key) => key !== "SC"); + out.splice(out.indexOf("Cost"), 0, "SC"); + return out; + } + function label(project, key) { + if (!project.options?.JungSangShow) return null; + return { SC: "당초금액", Cost: "정산금액" }[key] || null; + } + + function handle(name, state) { + if (name === "jungsang-init") { + if (!confirm("당초금액이 정산금액으로 대체됩니다.")) return true; + EstApp.change((project) => init(project)); + } else if (name === "jungsang-toggle") { + let on; + EstApp.change((project) => (on = toggle(project))); + state.notice = on ? "정산원가계산서를 작성합니다." : "정산원가계산서를 해제했습니다."; + } else return false; + return true; + } + + return { difference, init, clear, toggle, columns, label, handle }; +})(); diff --git a/tools/est-test/native-views.js b/tools/est-test/native-views.js index 5efd9e593..51de43517 100644 --- a/tools/est-test/native-views.js +++ b/tools/est-test/native-views.js @@ -76,7 +76,7 @@ const EstViews = (() => { } function grid(table, list, columns) { const { esc, number, state } = EstApp; - return `${columns.map((key) => ``).join("")}${list.map(({ row, index }) => ` 0 ? "heading " : ""}${Number(row.Level) > 0 ? "level " : ""}${row.Error ? "error-row" : ""}">${columns.map((key) => ``).join("")}`).join("")}
선택${esc(EstModel.labels[key] || key)}
${cell(table, index, key, row[key])}
`; + return `${columns.map((key) => ``).join("")}${list.map(({ row, index }) => ` 0 ? "heading " : ""}${Number(row.Level) > 0 ? "level " : ""}${row.Error ? "error-row" : ""}">${columns.map((key) => ``).join("")}`).join("")}
선택${esc((table === "Wonga" && typeof EstJungSang !== "undefined" && EstJungSang.label(state.project, key)) || EstModel.labels[key] || key)}
${cell(table, index, key, row[key])}
`; } function render(state) { const { project, view } = state, @@ -121,7 +121,10 @@ const EstViews = (() => { const all = EstApp.visibleRows(), start = state.page * 100, list = all.slice(start, start + 100), - columns = EstModel.columns(view); + columns = + view === "Wonga" && typeof EstJungSang !== "undefined" + ? EstJungSang.columns(project, EstModel.columns(view)) + : EstModel.columns(view); byId("work-content").innerHTML = grid(view, list, columns); byId("paging").hidden = false; byId("page-info").textContent = diff --git a/tools/est-test/native-wonga.js b/tools/est-test/native-wonga.js index a7e63c2c4..50d9f871f 100644 --- a/tools/est-test/native-wonga.js +++ b/tools/est-test/native-wonga.js @@ -75,6 +75,7 @@ const EstWonga = (() => { row.Cost = defaultBases[row.BaseField]; } else if ( row.VSusic && + num(row.LinkIndex) !== 66 && !/^원금액\s*:/.test(row.VSusic) && ![1, 3].includes(num(row.CalcIndex)) ) { diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index aeb685f74..c1d3d73bf 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -22,6 +22,7 @@ const scripts = [ "native-text.js", "native-quantity.cjs", "native-wonga.js", + "native-jungsang.js", "native-engine.js", "native-row-match.js", "native-escalation.js",