diff --git a/resources/tester/test_est_progress_wonga.py b/resources/tester/test_est_progress_wonga.py new file mode 100644 index 000000000..be4c1f60f --- /dev/null +++ b/resources/tester/test_est_progress_wonga.py @@ -0,0 +1,95 @@ +"""EST 테스트 앱 기성 원가계산서 편집(원본 TfrmWongaAccomH/V 팝업) — 회차별 원가 서식 사본.""" + +import json +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-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", + "native-wonga-tools.js", + "native-progress-wonga.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_progress_wonga_edits_apply_to_round_copy_only(): + result = run_js( + r"""(() => { + const p = EstNative.calculate(EstModel.validate(structuredClone(source))); + const inputs = {}; + p.tables.EstMainDat.forEach((row, i) => { + if (row.Code && !Number(row.Level)) inputs[EstBusiness.rowKey(row, i)] = { Gum: 50, GumMode: 'ratio' }; + }); + p.progress = [{ id: 'one', name: '1회', inputs }]; + const round = p.progress[0], ops = EstProgressWonga.ops; + const calc = () => EstProgress.calculate(p, 'one'); + const plain = (t) => String(t).replace(/\s+/g, ''); + const base = calc(); + const anjun = base.costs.findIndex((r) => plain(r.Name).includes('산업안전') && Number(r.CalcIndex) !== 2); + const total = base.costs.findIndex((r) => Number(r.LinkIndex) === 55); + const out = { errors: base.errors, baseTotal: base.total, length: base.costs.length }; + ops.insert(p, round, 1); + const ins = calc(); + out.inserted = [ins.costs.length, ins.costs[1].Name, ins.costs[1].ContractCost, ins.costs[2].ContractCost === base.costs[1].ContractCost, p.tables.Wonga.length]; + ops.undo(p, round); + out.undone = ['wonga' in round, 'wongaUndo' in round]; + ops.position(p, round, anjun, 2, 3); + const cut = calc(); + out.anjun = [base.costs[anjun].GumCost, cut.costs[anjun].GumCost, cut.costs[anjun].ContractCost]; + ops.total(p, round, 4); + const fixed = calc(); + out.total = [cut.total, fixed.total, fixed.errors]; + ops.clear(p, round, 0); + const r0 = round.wonga[0]; + out.cleared = [r0.VSusic, r0.Cost, r0.Youl, r0.Diget, r0.Name !== '']; + ops.init(p, round); + out.init = ['wonga' in round, 'wongaTotalDigits' in round, calc().total === base.total]; + ops.undo(p, round); + out.reinit = Array.isArray(round.wonga); + ops.init(p, round); + round.locked = true; + try { ops.append(p, round); } catch (e) { out.locked = e.message; } + return out; + })()""" + ) + assert result["errors"] == [] + assert result["baseTotal"] > 0 + n = result["length"] + assert result["inserted"] == [n + 1, "", 0, True, n] + assert result["undone"] == [False, False] + before, after, contract = result["anjun"] + assert after == before // 1000 * 1000 and after != before and contract > 0 + cut_total, fixed_total, errors = result["total"] + assert fixed_total == cut_total // 10000 * 10000 and errors == [] + assert result["cleared"] == ["", 0, "", 0, True] + assert result["init"] == [False, False, True] + assert result["reinit"] is True + assert result["locked"] == "확정한 회차는 고칠 수 없습니다." diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index c57b68e0b..762c75261 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -485,6 +485,8 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - 남음: 구성비·요율 전환(CalcBase), 약어 표시(SName), 제비율 기준 문서 보기(원본 한글 파일). - [x] 수량산출서 검색·전체보기(원본 TFRMMULLANG btnSerch·btnAllDisplay — 빈칸·|·괄호 무시 포함 검색) — 고르기 줄을 `native-quantity-sheets.js` 로 옮겨 700줄 제한 유지. - 검증완료: `test_est_quantity_sheets.py` 검색 시험 포함 수량 시험 22개 통과. ORCA 브라우저에서 「집수」 검색 → 항목에 집수정이 있는 「배수」만·선택 이동, 전체보기 → 3개 복귀, 샘플 복구. + - [x] 기성 원가계산서 편집(원본 TfrmWongaAccomH/V 팝업 A3~A8 — 디스어셈블 근거) — 회차마다 원가 서식 사본(`round.wonga`)에 한줄 삽입·추가(끝줄 표지 이어받기)·삭제(확인), 내용 삭제(연결·금액·요율·수식·자리 비움), 선택항목 절하(도급액 행 허용), 선택데이타 절하(직전 자리, 구성비 기준 행 제외), 공사비역산 자리수(금회 도급액 10^n 미만 절하 → 이윤 역산), 초기화(사본 지움 = 도급 원가 서식), 되살리기(직전 조작 전), 산출근거 약어로 표시(수식칸 잠금만). 기성 행도 Diget>0 이면 자리맞춤. 도급금액 열은 순서가 아닌 기호·이름으로 짝지음. 원본의 번호·참조 재작성은 하지 않음(새 행 기호는 직접 입력). 확정 회차는 거부. 서버 허용 목록에서 빠져 있던 할증·부가세 스크립트(404)도 넣음. + - 검증완료: `test_est_progress_wonga.py`(삽입·되살리기·절하 1,000 단위·역산 4자리·내용 삭제·초기화와 되살리기·확정 거부, 도급 원가 서식 불변) 및 기성 관련 시험 40개 통과. ORCA 브라우저에서 시험 회차(50%) 한줄 삽입 31→32행·되살리기 31행·역산 4자리 42,409,505→42,400,000, 시험 회차 삭제로 복구. - [x] 원가 행 자리수(Diget·DigetIndex) 반영(원본 행 계산 c9d278·c9d818·기성 ca6a6c 끝부분, 자리맞춤 0x11293e8, 선택항목 절하 확인 0xd094dc — 디스어셈블 근거) — 수식 결과 소수 4자리 반올림 뒤 원 미만 절사, 도급액(55) 아니고 Diget>0 이면 10^Diget 단위 자리맞춤(DigetIndex 0 처리 없음 · 1 사사오입 · 2 절하 · 3 절상 · 4+ 절하). 원가계산서 화면의 「선택 행 자릿수 설정」은 원본 「선택항목 절하하기」 대화(사사오입·절하·절상·초기화, 0 원~6 백만원, 도급액 행 거부, 입력 행은 원금액 TC 기준, 마지막 자리 기억). 기성 원가도 같은 엔진. - 검증완료: `test_est_wonga_tools.py` 4개(자리맞춤 방식별·음수·소수·4자리 반올림·도급액 제외, 샘플 산업안전 1,660,273·비교 1,887,446·이윤 7,620,104·도급액 79,603,000 그대로, 대화 절하 1,660,000·초기화 복귀·도급액 행 거부·입력 행 TC) 통과. ORCA 브라우저에서 샘플 산업안전 행 천원 절하 → 1,660,000, 도급액 79,603,000 유지, 복구. - [x] 견적서 작성(원본 mnuEstiMatePrn → TfrmEstiMatePrn 0x10c1994·그리기 0x10c4768 — 디스어셈블 근거) — 공사별 설정(`project.estimatePrint`), 원본 `.einf` INI 세 구역 저장·두 구역 불러오기, 레벨 행 선택, 7열, 1쪽 머리·공급자·합계금액(아래에서 위로 Checked 금액, 한글), 첫/나머지 쪽 줄수, 미리보기·인쇄. 수탁공사변환·XML 역변환 비교는 원본에서 숨긴 개발용 메뉴라 범위 밖. diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 24619084d..672cb35f3 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -72,6 +72,8 @@ 원가계산서에서 행을 고르고 `선택 행 자릿수 설정`을 누르면 원본 「선택항목 절하하기」처럼 원·십원·백원… 단위로 사사오입·절하·절상하거나 초기화합니다. 수식 행은 계산 때마다 그 자리수로 맞추고, 도급액 고정 행은 바꿀 수 없습니다. +기성고 화면 아래 `기성 원가계산서`는 회차마다 따로 고칠 수 있습니다. 줄을 고른 뒤 한줄 삽입·추가·삭제, 내용 삭제, 선택항목 절하, 공사비역산 자리수 맞추기를 하고, `되살리기`로 바로 앞 조작을, `원가계산서 초기화`로 도급 원가 서식을 되돌립니다. 확정한 회차는 고칠 수 없습니다. + 원본의 엑셀형 수량산출서(본문이 옛 Excel .xls)는 가져올 때 셀 값과 수식을 함께 읽어 수량산출 화면 아래 격자로 보입니다. 셀을 누르면 수식줄에서 값이나 `=수식`을 고치고 바로 다시 계산합니다(참조·범위·사칙·SUM·ROUND 등). `우측·위·좌측·아래 합`은 원본처럼 그 방향의 숫자 셀을 더하는 SUM 을 넣고, `수량으로 지정`은 셀을 수량 항목에 연결해 항목 수량이 그 셀 값(산출서 자리수)을 따르게 합니다. 원본 파일에는 셀과 항목의 대응이 보이지 않아, 가져올 때 저장 수량과 값이 같은 합계 셀을 「추정 연결」로 둡니다. 셀을 고친 엑셀형 산출서는 ESTX 저장을 거부합니다. 산출 줄 옆 `수량 지정`은 줄 글에서 드래그해 고른 숫자를 결과 표지로 감싸고 수량 항목을 새로 만듭니다. 품명·규격·단위 표지가 있으면 항목 이름에 옮깁니다. `지정 해제`는 커서가 놓인 결과 표지를 풀고, 다른 줄에서 쓰지 않는 항목을 지웁니다. 내역에 연결된 항목은 먼저 연결을 비워야 합니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부합니다. diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 090bb6a7a..2a81ed75a 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -63,6 +63,7 @@ + diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js index b06a7fe5c..8d95a4e42 100644 --- a/tools/est-test/native-business-actions.js +++ b/tools/est-test/native-business-actions.js @@ -623,7 +623,8 @@ const EstBusinessActions = (() => { (typeof EstPrintBatch !== "undefined" && EstPrintBatch.handle(name, state)) || (typeof EstMachFormula !== "undefined" && EstMachFormula.handle(name, state, node)) || (typeof EstDefaults !== "undefined" && EstDefaults.handle(name, state, node)) || - (typeof EstExcelView !== "undefined" && EstExcelView.handle(name, state, node)) + (typeof EstExcelView !== "undefined" && EstExcelView.handle(name, state, node)) || + (typeof EstProgressWonga !== "undefined" && EstProgressWonga.handle(name, state)) ) { EstApp.render(); } else if (name === "load-contract" || name === "load-bid") { diff --git a/tools/est-test/native-extra.js b/tools/est-test/native-extra.js index 3ea8e8b2c..cb7dee5c1 100644 --- a/tools/est-test/native-extra.js +++ b/tools/est-test/native-extra.js @@ -220,17 +220,7 @@ const EstExtra = (() => { }), ) + `
${esc((output.errors || []).join(" / "))} 금회 도급액 ${number(output.total)} · 기성비율 ${number(output.ratio)}%
`; - body += table( - ["원가 비목", "도급금액", "전회", "금회", "사정", "누계"], - (output.costs || []).map((row) => [ - esc(row.Name || row.Gubun), - number(row.ContractCost), - number(row.JunCost), - number(row.GumCost), - number(row.SaCost), - number(row.TotalCost), - ]), - ); + body += EstProgressWonga.render(state, round, output); } html = box( "기성고", diff --git a/tools/est-test/native-model.js b/tools/est-test/native-model.js index e303e707a..6b3e719c8 100644 --- a/tools/est-test/native-model.js +++ b/tools/est-test/native-model.js @@ -442,6 +442,8 @@ const EstModel = (() => { for (const field of ["locked", "approved", "separateComponents"]) if (round[field] != null && typeof round[field] !== "boolean") throw Error("기성 옵션 형식 오류"); + if (round.wonga != null && !Array.isArray(round.wonga)) + throw Error("기성 원가 서식 형식 오류"); if (round.name != null && typeof round.name !== "string") throw Error("기성 회차 이름 형식 오류"); round.inputs ??= {}; diff --git a/tools/est-test/native-progress-wonga.js b/tools/est-test/native-progress-wonga.js index 4bcb77edc..a1e3b2aa6 100644 --- a/tools/est-test/native-progress-wonga.js +++ b/tools/est-test/native-progress-wonga.js @@ -158,7 +158,13 @@ const EstProgressWonga = (() => { if (!names.has(name)) return false; const r = round(state); if (!r) throw Error("기성 회차를 고르세요."); - const run = (fn) => EstApp.change((project) => fn(project, project.progress.find((x) => x.id === r.id))); + const run = (fn) => + EstApp.change((project) => + fn( + project, + project.progress.find((x) => x.id === r.id), + ), + ); if (name === "pw-insert") run((p, x) => ops.insert(p, x, at(state))); else if (name === "pw-append") run((p, x) => ops.append(p, x)); else if (name === "pw-delete") { diff --git a/tools/est-test/native-progress.js b/tools/est-test/native-progress.js index 986666ce1..9ff15966f 100644 --- a/tools/est-test/native-progress.js +++ b/tools/est-test/native-progress.js @@ -164,6 +164,11 @@ const EstProgress = (() => { .slice(0, boundary < 0 ? rows.length : boundary) .filter((row) => ordinary(row) && num(row.CalcIndex) < 2); const contract = direct.reduce((n, row) => n + num(row.TC), 0); + // 기성 행 자리맞춤(ca6a6c): 도급액 아니고 Diget>0 이면 원 미만 절사 뒤 자리맞춤 + const scaled = (row, v) => + num(row.LinkIndex) !== 55 && num(row.Diget) > 0 + ? EstWonga.digetRound(row, Math.trunc(v)) + : Math.trunc(v); function costsFor(prefix) { const current = direct.reduce((n, row) => n + num(row[prefix + "TC"]), 0), ratio = contract ? current / contract : 0; @@ -175,14 +180,14 @@ const EstProgress = (() => { copy.TC = num(row[prefix + "TC"]); return copy; }); - working.tables.Wonga = structuredClone(source.wonga); + working.tables.Wonga = structuredClone(round.wonga || source.wonga); for (const row of working.tables.Wonga) { if (!separated && row.BaseField === "TC") { row.Cost = current; row.BaseField = ""; row.CalcIndex = 1; } else if (row.Youl && !String(row.Name).replace(/\s/g, "").includes("부가")) { - row.Cost = Math.trunc(num(row.Cost) * ratio); + row.Cost = scaled(row, num(row.Cost) * ratio); row.VSusic = ""; row.CalcIndex = 1; } else if ( @@ -191,9 +196,22 @@ const EstProgress = (() => { ![1, 3].includes(num(row.CalcIndex)) && !/관급/.test(String(row.Name)) ) - row.Cost = Math.trunc(num(row.Cost) * ratio); + row.Cost = scaled(row, num(row.Cost) * ratio); + } + let calculated = EstNative.calculate(working); + const fixed = working.tables.Wonga.findIndex((row) => num(row.LinkIndex) === 55), + unit = 10 ** num(round.wongaTotalDigits); + if (unit > 1 && fixed >= 0) { + // 기성 공사비역산 자리수(popupTotal): 도급액을 10^n 미만 절하해 고정하고 이윤으로 역산 + working.tables.Wonga[fixed].Cost = + Math.trunc(num(calculated.calculation.total) / unit) * unit; + working.options.TotalFix = true; + // 기성 이윤은 비율로 고정된 값(CalcIndex 1)이라 역산 대상이 되도록 되돌림 + for (const row of working.tables.Wonga) + if (/이윤/.test(String(row.Name).replace(/\s/g, "")) && num(row.CalcIndex) === 1) + row.CalcIndex = 0; + calculated = EstNative.calculate(working); } - const calculated = EstNative.calculate(working); if (!separated) calculated.calculation.direct.TC = current; errors.push(...calculated.calculation.errors); const names = new Map( @@ -255,13 +273,15 @@ const EstProgress = (() => { }; const oldCosts = previous?.costs || previous?.wonga || []; const priorCosts = new Map(costKeys(oldCosts).map((key, index) => [key, oldCosts[index]])); - const currentKeys = costKeys(acceptedCost.rows); + const currentKeys = costKeys(acceptedCost.rows), + contractKeys = costKeys(source.wonga || []), + contractCosts = new Map(contractKeys.map((k, i) => [k, num(source.wonga[i]?.Cost)])); const costs = acceptedCost.rows.map((row, index) => { const previous = priorCosts.get(currentKeys[index]); const jun = num(previous?.TotalCost ?? previous?.Cost); return { ...row, - ContractCost: num(source.wonga[index]?.Cost), + ContractCost: contractCosts.get(currentKeys[index]) ?? 0, JunCost: jun, GumCost: num(gum.rows[index]?.Cost), SaCost: num(sa.rows[index]?.Cost), diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index 3ed47cd1e..3814b3fad 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -85,6 +85,9 @@ const allowed = new Set([ "native-mach-formula.js", "native-defaults.js", "native-excel-view.js", + "native-progress-wonga.js", + "native-haljung.js", + "native-vat.js", "native-issues.js", "native-attachments.js", "native-standard.js",