const EstSchedule = (() => { function day(value) { if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) throw Error("공정 날짜는 YYYY-MM-DD 형식입니다."); const time = Date.parse(value + "T00:00:00Z"); if (!Number.isFinite(time) || new Date(time).toISOString().slice(0, 10) !== value) throw Error("공정 날짜 오류"); return time / 86400000; } function validate(project) { if (project.schedule == null) return; if (!Array.isArray(project.schedule) || project.schedule.length > 10000) throw Error("공정표 형식/행 수 오류"); const ids = new Set(); for (const row of project.schedule) { if (!row || typeof row.id !== "string" || !row.id || ids.has(row.id)) throw Error("공정 항목 ID 오류"); ids.add(row.id); if ( row.SourceKey != null && (typeof row.SourceKey !== "string" || row.SourceKey.length > 1000) ) throw Error("공정 내역 연결 오류"); for (const key of ["Code", "Name", "start", "end"]) if (typeof row[key] !== "string" || row[key].length > 1000) throw Error("공정 문자열 오류"); for (const key of ["weight", "progress"]) if ( typeof row[key] !== "number" || !Number.isFinite(row[key]) || row[key] < 0 || row[key] > Number.MAX_SAFE_INTEGER ) throw Error("공정 수치 오류"); if (row.progress > 100) throw Error("공정 진도는 0~100%입니다."); if (row.start) day(row.start); if (row.end) day(row.end); if (row.start && row.end && day(row.start) > day(row.end)) throw Error("공정 종료일이 시작일보다 빠릅니다."); } } function calculate(project) { validate(project); const rows = project.schedule || [], dated = rows.filter((r) => r.start && r.end), first = dated.length ? Math.min(...dated.map((r) => day(r.start))) : 0, last = dated.length ? Math.max(...dated.map((r) => day(r.end))) : first, span = last - first + 1, weight = rows.reduce((sum, r) => sum + r.weight, 0); return { rows: rows.map((r) => ({ ...r, left: r.start && r.end ? ((day(r.start) - first) / span) * 100 : 0, width: r.start && r.end ? ((day(r.end) - day(r.start) + 1) / span) * 100 : 0, })), weight, progress: weight ? rows.reduce((sum, r) => sum + r.weight * r.progress, 0) / weight : 0, start: dated.length ? new Date(first * 86400000).toISOString().slice(0, 10) : "", end: dated.length ? new Date(last * 86400000).toISOString().slice(0, 10) : "", }; } function estimateRows(project) { const rows = new Map(), codes = new Map(); for (const row of project.tables.EstMainDat) { if ( Number(row.Level) > 0 || Number(row.CalcIndex) >= 2 || [54, 55, 58, 59].includes(Number(row.LinkIndex)) ) continue; const key = row.PkID != null ? `id:${row.PkID}` : row.Code ? `code:${row.Code}` : ""; if (!key) continue; if (rows.has(key)) throw Error(`내역 행 식별자 중복: ${key}`); rows.set(key, row); if (row.Code) { const matches = codes.get(row.Code) || []; matches.push(key); codes.set(row.Code, matches); } } function sourceKey(row) { if (row.SourceKey) return rows.has(row.SourceKey) ? row.SourceKey : ""; const matches = codes.get(row.Code) || []; if (matches.length > 1) throw Error(`기존 공정의 내역코드 중복: ${row.Code}`); return matches[0] || ""; } return { rows, sourceKey }; } function fromEstimate(project, makeId) { const { rows, sourceKey } = estimateRows(project), existing = new Set((project.schedule || []).map(sourceKey).filter(Boolean)); return [...rows.entries()] .filter(([key]) => !existing.has(key)) .map(([key, row]) => ({ id: makeId(), SourceKey: key, Code: String(row.Code || ""), Name: String(row.Name || ""), start: "", end: "", weight: Math.max(0, Number(row.TC) || 0), progress: 0, })); } function refreshWeights(project) { const { rows, sourceKey } = estimateRows(project); return (project.schedule || []).map((row) => { const key = sourceKey(row); return { ...row, weight: key ? Math.max(0, Number(rows.get(key).TC) || 0) : row.weight }; }); } function refreshProgress(project) { const round = (project.progress || []).findLast((item) => item.locked && item.result); if (!round) throw Error("확정된 기성 회차가 없습니다."); const { sourceKey } = estimateRows(project), saved = new Map(); for (const row of round.result.rows) { if ( Number(row.Level) > 0 || Number(row.CalcIndex) >= 2 || [54, 55, 58, 59].includes(Number(row.LinkIndex)) ) continue; const key = row.PkID != null ? `id:${row.PkID}` : row.Code ? `code:${row.Code}` : ""; if (!key) continue; if (saved.has(key)) throw Error(`기성 내역 식별자 중복: ${key}`); saved.set(key, row); } return (project.schedule || []).map((entry) => { const row = saved.get(sourceKey(entry)); if (!row || !Number(row.GN)) return { ...entry }; const progress = (Number(row.TotalGN) / Number(row.GN)) * 100; if (!Number.isFinite(progress) || progress < 0 || progress > 100) throw Error(`${entry.Name || entry.Code}: 기성 누계 진도가 0~100% 범위를 벗어났습니다.`); return { ...entry, progress }; }); } function moveRow(project, index, direction) { if (!project.schedule || !Array.isArray(project.schedule)) return; const target = index + direction; if ( index < 0 || index >= project.schedule.length || target < 0 || target >= project.schedule.length ) return; const temp = project.schedule[index]; project.schedule[index] = project.schedule[target]; project.schedule[target] = temp; } function clearSchedule(project) { project.schedule = []; } function render() { const { esc, number, state } = EstApp, output = calculate(state.project); const rowsCount = output.rows.length; EstApp.byId("work-content").innerHTML = `
시작 ${esc(output.start)} · 종료 ${esc(output.end)} · 가중치 합계 ${number(output.weight)} · 입력 진도 가중평균 ${number(output.progress)}%

기간과 진도는 직접 입력합니다. 가중치는 공종 금액 또는 사용자가 정한 값을 사용합니다.

${["코드", "공정명", "시작일", "종료일", "가중치", "진도(%)", "기간", "작업"].map((h) => ``).join("")}${output.rows.map((r, i) => `${["Code", "Name", "start", "end", "weight", "progress"].map((k) => ``).join("")}`).join("")}
${h}
`; } function install() { document.addEventListener("click", (event) => { const node = event.target.closest("[data-action]"); if (!node) return; const p = EstApp.state.project; if ( ["schedule-import", "schedule-weights", "schedule-progress"].includes(node.dataset.action) ) { try { if (node.dataset.action === "schedule-import") { const added = fromEstimate(p, EstApp.uid); EstApp.change(() => (p.schedule ||= []).push(...added), false); } else { const rows = node.dataset.action === "schedule-progress" ? refreshProgress(p) : refreshWeights(p); EstApp.change(() => (p.schedule = rows), false); } } catch (error) { EstApp.state.notice = error.message; EstApp.render(); } return; } if (node.dataset.action === "schedule-add") { try { const row = fromEstimate(p, EstApp.uid)[0] || { id: EstApp.uid(), Code: "", Name: "새 공정", start: "", end: "", weight: 0, progress: 0, }; EstApp.change(() => (p.schedule ||= []).push(row), false); } catch (error) { EstApp.state.notice = error.message; EstApp.render(); } } if (node.dataset.action === "schedule-move") { const index = Number(node.dataset.index), direction = Number(node.dataset.direction); EstApp.change(() => moveRow(p, index, direction), false); } if (node.dataset.action === "schedule-clear") { if (confirm("예정공정표의 모든 공정 항목을 비우시겠습니까?")) EstApp.change(() => clearSchedule(p), false); } if (node.dataset.action === "schedule-delete") EstApp.change(() => p.schedule.splice(Number(node.dataset.index), 1), false); }); document.addEventListener("change", (event) => { const node = event.target; if (!node.dataset.schedule) return; const p = structuredClone(EstApp.state.project), row = p.schedule[Number(node.dataset.index)], key = node.dataset.schedule; try { if (key === "Code" && row.Code !== node.value) delete row.SourceKey; row[key] = ["weight", "progress"].includes(key) ? Number(node.value) : node.value; validate(p); EstApp.change(() => (EstApp.state.project = p), false); } catch (error) { EstApp.state.notice = error.message; EstApp.render(); } }); } return { validate, calculate, fromEstimate, refreshWeights, refreshProgress, moveRow, clearSchedule, render, install, }; })();