375 lines
16 KiB
JavaScript
375 lines
16 KiB
JavaScript
const EstIssues = (() => {
|
||
let page = 0,
|
||
tableFilter = "all",
|
||
typeFilter = "all";
|
||
const grouped = new Set(["HeadMainDat", "CostMainDat", "MachMainDat"]);
|
||
const kinds = {
|
||
5: "StuffcDat",
|
||
6: "LaborcDat",
|
||
21: "SilJukDat",
|
||
101: "EstiMateDat",
|
||
103: "HeadMainDat",
|
||
104: "CostMainDat",
|
||
105: "MachMainDat",
|
||
};
|
||
function scan(project) {
|
||
const issues = [],
|
||
sources = new Map();
|
||
for (const [kind, table] of Object.entries(kinds)) {
|
||
const codes = new Map();
|
||
(project.tables[table] || []).forEach((row, index) => {
|
||
if (!row.Code || (grouped.has(table) && !(Number(row.Index) > 0))) return;
|
||
const list = codes.get(String(row.Code)) || [];
|
||
list.push({ row, index });
|
||
codes.set(String(row.Code), list);
|
||
});
|
||
sources.set(Number(kind), codes);
|
||
for (const [code, list] of codes)
|
||
if (list.length > 1)
|
||
for (const { index } of list) issues.push({ table, index, code, message: "코드 중복" });
|
||
}
|
||
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(link);
|
||
if (!codes) return;
|
||
const matches = codes.get(String(row.Code)) || [];
|
||
if (!matches.length)
|
||
issues.push({ table, index, code: row.Code, message: "연결 자료 없음" });
|
||
else if (matches.length > 1)
|
||
issues.push({ table, index, code: row.Code, message: "연결 코드 중복" });
|
||
else
|
||
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))
|
||
issues.push({
|
||
table,
|
||
index,
|
||
code: row.Code,
|
||
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+) /),
|
||
code = match?.[1],
|
||
group = code && costGroups.get(code),
|
||
offset = match ? Number(match[2]) - 1 : -1,
|
||
unique = group && sources.get(104)?.get(code)?.length === 1,
|
||
index = unique && offset >= 0 && offset < group.rows.length ? group.start + offset : -1;
|
||
issues.push({
|
||
table: index >= 0 ? "CostMainDat" : "",
|
||
index,
|
||
code: index >= 0 ? code : "",
|
||
message: text,
|
||
});
|
||
}
|
||
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,
|
||
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>${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) {
|
||
page += Number(step.dataset.issuePage);
|
||
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,
|
||
index = Number(node.dataset.issueRow),
|
||
rows = EstApp.state.project.tables[table] || [];
|
||
if (!rows[index]) return;
|
||
let group = "";
|
||
if (grouped.has(table))
|
||
for (const [code, item] of EstNative.groups(rows))
|
||
if (index >= item.start && index < item.start + item.rows.length) {
|
||
group = code;
|
||
break;
|
||
}
|
||
if (grouped.has(table) && !group) {
|
||
EstApp.state.notice = "묶음 코드가 중복되거나 해당 행의 묶음이 없어 이동할 수 없습니다.";
|
||
EstApp.render();
|
||
return;
|
||
}
|
||
EstApp.navigate(table, group);
|
||
const visible = EstApp.visibleRows(),
|
||
position = visible.findIndex((item) => item.index === index);
|
||
if (position >= 0) EstApp.state.page = Math.floor(position / 100);
|
||
EstApp.state.selection = new Set([index]);
|
||
EstApp.render();
|
||
});
|
||
}
|
||
return { scan, render, install };
|
||
})();
|