wip(est-test): 개발 중 저장 — 전역 깃 싱크(요구 686-2) · tools/est-test · resources/tester

This commit is contained in:
2026-11-08 12:12:37 +09:00
parent 5e72aff136
commit 1beaefa529
48 changed files with 7159 additions and 316 deletions
+268 -9
View File
@@ -1,5 +1,7 @@
const EstIssues = (() => {
let page = 0;
let page = 0,
tableFilter = "all",
typeFilter = "all";
const grouped = new Set(["HeadMainDat", "CostMainDat", "MachMainDat"]);
const kinds = {
5: "StuffcDat",
@@ -26,10 +28,26 @@ const EstIssues = (() => {
if (list.length > 1)
for (const { index } of list) issues.push({ table, index, code, message: "코드 중복" });
}
for (const table of ["EstMainDat", "HeadMainDat", "MachMainDat"]) {
for (const table of ["EstMainDat", "HeadMainDat", "MachMainDat", "CostMainDat"]) {
(project.tables[table] || []).forEach((row, index) => {
const link = Number(row.LinkIndex);
if (
Number(row.Level || 0) === 0 &&
Number(row.Index || 0) === 0 &&
[52, 57, 58, 62, 67].includes(link) &&
row.Unite &&
!["%", "%"].includes(String(row.Unite).trim())
) {
issues.push({
table,
index,
code: row.Code,
message: "단위 백분율(%) 불일치",
rowUnite: row.Unite,
});
}
if (Number(row.Level) > 0 || Number(row.Index) > 0 || !row.Code) return;
const codes = sources.get(Number(row.LinkIndex));
const codes = sources.get(link);
if (!codes) return;
const matches = codes.get(String(row.Code)) || [];
if (!matches.length)
@@ -37,7 +55,7 @@ const EstIssues = (() => {
else if (matches.length > 1)
issues.push({ table, index, code: row.Code, message: "연결 코드 중복" });
else
for (const field of ["Name", "Size"]) {
for (const field of ["Name", "Size", "Unite"]) {
const a = row[field],
b = matches[0].row[field];
if (a && b && EstNative.normalize(a) !== EstNative.normalize(b))
@@ -45,13 +63,95 @@ const EstIssues = (() => {
table,
index,
code: row.Code,
message: field === "Name" ? "연결 품명 차이" : "연결 규격 차이",
message:
field === "Name"
? "연결 품명 차이"
: field === "Size"
? "연결 규격 차이"
: "연결 단위 차이",
sourceName: matches[0].row.Name,
sourceSize: matches[0].row.Size,
sourceUnite: matches[0].row.Unite,
});
}
});
}
// 수량산출서 및 직접 수식표 항목의 내역 연결(TargetKey 또는 TargetCode/LinkCode) 검사:
// 실제 totals() 연결 규칙과 일치하도록:
// 1) item.TargetKey 가 있으면 row.PkID != null && `id:${row.PkID}` === item.TargetKey 우선 매칭
// 2) 없으면 code = item.TargetCode ?? item.LinkCode 로 row.Code === code 매칭
// 3) level > 0 (공종/비고행) 제외
const sourcesToCheck = [];
(project.quantitySheets || []).forEach((sheet) => {
(sheet.items || []).forEach((item, itemIndex) => {
sourcesToCheck.push({ item, sheetCode: sheet.Code, itemIndex });
});
});
(project.quantities || []).forEach((item, itemIndex) => {
sourcesToCheck.push({ item, sheetCode: "manual", itemIndex });
});
const estRows = (project.tables.EstMainDat || []).map((row, index) => ({ row, index }));
sourcesToCheck.forEach(({ item, sheetCode, itemIndex }) => {
const code = item.TargetCode ?? item.LinkCode;
if (!code && !item.TargetKey) {
issues.push({
table: "quantity",
sheetCode,
index: itemIndex,
code: item.Code,
message: "수량 내역 미연결",
unlinked: true,
item,
});
return;
}
const targets = estRows.filter(
({ row }) =>
!Number(row.Level) &&
(item.TargetKey
? row.PkID != null && `id:${row.PkID}` === item.TargetKey
: row.Code === code),
);
if (!targets.length) {
issues.push({
table: "quantity",
sheetCode,
index: itemIndex,
code: item.Code,
message: `수량 대상 내역 없음: ${item.TargetKey || code}`,
unlinked: true,
item,
});
} else if (targets.length > 1) {
issues.push({
table: "quantity",
sheetCode,
index: itemIndex,
code: item.Code,
message: `수량 대상 내역 중복: ${item.TargetKey || code}`,
});
}
});
const costs = project.tables.CostMainDat || [],
costGroups = EstNative.groups(costs);
if (typeof EstWonga !== "undefined" && typeof EstWonga.scan === "function") {
const direct = project.calculation?.direct || {};
const bases = {
NC: Number(direct.NC) || 0,
GC: Number(direct.GC) || 0,
JC: Number(direct.JC) || 0,
SC: Number(direct.SC) || 0,
TC: Number(direct.TC) || 0,
노무비: Number(direct.NC) || 0,
재료비: Number(direct.GC) || 0,
경비: Number(direct.JC) || 0,
실적: Number(direct.SC) || 0,
직접공사비: Number(direct.TC) || 0,
};
const wongaIssues = EstWonga.scan(project.tables?.Wonga, project.options || {}, bases);
issues.push(...wongaIssues);
}
for (const message of project.calculation?.errors || []) {
const text = String(message),
match = text.match(/^(.+):(\d+) /),
@@ -69,15 +169,108 @@ const EstIssues = (() => {
}
return issues;
}
function targetSelect(project, item, sheetCode, itemIndex) {
const { esc } = EstApp;
const rows = (project.tables?.EstMainDat || []).filter(
(row) => row.PkID != null && !Number(row.Level),
);
return `<select data-issue-target-select="${esc(sheetCode)}" data-issue-item-index="${itemIndex}" aria-label="연결 내역 선택"><option value="">내역 행 선택</option>${rows
.map((row) => {
const key = `id:${row.PkID}`;
return `<option value="${esc(key)}" ${item.TargetKey === key ? "selected" : ""}>${esc(row.Name || "내역")} · ${esc(row.Code || "코드 없음")} · ${esc(row.PkID)}</option>`;
})
.join("")}</select>`;
}
function render() {
const { state, esc, byId, titles } = EstApp,
issues = scan(state.project);
page = Math.max(0, Math.min(page, Math.ceil(issues.length / 100) - 1));
const rows = issues.slice(page * 100, page * 100 + 100);
allIssues = scan(state.project);
const tables = [...new Set(allIssues.map((i) => i.table || ""))];
const messages = [...new Set(allIssues.map((i) => i.message))];
const filtered = allIssues.filter((i) => {
if (tableFilter !== "all" && (i.table || "") !== tableFilter) return false;
if (typeFilter !== "all" && i.message !== typeFilter) return false;
return true;
});
page = Math.max(0, Math.min(page, Math.ceil(filtered.length / 100) - 1));
const rows = filtered.slice(page * 100, page * 100 + 100);
const tableSelect = `<label>자료 <select id="issue-table-filter"><option value="all">전체 자료</option>${tables
.map(
(t) =>
`<option value="${esc(t)}" ${tableFilter === t ? "selected" : ""}>${esc(titles[t] || t || "전체 계산")}</option>`,
)
.join("")}</select></label>`;
const typeSelect = `<label>내용 <select id="issue-type-filter"><option value="all">전체 유형</option>${messages
.map(
(m) => `<option value="${esc(m)}" ${typeFilter === m ? "selected" : ""}>${esc(m)}</option>`,
)
.join("")}</select></label>`;
byId("work-content").innerHTML =
`<div class="business-tools"><b>자료 오류 검색</b><span>${issues.length}건 · ${page + 1}/${Math.max(1, Math.ceil(issues.length / 100))}쪽</span><button data-issue-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-issue-page="1" ${(page + 1) * 100 >= issues.length ? "disabled" : ""}>다음</button></div><p>연결 품명·규격 차이는 확인이 필요한 항목으로 표시합니다.</p><table class="sheet"><thead><tr><th>자료</th><th>행</th><th>코드</th><th>내용</th><th>이동</th></tr></thead><tbody>${rows.map((item) => `<tr><td>${esc(titles[item.table] || item.table || "전체 계산")}</td><td>${item.index >= 0 ? item.index + 1 : ""}</td><td>${esc(item.code)}</td><td>${esc(item.message)}</td><td>${item.table ? `<button data-issue-table="${esc(item.table)}" data-issue-row="${item.index}">행 열기</button>` : ""}</td></tr>`).join("")}</tbody></table>`;
`<div class="business-tools"><b>자료 오류 검색</b>${tableSelect} ${typeSelect}<span>검색 ${filtered.length}건 (전체 ${allIssues.length}건) · ${page + 1}/${Math.max(1, Math.ceil(filtered.length / 100))}쪽</span><button data-issue-page="-1" ${page === 0 ? "disabled" : ""}>이전</button><button data-issue-page="1" ${(page + 1) * 100 >= filtered.length ? "disabled" : ""}>다음</button>${
filtered.some(
(i) => i.sourceName && ["연결 품명 차이", "연결 규격 차이"].includes(i.message),
)
? ' <button data-issue-sync-all="1">불일치 원천 명칭/규격 일괄 반영</button>'
: ""
}</div><p>연결 품명·규격 차이는 확인 후 개별 또는 일괄로 반영할 수 있으며, 단위 차이는 수량·단가 환산 보호를 위해 개별 확인 시에만 반영됩니다.</p><table class="sheet"><thead><tr><th>자료</th><th>행</th><th>코드</th><th>내용</th><th>원천 실제 명칭</th><th>이동 및 조작</th></tr></thead><tbody>${rows
.map(
(item) =>
`<tr><td>${esc(titles[item.table] || item.table || "전체 계산")}</td><td>${item.index >= 0 ? item.index + 1 : ""}</td><td>${esc(item.code)}</td><td>${esc(item.message)}</td><td>${
item.sourceName
? `${esc(item.sourceName)}${item.sourceSize ? " · " + esc(item.sourceSize) : ""}${item.sourceUnite ? " · " + esc(item.sourceUnite) : ""}`
: ""
}</td><td>${
item.table === "quantity"
? `<button data-issue-quantity="${esc(item.sheetCode || "")}" data-issue-row="${item.index}">산출서 열기</button>${
item.unlinked
? " " + targetSelect(state.project, item.item, item.sheetCode, item.index)
: ""
}`
: item.table
? `<button data-issue-table="${esc(item.table)}" data-issue-row="${item.index}">행 열기</button>${
item.sourceName &&
["연결 품명 차이", "연결 규격 차이", "연결 단위 차이"].includes(item.message)
? ` <button data-issue-sync-row="${esc(item.table)}" data-issue-sync-index="${item.index}" data-issue-sync-msg="${esc(item.message)}">${
item.message === "연결 단위 차이" ? "원천단위 반영" : "원천명칭 반영"
}</button>`
: ""
}`
: ""
}</td></tr>`,
)
.join("")}</tbody></table>`;
}
function install() {
document.addEventListener("change", (event) => {
if (event.target.id === "issue-table-filter") {
tableFilter = event.target.value;
page = 0;
render();
return;
}
if (event.target.id === "issue-type-filter") {
typeFilter = event.target.value;
page = 0;
render();
return;
}
const select = event.target.closest("[data-issue-target-select]");
if (!select) return;
const { state } = EstApp,
sheetCode = select.dataset.issueTargetSelect,
itemIndex = Number(select.dataset.issueItemIndex);
const item =
sheetCode === "manual"
? state.project.quantities?.[itemIndex]
: state.project.quantitySheets?.find((s) => s.Code === sheetCode)?.items?.[itemIndex];
if (!item) return;
const row = (state.project.tables.EstMainDat || []).find(
(r) => r.PkID != null && `id:${r.PkID}` === select.value && !Number(r.Level),
);
EstApp.change(() => {
item.TargetKey = row ? select.value : "";
item.TargetCode = row?.Code || "";
}, false);
});
document.addEventListener("click", (event) => {
const step = event.target.closest("[data-issue-page]");
if (step) {
@@ -85,6 +278,72 @@ const EstIssues = (() => {
render();
return;
}
const qNode = event.target.closest("[data-issue-quantity]");
if (qNode) {
const sheetCode = qNode.dataset.issueQuantity;
EstApp.state.view = "quantity";
EstApp.state.quantitySheet = sheetCode || "manual";
EstApp.navigate("quantity");
return;
}
const syncAllBtn = event.target.closest("[data-issue-sync-all]");
if (syncAllBtn) {
const { state } = EstApp;
// 현재 필터링 조건에 일치하는 품명·규격 불일치 항목만 일괄 반영 대상
const currentFiltered = scan(state.project).filter((i) => {
if (tableFilter !== "all" && (i.table || "") !== tableFilter) return false;
if (typeFilter !== "all" && i.message !== typeFilter) return false;
return i.sourceName && (i.message === "연결 품명 차이" || i.message === "연결 규격 차이");
});
if (!currentFiltered.length) return;
EstApp.change(() => {
for (const issue of currentFiltered) {
const row = state.project.tables[issue.table]?.[issue.index];
if (!row) continue;
if (issue.message === "연결 품명 차이" && issue.sourceName) {
row.Name = issue.sourceName;
} else if (issue.message === "연결 규격 차이") {
if (issue.sourceSize && issue.sourceSize.trim() !== "") {
row.Size = issue.sourceSize;
}
}
}
});
render();
return;
}
const syncRowBtn = event.target.closest("[data-issue-sync-row]");
if (syncRowBtn) {
const { state } = EstApp;
const targetTable = syncRowBtn.dataset.issueSyncRow,
targetIndex = Number(syncRowBtn.dataset.issueSyncIndex),
targetMsg = syncRowBtn.dataset.issueSyncMsg;
const issue = scan(state.project).find(
(i) =>
i.table === targetTable &&
i.index === targetIndex &&
(!targetMsg || i.message === targetMsg) &&
i.sourceName,
);
if (!issue) return;
EstApp.change(() => {
const row = state.project.tables[targetTable]?.[targetIndex];
if (!row) return;
if (issue.message === "연결 품명 차이" && issue.sourceName) {
row.Name = issue.sourceName;
} else if (issue.message === "연결 규격 차이") {
if (issue.sourceSize && issue.sourceSize.trim() !== "") {
row.Size = issue.sourceSize;
}
} else if (issue.message === "연결 단위 차이") {
if (issue.sourceUnite !== undefined) {
row.Unite = issue.sourceUnite;
}
}
});
render();
return;
}
const node = event.target.closest("[data-issue-table]");
if (!node) return;
const table = node.dataset.issueTable,