const EstModel = (() => { const labels = { Index: "구분", Code: "코드", Name: "품명", Size: "규격", Unite: "단위", GN: "수량", TU: "단가", TC: "금액", NU: "노무단가", GU: "재료단가", JU: "경비단가", SU: "실적단가", NC: "노무비", GC: "재료비", JC: "경비", SC: "실적", Level: "레벨", LinkIndex: "연결종류", CalcIndex: "계산구분", SCheck: "결과비목", OCheck: "기준비목", SKind: "구간", Diget: "자릿수코드", DigetIndex: "처리방식", Gangub: "맞춤자리", Ratio: "조정률", NachalRatio: "낙찰률", ValueIndex: "단가선택", SelectField: "선택출처", NCGasu: "운전사계수", LineData: "산출근거", Gubun: "기호", Cost: "원가금액", Youl: "요율", VSusic: "수식", BaseField: "기초연결", InputTC: "입력금액", Precision: "줄 소수자리", RoundMode: "줄 처리방식", }; for (let i = 1; i <= 6; i++) { labels[`Cost${i}`] = `출처${i}`; labels[`Page${i}`] = `쪽수${i}`; } const numeric = new Set([ "Index", "PkID", "GN", "TU", "TC", "NU", "GU", "JU", "SU", "NC", "GC", "JC", "SC", "Level", "LinkIndex", "CalcIndex", "SCheck", "OCheck", "SKind", "Diget", "DigetIndex", "Gangub", "Ratio", "NachalRatio", "ValueIndex", "SelectField", "NCGasu", "InputTC", "Precision", "RoundMode", "Cost", ...Array.from({ length: 6 }, (_, i) => `Cost${i + 1}`), ]); for (const key of [ "ANU", "AGU", "AJU", "ASU", "EscPlanned", "EscActual", "EscProgress", "EscManualQuantity", "EscConstruction", "EscPS", "EscBefore", ]) numeric.add(key); for (const prefix of ["Jun", "Gum", "Sa", "Total"]) for (const field of ["GN", "TC", "Ratio", "NC", "GC", "JC", "SC"]) numeric.add(prefix + field); for (const field of ["ContractCost", "JunCost", "GumCost", "SaCost", "TotalCost"]) numeric.add(field); const definitions = { EstMainDat: [ "Level", "Code", "Name", "Size", "Unite", "GN", "TU", "NU", "GU", "JU", "SU", "TC", "NC", "GC", "JC", "SC", "LinkIndex", "CalcIndex", "SCheck", "OCheck", "SKind", "Gangub", "Index", "Diget", "DigetIndex", ], HeadMainDat: [ "Index", "Code", "Name", "Size", "Unite", "GN", "NU", "GU", "JU", "SU", "TC", "NC", "GC", "JC", "SC", "LinkIndex", "CalcIndex", "SCheck", "OCheck", "SKind", "Diget", "DigetIndex", ], CostMainDat: [ "Index", "Code", "LineData", "Level", "LinkIndex", "SCheck", "Precision", "RoundMode", "TC", "NC", "GC", "JC", "SC", ], MachMainDat: [ "Index", "Code", "Name", "Size", "Unite", "GN", "TU", "Ratio", "TC", "NC", "GC", "JC", "NCGasu", "SCheck", "LinkIndex", "CalcIndex", ], MachGijunDat: ["Index", "Name", "TC", "Bigo"], Wonga: [ "Gubun", "Name", "Youl", "VSusic", "Cost", "BaseField", "LinkIndex", "CalcIndex", "Diget", "DigetIndex", ], StuffcDat: [ "Code", "Name", "Size", "Unite", "ValueIndex", "SelectField", "Cost1", "Cost2", "Cost3", "Cost4", "Cost5", "Cost6", "TC", "Ratio", "NachalRatio", "Diget", "DigetIndex", ], LaborcDat: [ "Code", "Name", "Unite", "ValueIndex", "SelectField", "Cost1", "Cost2", "Cost3", "Cost4", "Cost5", "Cost6", "TC", "Ratio", "NachalRatio", ], EstiMateDat: [ "Code", "Name", "Size", "Unite", "InputTC", "TC", "NC", "GC", "JC", "SC", "Ratio", "NachalRatio", ], SilJukDat: [ "Code", "Name", "Size", "Unite", "Cost1", "TC", "NC", "GC", "JC", "SC", "Ratio", "NachalRatio", ], }; function columns(table) { return ( definitions[table] || ["Index", "Code", "Name", "Size", "Unite", "TC", "NC", "GC", "JC", "SC"] ); } function validate(project) { if ( !project || project.schema !== "est-web-2" || typeof project.name !== "string" || !project.tables || typeof project.tables !== "object" || Array.isArray(project.tables) ) throw Error("EST 웹앱 프로젝트 형식이 아닙니다."); let records = 0; for (const [table, rows] of Object.entries(project.tables)) { if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(table) || !Array.isArray(rows)) throw Error("테이블 형식 오류"); records += rows.length; if (records > 500000) throw Error("레코드 수 한도 초과"); for (const row of rows) { if (!row || typeof row !== "object" || Array.isArray(row)) throw Error("행 형식 오류"); for (const [key, value] of Object.entries(row)) { if (["__proto__", "constructor", "prototype"].includes(key)) throw Error("허용하지 않는 필드 이름"); if ( (numeric.has(key) || (key.endsWith("_") && numeric.has(key.slice(0, -1)))) && value != null && (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > Number.MAX_SAFE_INTEGER) ) throw Error(`수치 필드 오류: ${table}.${key}`); if (typeof value === "string" && value.length > 200000) throw Error("문자열 길이 한도 초과"); } } } project.options ||= {}; EstSchedule.validate(project); EstReview.validate(project); if (typeof project.options !== "object" || Array.isArray(project.options)) throw Error("계산 옵션 형식 오류"); const validNumber = (value, label) => { if ( typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > Number.MAX_SAFE_INTEGER ) throw Error(`수치 필드 오류: ${label}`); }; if (project.options.EscDesignTotal != null) { validNumber(project.options.EscDesignTotal, "조사 도급액"); if (project.options.EscDesignTotal < 0) throw Error("조사 도급액은 0 이상입니다."); } if (project.printSettings != null) { const s = project.printSettings; if (typeof s !== "object" || Array.isArray(s)) throw Error("인쇄 설정 형식 오류"); for (const [key, min, max] of [ ["margin", 0, 50], ["marginTop", 0, 50], ["marginBottom", 0, 50], ["marginLeft", 0, 50], ["marginRight", 0, 50], ["font", 6, 20], ["rowsPerPage", 1, 100], ["startPage", 1, 99999], ]) if (s[key] != null) { validNumber(s[key], key); if ( s[key] < min || s[key] > max || (["rowsPerPage", "startPage"].includes(key) && !Number.isInteger(s[key])) ) throw Error("인쇄 설정 범위 오류"); } if ( (s.paper != null && !["A3", "A4"].includes(s.paper)) || (s.orientation != null && !["landscape", "portrait"].includes(s.orientation)) ) throw Error("인쇄 용지/방향 오류"); for (const key of ["form", "company", "notes"]) if (s[key] != null && (typeof s[key] !== "string" || s[key].length > 100000)) throw Error("인쇄 문구 형식 오류"); } if (project.excelQuantityLinks != null) { if (!Array.isArray(project.excelQuantityLinks)) throw Error("Excel 수량 연결 형식 오류"); const targets = new Set(); for (const link of project.excelQuantityLinks) { if ( !link || !Number.isSafeInteger(link.target) || targets.has(link.target) || typeof link.file !== "string" || !link.file || link.file.length > 255 || typeof link.sheet !== "string" || !link.sheet || link.sheet.length > 255 || !EstQuantity.validExcelAddress(link.cell) ) throw Error("Excel 수량 연결 오류"); targets.add(link.target); } } if (project.documents != null) { if (typeof project.documents !== "object" || Array.isArray(project.documents)) throw Error("문서 본문 형식 오류"); const extraForms = new Set([ "progress-summary", "progress-costs", "escalation-summary", "escalation-costs", "annual-summary", "annual-items", "change-costs", "standard-usage", "standard-change", "estimate-usage", "estimate-change", "material-usage", "material-change", "labor-usage", "labor-change", "machine-usage", "machine-change", ]); for (const [key, value] of Object.entries(project.documents)) if ( (!/^(current|\d+)$/.test(key) && !extraForms.has(key)) || typeof value !== "string" || value.length > 100000 ) throw Error("문서 본문 형식 오류"); } for (const key of ["quantities", "progress", "annual", "drawings", "attachments"]) { if (project[key] === undefined) project[key] = []; if (!Array.isArray(project[key])) throw Error(`${key} 형식 오류`); } const attachmentIds = new Set(); for (const item of project.attachments) { if ( !item || typeof item.id !== "string" || !item.id || attachmentIds.has(item.id) || typeof item.name !== "string" || !item.name || item.name.length > 1000 || typeof item.type !== "string" || item.type.length > 1000 || !Number.isSafeInteger(item.size) || item.size < 0 || typeof item.data !== "string" || item.data.length % 4 || /[^A-Za-z0-9+/=]/.test(item.data) ) throw Error("첨부파일 형식 오류"); const body = item.data.replace(/={1,2}$/, ""), padding = item.data.length - body.length; if ( body.includes("=") || body.length % 4 === 1 || (item.data.length / 4) * 3 - padding !== item.size ) throw Error("첨부파일 데이터 오류"); attachmentIds.add(item.id); } const ids = new Set(); for (const round of project.progress) { if ( !round || typeof round !== "object" || typeof round.id !== "string" || !round.id || ids.has(round.id) ) throw Error("기성 회차 ID가 없거나 중복됩니다."); ids.add(round.id); for (const field of ["locked", "approved", "separateComponents"]) if (round[field] != null && typeof round[field] !== "boolean") throw Error("기성 옵션 형식 오류"); if (round.name != null && typeof round.name !== "string") throw Error("기성 회차 이름 형식 오류"); round.inputs ??= {}; if (typeof round.inputs !== "object" || Array.isArray(round.inputs)) throw Error("기성 입력 형식 오류"); round.inputs = Object.assign(Object.create(null), round.inputs); for (const input of Object.values(round.inputs)) { if (!input || typeof input !== "object" || Array.isArray(input)) throw Error("기성 입력 행 형식 오류"); for (const prefix of ["Gum", "Sa"]) { if ( input[prefix + "Mode"] != null && !["quantity", "amount", "ratio", "components"].includes(input[prefix + "Mode"]) ) throw Error("기성 입력방법 오류"); for (const field of ["", "NC", "GC", "JC", "SC"]) if (input[prefix + field] != null) validNumber(input[prefix + field], "기성 " + prefix + field); } for (const field of ["QuantityDigits", "AmountDigits"]) if (input[field] != null) { validNumber(input[field], field); if (!Number.isInteger(input[field]) || input[field] < 0 || input[field] > 12) throw Error("기성 자릿수 형식 오류"); } } if ( round.amountDigits != null && (!Number.isInteger(round.amountDigits) || round.amountDigits < 0 || round.amountDigits > 4) ) throw Error("기성 금액 자릿수는 0~4입니다."); if (round.contract) { validate({ schema: "est-web-2", name: "기성 계약", tables: { EstMainDat: round.contract.rows, Wonga: round.contract.wonga }, options: round.contract.options, }); } if (round.result?.rows) validate({ schema: "est-web-2", name: "기성 결과", tables: { EstMainDat: round.result.rows }, }); if (round.result) for (const field of ["total", "ratio"]) if (round.result[field] != null) validNumber(round.result[field], "확정 기성 " + field); } for (const year of project.annual) if (!year || typeof year.name !== "string" || typeof year.formula !== "string") throw Error("연차 이름·수식 형식 오류"); if (project.baseline) { if (!project.baseline.tables || !Array.isArray(project.baseline.tables.EstMainDat)) throw Error("당초 내역 형식 오류"); validate({ schema: "est-web-2", name: "당초", tables: project.baseline.tables }); } return project; } function legacy(data) { if (data.schema === "est-web-2") return validate(data); if (!["sources", "units", "estimate", "costs"].every((key) => Array.isArray(data[key]))) throw Error("지원하지 않는 프로젝트 파일"); const project = EstNative.empty(), mapping = { 재료비: ["StuffcDat", 5, "GC"], 노무비: ["LaborcDat", 6, "NC"], 경비: ["EstiMateDat", 101, "JC"], 실적: ["SilJukDat", 21, "SC"], }; project.name = data.projectName || "이전 작업"; project.legacyOriginal = structuredClone(data); for (const source of data.sources) { const [table, kind, component] = mapping[source.kind] || mapping.재료비; project.tables[table].push({ Code: source.id, Name: source.name, Size: source.spec, Unite: source.unit, TC: source.price, InputTC: source.price, [component]: source.price, ValueIndex: 4, }); source._nativeKind = kind; } data.units.forEach((unit, index) => { project.tables.HeadMainDat.push({ Index: index + 1, Code: unit.id, Name: unit.name, Unite: unit.unit, }); unit.lines.forEach((line) => { const source = data.sources.find((s) => s.id === line.sourceId); project.tables.HeadMainDat.push({ Index: 0, Code: line.type === "일위대가" ? line.unitId : line.sourceId, Name: source?.name, Size: line.base === "합계" ? "합계" : line.base, Unite: line.type === "비율" ? "%" : source?.unit, GN: line.quantity, LinkIndex: line.type === "일위대가" ? 103 : line.type === "비율" ? 58 : source?._nativeKind || 0, SCheck: { 재료비: 1, 노무비: 2, 경비: 3 }[line.kind] || 0, Diget: line.digits ? line.digits + 1 : 0, DigetIndex: line.digits ? 2 : 0, }); }); project.tables.HeadTitleDat.push({ Code: unit.id, Name: unit.name, Unite: unit.unit }); }); project.tables.EstMainDat = data.estimate.map((row, index) => ({ PkID: index + 1, Index: 0, Code: row.unitId, Name: row.name, GN: row.quantity, LinkIndex: 103, Level: 0, NewItem: true, })); project.tables.EstMainDat.push({ Name: "순공사비", LinkIndex: 54 }); const bases = { 재료비: "GC", 노무비: "NC", 경비: "JC", 실적: "SC", 직접공사비: "TC" }; project.tables.Wonga = [{ Gubun: "기초", Name: "직접공사비", BaseField: "TC", Cost: 0 }]; const running = ["기초"]; for (const [index, row] of data.costs.entries()) { const id = row.id || `추가${index + 1}`; project.tables.Wonga.push({ Gubun: id, Name: row.name, Youl: `${row.rate}%`, VSusic: row.formula || `(${row.base === "누계" ? running.map((key) => `[${key}]`).join("+") : `[${bases[row.base] || "TC"}]`})*${row.rate}/100`, }); running.push(id); } project.tables.Wonga.push({ Gubun: "도급계", Name: "도급액", LinkIndex: 55, VSusic: running.map((key) => `[${key}]`).join("+"), }); return validate(project); } function editCode(project, table, index, value) { const rows = project.tables[table], row = rows[index]; const grouped = ["HeadMainDat", "CostMainDat", "MachMainDat"].includes(table); const kind = Number( Object.entries(EstNative.sourceTables).find(([, name]) => name === table)?.[0] || { HeadMainDat: 103, CostMainDat: 104, MachMainDat: 105 }[table], ); if (!kind || (grouped && !(Number(row.Index) > 0))) { row.Code = value; return; } const code = String(value || "").trim(), old = row.Code; if ( !code || rows.some( (other, at) => at !== index && (!grouped || Number(other.Index) > 0) && (other.Code === code || other.PreviousCodes?.includes(code)), ) ) throw Error("비어 있거나 중복된 원천 코드입니다."); if (old === code) return; row.Code = code; row.PreviousCodes = [...new Set([...(row.PreviousCodes || []), old].filter(Boolean))]; for (const list of Object.values(project.tables)) for (const linked of list) if (old && Number(linked.LinkIndex) === kind && linked.Code === old) linked.Code = code; if (grouped) { const titles = project.tables[EstNative.sourceTables[kind]] || []; const title = titles.find((item) => item.Code === old); if (title) Object.assign(title, { Code: code, PreviousCodes: [...row.PreviousCodes] }); for ( let at = index; at < rows.length && (at === index || !(Number(rows[at].Index) > 0)); at++ ) if (rows[at].TitleCode === old) rows[at].TitleCode = code; } } function newRow(table, rows, project) { const historical = [rows, project?.baseline?.tables?.[table] || []]; if (table === "EstMainDat") for (const round of project?.progress || []) historical.push(round.contract?.rows || [], round.result?.rows || []); let max = historical.reduce( (maximum, list) => list.reduce((value, row) => Math.max(value, Number(row.PkID) || 0), maximum), 0, ); if (table === "EstMainDat") for (const link of project?.excelQuantityLinks || []) max = Math.max(max, link.target); const row = { PkID: max + 1, Index: 0, Name: "새 항목", NewItem: true, GN: 1, CalcIndex: 0, LinkIndex: 0, Level: 0, }; if (["StuffcDat", "LaborcDat", "SilJukDat", "EstiMateDat"].includes(table)) Object.assign(row, { Code: `U${Date.now().toString(36)}_${max + 1}`, ValueIndex: 4, InputTC: 0, TC: 0, }); if (table === "Wonga") Object.assign(row, { Gubun: `R${max + 1}`, VSusic: "", Cost: 0 }); if (table === "CostMainDat") Object.assign(row, { LineData: "Q = 1 + 1 = \x122\x12" }); return row; } return { labels, numeric, columns, validate, legacy, newRow, editCode }; })();