Files
Aislo/tools/est-test/native-business.js
T

415 lines
15 KiB
JavaScript

// REA 전체점검 4~5: 변경 보존, 기성 3입력, 품목조정률, 연차 사본 계산.
const EstBusiness = (() => {
const { num, components, units, floating, sum } = EstMath;
const copy = (value) => structuredClone(value);
const rowKey = (row, index) => String(row.PkID ?? (row.Code || `row:${index}`));
const special = (row) => [57, 58, 62, 67].includes(num(row.LinkIndex));
function progressExclusions(project, result) {
const saved = new Map();
result.rows.forEach((row, index) => {
const key = rowKey(row, index);
if (saved.has(key)) throw Error(`기성 내역 식별자가 중복됩니다: ${key}`);
const value = Number(row.TotalGN);
if (!Number.isFinite(value)) throw Error(`기성 누계수량 오류: ${key}`);
saved.set(key, value);
});
const counts = new Map();
project.tables.EstMainDat.forEach((row, index) => {
const key = rowKey(row, index);
counts.set(key, (counts.get(key) || 0) + 1);
});
return project.tables.EstMainDat.flatMap((row, index) => {
const key = rowKey(row, index);
if (!saved.has(key)) return [];
if (counts.get(key) !== 1) throw Error(`현재 내역 식별자가 중복됩니다: ${key}`);
return [{ index, value: saved.get(key) }];
});
}
function moveEstimateRows(rows, selection, direction) {
const indices = [...selection].sort((a, b) => a - b);
if (!indices.length) throw Error("이동할 내역 행을 선택하세요.");
if (
![1, -1].includes(direction) ||
indices.some((value, index) => value !== indices[0] + index)
)
throw Error("연속된 내역 행을 선택하세요.");
const first = indices[0],
last = indices.at(-1),
neighbor = direction < 0 ? first - 1 : last + 1;
if (neighbor < 0 || neighbor >= rows.length) return indices;
const affected = rows.slice(Math.min(first, neighbor), Math.max(last, neighbor) + 1);
if (affected.some((row) => [54, 55, 58, 59].includes(num(row.LinkIndex))))
throw Error("합계 행은 이동하거나 건너뛸 수 없습니다.");
const counts = new Map();
rows.forEach((row, index) => {
const key = rowKey(row, index);
counts.set(key, (counts.get(key) || 0) + 1);
});
for (let index = Math.min(first, neighbor); index <= Math.max(last, neighbor); index++) {
const row = rows[index];
if ((row.PkID == null && !row.Code) || counts.get(rowKey(row, index)) !== 1)
throw Error("행 식별자가 없거나 중복되어 연결을 보존할 수 없습니다.");
}
const group = rows.splice(first, indices.length);
rows.splice(first + direction, 0, ...group);
return indices.map((index) => index + direction);
}
function baseline(project) {
const working = { ...project };
delete working.baseline;
const next = copy(working);
// Original recalculation replaces source catalogs from the working project.
// Keep its contract inputs once; drawings and pre-existing '_' copies stay in the working file.
const tables = {};
for (const table of [
"EstMainDat",
"HeadMainDat",
"HeadTitleDat",
"CostMainDat",
"CostTitleDat",
"MachTitleDat",
"Wonga",
]) {
tables[table] = (project.tables[table] || []).map((row) => {
const values = Object.fromEntries(
Object.entries(row).filter(([key]) => !key.endsWith("_")),
);
return copy(values);
});
}
next.baseline = { tables, name: project.name };
for (const rows of Object.values(next.baseline.tables))
for (const row of rows) row.NewItem = false;
for (const rows of Object.values(next.tables))
for (const row of rows) {
row.NewItem = false;
for (const key of [
"Code",
"Name",
"Size",
"Unite",
"GN",
"TU",
"TC",
...units,
...components,
"LineData",
"VSusic",
"Cost",
])
if (Object.hasOwn(row, key)) row[`${key}_`] = copy(row[key]);
}
next.options.Mode = "설계변경";
return next;
}
function changes(project) {
const old = project.baseline?.tables.EstMainDat || [];
const groups = new Map();
old.forEach((row, index) => {
const key = rowKey(row, index),
group = groups.get(key) || [];
group.push({ row, index });
groups.set(key, group);
});
const changes = project.tables.EstMainDat.map((row, index) => {
const saved = groups.get(rowKey(row, index))?.shift()?.row;
const stored =
!project.baseline && ["Code_", "Name_", "GN_", "TC_"].some((key) => row[key] != null)
? Object.fromEntries(
Object.entries(row)
.filter(([key]) => key.endsWith("_"))
.map(([key, value]) => [key.slice(0, -1), value]),
)
: null;
const original = saved || stored;
return {
key: rowKey(row, index),
Code: row.Code || "",
Name: row.Name || "",
GN: num(row.GN),
originalGN: num(original?.GN),
TC: num(row.TC),
originalTC: num(original?.TC),
difference: num(row.TC) - num(original?.TC),
NewItem: !original,
status: !original
? "new"
: ["Code", "Name", "Size", "Unite", "GN", "TC", ...units, ...components].some(
(key) => original[key] != null && original[key] !== row[key],
)
? "changed"
: "unchanged",
originalSource: saved ? "baseline" : stored ? "file" : null,
};
});
for (const group of groups.values())
for (const { row, index } of group) {
changes.push({
key: rowKey(row, index),
Code: row.Code || "",
Name: row.Name || "",
originalGN: num(row.GN),
originalTC: num(row.TC),
GN: 0,
TC: 0,
difference: -num(row.TC),
NewItem: false,
status: "deleted",
originalSource: "baseline",
});
}
return changes;
}
function costChanges(project) {
const current = project.tables.Wonga || [];
if (!project.baseline) {
if (!current.some((row) => row.Cost_ != null)) return [];
return current.map((row) => ({
Gubun: row.Gubun,
Name: row.Name,
originalCost: num(row.Cost_),
changedCost: num(row.Cost),
difference: num(row.Cost) - num(row.Cost_),
}));
}
const old = project.baseline.tables.Wonga || [];
function keyed(rows) {
const counts = new Map();
return rows.map((row) => {
const identity = JSON.stringify(row.Gubun ? ["code", row.Gubun] : ["name", row.Name || ""]);
const occurrence = counts.get(identity) || 0;
counts.set(identity, occurrence + 1);
return { row, key: JSON.stringify([identity, occurrence]) };
});
}
const previous = new Map(keyed(old).map(({ row, key }) => [key, row]));
const rows = keyed(current).map(({ row, key }) => {
const saved = previous.get(key);
previous.delete(key);
return {
Gubun: row.Gubun,
Name: row.Name,
originalCost: num(saved?.Cost),
changedCost: num(row.Cost),
difference: num(row.Cost) - num(saved?.Cost),
};
});
for (const row of previous.values())
rows.push({
Gubun: row.Gubun,
Name: row.Name,
originalCost: num(row.Cost),
changedCost: 0,
difference: -num(row.Cost),
});
return rows;
}
function bid(project, rates) {
const next = copy(project);
for (const key of ["HeadMainDat", "EstMainDat"])
for (const row of next.tables[key]) {
if (
num(row.Level) > 0 ||
num(row.Index) > 0 ||
[51, 52, 54, 55, 57, 58, 59, 62, 67, 68].includes(num(row.LinkIndex))
)
continue;
units.forEach((field, i) => {
row[`O${field}`] ??= num(row[field]);
row[field] = floating((num(row[`O${field}`]) * num(rates[i] ?? rates[0])) / 100);
});
row.TU = units.reduce((n, k) => n + num(row[k]), 0);
row.ApplyNacChal = 1;
}
next.options.SourceRefresh = false;
return EstNative.calculate(next);
}
function aligned(project, incoming) {
const a = project.tables.EstMainDat,
b = incoming.tables.EstMainDat;
// 10b5934 moves the source table to the current record number, rather than seeking a code.
if (
a.length !== b.length ||
a.some(
(row, index) =>
String(row.Code || "") !== String(b[index].Code || "") ||
num(row.Level) !== num(b[index].Level),
)
)
throw Error(
"불러올 내역의 행 순서·코드·레벨이 다릅니다. 같은 구조의 계약/입찰 내역을 선택하세요.",
);
return incoming.options?.AutoCalc === false ? incoming : EstNative.calculate(incoming);
}
function importContract(project, incoming) {
const source = aligned(project, incoming),
next = copy(project),
snapshot = baseline(source).baseline;
snapshot.tables.EstMainDat.forEach((row, index) => {
const current = next.tables.EstMainDat[index];
if (current.PkID != null) row.PkID = current.PkID;
else delete row.PkID;
for (const field of [
"Code",
"Name",
"Size",
"Unite",
"GN",
"TU",
"TC",
...units,
...components,
])
if (row[field] != null) current[field + "_"] = copy(row[field]);
});
next.baseline = snapshot;
return next;
}
function importBid(project, incoming) {
const source = aligned(project, incoming),
next = copy(project);
next.tables.EstMainDat.forEach((row, index) =>
units.forEach((unit) => (row["A" + unit] = num(source.tables.EstMainDat[index][unit]))),
);
return next;
}
function original(project, method = 0) {
if (!project.baseline) throw Error("당초 내역을 먼저 확정하세요.");
const old = {
...EstNative.empty(),
name: project.baseline.name,
tables: copy(project.baseline.tables),
options: {
...project.options,
RecalculateOriginal: true,
OldRecalcMethod: method,
SourceRefresh: true,
TotalFix: false,
},
};
for (const table of ["HeadMainDat", "CostMainDat", "EstMainDat"]) {
const rows = old.tables[table];
let previous = -1;
project.tables[table].forEach((row, index) => {
const existing = rows.findIndex((r, i) => rowKey(r, i) === rowKey(row, index));
if (existing >= 0) previous = existing;
else if (row.NewItem) rows.splice(++previous, 0, copy(row));
});
}
for (const table of [
"StuffcDat",
"LaborcDat",
"SilJukDat",
"EstiMateDat",
"MachGijunDat",
"MachMainDat",
])
old.tables[table] = copy(project.tables[table]);
const calculated = EstNative.calculate(old);
if (calculated.calculation.errors.length) throw Error(calculated.calculation.errors.join("\n"));
return { name: old.name, tables: calculated.tables };
}
function progress(project, roundId) {
return EstProgress.calculate(project, roundId);
}
function escalation(project) {
return EstEscalation.calculate(project);
}
function newAnnualName(years) {
const names = new Set(years.map((year) => String(year.name || "").trim()));
for (const year of years)
for (const match of String(year.formula || "").matchAll(/\[([^\]]+)\]/g)) names.add(match[1]);
let index = 1;
while (names.has(`${index}차`)) index++;
return `${index}차`;
}
function annual(project, full = true) {
const errors = [],
results = [];
const names = (project.annual || []).map((year) => String(year.name || "").trim());
if (names.some((name) => !name.trim()) || new Set(names).size !== names.length)
return { results, errors: ["차수 이름은 비어 있거나 중복될 수 없습니다."] };
if (names.some((name) => ["기준", "계약", "계약수량"].includes(name)))
return { results, errors: ["차수 이름에 기준·계약·계약수량은 사용할 수 없습니다."] };
for (const year of project.annual || []) {
const next = full ? copy(project) : EstNative.empty();
if (!full) {
next.tables.EstMainDat = copy(project.tables.EstMainDat);
next.tables.Wonga = copy(project.tables.Wonga);
next.options = { ...project.options };
}
const name = String(year.name).trim();
next.name = `${project.name} · ${name}`;
delete next.id;
next.progress = [];
next.annual = [];
next.options.TotalFix = false;
next.options.SourceRefresh = false;
for (let index = 0; index < next.tables.EstMainDat.length; index++) {
const row = next.tables.EstMainDat[index];
if (num(row.LinkIndex) === 57) {
// f0fcc0 keeps the fee rate and allocates the amount as a fixed row (99).
row.AnnualSourceLink = 57;
row.LinkIndex = 99;
}
if (num(row.Level) > 0 || [54, 55, 58, 59, 62, 67].includes(num(row.LinkIndex))) continue;
try {
// f0f9b8/f1098c resolve the same column formula against each current field.
const value = (field) =>
EstExpression.evaluate(year.formula || "[기준]", (name) => {
if (["기준", "계약", "계약수량"].includes(name))
return num(project.tables.EstMainDat[index][field]);
const previous = results.find((result) => result.name === name);
return previous ? num(previous.project.tables.EstMainDat[index]?.[field]) : undefined;
});
if (num(row.LinkIndex) !== 99) row.GN = value("GN");
components.forEach((key) => (row[key] = Math.trunc(value(key))));
row.TC = sum(row);
units.forEach((key) => (row[key] = 0));
row.TU = 0;
row.CalcIndex = num(row.CalcIndex) >= 2 ? 3 : 1;
} catch (error) {
errors.push(`${year.name}: ${error.message}`);
}
}
const calculated = EstNative.calculate(next);
errors.push(...calculated.calculation.errors);
results.push({ name, project: calculated });
}
return { results, errors };
}
function annualCopy(project, index) {
const result = annual(project, false);
if (result.errors.length) throw Error(result.errors.join("\n"));
const chosen = result.results[index]?.project;
if (!chosen) throw Error("연차 내역이 없습니다.");
const next = copy(project);
delete next.id;
next.name = chosen.name;
next.annual = [];
next.progress = [];
next.options = chosen.options;
next.tables.EstMainDat = chosen.tables.EstMainDat;
next.tables.Wonga = chosen.tables.Wonga;
next.calculation = chosen.calculation;
return next;
}
return {
baseline,
changes,
costChanges,
bid,
original,
progress,
escalation,
annual,
newAnnualName,
rowKey,
progressExclusions,
moveEstimateRows,
importContract,
importBid,
annualCopy,
};
})();