feat(est-test): 동원 확인 탭(장비·단산·일위 대조)·내역서 줄별 펼침

- 호표 1 단위 펼친 항목 합과 호표 금액 대조, 10원 넘게 어긋나면 표시
- 당초값·계산값(노무·재료·경비·실적)과 항목 목록
- 동원 계산에 호표 단위 펼치기(roots) 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 19:33:32 +09:00
co-authored by Claude Opus 5.5
parent 4cf7ebd783
commit b1c7d3dbc3
9 changed files with 167 additions and 3 deletions
+1
View File
@@ -39,6 +39,7 @@ for (const file of [
"native-annual-copy.js",
"native-print-range.js",
"native-work-mode.js",
"native-dongwon-check.js",
"native-business-actions.js",
"native-forms.js",
"native-excel-to-est.js",
@@ -0,0 +1,26 @@
"""동원 확인 탭: 호표 1 단위 펼침 합과 호표 금액 대조(10원 넘으면 표시)."""
from test_est_rea_parity import run
def test_head_check_flags_mismatch_over_ten_won():
result = run(
"math",
"""(()=>{
const p=EstNative.empty();p.options.SourceRefresh=false;
p.tables.LaborcDat=[{Code:'L1',Name:'보통인부',TC:100,NC:100}];
p.tables.StuffcDat=[{Code:'M1',Name:'자갈',TC:50,GC:50}];
p.tables.HeadMainDat=[{Code:'H1',Index:1,Name:'A'},{Code:'L1',LinkIndex:6,GN:2,NU:100,NC:200},
{Code:'M1',LinkIndex:5,GN:1,GU:50,GC:50}];
const q=EstNative.calculate(p);
const a=EstDongwonCheck.check(q,103).rows[0];
q.tables.HeadTitleDat[0].TC=a.TC+11;
const b=EstDongwonCheck.check(q,103).rows[0];
return {tc:a.TC,sum:Math.trunc(a.sum),calc:[a.calc.NC,a.calc.GC],a:a.checked,b:b.checked,
items:a.items.map(r=>r.Code).sort()};
})()""",
)
assert result["tc"] == 250 and result["sum"] == 250
assert result["calc"] == [200, 50]
assert result["a"] is False and result["b"] is True
assert result["items"] == ["L1", "M1"]
+2
View File
@@ -506,6 +506,8 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- [x] 작업모드 표준내역서·표준설계변경·최근 계약심사목록(spec_pc §4) — 작업모드 메뉴 내역서·설계변경·표준내역서·표준설계변경이 모드 값(CurrentWorkMode, ESTX 읽기·쓰기)을 바꿈. 일반↔표준 묶음이 바뀌면 지금 원가계산서를 옛 묶음 칸에 두고 새 묶음 것을 꺼냄(없으면 설치 양식목록 — 일반 WongaFileList·표준 SWongaFileList 각 30종 — 에서 고름, 번호 기준 계산). 원본은 모드를 바꿀 때 문서를 닫고 최근 목록을 띄우나 웹은 지금 작업 유지. 최근 계약심사목록 = 저장한 작업 중 계약심사 입력이 있는 것.
- 미결: 표준 양식의 산업안전보건관리비 식 「A × 0.496」 의 A 가 무엇인지 미확인 — 웹은 「참조 항목 없음: A」 로 알림(원본 확인 필요).
- 검증완료: 작업모드 시험 2개·ESTX 저장 시험 34개 통과. ORCA 브라우저에서 표준내역서로 바꿈 → 표준 양식 30종 → 열기, 내역서로 돌아오면 원래 원가계산서 31행 복원, 계약심사목록 창 확인, 원복.
- [x] 동원 확인·내역서 탭(TfrmDongWon Button1~4 · spec_part §4) — 동원 화면 단추 확인 장비·단산·일위: 호표마다 1 단위를 펼친 항목 금액 합과 호표 금액을 대조해 10원 넘게 어긋나면 표시(어긋난 것만 보기), 행을 고르면 당초값·계산값(노무·재료·경비·실적)·항목 목록. 내역서: 내역 줄마다 펼친 항목. 웹 동원 계산은 기계경비를 더 쪼개지 않아 장비 확인은 늘 맞음.
- 검증완료: 확인 시험 1개·동원 관련 시험 35개 통과. ORCA 브라우저에서 일위 8건 모두 일치·당초값=계산값, 항목 3개 표시.
- [x] 다른 공사 불러오기 창(원본 barbtnSelfLoad → TfrmOtherLoad, 찾기 popSerch) — 저장된 다른 작업을 골라 같은 표를 띄우고 찾기(품명·규격, A&B 둘 다)·고른 줄을 하위 자료와 함께 가져오기(내역서는 고른 줄 아래에 줄, 나머지는 자료·호표 합침, 같은 코드는 이 공사 것). 검증완료: 바인딩 통과, ORCA 브라우저에서 산불진화임도 작업의 일위대가 3개 → 「취수장」 1개 → 가져오기 호표 8→9·하위 41건, 샘플 복구.
- [x] 사용위치 검색 창(원본 TfrmUsesDataSerch btnSerch·btnAllDisplay) — 현재 표 항목을 품명·규격(빈칸·|·괄호 무시)으로 거르고 골라 사용위치 보기. 검증완료: 바인딩 통과, ORCA 브라우저에서 노임 9개 → 「보 통」 1개 → 보통인부 사용위치 11곳.
- [x] 인쇄양식 불러오기·저장하기(원본 mnuPrintFormatLoad·Save 0x10ed318·0x10ed000 — spec_pc §3) — .pfot(인쇄정보표 MIDAS 이진) 읽기·쓰기 `print-format.cjs`(설명 BLOB·빈 칸 표지까지 보존, 원본 파일 그대로 다시 쓰면 바이트 같음). 불러오면 공사 인쇄표를 그 값으로, 저장은 지금 인쇄표를 원본 설치 main.pfot 필드 설명자 위에(`EST_PFOT_TEMPLATE`). .gfot(cxGrid 열 배치)는 웹 격자와 호환되지 않아 하지 않음.
+1
View File
@@ -27,6 +27,7 @@
<script defer src="native-annual-copy.js"></script>
<script defer src="native-print-range.js"></script>
<script defer src="native-work-mode.js"></script>
<script defer src="native-dongwon-check.js"></script>
<script defer src="native-actions-more.js"></script>
<script defer src="native-wonga.js"></script>
<script defer src="native-jungsang.js"></script>
+1
View File
@@ -12,6 +12,7 @@ const EstActionsMore = (() => {
typeof EstAnnualCopy !== "undefined" && EstAnnualCopy,
typeof EstPrintRange !== "undefined" && EstPrintRange,
typeof EstWorkMode !== "undefined" && EstWorkMode,
typeof EstDongwonCheck !== "undefined" && EstDongwonCheck,
].filter(Boolean);
function handle(name, state, node) {
for (const module of modules()) if (module.handle(name, state, node)) return true;
+122
View File
@@ -0,0 +1,122 @@
// 원본 동원인원·자재·장비 「확인」·「내역서」 탭(TfrmDongWon Button1~4 — spec_part §4): 동원 계산이 펼친 중간 결과 보기.
// 장비·단산·일위 = 호표마다 1 단위를 동원 항목으로 펼쳐 금액 합을 내고, 호표 금액(TC)과 10원 넘게 어긋나면 표시(1095cdc
// Checked := |TC − Trunc(Σ Cost)| > 10). 행을 고르면 당초값(호표 TC·NC·GC·JC)과 계산값(항목 금액을 노무·재료·경비·실적으로
// 나눠 더한 값)·항목 목록. 내역서 = 내역 줄마다 펼친 항목(점검 없음). 웹 동원 계산은 기계경비를 더 쪼개지 않아 장비는 늘 맞음.
const EstDongwonCheck = (() => {
const num = (v) => Number(v) || 0;
const TITLE = { 103: "HeadTitleDat", 104: "CostTitleDat", 105: "MachTitleDat" };
const KIND = { 5: "자재", 6: "노임", 21: "표준", 101: "견적", 105: "기계" };
// 항목 → 비목: 노임·노무비 금액분 = 노무, 자재·견적·재료비 금액분 = 재료, 기계·경비 = 경비, 표준 = 실적
const part = (item) =>
item.Code === "LZZZZZZZZZZ" || item.kind === 6
? "NC"
: item.Code === "EZZZZZZZZZZ" || item.kind === 105
? "JC"
: item.Code === "SZZZZZZZZZZ" || item.kind === 21
? "SC"
: "GC";
function check(project, kind) {
const titles = (project.tables[TITLE[kind]] || []).filter((t) => t.Code);
const out = EstResources.calculate(
project,
true,
titles.map((t) => ({ kind, code: t.Code, WorkCode: t.Code, WorkName: t.Name })),
);
const items = new Map();
for (const row of out.rows)
(items.get(row.WorkCode) || items.set(row.WorkCode, []).get(row.WorkCode)).push(row);
return {
errors: out.errors,
rows: titles.map((t) => {
const list = items.get(t.Code) || [],
sum = list.reduce((s, r) => s + num(r.amount), 0),
calc = { NC: 0, GC: 0, JC: 0, SC: 0 };
for (const r of list) calc[part(r)] += num(r.amount);
return {
Code: t.Code,
Name: t.Name,
Size: t.Size,
TC: num(t.TC),
NC: num(t.NC),
GC: num(t.GC),
JC: num(t.JC),
sum,
calc,
checked: Math.abs(num(t.TC) - Math.trunc(sum)) > 10,
items: list,
};
}),
};
}
function estimate(project) {
const out = EstResources.calculate(project, true),
groups = new Map();
for (const row of out.rows) {
const key = `${row.WorkCode}\u0000${row.WorkName}`;
if (!groups.has(key)) groups.set(key, { Code: row.WorkCode, Name: row.WorkName, items: [] });
groups.get(key).items.push(row);
}
return { errors: out.errors, rows: [...groups.values()] };
}
let shown = null;
function draw(state) {
const { esc, number } = EstApp,
{ kind, data, pick, only } = shown;
const list = data.rows.map((r, i) => [r, i]).filter(([r]) => !only || r.checked);
const chosen = data.rows[pick];
const head =
kind === "est"
? "<tr><th>코드</th><th>공종</th><th>항목 수</th></tr>"
: "<tr><th>코드</th><th>품명</th><th>규격</th><th>호표 금액</th><th>항목 합</th><th>어긋남</th></tr>";
const body = list
.map(([r, i]) =>
kind === "est"
? `<tr data-dw-pick="${i}" style="cursor:pointer${i === pick ? ";background:#dde8ff" : ""}"><td>${esc(r.Code)}</td><td style="text-align:left">${esc(r.Name)}</td><td>${r.items.length}</td></tr>`
: `<tr data-dw-pick="${i}" style="cursor:pointer${i === pick ? ";background:#dde8ff" : ""}${r.checked ? ";color:#b00" : ""}"><td>${esc(r.Code)}</td><td style="text-align:left">${esc(r.Name)}</td><td>${esc(r.Size ?? "")}</td><td>${number(r.TC)}</td><td>${number(Math.trunc(r.sum))}</td><td>${r.checked ? "✔" : ""}</td></tr>`,
)
.join("");
const detail = chosen
? (kind === "est"
? ""
: `<p>당초값 계 ${number(chosen.TC)} · 노무 ${number(chosen.NC)} · 재료 ${number(chosen.GC)} · 경비 ${number(chosen.JC)}<br>계산값 계 ${number(Math.trunc(chosen.sum))} · 노무 ${number(Math.trunc(chosen.calc.NC))} · 재료 ${number(Math.trunc(chosen.calc.GC))} · 경비 ${number(Math.trunc(chosen.calc.JC))}${chosen.calc.SC ? ` · 실적 ${number(Math.trunc(chosen.calc.SC))}` : ""}</p>`) +
`<table class="sheet"><thead><tr><th>종류</th><th>코드</th><th>품명</th><th>규격</th><th>소요량</th><th>금액</th></tr></thead><tbody>${chosen.items.map((r) => `<tr><td>${esc(KIND[r.kind] || r.kind)}</td><td>${esc(r.Code)}</td><td style="text-align:left">${esc(r.Name)}</td><td>${esc(r.Size ?? "")}</td><td>${number(r.quantity)}</td><td>${number(r.amount)}</td></tr>`).join("")}</tbody></table>`
: "<p>위에서 행을 고르세요.</p>";
const title = { 105: "확인 — 장비", 104: "확인 — 단산", 103: "확인 — 일위", est: "내역서" }[
kind
];
EstApp.showDialog(
`동원인원,자재,장비 · ${title}`,
`${kind === "est" ? "" : `<label><input type="checkbox" data-dw-only ${only ? "checked" : ""}> 어긋난 것만 (${data.rows.filter((r) => r.checked).length}건)</label>`} <span>${data.rows.length}건</span><div style="max-height:260px;overflow:auto"><table class="sheet"><thead>${head}</thead><tbody>${body}</tbody></table></div><div style="max-height:260px;overflow:auto;margin-top:6px">${detail}</div>${data.errors.length ? `<p class="business-note">${esc(data.errors.slice(0, 5).join(" / "))}</p>` : ""}`,
);
}
function install() {
document.addEventListener("click", (event) => {
const tr = event.target.closest?.("[data-dw-pick]");
if (!tr || !shown) return;
shown.pick = Number(tr.dataset.dwPick);
draw(EstApp.state);
});
document.addEventListener("change", (event) => {
if (event.target.dataset?.dwOnly === undefined || !shown) return;
shown.only = event.target.checked;
draw(EstApp.state);
});
}
if (typeof document !== "undefined" && document.addEventListener) install();
function handle(name, state, node) {
if (name !== "dongwon-check") return false;
const kind = node.dataset.kind === "est" ? "est" : Number(node.dataset.kind);
shown = {
kind,
data: kind === "est" ? estimate(state.project) : check(state.project, kind),
pick: -1,
only: false,
};
draw(state);
return true;
}
return { check, estimate, handle };
})();
+5 -1
View File
@@ -220,7 +220,11 @@ const EstExtra = (() => {
});
html = box(
"동원인원·자재·장비",
`${kindSelect} ${byWorkCheck} <span>(총 ${filtered.length}건)</span>`,
`${kindSelect} ${byWorkCheck} <span>(총 ${filtered.length}건)</span>` +
button("dongwon-check", "확인 장비", 'data-kind="105"') +
button("dongwon-check", "확인 단산", 'data-kind="104"') +
button("dongwon-check", "확인 일위", 'data-kind="103"') +
button("dongwon-check", "내역서(줄별)", 'data-kind="est"'),
table(headers, rows),
esc(output.errors.join(" / ")),
);
+8 -2
View File
@@ -1,6 +1,7 @@
const EstResources = (() => {
const { num, components } = EstMath;
function calculate(input, byWork = false) {
// roots: [{ kind, code, WorkCode, WorkName }] 를 주면 내역서 대신 그 호표마다 1 단위를 따로 펼친다(동원 확인 탭)
function calculate(input, byWork = false, roots = null) {
const project = EstNative.calculate({
...structuredClone(input),
options: { ...input.options, ResourceTracing: true },
@@ -180,7 +181,12 @@ const EstResources = (() => {
const source = tables[EstNative.sourceTables[kind]]?.find((row) => row.Code === code);
record(kind, code, multiplier * weightedRate(source, rates));
}
for (const [index, row] of tables.EstMainDat.entries()) {
for (const [index, root] of (roots || []).entries()) {
workKey = `${index}:`;
work = { WorkCode: root.WorkCode, WorkName: root.WorkName };
expand(root.kind, root.code, 1, []);
}
for (const [index, row] of (roots ? [] : tables.EstMainDat).entries()) {
if (num(row.LinkIndex) === 54) break;
if (
num(row.Level) > 0 ||
+1
View File
@@ -111,6 +111,7 @@ const allowed = new Set([
"native-annual-copy.js",
"native-print-range.js",
"native-work-mode.js",
"native-dongwon-check.js",
"native-actions-more.js",
"native-wonga-tools.js",
"native-estimate-print.js",