// Schema: knowledge/original/경쟁사 프로그램/STmate 분석/30_원자료/조달청_공사원가호환규정.xsd (1.0.5). const EstC3r = (() => { const kinds = { 5: "자재", 6: "노무", 21: "표준시장단가", 101: "경비항목", 103: "일위대가", 104: "중기단가산출", 105: "기계경비", }; const esc = (value) => { if (/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(String(value ?? ""))) throw Error("XML에 사용할 수 없는 제어문자가 있습니다."); return String(value ?? "").replace( /[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c], ); }; const tag = (name, value) => `<${name}>${esc(value)}`; const raw = (name, value) => `<${name}>${value}`; const n = (value) => { const number = Number(value || 0); if (!Number.isFinite(number)) throw Error("XML 수치 오류"); return number; }; function text(value, length, label) { const s = String(value ?? "").replace(/[\r\n\t]/g, " "); if (s.length > length) throw Error(`${label}: XML 길이 ${length}자 초과`); return s; } function code(value, length = 17) { const s = String(value || ""); if (!s || s.length > length || !/^[A-Z0-9]+$/.test(s)) throw Error(`XML 코드 형식 오류: ${s} (영문 대문자/숫자 ${length}자 이내)`); return s; } const name = (row) => tag("품명", text(row.Name, 50, "품명")) + tag("규격", text(row.Size, 100, "규격")) + tag("단위", text(row.Unite, 10, "단위")); const amounts = (row) => tag("재료비", n(row.GC)) + tag("노무비", n(row.NC)) + tag("경비", n(row.JC) + n(row.SC)); const prices = (row) => tag("재료비단가", n(row.GU)) + tag("노무비단가", n(row.NU)) + tag("경비단가", n(row.JU) + n(row.SU)); const remarks = (row) => tag("비고", text(row.Bigo || row.Bigo1 || "", 50, "비고")); const flag = (value) => (n(value) >= 2 ? "true" : "false"); function output(project, meta) { if (!meta?.company?.trim() || !meta?.kind) throw Error("XML 작성업체명과 공사구분을 입력하세요."); if ( ![ "토목공사", "건축공사", "설비공사", "전기공사", "통신공사", "조경공사", "소방공사", "문화재수리공사", "국가유산수리공사", ].includes(meta.kind) ) throw Error("XML 공사구분 오류"); const date = meta.date || new Date().toISOString().slice(0, 10); if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || Number.isNaN(Date.parse(date))) throw Error("XML 제작일 오류"); const source = project.tables, generated = [], warnings = []; const available = new Map( Object.keys(kinds).map((kind) => [ Number(kind), new Set((source[EstNative.sourceTables[kind]] || []).map((row) => row.Code)), ]), ); function requireSource(kind, key) { if (!available.get(n(kind))?.has(key)) throw Error(`XML 연결 자료 없음: ${kind}/${key}`); } const expenseCodes = new Set((source.EstiMateDat || []).map((r) => r.Code)); let serial = 0; function expense(row, unit = false) { let value; do { value = "X" + String(++serial).padStart(10, "0"); } while (expenseCodes.has(value)); expenseCodes.add(value); generated.push({ Code: value, Name: row.Name, Size: row.Size, Unite: row.Unite, NC: unit ? n(row.NU) : n(row.NC), GC: unit ? n(row.GU) : n(row.GC), JC: unit ? n(row.JU) + n(row.SU) : n(row.JC) + n(row.SC), }); return value; } function detail(row, index) { let kind = kinds[n(row.LinkIndex)], key = row.Code, gn = n(row.GN), current = { ...row }; if (!kind || !key || [1, 3].includes(n(row.CalcIndex))) { kind = "경비항목"; key = expense(row); gn = 1; current.GU = n(row.GC); current.NU = n(row.NC); current.JU = n(row.JC) + n(row.SC); current.SU = 0; warnings.push(`${row.Code || row.Name || index}: 독립 금액 항목으로 변환`); } else requireSource(row.LinkIndex, key); code(key); return raw( "세부공종", tag("세부공종코드", key) + tag("자원구분", kind) + tag("계산제외", flag(row.CalcIndex)) + tag("세부공종번호", index) + tag("세부공종명", text(row.Name, 50, "세부공종명")) + tag("규격", text(row.Size, 100, "규격")) + tag("단위", text(row.Unite, 10, "단위")) + tag("수량", gn) + prices(current) + amounts(row) + tag("관급", "false") + tag("자재구분", "구분없음") + tag("세부공종구분", "일반내역") + remarks(row), ); } let active = null; const groups = [], stack = [], siblings = new Map(); function addGroup(name, level, header = null) { while (stack.length && stack.at(-1).level <= level) stack.pop(); const parent = stack.at(-1)?.code || "", number = (siblings.get(parent) || 0) + 1; if (number > 99 || parent.length >= 20) throw Error("XML 공종 계층/동일 단계 공종 수 한도 초과"); siblings.set(parent, number); active = { name, level, header, code: parent + String(number).padStart(2, "0"), rows: [] }; groups.push(active); stack.push(active); } for (const row of source.EstMainDat) { if (n(row.LinkIndex) === 54) break; if (n(row.Level) > 0) { addGroup(row.Name, n(row.Level), row); continue; } if (!active) addGroup(project.name, 10); active.rows.push(row); } if (!groups.length) addGroup(project.name, 10); const boq = groups .map((group, i) => raw( "내역서", tag("내역서번호", group.code) + tag("공종구분", "일반공종") + tag("계산제외", "false") + tag("공종번호", i + 1) + tag("공종명", text(group.name, 50, "공종명")) + amounts( group.header || { GC: group.rows.reduce((s, r) => s + (n(r.CalcIndex) < 2 ? n(r.GC) : 0), 0), NC: group.rows.reduce((s, r) => s + (n(r.CalcIndex) < 2 ? n(r.NC) : 0), 0), JC: group.rows.reduce( (s, r) => s + (n(r.CalcIndex) < 2 ? n(r.JC) + n(r.SC) : 0), 0, ), }, ) + tag("비고", "") + group.rows.map((r, j) => detail(r, j + 1)).join(""), ), ) .join(""); const head = [...EstNative.groups(source.HeadMainDat || []).values()] .map((group) => { const title = source.HeadTitleDat?.find((r) => r.Code === group.rows[0].Code) || group.rows[0]; return raw( "일위대가", tag("일위대가코드", code(group.rows[0].Code, 12)) + name(title) + amounts(title) + tag("비목구분", "합계") + remarks(title) + group.rows .slice(1) .map((row) => { let kind = kinds[n(row.LinkIndex)], key = row.Code, current = { ...row }; if (!kind || !key || [1, 3].includes(n(row.CalcIndex))) { kind = "경비항목"; key = expense(row); current.GN = 1; current.GU = n(row.GC); current.NU = n(row.NC); current.JU = n(row.JC) + n(row.SC); current.SU = 0; warnings.push(`${group.rows[0].Code}: 연결 없는 구성 항목을 독립 단가로 변환`); } else requireSource(row.LinkIndex, key); return raw( "일위대가상세", tag("자원구분", kind) + tag("자원코드", code(key)) + tag("계산제외", flag(row.CalcIndex)) + name(row) + tag("수량", n(current.GN)) + prices(current) + amounts(row) + tag("관급", "false") + remarks(row), ); }) .join(""), ); }) .join(""); const costs = [...EstNative.groups(source.CostMainDat || []).values()] .map((group) => { const title = source.CostTitleDat?.find((r) => r.Code === group.rows[0].Code) || group.rows[0]; return raw( "중기단가산출", tag("중기단가산출코드", code(title.Code, 12)) + name(title) + amounts(title) + tag("비목구분", "합계") + remarks(title) + tag( "중기단가산출식", group.rows .slice(1) .map((r) => EstText.clean(r.LineData || "")) .join("\n"), ), ); }) .join(""); const machines = [...EstNative.groups(source.MachMainDat || []).values()] .map((group) => { const title = source.MachTitleDat?.find((r) => r.Code === group.rows[0].Code) || group.rows[0]; warnings.push(`${title.Code}: 기계경비 상세는 경비항목 단가로 전개`); return raw( "기계경비", tag("기계경비코드", code(title.Code)) + name(title) + tag("작업시간", "E0") + tag("작업장소", "") + amounts(title) + remarks(title) + group.rows .slice(1) .filter((r) => n(r.Index) === 0) .map((row) => { const key = expense(row); return raw( "기계경비상세", tag("자원구분", "경비항목") + tag("자원코드", key) + tag("계산제외", flag(row.CalcIndex)) + name(row) + tag("수량", 1) + prices({ GU: n(row.GC), NU: n(row.NC), JU: n(row.JC) + n(row.SC) }) + amounts(row) + remarks(row), ); }) .join(""), ); }) .join(""); const materials = (source.StuffcDat || []) .map((row) => raw( "자재", tag("자재코드", code(row.Code, 16)) + name(row) + tag("재료비단가", n(row.TC)) + remarks(row), ), ) .join(""); const labor = (source.LaborcDat || []) .map((row) => raw( "노무", tag("노무코드", code(row.Code, 16)) + name(row) + tag("노무비단가", n(row.TC)) + remarks(row), ), ) .join(""); const expenses = [...(source.EstiMateDat || []), ...generated] .map((row) => raw( "경비항목", tag("경비항목코드", code(row.Code, 16)) + tag("경비구분", "견적항목") + name(row) + tag("통화구분", "") + tag("재료비단가", n(row.GC)) + tag("노무비단가", n(row.NC)) + tag("경비단가", n(row.JC) + n(row.SC)) + remarks(row), ), ) .join(""); const market = (source.SilJukDat || []) .map((row) => { const key = code(row.Code, 12); if (!/^[ACEMT]/.test(key)) throw Error(`표준시장단가 코드 접두어 오류: ${key}`); return raw( "실적단가", tag("항목구분", "표준시장단가") + tag("공종구분", meta.kind === "건축공사" ? "건축공사" : "토목공사") + tag("실적공사비코드", key) + name(row) + tag("재료비단가", n(row.GC)) + tag("노무비단가", n(row.NC)) + tag("경비단가", n(row.JC) + n(row.SC)) + remarks(row), ); }) .join(""); const fees = (source.Wonga || []) .map((row) => raw( "제경비항목", tag("세부공종코드", code(row.Gubun, 12)) + tag("집계항목여부", row.BaseField ? "true" : "false") + tag("비목명", text(row.Name, 50, "비목명")) + tag("적용산식", text(row.VSusic, 200, "원가 수식")) + tag("적용요율", n(String(row.Youl || 0).replace("%", ""))) + tag("추가금액", 0) + tag("산출금액", n(row.Cost)) + tag("적용산식설명", ""), ), ) .join(""); const body = raw("공사제경비", fees) + raw( "단일공사내역서", raw("단일공사정보", tag("공사구분", meta.kind) + tag("공사설명", "")) + boq + head + costs + machines + materials + labor + expenses + market, ); return { xml: '' + raw( "공사원가호환규정", tag("스키마버전", "1.0.5") + raw( "작성정보", tag("작성업체명", text(meta.company, 50, "작성업체명")) + tag("프로그램명", "EST 독립 웹앱") + tag("프로그램버전", "est-web-2") + tag("파일제작일", date) + tag("파일검증여부", "false") + tag("건축공사사업내용작성여부", "false"), ) + raw( "공사정보", tag("공사관리번호", text(meta.number || "", 20, "관리번호")) + tag("주공사", meta.kind) + tag("공사명", text(project.name, 100, "공사명")) + tag("비고", ""), ) + raw("공사내역서", body), ), warnings: [...new Set(warnings)], }; } function read(source) { if (/ [...node.children].filter((c) => c.localName === name); const value = (node, name) => children(node, name)[0]?.textContent || ""; const amount = (node, name) => { const raw = value(node, name); if (raw.trim() === "" || !Number.isFinite(Number(raw))) throw Error(`XML 숫자 오류: ${name}`); return Number(raw); }; const rows = (node, name) => [...node.getElementsByTagName(name)]; const result = EstNative.empty(); result.options.SourceRefresh = false; result.options.AutoCalc = false; // Imported monetary values are authoritative until explicitly recalculated. const info = children(doc.documentElement, "공사정보")[0]; if (!info) throw Error("XML 공사정보 없음"); result.name = value(info, "공사명"); result.exportMeta = { company: value(children(doc.documentElement, "작성정보")[0], "작성업체명"), kind: value(info, "주공사"), number: value(info, "공사관리번호"), }; const fields = (node) => ({ Name: value(node, "품명"), Size: value(node, "규격"), Unite: value(node, "단위"), Bigo: value(node, "비고"), }); const money = (node) => ({ GC: amount(node, "재료비"), NC: amount(node, "노무비"), JC: amount(node, "경비"), SC: 0, TC: amount(node, "재료비") + amount(node, "노무비") + amount(node, "경비"), }); const unit = (node) => ({ GU: amount(node, "재료비단가"), NU: amount(node, "노무비단가"), JU: amount(node, "경비단가"), SU: 0, TU: amount(node, "재료비단가") + amount(node, "노무비단가") + amount(node, "경비단가"), }); const linked = (node) => ({ Code: code(value(node, "자원코드")), LinkIndex: Number( Object.entries(kinds).find(([, v]) => v === value(node, "자원구분"))?.[0] || 0, ), GN: amount(node, "수량"), CalcIndex: ["true", "1"].includes(value(node, "계산제외")) ? 2 : 0, ...fields(node), ...money(node), ...unit(node), }); let pk = 0; const contracts = rows(doc.documentElement, "단일공사내역서"); if (contracts.length !== 1) throw Error("복합공사 XML은 단일공사별 파일로 나눈 뒤 불러오세요."); const contract = contracts[0]; const contractGroups = children(contract, "내역서"), maxDepth = Math.max( 1, ...contractGroups.map((group) => value(group, "내역서번호").length / 2), ); for (const group of contractGroups) { result.tables.EstMainDat.push({ PkID: ++pk, Level: Math.max(1, maxDepth - value(group, "내역서번호").length / 2 + 1), Code: value(group, "내역서번호"), Name: value(group, "공종명"), ...money(group), }); for (const row of children(group, "세부공종")) { const next = { PkID: ++pk, Code: value(row, "세부공종코드"), Name: value(row, "세부공종명"), Size: value(row, "규격"), Unite: value(row, "단위"), GN: amount(row, "수량"), ...money(row), ...unit(row), CalcIndex: ["true", "1"].includes(value(row, "계산제외")) ? 2 : 0, LinkIndex: Number( Object.entries(kinds).find(([, v]) => v === value(row, "자원구분"))?.[0] || 0, ), }; result.tables.EstMainDat.push(next); } } const direct = result.tables.EstMainDat.filter((r) => !r.Level && r.CalcIndex < 2); result.tables.EstMainDat.push({ PkID: ++pk, Name: "순공사비", LinkIndex: 54, ...Object.fromEntries( [...EstMath.components, "TC"].map((k) => [k, direct.reduce((s, r) => s + n(r[k]), 0)]), ), }); for (const [element, table, key] of [ ["자재", "StuffcDat", "자재코드"], ["노무", "LaborcDat", "노무코드"], ["경비항목", "EstiMateDat", "경비항목코드"], ["실적단가", "SilJukDat", "실적공사비코드"], ]) children(contract, element).forEach((row, i) => { const amounts = element === "자재" ? { GC: amount(row, "재료비단가") } : element === "노무" ? { NC: amount(row, "노무비단가") } : { GC: amount(row, "재료비단가"), NC: amount(row, "노무비단가"), JC: amount(row, "경비단가"), }; const tc = Object.values(amounts).reduce((a, b) => a + b, 0); result.tables[table].push({ PkID: i + 1, Code: code(value(row, key)), ...fields(row), ...amounts, TC: tc, InputTC: tc, ValueIndex: 4, }); }); for (const [element, main, title, key] of [ ["일위대가", "HeadMainDat", "HeadTitleDat", "일위대가코드"], ["기계경비", "MachMainDat", "MachTitleDat", "기계경비코드"], ]) children(contract, element).forEach((row, i) => { const header = { Code: code(value(row, key)), Index: i + 1, ...fields(row), ...money(row) }; result.tables[title].push({ ...header }); result.tables[main].push({ PkID: result.tables[main].length + 1, ...header }); children(row, element + "상세").forEach((child) => result.tables[main].push({ PkID: result.tables[main].length + 1, Index: 0, TitleCode: header.Code, ...linked(child), }), ); }); children(contract, "중기단가산출").forEach((row, i) => { const header = { Code: code(value(row, "중기단가산출코드")), Index: i + 1, ...fields(row), ...money(row), }; result.tables.CostTitleDat.push({ ...header }); result.tables.CostMainDat.push({ PkID: result.tables.CostMainDat.length + 1, ...header }); result.tables.CostMainDat.push({ PkID: result.tables.CostMainDat.length + 1, Index: 0, TitleCode: header.Code, LineData: value(row, "중기단가산출식"), ...money(row), CalcIndex: 1, }); }); const fees = children(doc.documentElement, "공사내역서")[0]; for (const row of children(children(fees, "공사제경비")[0] || fees, "제경비항목")) result.tables.Wonga.push({ Gubun: value(row, "세부공종코드"), Name: value(row, "비목명"), Youl: value(row, "적용요율"), VSusic: value(row, "적용산식"), Cost: amount(row, "산출금액"), }); result.originalC3r = source; result.importWarnings = [ "XML의 저장 금액을 보존하기 위해 자동계산을 껐습니다. 재계산 전 원본 상세·산식을 확인하세요.", "XML 원문은 프로젝트에 보존됩니다. 제출기관 인증/등록 검사는 별도입니다.", ]; result.calculation = { errors: [], warnings: result.importWarnings, direct: result.tables.EstMainDat.at(-1), total: result.tables.Wonga.at(-1)?.Cost ?? result.tables.EstMainDat.at(-1).TC, }; return EstModel.validate(result); } return { output, read }; })(); if (typeof module !== "undefined" && module.exports) module.exports = EstC3r;