feat(est-test): 내역·일위대가 단가 고르기 창

- 원본 검색 창(TFRMSERCHPROJECT)처럼 7종 자료를 검색어로 찾아 여러 개 골라 넣기
- 원본 데이타 변경 확인(TFRMDBLCLICKRTN)처럼 선택 행 연결 바꾸기, 같은 코드 전체 변경 선택
- 넣거나 바꾼 행만 원천 단가 재조회, 실패 시 되돌림
- 시험 test_est_pick.py 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-11 14:36:34 +09:00
co-authored by Claude Opus 5.5
parent 8e6246493f
commit d25183e89d
7 changed files with 388 additions and 1 deletions
+136
View File
@@ -0,0 +1,136 @@
"""EST 테스트 앱 단가 고르기: 검색·넣기·바꾸기와 계산 연결."""
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-pick.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.project = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8'));
process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx)));
"""
out = subprocess.check_output(["node", "-e", runner], cwd=ROOT)
return json.loads(out.decode("utf-8"))
def test_search_filters_by_kind_and_all_words():
result = run_js("""(() => {
const labor = project.tables.LaborcDat.find((r) => r.Code && r.Name);
const word = String(labor.Name).replace(/\\s+/g, "").slice(0, 2);
const one = EstPick.search(project, 6, word);
const all = EstPick.search(project, 0, labor.Code);
const none = EstPick.search(project, 6, word + " 없는말없는말");
return {
kinds: [...new Set(one.rows.map((r) => r.kind))],
hit: one.rows.some((r) => r.Code === labor.Code),
exact: all.rows.filter((r) => r.Code === labor.Code).length,
none: none.total,
};
})()""")
assert result == {"kinds": [6], "hit": True, "exact": 1, "none": 0}
def test_insert_links_rows_and_engine_prices_them():
result = run_js("""(() => {
const labor = project.tables.LaborcDat.find((r) => r.Code && Number(r.TC) > 0);
const pick = EstPick.items(project, 6).find((r) => r.Code === labor.Code);
const rows = project.tables.EstMainDat, before = rows.length;
const added = EstPick.insert(project, "EstMainDat", 0, "", [pick, pick]);
const done = EstNative.calculate(EstModel.validate(project));
const row = done.tables.EstMainDat[added[0]];
return {
added, grew: done.tables.EstMainDat.length - before,
link: row.LinkIndex, code: row.Code === labor.Code, name: row.Name === labor.Name,
priced: Number(row.NU) > 0 && Number(row.NU) === Number(pick.price),
};
})()""")
assert result == {
"added": [1, 2],
"grew": 2,
"link": 6,
"code": True,
"name": True,
"priced": True,
}
def test_replace_current_row_or_all_same_code():
result = run_js("""(() => {
const rows = project.tables.EstMainDat;
const idx = rows.findIndex((r) => r.Code && !(Number(r.Index) > 0) && [5, 6, 103, 104].includes(Number(r.LinkIndex)));
const target = rows[idx];
const twin = { ...target, PkID: 999999 };
rows.splice(idx + 1, 0, twin);
const pick = EstPick.items(project, 5).find((r) => r.Code !== target.Code);
const one = EstPick.replace(structuredClone(project), "EstMainDat", idx, "", pick, false);
const copy = structuredClone(project);
const all = EstPick.replace(copy, "EstMainDat", idx, "", pick, true);
return {
one: one.length, all: all.length >= 2,
moved: copy.tables.EstMainDat[idx + 1].Code === pick.Code,
kind: copy.tables.EstMainDat[idx].LinkIndex,
untouched: project.tables.EstMainDat[idx].Code === target.Code,
};
})()""")
assert result == {"one": 1, "all": True, "moved": True, "kind": 5, "untouched": True}
def test_guards_reject_wrong_view_self_reference_and_title_rows():
result = run_js("""(() => {
const out = [];
const tryIt = (fn) => { try { fn(); out.push("ok"); } catch (e) { out.push(e.message.slice(0, 12)); } };
const head = EstPick.items(project, 103)[0];
tryIt(() => EstPick.insert(project, "CostMainDat", 0, "", [head]));
tryIt(() => EstPick.insert(project, "HeadMainDat", 0, head.Code, [head]));
tryIt(() => EstPick.insert(project, "EstMainDat", 0, "", []));
const title = project.tables.HeadMainDat.findIndex((r) => Number(r.Index) > 0);
tryIt(() => EstPick.replace(project, "HeadMainDat", title, "", EstPick.items(project, 6)[0], false));
return out;
})()""")
expected = [
"내역서·일위대가 화면",
"일위대가 자신을 하위",
"넣을 자료를 하나 이상",
"일반 내역 행 하나를",
]
assert len(result) == len(expected)
assert all(message.startswith(head[:8]) for message, head in zip(result, expected))
def test_insert_into_head_group_prices_from_source():
result = run_js("""(() => {
const rows = project.tables.HeadMainDat;
const title = rows.findIndex((r) => Number(r.Index) > 0);
const stuff = EstPick.items(project, 5).find((r) => r.price > 0);
const added = EstPick.insert(project, "HeadMainDat", title, rows[title].Code, [stuff]);
const row = project.tables.HeadMainDat[added[0]];
return { at: added[0] === title + 1, link: row.LinkIndex, unit: Number(row.GU) === stuff.price };
})()""")
assert result == {"at": True, "link": 5, "unit": True}
+3
View File
@@ -426,6 +426,9 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- [ ] 원본 메뉴·단추 대조(CAD 제외 약 880개)에서 나온 핵심 미구현 처리. 대조표 = 작업 폴더 `tmp/est-audit/gap_*.md`.
- [x] 처리기만 있고 단추가 없던 동작 4개 연결 — 레벨 일괄 증가·감소(LEVEL 메뉴), 원가계산서 초기화(원가계산서 메뉴), 설계변경 초기화(설계변경 화면). 처리기마다 단추가 있는지 보는 지킴 시험 `test_est_action_bindings.py` 추가.
- 검증완료: 옛 코드에서 시험 실패(4개 검출)·새 코드 통과, `test_est_row_actions.py` 포함 16개 통과. ORCA 브라우저에서 두 행 레벨 2→3→2 왕복·되돌리기 복원, 원가 초기화 확인창, 당초 없음 차단 문구 확인.
- [x] 단가 고르기(원본 TFRMSERCHPROJECT·TFRMDBLCLICKRTN) — 내역서·일위대가에서 7종 자료 검색·여러 개 선택·선택 행 아래 넣기, 선택 행 연결 바꾸기(변경 전후 확인·같은 코드 전체 변경). 넣거나 바꾼 행만 원천 단가 재조회, 실패 시 되돌림. 자기 일위대가 순환·제목·요율 행 차단.
- 검증완료: `test_est_pick.py` 5개 통과(검색·넣기·단가 반영·바꾸기·차단·일위대가 경로). ORCA 브라우저에서 보통인부 넣기 → 노무단가 172,068 반영, 경유로 바꾸기 → 재료단가 1,376 반영, 되돌리기 2회로 76행·도급액 79,603,000 복원.
- 남음: 단가산출 산출근거(LineData) 행 넣기, 원본 「Main값우선」 선택, 기계경비 상세 행.
- [x] 연차 수수료의 원본 연결값 `LinkIndex_`가 57로 보존된 경우 ESTX 재열기에서 원가 계산에 반영하고, 없는 경우 저장 차단을 유지한다.
- 자체 검증: 설치 sample의 조달수수료 2건을 연차 사본으로 저장하고 관급 수수료 제외 옵션을 켠 상태에서 재열어 원가 전 항목과 인덱스 바이트가 일치함을 확인. 기존 연결값이 없는 경우 저장 거부. 연차·기성·인덱스 관련 통합 회귀 43개 통과. `99`·고정 행·기존 연결값 `57` 조합에만 적용하며 원본 EST 실행 비교는 하지 못했다.
+2
View File
@@ -20,6 +20,8 @@
단가산출 화면의 `편집 → 산출근거 문구 바꾸기`는 현재 호표 또는 전체 산출근거에서 문구를 일괄 변경합니다. 결과값·연결 단가 등의 계산 표지는 치환하지 않으며, 자동계산이 켜져 있으면 변경 후 재계산합니다. 실행 취소로 복원할 수 있습니다.
내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다.
내역서 편집 메뉴의 `내역 수량 초기화`는 선택한 상세 행 또는 모든 상세 행의 수량을 0으로 바꿉니다. 비율·합계 자동행은 건드리지 않으며 실행 취소할 수 있습니다.
원천 자료에 연결된 내역 또는 일위대가 상세 행의 결과비목을 재료·노무·경비로 바꾸면 현재 단가 합계를 유지해 지정한 비목으로 옮깁니다. 비목별 구성으로 되돌릴 때 원천 단가의 합계가 현재 단가와 다르거나 원천이 불명확하면 임의로 복원하지 않고 오류를 표시합니다. 자동계산이 꺼져 있으면 금액은 자동 재계산을 켤 때까지 유지합니다.
+4
View File
@@ -37,6 +37,7 @@
<script defer src="native-exchange.js"></script>
<script defer src="native-c3r.js"></script>
<script defer src="native-block-actions.js"></script>
<script defer src="native-pick.js"></script>
<script defer src="native-actions.js"></script>
<script defer src="native-business-actions.js"></script>
<script defer src="native-app.js"></script>
@@ -88,6 +89,9 @@
><button data-action="block-del">블럭 삭제</button>
<hr />
<button data-action="find">찾기</button>
<button data-action="pick-open">
단가 고르기(자재·노임·기계·일위·단가산출·견적·실적)
</button>
<button data-action="uses-data-search">데이터 사용위치 찾기</button>
<button data-action="replace-cost-words">산출근거 문구 바꾸기</button>
<button data-action="set-cost-line-kind">산출근거 비목 지정/해제</button>
+4 -1
View File
@@ -597,7 +597,10 @@ const EstBusinessActions = (() => {
});
},
);
} else if (EstBlockActions.handle(name, state)) {
} else if (
EstBlockActions.handle(name, state) ||
(typeof EstPick !== "undefined" && EstPick.handle(name, state))
) {
EstApp.render();
} else if (name === "load-contract" || name === "load-bid") {
purpose = name;
+238
View File
@@ -0,0 +1,238 @@
// 원본 TFRMSERCHPROJECT(검색어·여러개선택·동시검색·적용)와 TFRMDBLCLICKRTN(데이타 변경 확인)을 옮긴 단가 고르기.
// 내역서·일위대가 행에 기초자료를 골라 넣거나 선택 행의 연결을 바꾼다. 단가산출 산출근거(LineData)는 대상이 아니다.
const EstPick = (() => {
const kinds = [
[5, "StuffcDat", "자재"],
[6, "LaborcDat", "노임"],
[105, "MachTitleDat", "기계경비"],
[103, "HeadTitleDat", "일위대가"],
[104, "CostTitleDat", "단가산출"],
[101, "EstiMateDat", "견적"],
[21, "SilJukDat", "실적"],
];
const views = ["EstMainDat", "HeadMainDat"];
const limit = 300;
const text = (value) =>
String(value ?? "")
.normalize("NFKC")
.toLowerCase()
.replace(/\s+/g, "");
const amount = (row) => {
const total = Number(row.TC) || 0;
return total || ["NC", "GC", "JC", "SC"].reduce((sum, key) => sum + (Number(row[key]) || 0), 0);
};
function items(project, kind) {
const list = [];
for (const [k, table, label] of kinds) {
if (kind && k !== kind) continue;
for (const row of project.tables[table] || [])
if (row.Code && Number(row.Index ?? 1) >= 0)
list.push({
kind: k,
label,
Code: row.Code,
Name: row.Name ?? "",
Size: row.Size ?? "",
Unite: row.Unite ?? "",
price: amount(row),
});
}
return list;
}
function search(project, kind, query) {
const words = String(query || "")
.split(/\s+/)
.map(text)
.filter(Boolean);
const found = items(project, kind).filter((item) => {
const haystack = text(`${item.Code} ${item.Name} ${item.Size}`);
return words.every((word) => haystack.includes(word));
});
return { rows: found.slice(0, limit), total: found.length };
}
function guard(view, group, picks) {
if (!views.includes(view))
throw Error("내역서·일위대가 화면에서만 단가를 골라 넣을 수 있습니다.");
if (!picks.length) throw Error("넣을 자료를 하나 이상 고르세요.");
if (view === "HeadMainDat" && picks.some((p) => p.kind === 103 && p.Code === group))
throw Error("일위대가 자신을 하위 자료로 넣을 수 없습니다.");
}
function linked(base, pick) {
return {
...base,
Code: pick.Code,
Name: pick.Name,
Size: pick.Size,
Unite: pick.Unite,
LinkIndex: pick.kind,
};
}
function insert(project, view, at, group, picks) {
guard(view, group, picks);
const rows = project.tables[view];
const added = [];
for (const pick of picks) {
const row = linked(EstModel.newRow(view, rows, project), pick);
rows.splice(at + 1 + added.length, 0, row);
added.push(at + 1 + added.length);
}
try {
refresh(project, view, added);
} catch (error) {
rows.splice(at + 1, added.length);
throw error;
}
return added;
}
function refresh(project, view, indices) {
// 내역·일위대가 행은 요청한 행만 원천 단가를 다시 읽는다(행 비목 지정과 같은 경로).
let failures = null;
const done = EstNative.calculate(
EstModel.validate(structuredClone(project)),
view === "HeadMainDat"
? { refreshHeadRows: indices, onHeadRefresh: (errors) => (failures = errors) }
: { refreshEstimateRows: indices, onEstimateRefresh: (errors) => (failures = errors) },
);
if (!failures) throw Error("고른 자료의 단가를 다시 읽지 못했습니다.");
if (failures.length) throw Error(failures.join(" / "));
for (const index of indices)
for (const key of EstMath.units)
project.tables[view][index][key] = done.tables[view][index][key];
}
function replaceable(target) {
if (
!target ||
Number(target.Index) > 0 ||
[54, 55, 57, 58, 61, 62, 67, 68].includes(Number(target.LinkIndex))
)
throw Error("일반 내역 행 하나를 선택하세요. 제목·요율·순공사비 행은 바꿀 수 없습니다.");
}
function replace(project, view, index, group, pick, all) {
guard(view, group, [pick]);
const rows = project.tables[view],
target = rows[index];
replaceable(target);
const old = { kind: Number(target.LinkIndex) || 0, Code: target.Code };
const changed = [],
backup = structuredClone(rows);
rows.forEach((row, i) => {
const same =
i === index ||
(all &&
old.Code &&
row.Code === old.Code &&
Number(row.LinkIndex) === old.kind &&
Number(row.Index ?? 0) <= 0);
if (!same) return;
Object.assign(row, linked({}, pick));
changed.push(i);
});
try {
refresh(project, view, changed);
} catch (error) {
project.tables[view] = backup;
throw error;
}
return changed;
}
function cell(value) {
return EstApp.esc(value);
}
function results(state) {
const pick = state.pick,
{ rows, total } = search(state.project, pick.kind, pick.query);
pick.rows = rows;
const body = rows
.map(
(item, i) =>
`<tr><td><input type="checkbox" name="pick-row" value="${i}"></td><td>${cell(item.label)}</td><td>${cell(item.Code)}</td><td style="text-align:left">${cell(item.Name)}</td><td>${cell(item.Size)}</td><td>${cell(item.Unite)}</td><td style="text-align:right">${EstApp.number(item.price)}</td></tr>`,
)
.join("");
return `<div style="max-height:380px;overflow-y:auto;border:1px solid #ccc"><table class="sheet" style="width:100%;font-size:12px"><thead><tr><th></th><th>구분</th><th>코드</th><th>품명</th><th>규격</th><th>단위</th><th>단가</th></tr></thead><tbody>${body}</tbody></table></div><p>찾은 자료 ${total}건${total > limit ? ` 중 앞 ${limit}건 표시 — 검색어를 더 넣으세요` : ""}</p>`;
}
function open(state) {
if (!views.includes(state.view))
throw Error("내역서·일위대가 화면에서만 단가를 골라 넣을 수 있습니다.");
state.pick ||= { kind: 0, query: "" };
const options = [[0, "전체 동시검색"], ...kinds.map(([k, , label]) => [k, label])]
.map(
([k, label]) =>
`<option value="${k}" ${k === state.pick.kind ? "selected" : ""}>${cell(label)}</option>`,
)
.join("");
EstApp.showDialog(
"단가 고르기",
`<div class="form-fields"><label>자료<select name="pick-kind">${options}</select></label><label>검색어<input name="pick-query" value="${cell(state.pick.query)}"></label></div><button data-action="pick-search">검색</button><div id="pick-results">${results(state)}</div><button data-action="pick-insert">선택 행 아래에 넣기</button> <button data-action="pick-replace">선택 행을 이것으로 바꾸기</button>`,
);
}
function chosen(state) {
return [...document.querySelectorAll('#dialog-content [name="pick-row"]:checked')].map(
(node) => state.pick.rows[Number(node.value)],
);
}
function confirmReplace(target, pick) {
const line = (row) =>
`${row.Name || ""} / ${row.Size || ""} / ${row.Unite || ""}${row.Code ? ` [${row.Code}]` : ""}`;
return confirm(
`데이타 변경 확인\n변경 전: ${line(target)}\n변경 후: ${line(pick)}\n\n변경하시겠습니까?`,
);
}
function handle(name, state) {
if (name === "pick-open") open(state);
else if (name === "pick-search") {
state.pick.kind = Number(document.querySelector('#dialog-content [name="pick-kind"]').value);
state.pick.query = document.querySelector('#dialog-content [name="pick-query"]').value;
EstApp.byId("pick-results").innerHTML = results(state);
} else if (name === "pick-insert") {
const picks = chosen(state),
rows = state.project.tables[state.view];
guard(state.view, state.group, picks);
const group = EstNative.groups(rows).get(state.group);
const at =
[...state.selection].sort((a, b) => a - b).at(-1) ??
(group ? group.start + group.rows.length - 1 : rows.length - 1);
EstApp.byId("dialog").close();
EstApp.change((project) => {
state.selection = new Set(insert(project, state.view, at, state.group, picks));
});
state.notice = `${picks.length}개 자료를 넣었습니다.`;
} else if (name === "pick-replace") {
const picks = chosen(state),
index = [...state.selection][0];
if (picks.length !== 1 || state.selection.size !== 1)
throw Error("바꿀 행 하나와 자료 하나를 고르세요.");
const target = state.project.tables[state.view][index];
guard(state.view, state.group, picks);
replaceable(target);
if (!confirmReplace(target, picks[0])) return true;
const all =
Boolean(target.Code) &&
confirm(
`같은 코드(${target.Code})를 쓰는 이 표의 다른 행도 함께 바꾸시겠습니까?\n[취소] 현재 행만 변경`,
);
EstApp.byId("dialog").close();
let changed = [];
EstApp.change((project) => {
changed = replace(project, state.view, index, state.group, picks[0], all);
});
state.notice = `${changed.length}개 행의 연결을 바꿨습니다.`;
} else return false;
return true;
}
return { kinds, items, search, insert, replace, handle };
})();
+1
View File
@@ -57,6 +57,7 @@ const allowed = new Set([
"native-actions.js",
"native-business-actions.js",
"native-block-actions.js",
"native-pick.js",
"native-issues.js",
"native-attachments.js",
"native-standard.js",