701 lines
24 KiB
JavaScript
701 lines
24 KiB
JavaScript
// JDLCHK.dll field declarations: 4e926c..4fb010; EST writer e44f28/e4739c/e541ec.
|
||
const { pack, unpack } = require("./archive.cjs");
|
||
const { createHash } = require("node:crypto");
|
||
const { cp949 } = require("./export-estx.cjs");
|
||
const schema = require("./jdl-schema.json");
|
||
const { exportMachines } = require("./jdl-machine-export.cjs");
|
||
const { orderUnits } = require("./jdl-unit-order.cjs");
|
||
const { isFixedPart, exportUnitPart } = require("./jdl-unit-part.cjs");
|
||
const { canPreserveSpecial, fixedSpecialPart } = require("./jdl-special-unit.cjs");
|
||
const { ordinaryMachinePart, machineQuantity } = require("./jdl-machine-parts.cjs");
|
||
const { matchesAmounts } = require("./jdl-mainpart.cjs");
|
||
const decoder = new TextDecoder("euc-kr", { fatal: true });
|
||
function fingerprint(project) {
|
||
const { originalJdlState, id, ...content } = project;
|
||
return createHash("sha256").update(JSON.stringify(content)).digest("hex");
|
||
}
|
||
function markOriginal(project) {
|
||
project.originalJdlState = fingerprint(project);
|
||
return project;
|
||
}
|
||
const num = (v) => {
|
||
const n = Number(v || 0);
|
||
if (!Number.isFinite(n)) throw Error("JDL 수치 오류");
|
||
return n;
|
||
};
|
||
function text(v, max = 40) {
|
||
const s = String(v ?? "");
|
||
if (/[\x00-\x1f]/.test(s)) throw Error("JDL 문자열에 제어문자가 있습니다.");
|
||
if (cp949(s).length > max) throw Error(`JDL 문자열 길이 ${max}바이트 초과: ${s}`);
|
||
return s;
|
||
}
|
||
function encode(name, rows) {
|
||
const fields = schema[name];
|
||
return cp949(
|
||
rows
|
||
.map(
|
||
(row) =>
|
||
fields
|
||
.map((f) => {
|
||
const v = row[f.name];
|
||
return f.kind === 6 ? String(num(v)) : text(v, f.kind === 16 ? 100000 : f.size || 1);
|
||
})
|
||
.join("\t") + "\x05\r\n",
|
||
)
|
||
.join(""),
|
||
);
|
||
}
|
||
function parse(name, bytes) {
|
||
const fields = schema[name];
|
||
if (!fields || !bytes) return [];
|
||
return decoder
|
||
.decode(bytes)
|
||
.split(/\r?\n/)
|
||
.filter((v) => v.trim())
|
||
.map((line, i) => {
|
||
const values = line
|
||
.replace(/\x05\s*$/, "")
|
||
.split("\t")
|
||
.map((v) => v.trim());
|
||
if (values.length !== fields.length)
|
||
throw Error(
|
||
`${name} ${i + 1}행: ${fields.length}개 필드가 필요합니다 (${values.length}개).`,
|
||
);
|
||
return Object.fromEntries(
|
||
fields.map((f, j) => [
|
||
f.name,
|
||
f.kind === 6 ? num(values[j]) : text(values[j], f.kind === 16 ? 100000 : f.size || 1),
|
||
]),
|
||
);
|
||
});
|
||
}
|
||
function output(project) {
|
||
if (project.originalJdl && project.originalJdlState === fingerprint(project))
|
||
return {
|
||
bytes: Buffer.from(project.originalJdl, "base64"),
|
||
warnings: [
|
||
"변경되지 않은 원본 JDL을 그대로 내보냅니다. 원본 검사 DLL 인증은 확인하지 않았습니다.",
|
||
],
|
||
};
|
||
const tables = Object.fromEntries(Object.keys(schema).map((k) => [k, []])),
|
||
warnings = [],
|
||
map = new Map(),
|
||
ambiguous = new Set();
|
||
let serial = 0;
|
||
function source(kind, row) {
|
||
const key = kind + ":" + row.Code;
|
||
if (row.Code && map.has(key)) {
|
||
map.delete(key);
|
||
ambiguous.add(key);
|
||
warnings.push(`${row.Code}: 중복 자원 코드는 독립 금액으로 전개합니다.`);
|
||
}
|
||
const prefix = kind === 5 ? "M" : kind === 6 ? "L" : "W",
|
||
code = prefix + String(++serial).padStart(10, "0");
|
||
const price =
|
||
kind === 5
|
||
? { F_MPRICE: num(row.TC) }
|
||
: kind === 6
|
||
? { F_LPRICE: num(row.TC) }
|
||
: { F_MPRICE: num(row.GC), F_LPRICE: num(row.NC), F_EPRICE: num(row.JC) + num(row.SC) };
|
||
const name = prefix === "M" ? "m.txt" : prefix === "L" ? "l.txt" : "w.txt";
|
||
tables[name].push({
|
||
F_CODE: code,
|
||
F_DES: row.Name,
|
||
F_SIZE: row.Size,
|
||
F_UNIT: row.Unite,
|
||
...price,
|
||
F_ORICODE: "00000000000",
|
||
});
|
||
if (row.Code && !ambiguous.has(key)) map.set(key, code);
|
||
return code;
|
||
}
|
||
for (const [kind, table] of [
|
||
[5, "StuffcDat"],
|
||
[6, "LaborcDat"],
|
||
[21, "SilJukDat"],
|
||
[101, "EstiMateDat"],
|
||
])
|
||
for (const row of project.tables[table] || []) source(kind, row);
|
||
for (const table of ["CostTitleDat", "MachTitleDat", "HeadTitleDat"])
|
||
for (const row of project.tables[table] || [])
|
||
source({ CostTitleDat: 104, MachTitleDat: 105, HeadTitleDat: 103 }[table], row);
|
||
for (const title of project.tables.CostTitleDat || []) {
|
||
if (!title.Code || ambiguous.has("104:" + title.Code)) continue;
|
||
const code = "D" + String(++serial).padStart(10, "0");
|
||
map.set("104:" + title.Code, code);
|
||
tables["danga.txt"].push({
|
||
F_CODE: code,
|
||
F_DES: title.Name,
|
||
F_SIZE: title.Size,
|
||
F_UNIT: title.Unite,
|
||
F_MPRICE: num(title.GC),
|
||
F_LPRICE: num(title.NC),
|
||
F_EPRICE: num(title.JC) + num(title.SC),
|
||
F_ORICODE: "00000000000",
|
||
});
|
||
}
|
||
exportMachines(project, tables, map, ambiguous, num, () => ++serial, source, warnings);
|
||
const unitGroups = new Map();
|
||
let unitGroup;
|
||
for (const row of project.tables.HeadMainDat || []) {
|
||
if (num(row.Index) > 0) {
|
||
unitGroup = [];
|
||
if (unitGroups.has(row.Code)) {
|
||
if (unitGroups.get(row.Code))
|
||
warnings.push(`${row.Code}: 중복 일위 상세는 고정 단가로 전개합니다.`);
|
||
unitGroups.set(row.Code, null);
|
||
} else unitGroups.set(row.Code, unitGroup);
|
||
} else if (unitGroup) unitGroup.push(row);
|
||
}
|
||
const exportedUnits = new Set();
|
||
for (const title of orderUnits(project.tables.HeadTitleDat || [], unitGroups)) {
|
||
const parts = unitGroups.get(title.Code);
|
||
if (
|
||
!title.Code ||
|
||
ambiguous.has("103:" + title.Code) ||
|
||
num(title.SC) ||
|
||
!parts?.length ||
|
||
parts.some(
|
||
(row) =>
|
||
(!isFixedPart(row, num) &&
|
||
(![5, 6, 21, 101, 103, 104, 105].includes(num(row.LinkIndex)) ||
|
||
num(row.CalcIndex) ||
|
||
(num(row.LinkIndex) === 103 && row.Code === title.Code) ||
|
||
!map.has(num(row.LinkIndex) + ":" + row.Code))) ||
|
||
["SKind", "Gangub", "OCheck", "Biul", "SC", "SU"].some((key) => num(row[key])),
|
||
)
|
||
)
|
||
continue;
|
||
const code = "U" + String(++serial).padStart(10, "0");
|
||
map.set("103:" + title.Code, code);
|
||
for (const row of parts)
|
||
if (!isFixedPart(row, num) && num(row.LinkIndex) === 103 && !exportedUnits.has(row.Code))
|
||
warnings.push(`${title.Code}/${row.Code}: 하위 일위대가를 고정 단가로 전개합니다.`);
|
||
if (parts.some((row) => isFixedPart(row, num)))
|
||
warnings.push(`${title.Code}: 일위 상세의 고정 금액을 별도 단가로 내보냅니다.`);
|
||
exportedUnits.add(title.Code);
|
||
tables["unit.txt"].push({
|
||
F_CODE: code,
|
||
F_DES: title.Name,
|
||
F_SIZE: title.Size,
|
||
F_UNIT: title.Unite,
|
||
F_MPRICE: num(title.GC),
|
||
F_LPRICE: num(title.NC),
|
||
F_EPRICE: num(title.JC) + num(title.SC),
|
||
F_ORICODE: "00000000000",
|
||
});
|
||
for (const [index, row] of parts.entries())
|
||
tables["unitpart.txt"].push(
|
||
exportUnitPart(row, code, index, map, source, num, () => ++serial),
|
||
);
|
||
}
|
||
let group = null,
|
||
groupNumber = 0,
|
||
sequence = 0;
|
||
function addGroup(name) {
|
||
if (++groupNumber > 99) throw Error("JDL 단일 레벨 변환은 공종 99개까지 가능합니다.");
|
||
group = {
|
||
F_CODE: String(groupNumber).padStart(2, "0"),
|
||
F_DES: text(name),
|
||
F_QQTY: 1,
|
||
F_MPRICE: 0,
|
||
F_LPRICE: 0,
|
||
F_EPRICE: 0,
|
||
F_ORICODE: "00000000000",
|
||
};
|
||
tables["gongjong.txt"].push(group);
|
||
sequence = 0;
|
||
}
|
||
for (const row of project.tables.EstMainDat) {
|
||
if (num(row.LinkIndex) === 54) break;
|
||
if (num(row.Level) > 0) {
|
||
addGroup(row.Name);
|
||
continue;
|
||
}
|
||
if (!group) addGroup(project.name);
|
||
if (num(row.CalcIndex) >= 2) {
|
||
warnings.push(`${row.Code || row.Name}: 합산 제외 행 생략`);
|
||
continue;
|
||
}
|
||
let key = map.get(num(row.LinkIndex) + ":" + row.Code),
|
||
gn = num(row.GN),
|
||
m = num(row.GU),
|
||
l = num(row.NU),
|
||
e = num(row.JU) + num(row.SU);
|
||
if (
|
||
!key ||
|
||
num(row.CalcIndex) === 1 ||
|
||
num(row.CalcIndex) === 3 ||
|
||
!matchesAmounts(row, gn, [m, l, e], num)
|
||
) {
|
||
key = source(0, {
|
||
Code: "LITERAL" + ++serial,
|
||
Name: row.Name,
|
||
Size: row.Size,
|
||
Unite: row.Unite,
|
||
GC: row.GC,
|
||
NC: row.NC,
|
||
JC: row.JC,
|
||
SC: row.SC,
|
||
});
|
||
gn = ["%", "%"].includes(row.Unite) ? 100 : 1;
|
||
m = num(row.GC);
|
||
l = num(row.NC);
|
||
e = num(row.JC) + num(row.SC);
|
||
warnings.push(`${row.Code || row.Name}: 저장 금액을 독립 항목으로 전개`);
|
||
}
|
||
tables["mainpart.txt"].push({
|
||
F_CODE: group.F_CODE,
|
||
F_SEQ: String(++sequence),
|
||
F_CODE1: key,
|
||
F_DES: row.Name,
|
||
F_SIZE: row.Size,
|
||
F_UNIT: row.Unite,
|
||
F_QQTY: gn,
|
||
F_MPRICE: m,
|
||
F_LPRICE: l,
|
||
F_EPRICE: e,
|
||
F_SPROPER: "0",
|
||
F_LOADBM: "T",
|
||
F_SAVEBM: "T",
|
||
F_SRATE: 0,
|
||
F_KWAN: "0",
|
||
F_CLASS: "0",
|
||
F_ORICODE: "00000000000",
|
||
});
|
||
group.F_MPRICE += num(row.GC);
|
||
group.F_LPRICE += num(row.NC);
|
||
group.F_EPRICE += num(row.JC) + num(row.SC);
|
||
}
|
||
if (!tables["gongjong.txt"].length) addGroup(project.name);
|
||
const direct = tables["gongjong.txt"].reduce(
|
||
(a, r) => ({ m: a.m + r.F_MPRICE, l: a.l + r.F_LPRICE, e: a.e + r.F_EPRICE }),
|
||
{ m: 0, l: 0, e: 0 },
|
||
);
|
||
const global = `[BASIC]\r\nMAKER=ESTWEB\r\nVERSION=2.00\r\nCDATE=${new Date().toISOString().slice(0, 10).replaceAll("-", "")}\r\n[CPART]\r\nMQBIT=7C\r\nMABIT=0C\r\nMTBIT=0C\r\nMCMETHOD=0LME\r\nUABIT=0C\r\nUTBIT=0C\r\nUCMETHOD=0LME\r\nDCMETHOD=0LME\r\nJABIT=0C\r\nJTBIT=0C\r\nJCMETHOD=0LME\r\nDQBIT=2R\r\nDABIT=0C\r\nDTBIT=0C\r\n[JPART]\r\n[VPART]\r\n[COST]\r\nMCOST=${direct.m}\r\nLCOST=${direct.l}\r\nECOST=${direct.e}\r\nTCOST=${direct.m + direct.l + direct.e}\r\n`;
|
||
const files = new Map([
|
||
["Global.ini", cp949(global)],
|
||
["Main.txt", cp949(["0", text(project.name, 80), "02"].join("\t") + "\x05\r\n")],
|
||
]);
|
||
for (const [name, rows] of Object.entries(tables)) files.set(name, encode(name, rows));
|
||
const fees = (project.tables.Wonga || [])
|
||
.map(
|
||
(r) =>
|
||
[
|
||
text(r.Gubun, 12),
|
||
text(r.Name),
|
||
text(r.Youl, 25),
|
||
text(r.VSusic, 200),
|
||
String(num(r.Cost)),
|
||
].join("\t") + "\x05\r\n",
|
||
)
|
||
.join("");
|
||
files.set("CostAccount.txt", cp949(fees));
|
||
warnings.unshift(
|
||
"JDL 기본 표를 출력합니다. 산근·기계와 특수 일위의 편집 산식, 관급/특수 업무 구조는 JSON/ESTX에 보관하세요. 원본 검사 DLL의 인증은 미확인입니다.",
|
||
);
|
||
return { bytes: pack(files), warnings };
|
||
}
|
||
function read(bytes, project) {
|
||
const files = new Map();
|
||
for (const [name, data] of unpack(bytes)) {
|
||
const key = name.toLowerCase();
|
||
if (files.has(key)) throw Error(`JDL 파일명 중복: ${name}`);
|
||
files.set(key, data);
|
||
}
|
||
if (!files.has("global.ini") || !files.has("main.txt"))
|
||
throw Error("Global.ini/Main.txt 없는 JDL입니다.");
|
||
const main = decoder
|
||
.decode(files.get("main.txt"))
|
||
.replace(/\x05\s*$/, "")
|
||
.split("\t");
|
||
if (main.length !== 3) throw Error("JDL Main.txt 필드 오류");
|
||
project.name = main[1].trim();
|
||
project.options.AutoCalc = false;
|
||
project.options.SourceRefresh = false;
|
||
const rows = Object.fromEntries(
|
||
Object.keys(schema).map((name) => [name, parse(name, files.get(name))]),
|
||
);
|
||
const byCode = new Map();
|
||
for (const [file, table, kind] of [
|
||
["m.txt", "StuffcDat", 5],
|
||
["l.txt", "LaborcDat", 6],
|
||
["e.txt", "EstiMateDat", 101],
|
||
["w.txt", "EstiMateDat", 101],
|
||
["j.txt", "EstiMateDat", 101],
|
||
["unit.txt", "HeadTitleDat", 103],
|
||
["junit.txt", "MachTitleDat", 105],
|
||
["danga.txt", "CostTitleDat", 104],
|
||
])
|
||
for (const r of rows[file]) {
|
||
if (byCode.has(r.F_CODE)) throw Error("JDL 자원 코드 중복: " + r.F_CODE);
|
||
const row = {
|
||
PkID: project.tables[table].length + 1,
|
||
Code: r.F_CODE,
|
||
Name: r.F_DES,
|
||
Size: r.F_SIZE,
|
||
Unite: r.F_UNIT,
|
||
GC: num(r.F_MPRICE),
|
||
NC: num(r.F_LPRICE),
|
||
JC: num(r.F_EPRICE),
|
||
SC: 0,
|
||
};
|
||
row.TC = row.InputTC = row.GC + row.NC + row.JC;
|
||
row.ValueIndex = 4;
|
||
project.tables[table].push(row);
|
||
byCode.set(row.Code, { row, kind });
|
||
}
|
||
let unitPk = 0;
|
||
const unitWarnings = [];
|
||
const machineParts = new Map();
|
||
for (const part of rows["jpart.txt"]) {
|
||
if (!machineParts.has(part.F_CODE)) machineParts.set(part.F_CODE, []);
|
||
machineParts.get(part.F_CODE).push(part);
|
||
}
|
||
let machineDetailed = false,
|
||
machineWeighted = false,
|
||
machineSpecial = false,
|
||
machineFallback = false;
|
||
for (const title of rows["junit.txt"]) {
|
||
const gc = num(title.F_MPRICE),
|
||
nc = num(title.F_LPRICE),
|
||
jc = num(title.F_EPRICE);
|
||
const amounts = { GC: gc, NC: nc, JC: jc, SC: 0, TC: gc + nc + jc };
|
||
const parts = machineParts.get(title.F_CODE) || [];
|
||
const uniqueSequences = new Set(parts.map((part) => part.F_SEQ));
|
||
const identifiable =
|
||
parts.length > 0 &&
|
||
uniqueSequences.size === parts.length &&
|
||
parts.every((part) => Boolean(part.F_SEQ) && num(part.F_QQTY) > 0);
|
||
const ordinary = identifiable && parts.every((part) => ordinaryMachinePart(part, num));
|
||
const matches = (weighted) =>
|
||
[
|
||
["F_MPRICE", gc],
|
||
["F_LPRICE", nc],
|
||
["F_EPRICE", jc],
|
||
].every(
|
||
([field, value]) =>
|
||
Math.abs(
|
||
parts.reduce(
|
||
(sum, part) => sum + num(part[field]) * (weighted ? machineQuantity(part, num) : 1),
|
||
0,
|
||
) - value,
|
||
) < 1e-6,
|
||
);
|
||
const direct = identifiable && matches(false);
|
||
const weighted = identifiable && !direct && matches(true);
|
||
const basic = (direct && parts.every((part) => machineQuantity(part, num) === 1)) || weighted;
|
||
project.tables.MachMainDat.push({
|
||
PkID: project.tables.MachMainDat.length + 1,
|
||
Index: 1,
|
||
Code: title.F_CODE,
|
||
JdlSummary: true,
|
||
Name: title.F_DES,
|
||
Size: title.F_SIZE,
|
||
Unite: title.F_UNIT,
|
||
...amounts,
|
||
});
|
||
if (basic) {
|
||
machineDetailed = true;
|
||
machineWeighted ||= weighted;
|
||
machineSpecial ||= !ordinary;
|
||
for (const part of parts) {
|
||
const factor = weighted ? machineQuantity(part, num) : 1,
|
||
GC = num(part.F_MPRICE) * factor,
|
||
NC = num(part.F_LPRICE) * factor,
|
||
JC = num(part.F_EPRICE) * factor;
|
||
project.tables.MachMainDat.push({
|
||
PkID: project.tables.MachMainDat.length + 1,
|
||
Index: 0,
|
||
CalcIndex: 1,
|
||
JdlSummary: true,
|
||
JdlSourceCode: part.F_CODE1,
|
||
JdlSourceQuantity: num(part.F_QQTY),
|
||
Name: part.F_DES,
|
||
Size: part.F_SIZE,
|
||
Unite: part.F_UNIT,
|
||
GN: num(part.F_QQTY),
|
||
GC,
|
||
NC,
|
||
JC,
|
||
SC: 0,
|
||
TC: GC + NC + JC,
|
||
});
|
||
}
|
||
} else {
|
||
machineFallback = true;
|
||
project.tables.MachMainDat.push({
|
||
PkID: project.tables.MachMainDat.length + 1,
|
||
Index: 0,
|
||
CalcIndex: 1,
|
||
JdlSummary: true,
|
||
Name: "JDL 저장 금액 (상세 산식 미변환)",
|
||
...amounts,
|
||
});
|
||
}
|
||
}
|
||
if (machineDetailed)
|
||
unitWarnings.push(
|
||
"기계 상세는 저장 금액별로 표시하며, 산식과 원천 단가 연결은 원본 JDL에 보존됩니다.",
|
||
);
|
||
if (machineWeighted)
|
||
unitWarnings.push(
|
||
"일부 기계 상세는 단위에 따른 수량×단가가 저장 합계와 일치해 계산 금액으로 표시합니다.",
|
||
);
|
||
if (machineSpecial)
|
||
unitWarnings.push("특수 기계 상세는 저장 금액으로 고정하며 특수 산식은 원문에 보존합니다.");
|
||
if (machineFallback)
|
||
unitWarnings.push(
|
||
"일부 기계경비는 저장 금액으로 가져왔습니다. 상세 산식은 원본 JDL에 보존됩니다.",
|
||
);
|
||
if (rows["jpart.txt"].some((r) => !byCode.has(r.F_CODE) || byCode.get(r.F_CODE).kind !== 105))
|
||
unitWarnings.push("부모 기계 단가가 없는 상세 행은 원본 JDL에만 보존됩니다.");
|
||
for (const title of rows["danga.txt"]) {
|
||
const gc = num(title.F_MPRICE),
|
||
nc = num(title.F_LPRICE),
|
||
jc = num(title.F_EPRICE);
|
||
project.tables.CostMainDat.push({
|
||
PkID: project.tables.CostMainDat.length + 1,
|
||
Index: 1,
|
||
Code: title.F_CODE,
|
||
JdlSummary: true,
|
||
Name: title.F_DES,
|
||
Size: title.F_SIZE,
|
||
Unite: title.F_UNIT,
|
||
GC: gc,
|
||
NC: nc,
|
||
JC: jc,
|
||
SC: 0,
|
||
TC: gc + nc + jc,
|
||
});
|
||
// shortcut: JDL 단가 산출 상세는 원문에만 보존; 산식 형식 확인 후 상세 변환.
|
||
for (const [key, value, check] of [
|
||
["GC", gc, -2],
|
||
["NC", nc, -1],
|
||
["JC", jc, -3],
|
||
]) {
|
||
const literal = value.toLocaleString("en-US", {
|
||
useGrouping: false,
|
||
maximumFractionDigits: 20,
|
||
});
|
||
project.tables.CostMainDat.push({
|
||
PkID: project.tables.CostMainDat.length + 1,
|
||
Index: 0,
|
||
SCheck: check,
|
||
JdlSummary: true,
|
||
Name: "JDL 저장 금액",
|
||
LineData: `${literal} = \x12${literal}\x12`,
|
||
[key]: value,
|
||
TC: value,
|
||
});
|
||
}
|
||
}
|
||
if (rows["danga.txt"].length)
|
||
unitWarnings.push("단가산출은 저장 금액으로 가져왔습니다. 상세 산식은 원본 JDL에 보존됩니다.");
|
||
const unitCodes = new Set(rows["unit.txt"].map((r) => r.F_CODE));
|
||
const partsByUnit = new Map();
|
||
for (const part of rows["unitpart.txt"]) {
|
||
if (!partsByUnit.has(part.F_CODE)) partsByUnit.set(part.F_CODE, []);
|
||
partsByUnit.get(part.F_CODE).push(part);
|
||
}
|
||
for (const title of rows["unit.txt"]) {
|
||
const gc = num(title.F_MPRICE),
|
||
nc = num(title.F_LPRICE),
|
||
jc = num(title.F_EPRICE);
|
||
const parts = partsByUnit.get(title.F_CODE) || [];
|
||
const preserveSpecial = canPreserveSpecial(parts, title, num);
|
||
let incomplete = !parts.length;
|
||
const included = { GC: 0, NC: 0, JC: 0 };
|
||
project.tables.HeadMainDat.push({
|
||
PkID: ++unitPk,
|
||
Index: 1,
|
||
Code: title.F_CODE,
|
||
TitleCode: title.F_CODE,
|
||
JdlSummary: true,
|
||
Name: title.F_DES,
|
||
Size: title.F_SIZE,
|
||
Unite: title.F_UNIT,
|
||
GC: gc,
|
||
NC: nc,
|
||
JC: jc,
|
||
SC: 0,
|
||
TC: gc + nc + jc,
|
||
});
|
||
for (const part of parts) {
|
||
const source = byCode.get(part.F_CODE1);
|
||
const basic =
|
||
source &&
|
||
["", "0"].includes(part.F_SPROPER) &&
|
||
["", "T"].includes(part.F_LOADBM) &&
|
||
["", "T"].includes(part.F_SAVEBM) &&
|
||
!num(part.F_SRATE) &&
|
||
["", "0"].includes(part.F_KWAN) &&
|
||
["", "0"].includes(part.F_CLASS);
|
||
if (!basic) {
|
||
if (preserveSpecial) {
|
||
const row = fixedSpecialPart(part, source, ++unitPk, num);
|
||
project.tables.HeadMainDat.push(row);
|
||
for (const key of ["GC", "NC", "JC"]) included[key] += row[key];
|
||
unitWarnings.push(
|
||
`일위 ${title.F_CODE}/${part.F_SEQ}: 특수 구성은 저장 금액으로 고정하며 산식은 원문에 보존합니다.`,
|
||
);
|
||
continue;
|
||
}
|
||
incomplete = true;
|
||
unitWarnings.push(`일위 ${title.F_CODE}/${part.F_SEQ}: 특수 구성은 원문에만 보존`);
|
||
continue;
|
||
}
|
||
const gc = num(part.F_MCOST),
|
||
nc = num(part.F_LCOST),
|
||
jc = num(part.F_ECOST);
|
||
const quantity = num(part.F_QQTY) / (["%", "%"].includes(part.F_UNIT) ? 100 : 1);
|
||
const fixedAmount = [
|
||
[gc, num(part.F_MPRICE)],
|
||
[nc, num(part.F_LPRICE)],
|
||
[jc, num(part.F_EPRICE)],
|
||
].some(([amount, price]) => Math.abs(amount - Math.trunc(quantity * price)) > 0.000001);
|
||
if (fixedAmount)
|
||
unitWarnings.push(`일위 ${title.F_CODE}/${part.F_SEQ}: 상세의 저장 금액을 고정합니다.`);
|
||
included.GC += gc;
|
||
included.NC += nc;
|
||
included.JC += jc;
|
||
project.tables.HeadMainDat.push({
|
||
PkID: ++unitPk,
|
||
Index: 0,
|
||
Code: part.F_CODE1,
|
||
Name: part.F_DES,
|
||
Size: part.F_SIZE,
|
||
Unite: part.F_UNIT,
|
||
LinkIndex: source.kind,
|
||
CalcIndex: fixedAmount ? 1 : 0,
|
||
...(fixedAmount ? { JdlSummary: true } : {}),
|
||
GN: num(part.F_QQTY),
|
||
GU: num(part.F_MPRICE),
|
||
NU: num(part.F_LPRICE),
|
||
JU: num(part.F_EPRICE),
|
||
SU: 0,
|
||
GC: gc,
|
||
NC: nc,
|
||
JC: jc,
|
||
SC: 0,
|
||
TC: gc + nc + jc,
|
||
TU: num(part.F_MPRICE) + num(part.F_LPRICE) + num(part.F_EPRICE),
|
||
});
|
||
}
|
||
if (
|
||
incomplete ||
|
||
["GC", "NC", "JC"].some((key, i) => Math.abs([gc, nc, jc][i] - included[key]) > 0.000001)
|
||
) {
|
||
const gc = num(title.F_MPRICE) - included.GC,
|
||
nc = num(title.F_LPRICE) - included.NC,
|
||
jc = num(title.F_EPRICE) - included.JC;
|
||
project.tables.HeadMainDat.push({
|
||
PkID: ++unitPk,
|
||
Index: 0,
|
||
CalcIndex: 1,
|
||
JdlSummary: true,
|
||
Name: "JDL 저장 금액 차액",
|
||
GC: gc,
|
||
NC: nc,
|
||
JC: jc,
|
||
SC: 0,
|
||
TC: gc + nc + jc,
|
||
});
|
||
unitWarnings.push(`일위 ${title.F_CODE}: 상세와 저장 합계의 차액을 보존합니다.`);
|
||
}
|
||
}
|
||
if (rows["unitpart.txt"].some((r) => !unitCodes.has(r.F_CODE)))
|
||
unitWarnings.push("부모 일위대가가 없는 상세 행은 원문에만 보존");
|
||
const headings = new Set();
|
||
const partsByGroup = new Map();
|
||
for (const part of rows["mainpart.txt"]) {
|
||
if (!partsByGroup.has(part.F_CODE)) partsByGroup.set(part.F_CODE, []);
|
||
partsByGroup.get(part.F_CODE).push(part);
|
||
}
|
||
let pk = 0;
|
||
for (const group of rows["gongjong.txt"]) {
|
||
if (headings.has(group.F_CODE)) throw Error("JDL 공종 코드 중복");
|
||
headings.add(group.F_CODE);
|
||
project.tables.EstMainDat.push({
|
||
PkID: ++pk,
|
||
Level: 1,
|
||
Code: group.F_CODE,
|
||
Name: group.F_DES,
|
||
GC: group.F_MPRICE,
|
||
NC: group.F_LPRICE,
|
||
JC: group.F_EPRICE,
|
||
SC: 0,
|
||
TC: group.F_MPRICE + group.F_LPRICE + group.F_EPRICE,
|
||
});
|
||
for (const r of partsByGroup.get(group.F_CODE) || []) {
|
||
const source = byCode.get(r.F_CODE1);
|
||
if (!source) throw Error("JDL 연결 자원 없음: " + r.F_CODE1);
|
||
const row = {
|
||
PkID: ++pk,
|
||
Code: r.F_CODE1,
|
||
Name: r.F_DES,
|
||
Size: r.F_SIZE,
|
||
Unite: r.F_UNIT,
|
||
GN: r.F_QQTY,
|
||
GU: r.F_MPRICE,
|
||
NU: r.F_LPRICE,
|
||
JU: r.F_EPRICE,
|
||
SU: 0,
|
||
LinkIndex: source.kind,
|
||
CalcIndex: 0,
|
||
};
|
||
const quantity = row.GN / (["%", "%"].includes(row.Unite) ? 100 : 1);
|
||
row.GC = Math.trunc(quantity * row.GU);
|
||
row.NC = Math.trunc(quantity * row.NU);
|
||
row.JC = Math.trunc(quantity * row.JU);
|
||
row.SC = 0;
|
||
row.TC = row.GC + row.NC + row.JC;
|
||
row.TU = row.GU + row.NU + row.JU;
|
||
project.tables.EstMainDat.push(row);
|
||
}
|
||
}
|
||
if (rows["mainpart.txt"].some((r) => !headings.has(r.F_CODE))) throw Error("JDL 부모 공종 없음");
|
||
const direct = project.tables.EstMainDat.filter((r) => !r.Level),
|
||
sum = (k) => direct.reduce((a, r) => a + num(r[k]), 0);
|
||
project.tables.EstMainDat.push({
|
||
PkID: ++pk,
|
||
Name: "순공사비",
|
||
LinkIndex: 54,
|
||
GC: sum("GC"),
|
||
NC: sum("NC"),
|
||
JC: sum("JC"),
|
||
SC: 0,
|
||
TC: sum("TC"),
|
||
});
|
||
if (files.has("costaccount.txt")) {
|
||
const lines = decoder
|
||
.decode(files.get("costaccount.txt"))
|
||
.split(/\r?\n/)
|
||
.filter((s) => s.trim());
|
||
for (const line of lines) {
|
||
const f = line.replace(/\x05\s*$/, "").split("\t");
|
||
if (f.length < 5) throw Error("JDL 원가 필드 오류");
|
||
project.tables.Wonga.push({
|
||
Gubun: f[0].trim(),
|
||
Name: f[1].trim(),
|
||
Youl: f[2].trim(),
|
||
VSusic: f[3].trim(),
|
||
Cost: num(f[4]),
|
||
});
|
||
}
|
||
}
|
||
project.originalJdl = bytes.toString("base64");
|
||
project.importWarnings = [
|
||
"JDL 저장 금액을 보존하기 위해 자동계산을 껐습니다. 복잡한 단가산출식·손료/기성 확장 파일은 원문에 보존되며 자동 변환하지 않습니다.",
|
||
...unitWarnings,
|
||
];
|
||
project.calculation = {
|
||
errors: [],
|
||
warnings: project.importWarnings,
|
||
direct: project.tables.EstMainDat.at(-1),
|
||
total: project.tables.Wonga.at(-1)?.Cost ?? sum("TC"),
|
||
};
|
||
return project;
|
||
}
|
||
module.exports = { output, read, parse, markOriginal };
|