feat(est-test): 정산원가계산서 작성과 당초금액 초기화

- 원본 처리 근거대로 당초금액 복사, 도급액 고정 연결 해제, (감)/(증) 차액 행
- 작성/해제 토글과 원가 화면 당초금액·정산금액 열, 재계산마다 차액 갱신

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-11 19:29:23 +09:00
co-authored by Claude Opus 5.5
parent 26a18a1709
commit 8c3a3d4267
10 changed files with 188 additions and 3 deletions
+87
View File
@@ -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"]
+3
View File
@@ -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. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기.
+2
View File
@@ -22,6 +22,8 @@
내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다.
`원가계산서 → 정산원가계산서 작성/해제`는 원본처럼 원가 표에 당초금액 열을 금액 앞에 보이고 금액 열을 정산금액으로 바꿉니다. 처음 켤 때(또는 `당초금액 초기화`) 원가 행마다 현재 금액을 당초금액으로 옮기고 도급액 고정 연결(55)을 풀며, 맨 아래 금액 행의 차이를 `(감)`·`(증)` 금액 행으로 적습니다. 켜져 있는 동안 재계산마다 차액 행을 다시 씁니다. 끄면 차액 행만 비우고 당초금액은 남깁니다.
`원가계산서 → 원가계산서 양식 목록·등록`은 현재 원가계산서의 항목·수식·요율을 이름 붙여 이 앱 `data/forms.json`에 등록하고, 목록에서 열기·삭제하며, 파일로 내려받거나 불러옵니다. 금액은 남기지 않고 열 때 다시 계산하므로 도급액 고정은 풀립니다.
예정공정표의 `눈금`은 원본처럼 1일·5일·10일·15일·20일·1주·1개월 단위로 기간 칸 위에 날짜를 적고 막대 뒤에 세로 눈금선을 그립니다. 눈금이 60개를 넘으면 앞 60개만 그립니다.
+3
View File
@@ -12,6 +12,7 @@
<script defer src="native-text.js"></script>
<script defer src="native-quantity.cjs"></script>
<script defer src="native-wonga.js"></script>
<script defer src="native-jungsang.js"></script>
<script defer src="native-engine.js"></script>
<script defer src="native-row-match.js"></script>
<script defer src="native-escalation.js"></script>
@@ -152,6 +153,8 @@
><button data-action="settings">자릿수·도급액 고정</button
><button data-action="reset-wonga">원가계산서 초기화</button>
<button data-action="wonga-forms">원가계산서 양식 목록·등록</button>
<button data-action="jungsang-toggle">정산원가계산서 작성/해제</button
><button data-action="jungsang-init">당초금액 초기화</button>
</div>
</details>
<details>
+2 -1
View File
@@ -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") {
+2
View File
@@ -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
+82
View File
@@ -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 };
})();
+5 -2
View File
@@ -76,7 +76,7 @@ const EstViews = (() => {
}
function grid(table, list, columns) {
const { esc, number, state } = EstApp;
return `<table class="sheet" data-grid="${table}"><thead><tr><th class="row-number">선택</th>${columns.map((key) => `<th class="col-${key}">${esc(EstModel.labels[key] || key)}</th>`).join("")}</tr></thead><tbody>${list.map(({ row, index }) => `<tr data-index="${index}" class="${state.selection.has(index) ? "selected " : ""}${Number(row.Index) > 0 ? "heading " : ""}${Number(row.Level) > 0 ? "level " : ""}${row.Error ? "error-row" : ""}"><th class="row-number"><label><input type="checkbox" data-select="${index}" ${state.selection.has(index) ? "checked" : ""}>${index + 1}</label></th>${columns.map((key) => `<td class="col-${key}">${cell(table, index, key, row[key])}</td>`).join("")}</tr>`).join("")}</tbody></table>`;
return `<table class="sheet" data-grid="${table}"><thead><tr><th class="row-number">선택</th>${columns.map((key) => `<th class="col-${key}">${esc((table === "Wonga" && typeof EstJungSang !== "undefined" && EstJungSang.label(state.project, key)) || EstModel.labels[key] || key)}</th>`).join("")}</tr></thead><tbody>${list.map(({ row, index }) => `<tr data-index="${index}" class="${state.selection.has(index) ? "selected " : ""}${Number(row.Index) > 0 ? "heading " : ""}${Number(row.Level) > 0 ? "level " : ""}${row.Error ? "error-row" : ""}"><th class="row-number"><label><input type="checkbox" data-select="${index}" ${state.selection.has(index) ? "checked" : ""}>${index + 1}</label></th>${columns.map((key) => `<td class="col-${key}">${cell(table, index, key, row[key])}</td>`).join("")}</tr>`).join("")}</tbody></table>`;
}
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 =
+1
View File
@@ -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))
) {
+1
View File
@@ -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",