feat(est-test): 기성 원가계산서 회차별 편집

- 원본 TfrmWongaAccomH/V 팝업: 한줄 삽입·추가·삭제, 내용 삭제, 선택항목·선택데이타 절하, 공사비역산 자리수, 초기화, 되살리기, 산출근거 약어 표시(수식칸 잠금)
- 기성 행 Diget 자리맞춤, 역산 시 이윤 역산, 도급금액 열을 기호·이름으로 짝지음
- 서버 허용 목록에 빠진 할증·부가세 스크립트 추가(404 수정)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 12:30:17 +09:00
co-authored by Claude Opus 5.5
parent 399c8fea49
commit 627e04e5a2
10 changed files with 141 additions and 19 deletions
@@ -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"] == "확정한 회차는 고칠 수 없습니다."
+2
View File
@@ -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 역변환 비교는 원본에서 숨긴 개발용 메뉴라 범위 밖.
+2
View File
@@ -72,6 +72,8 @@
원가계산서에서 행을 고르고 `선택 행 자릿수 설정`을 누르면 원본 「선택항목 절하하기」처럼 원·십원·백원… 단위로 사사오입·절하·절상하거나 초기화합니다. 수식 행은 계산 때마다 그 자리수로 맞추고, 도급액 고정 행은 바꿀 수 없습니다.
기성고 화면 아래 `기성 원가계산서`는 회차마다 따로 고칠 수 있습니다. 줄을 고른 뒤 한줄 삽입·추가·삭제, 내용 삭제, 선택항목 절하, 공사비역산 자리수 맞추기를 하고, `되살리기`로 바로 앞 조작을, `원가계산서 초기화`로 도급 원가 서식을 되돌립니다. 확정한 회차는 고칠 수 없습니다.
원본의 엑셀형 수량산출서(본문이 옛 Excel .xls)는 가져올 때 셀 값과 수식을 함께 읽어 수량산출 화면 아래 격자로 보입니다. 셀을 누르면 수식줄에서 값이나 `=수식`을 고치고 바로 다시 계산합니다(참조·범위·사칙·SUM·ROUND 등). `우측·위·좌측·아래 합`은 원본처럼 그 방향의 숫자 셀을 더하는 SUM 을 넣고, `수량으로 지정`은 셀을 수량 항목에 연결해 항목 수량이 그 셀 값(산출서 자리수)을 따르게 합니다. 원본 파일에는 셀과 항목의 대응이 보이지 않아, 가져올 때 저장 수량과 값이 같은 합계 셀을 「추정 연결」로 둡니다. 셀을 고친 엑셀형 산출서는 ESTX 저장을 거부합니다.
산출 줄 옆 `수량 지정`은 줄 글에서 드래그해 고른 숫자를 결과 표지로 감싸고 수량 항목을 새로 만듭니다. 품명·규격·단위 표지가 있으면 항목 이름에 옮깁니다. `지정 해제`는 커서가 놓인 결과 표지를 풀고, 다른 줄에서 쓰지 않는 항목을 지웁니다. 내역에 연결된 항목은 먼저 연결을 비워야 합니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부합니다.
+1
View File
@@ -63,6 +63,7 @@
<script defer src="native-mach-formula.js"></script>
<script defer src="native-defaults.js"></script>
<script defer src="native-excel-view.js"></script>
<script defer src="native-progress-wonga.js"></script>
<script defer src="native-actions.js"></script>
<script defer src="native-business-actions.js"></script>
<script defer src="native-app.js"></script>
+2 -1
View File
@@ -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") {
+1 -11
View File
@@ -220,17 +220,7 @@ const EstExtra = (() => {
}),
) +
`<p class="business-note">${esc((output.errors || []).join(" / "))} 금회 도급액 ${number(output.total)} · 기성비율 ${number(output.ratio)}%</p>`;
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(
"기성고",
+2
View File
@@ -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 ??= {};
+7 -1
View File
@@ -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") {
+26 -6
View File
@@ -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),
+3
View File
@@ -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",