Files

580 lines
22 KiB
JavaScript

// 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[c],
);
};
const tag = (name, value) => `<${name}>${esc(value)}</${name}>`;
const raw = (name, value) => `<${name}>${value}</${name}>`;
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:
'<?xml version="1.0" encoding="UTF-8"?>' +
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 (/<!DOCTYPE|<!ENTITY/i.test(source)) throw Error("XML 외부 선언은 허용하지 않습니다.");
const doc = new DOMParser().parseFromString(source, "application/xml");
if (doc.querySelector("parsererror") || doc.documentElement.localName !== "공사원가호환규정")
throw Error("공사원가호환규정 XML 형식이 아닙니다.");
const children = (node, name) => [...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;