feat(est-test): 무관자료 삭제와 내역서순 정리
- 원본 처리 근거대로 내역서가 쓰는 자료만 하위까지 따라가 남기고 나머지 삭제 - 내역서 첫 등장 순서 재배열, 묶음 앞 빈 줄 정리, 미연결 수량산출서 삭제 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
"""EST 테스트 앱 무관자료 삭제(원본 mnuNotLinkDelete·DelSort·MulDelete)."""
|
||||
|
||||
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-cleanup.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_unused_rows_removed_and_totals_unchanged():
|
||||
result = run_js(
|
||||
"""(() => {
|
||||
const calc = (p) => EstNative.calculate(EstModel.validate(p));
|
||||
const p = calc(structuredClone(source));
|
||||
p.tables.StuffcDat.push({ Code: 'MZZZ', Name: '안 쓰는 자재', Index: 999, Cost1: 1, ValueIndex: 0 });
|
||||
p.tables.HeadMainDat.push({ Index: 900, Code: 'UZZZ', Name: '안 쓰는 일위', TitleCode: 'UZZZ' },
|
||||
{ Code: 'MZZZ', LinkIndex: 5, GN: 1 });
|
||||
const before = calc(p), total = before.calculation.total, direct = before.calculation.direct.TC;
|
||||
const counts = Object.fromEntries(['StuffcDat','LaborcDat','HeadMainDat','CostMainDat','MachMainDat'].map((t) => [t, p.tables[t].length]));
|
||||
const removed = EstCleanup.clean(p, false);
|
||||
const after = calc(p);
|
||||
return { total: after.calculation.total === total, direct: after.calculation.direct.TC === direct,
|
||||
errors: after.calculation.errors.length,
|
||||
gone: !p.tables.StuffcDat.some((r) => r.Code === 'MZZZ') && !EstNative.groups(p.tables.HeadMainDat).has('UZZZ'),
|
||||
removedStuff: removed.StuffcDat >= 1, removedHead: removed.HeadMainDat >= 1,
|
||||
estimate: p.tables.EstMainDat.length === before.tables.EstMainDat.length };
|
||||
})()"""
|
||||
)
|
||||
assert result == {
|
||||
"total": True,
|
||||
"direct": True,
|
||||
"errors": 0,
|
||||
"gone": True,
|
||||
"removedStuff": True,
|
||||
"removedHead": True,
|
||||
"estimate": True,
|
||||
}
|
||||
|
||||
|
||||
def test_sorted_mode_follows_first_use_and_keeps_codes():
|
||||
result = run_js(
|
||||
"""(() => {
|
||||
const calc = (p) => EstNative.calculate(EstModel.validate(p));
|
||||
const p = calc(structuredClone(source));
|
||||
const order = EstCleanup.used(p);
|
||||
const total = p.calculation.total;
|
||||
const codes = new Set(p.tables.HeadTitleDat.map((r) => r.Code));
|
||||
EstCleanup.clean(p, true);
|
||||
const after = calc(p);
|
||||
return { heads: [...EstNative.groups(p.tables.HeadMainDat).keys()].join() === order.get('HeadMainDat').join(),
|
||||
stuff: p.tables.StuffcDat.map((r) => r.Code).join() === order.get('StuffcDat').join(),
|
||||
codes: p.tables.HeadTitleDat.every((r) => codes.has(r.Code)),
|
||||
total: after.calculation.total === total, errors: after.calculation.errors.length };
|
||||
})()"""
|
||||
)
|
||||
assert result == {"heads": True, "stuff": True, "codes": True, "total": True, "errors": 0}
|
||||
|
||||
|
||||
def test_blank_rows_before_heads_and_quantity_sheets():
|
||||
result = run_js(
|
||||
"""(() => {
|
||||
const rows = [{ Index: 1, Code: 'A' }, { Name: 'x', GN: 1 }, {}, {}, { Index: 2, Code: 'B' }, {}];
|
||||
const trimmed = EstCleanup.trimBeforeHeads(rows, 'HeadMainDat').map((r) => r.Code || r.Name || '-');
|
||||
const p = { tables: { EstMainDat: [{ PkID: 7, Code: 'D1' }] }, quantitySheets: [
|
||||
{ Code: 'Q1', items: [{ TargetKey: 'id:7' }] }, { Code: 'Q2', items: [{ TargetCode: 'NONE' }] }, { Code: 'Q3', items: [] }] };
|
||||
const gone = EstCleanup.cleanQuantity(p);
|
||||
return { trimmed, gone, left: p.quantitySheets.map((s) => s.Code) };
|
||||
})()"""
|
||||
)
|
||||
assert result == {"trimmed": ["A", "x", "B", "-"], "gone": 2, "left": ["Q1"]}
|
||||
@@ -460,6 +460,8 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
|
||||
- [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, 해제 → 「원가금액」 복귀, 샘플 복구.
|
||||
- 남음: 당초분·정산분 색상 옵션.
|
||||
- [x] 무관자료 삭제(원본 mnuNotLinkDelete·DelSort → f039f0, MulDelete → 1116db0 — 디스어셈블 근거) — 내역서에서 LinkIndex 5·6·21·101·103·104·105·Code(설계변경 Code_ 포함)로 하위까지 따라가 쓰는 자료만 남김, 묶음 머리 앞 빈 줄 정리, 내역서순 재배열(코드 유지), 내역 미연결 수량산출서 삭제. 원본의 한 번 훑기 누락(기계경비 안에서만 쓰인 일위대가 하위)은 웹에서 끝까지 따라가 막음. LinkIndex 211–218 코드 비우기는 뜻 미확인으로 하지 않음.
|
||||
- 검증완료: `test_est_cleanup.py` 3개(안 쓰는 자재·일위 삭제·합계 불변·오류 0, 내역서순 순서·코드 유지, 빈 줄·수량산출서) 통과. 설치 main·sample·산림토목 ESTX에서 정리 전후 도급액·오류 수 같음. ORCA 브라우저에서 내역서순 정리 → 자재 30 → 17·도급액 79,603,000 유지·되살리기 복원.
|
||||
- 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133.
|
||||
- [ ] 남은 핵심(실무 빈도순):
|
||||
1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기.
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
|
||||
내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다.
|
||||
|
||||
`편집 → 무관자료 삭제`는 원본처럼 내역서가 쓰는 자재·노임·실적·견적과 일위대가·단가산출·기계경비 호표를 하위까지 따라가 표시하고 나머지를 지웁니다. 내역서·원가계산서·기계경비 적용기준은 지우지 않고, 묶음 머리 앞의 빈 줄을 정리합니다. `(내역서순)`은 표를 내역서 첫 등장 순서로 다시 늘어놓으며 코드는 바꾸지 않습니다. `무관 수량산출서 삭제`는 내역에 연결되지 않은 수량산출서를 지웁니다. 모두 되살리기로 되돌릴 수 있습니다.
|
||||
|
||||
`원가계산서 → 정산원가계산서 작성/해제`는 원본처럼 원가 표에 당초금액 열을 금액 앞에 보이고 금액 열을 정산금액으로 바꿉니다. 처음 켤 때(또는 `당초금액 초기화`) 원가 행마다 현재 금액을 당초금액으로 옮기고 도급액 고정 연결(55)을 풀며, 맨 아래 금액 행의 차이를 `(감)`·`(증)` 금액 행으로 적습니다. 켜져 있는 동안 재계산마다 차액 행을 다시 씁니다. 끄면 차액 행만 비우고 당초금액은 남깁니다.
|
||||
|
||||
`원가계산서 → 원가계산서 양식 목록·등록`은 현재 원가계산서의 항목·수식·요율을 이름 붙여 이 앱 `data/forms.json`에 등록하고, 목록에서 열기·삭제하며, 파일로 내려받거나 불러옵니다. 금액은 남기지 않고 열 때 다시 계산하므로 도급액 고정은 풀립니다.
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
<script defer src="native-transfer.js"></script>
|
||||
<script defer src="native-bid.js"></script>
|
||||
<script defer src="native-forms-library.js"></script>
|
||||
<script defer src="native-cleanup.js"></script>
|
||||
<script defer src="native-actions.js"></script>
|
||||
<script defer src="native-business-actions.js"></script>
|
||||
<script defer src="native-app.js"></script>
|
||||
@@ -116,6 +117,10 @@
|
||||
<button data-action="clear-estimate-quantities">내역 수량 초기화</button>
|
||||
<button data-action="bid-rates">낙찰률 적용</button
|
||||
><button data-action="restore-bid">당초단가(100%) 복원</button>
|
||||
<hr />
|
||||
<button data-action="notlink-delete">무관자료 삭제</button
|
||||
><button data-action="notlink-sort">무관자료 삭제(내역서순)</button
|
||||
><button data-action="notlink-quantity">무관 수량산출서 삭제</button>
|
||||
</div>
|
||||
</details>
|
||||
<details>
|
||||
|
||||
@@ -607,7 +607,8 @@ const EstBusinessActions = (() => {
|
||||
(typeof EstTransfer !== "undefined" && EstTransfer.handle(name, state)) ||
|
||||
(typeof EstBid !== "undefined" && EstBid.handle(name, state)) ||
|
||||
(typeof EstFormsLibrary !== "undefined" && EstFormsLibrary.handle(name, state, node)) ||
|
||||
(typeof EstJungSang !== "undefined" && EstJungSang.handle(name, state))
|
||||
(typeof EstJungSang !== "undefined" && EstJungSang.handle(name, state)) ||
|
||||
(typeof EstCleanup !== "undefined" && EstCleanup.handle(name, state))
|
||||
) {
|
||||
EstApp.render();
|
||||
} else if (name === "load-contract" || name === "load-bid") {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// 원본 무관자료 삭제(TfrmEstMain mnuNotLinkDelete 0x10cd2a8·mnuNotLinkDelSort 0x10cd2b8 → f039f0,
|
||||
// mnuNotLinkMulDelete 0x10cd2c8 → 1116db0). 내역서가 쓰는 자료(LinkIndex 5·6·21·101·103·104·105, Code 일치)를
|
||||
// 일위대가·단가산출·기계경비 묶음 안까지 따라가 표시하고 나머지 기초·묶음 자료를 지운다. 내역서·원가·적용기준은 지우지 않는다.
|
||||
// 내역서순: 내역서 첫 등장 순서(중첩은 깊이 우선)로 표를 다시 늘어놓고, 코드는 바꾸지 않는다.
|
||||
// 설계변경 당초 코드(Code_)도 함께 따라간다. 원본은 내역서→일위→단가→기계 차례 한 번 훑기라 기계경비 안에서만
|
||||
// 쓰인 일위대가의 하위를 놓칠 수 있으나, 웹은 끝까지 따라가 쓰는 자료를 지우지 않는다.
|
||||
const EstCleanup = (() => {
|
||||
const flat = { 5: "StuffcDat", 6: "LaborcDat", 21: "SilJukDat", 101: "EstiMateDat" };
|
||||
const grouped = {
|
||||
103: ["HeadTitleDat", "HeadMainDat"],
|
||||
104: ["CostTitleDat", "CostMainDat"],
|
||||
105: ["MachTitleDat", "MachMainDat"],
|
||||
};
|
||||
const num = (value) => Number(value) || 0;
|
||||
const text = (value) => String(value ?? "").trim();
|
||||
|
||||
function used(project) {
|
||||
const order = new Map(Object.values(flat).map((t) => [t, []])),
|
||||
seen = new Set(),
|
||||
groups = {};
|
||||
for (const [, [, main]] of Object.entries(grouped)) {
|
||||
order.set(main, []);
|
||||
groups[main] = EstNative.groups(project.tables[main] || []);
|
||||
}
|
||||
function visit(rows) {
|
||||
for (const row of rows)
|
||||
for (const code of new Set([row.Code, row.Code_].filter(Boolean))) {
|
||||
const kind = num(row.LinkIndex),
|
||||
id = `${kind}:${code}`;
|
||||
if (seen.has(id)) continue;
|
||||
if (flat[kind]) {
|
||||
if ((project.tables[flat[kind]] || []).some((r) => r.Code === code)) {
|
||||
seen.add(id);
|
||||
order.get(flat[kind]).push(code);
|
||||
}
|
||||
} else if (grouped[kind]) {
|
||||
const [, main] = grouped[kind],
|
||||
group = groups[main].get(code);
|
||||
if (!group) continue;
|
||||
seen.add(id);
|
||||
order.get(main).push(code);
|
||||
visit(group.rows.slice(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(project.tables.EstMainDat || []);
|
||||
return order;
|
||||
}
|
||||
|
||||
function blankRow(table, row) {
|
||||
if (table === "CostMainDat") return !text(row.LineData);
|
||||
return (
|
||||
!text(row.Name) &&
|
||||
!text(row.Size) &&
|
||||
!text(row.Unite) &&
|
||||
!(typeof row.Bigo === "string" && text(row.Bigo)) &&
|
||||
!num(row.GN) &&
|
||||
!num(row.TC)
|
||||
);
|
||||
}
|
||||
// 빈 줄 정리(f038ac): 묶음 머리 바로 앞의 빈 행을 지운다.
|
||||
function trimBeforeHeads(rows, table) {
|
||||
return rows.filter((row, i) => {
|
||||
if (!blankRow(table, row) || num(row.Index) > 0) return true;
|
||||
let next = i + 1;
|
||||
while (next < rows.length && blankRow(table, rows[next]) && !(num(rows[next].Index) > 0))
|
||||
next++;
|
||||
return !(next < rows.length && num(rows[next].Index) > 0);
|
||||
});
|
||||
}
|
||||
|
||||
function clean(project, sorted) {
|
||||
const order = used(project),
|
||||
removed = {};
|
||||
for (const table of Object.values(flat)) {
|
||||
const rows = project.tables[table] || [],
|
||||
keep = order.get(table),
|
||||
byCode = new Map(rows.map((r) => [r.Code, r]));
|
||||
const next = sorted
|
||||
? keep.map((code) => byCode.get(code))
|
||||
: rows.filter((r) => keep.includes(r.Code));
|
||||
removed[table] = rows.length - next.length;
|
||||
project.tables[table] = next;
|
||||
}
|
||||
for (const [title, main] of Object.values(grouped)) {
|
||||
const keep = order.get(main),
|
||||
groups = EstNative.groups(project.tables[main] || []),
|
||||
before = groups.size;
|
||||
const codes = sorted ? keep : [...groups.keys()].filter((code) => keep.includes(code));
|
||||
project.tables[main] = trimBeforeHeads(
|
||||
codes.flatMap((code) => groups.get(code)?.rows || []),
|
||||
main,
|
||||
);
|
||||
const titles = project.tables[title] || [],
|
||||
byCode = new Map(titles.map((r) => [r.Code, r]));
|
||||
project.tables[title] = sorted
|
||||
? codes.map((code) => byCode.get(code)).filter(Boolean)
|
||||
: titles.filter((r) => keep.includes(r.Code));
|
||||
removed[main] = before - codes.length;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
// 수량산출서: 내역 행이 쓰는(MullangIndex 9999·MulCode 앞 조각) 산출서와, 항목이 내역에 연결된 산출서만 남긴다.
|
||||
function cleanQuantity(project) {
|
||||
const sheets = project.quantitySheets || [],
|
||||
estimate = project.tables.EstMainDat || [],
|
||||
codes = new Set(estimate.map((r) => r.Code).filter(Boolean)),
|
||||
keys = new Set(estimate.map((r) => r.PkID != null && `id:${r.PkID}`).filter(Boolean)),
|
||||
named = new Set(
|
||||
estimate
|
||||
.filter((r) => num(r.MullangIndex) === 9999 && typeof r.MulCode === "string")
|
||||
.map((r) => r.MulCode.split("\t")[0]),
|
||||
);
|
||||
const keep = sheets.filter(
|
||||
(sheet) =>
|
||||
named.has(sheet.Code) ||
|
||||
(sheet.items || []).some(
|
||||
(item) =>
|
||||
(item.TargetKey && keys.has(item.TargetKey)) ||
|
||||
(item.TargetCode && codes.has(item.TargetCode)),
|
||||
),
|
||||
);
|
||||
project.quantitySheets = keep;
|
||||
return sheets.length - keep.length;
|
||||
}
|
||||
|
||||
const WARN =
|
||||
"내역서상에 사용한 데이타(수량산출)만 남기고 나머지는 삭제합니다.\n삭제된 자료는 되살리기로만 복구할 수 있습니다.";
|
||||
function handle(name, state) {
|
||||
if (name === "notlink-delete" || name === "notlink-sort") {
|
||||
if (!confirm(WARN)) return true;
|
||||
let removed;
|
||||
EstApp.change((project) => (removed = clean(project, name === "notlink-sort")));
|
||||
const total = Object.values(removed).reduce((a, b) => a + b, 0);
|
||||
state.notice = `안 쓰는 자료 ${total}건을 지웠습니다${name === "notlink-sort" ? "(내역서 순서로 정리)" : ""}.`;
|
||||
} else if (name === "notlink-quantity") {
|
||||
if (!confirm(WARN)) return true;
|
||||
let removed;
|
||||
EstApp.change((project) => (removed = cleanQuantity(project)), false);
|
||||
state.notice = `내역에 연결되지 않은 수량산출서 ${removed}개를 지웠습니다.`;
|
||||
} else return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
return { used, clean, cleanQuantity, trimBeforeHeads, handle };
|
||||
})();
|
||||
@@ -67,6 +67,7 @@ const allowed = new Set([
|
||||
"native-transfer.js",
|
||||
"native-bid.js",
|
||||
"native-forms-library.js",
|
||||
"native-cleanup.js",
|
||||
"native-issues.js",
|
||||
"native-attachments.js",
|
||||
"native-standard.js",
|
||||
|
||||
Reference in New Issue
Block a user