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 = `
기간과 진도는 직접 입력합니다. 가중치는 공종 금액 또는 사용자가 정한 값을 사용합니다.
| ${h} | `).join("")}||
|---|---|---|
| `).join("")} |