Files
Aislo/tools/est-test/native-progress.js

326 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// REA c90bd4/c91100/c914d4/c9261c/c9199c, c98928, ca4814/ca5e98.
const EstProgress = (() => {
const { num, components, units, floating, sum } = EstMath;
const precision = (value) => {
const [mantissa, exponent] = String(value ?? 0)
.toLowerCase()
.split("e");
return Math.max(0, (mantissa.split(".")[1]?.length || 0) - Number(exponent || 0));
};
const ordinary = (row) => num(row.Level) === 0 && ![54, 55, 58, 59].includes(num(row.LinkIndex));
function ambiguousKeys(rows) {
const counts = new Map();
rows.forEach((row, index) => {
const key = EstBusiness.rowKey(row, index);
counts.set(key, (counts.get(key) || 0) + 1);
});
return new Set([...counts].filter(([, count]) => count > 1).map(([key]) => key));
}
function distribute(amount, row, digits) {
const parts = components.map((field) => num(row[field]));
const base = sum(row);
const output = parts.map((part) => (base ? EstMath.fixed((amount * part) / base, digits) : 0));
if (base) {
const index = parts.map(Math.abs).indexOf(Math.max(...parts.map(Math.abs)));
output[index] += amount - output.reduce((a, b) => a + b, 0);
}
return output;
}
function calculate(project, roundId) {
const round = project.progress.find((item) => item.id === roundId);
if (!round) throw Error("기성 회차 없음");
const source = round.contract || {
rows: project.tables.EstMainDat,
wonga: project.tables.Wonga,
options: project.options,
};
const rows = structuredClone(source.rows),
options = source.options || project.options;
const errors = [],
position = project.progress.indexOf(round),
key = EstBusiness.rowKey;
const ambiguous = ambiguousKeys(rows);
const earlier = project.progress.slice(0, position);
if (earlier.some((item) => !item.locked)) errors.push("전회 기성 회차를 먼저 확정하세요.");
const previous = earlier.at(-1)?.result,
oldRows = new Map((previous?.rows || []).map((row, index) => [key(row, index), row]));
if (earlier.at(-1)?.locked && !Array.isArray(previous?.rows))
errors.push("확정된 전회 기성 결과가 없습니다. 전회 결과를 확인하세요.");
if (
earlier.at(-1)?.locked &&
source.wonga?.length &&
!Array.isArray(previous?.costs) &&
!Array.isArray(previous?.wonga)
)
errors.push("확정된 전회 기성 원가 결과가 없습니다. 전회 결과를 확인하세요.");
const segments = { Gum: [], Sa: [] };
const separated = round.separateComponents !== false;
rows.forEach((row, index) => {
const id = key(row, index),
input = ambiguous.has(id) ? {} : round.inputs?.[id] || {},
old = ambiguous.has(id) ? null : oldRows.get(id);
if (ambiguous.has(id) && (round.inputs?.[id] || oldRows.has(id)))
errors.push(`기성 내역 식별자가 중복됩니다: ${id}`);
for (const field of ["GN", "TC", "Ratio", ...components])
row["Jun" + field] = num(old?.["Total" + field]);
if (num(row.Level) > 0 || [54, 59].includes(num(row.LinkIndex))) {
segments.Gum = [];
segments.Sa = [];
}
for (const prefix of ["Gum", "Sa"]) {
components.forEach((field) => (row[prefix + field] = 0));
row[prefix + "GN"] = 0;
row[prefix + "TC"] = 0;
row[prefix + "Ratio"] = 0;
if (!ordinary(row)) continue;
const mode = input[prefix + "Mode"] || "quantity",
value = num(input[prefix]),
digits = input.AmountDigits ?? round.amountDigits ?? 0;
if (!["quantity", "amount", "ratio", "components"].includes(mode)) {
errors.push(`${row.Name || id}: 지원하지 않는 기성 입력방법`);
continue;
}
let quantity = value,
amount,
standaloneAmount;
if (mode === "amount") {
amount = value;
quantity = num(row.TC) ? (amount / num(row.TC)) * num(row.GN) : 0;
if (!num(row.TC) && amount)
errors.push(`${row.Name || id}: 도급금액이 0인 항목은 비목 금액으로 입력하세요.`);
} else if (mode === "components") {
components.forEach((field) => (row[prefix + field] = num(input[prefix + field])));
amount = components.reduce((n, field) => n + row[prefix + field], 0);
quantity = num(row.TC) ? (amount / num(row.TC)) * num(row.GN) : 0;
} else if (mode === "ratio")
quantity = floating(
(num(row.GN) * value) / 100,
input.QuantityDigits ?? precision(row.GN),
1,
);
const factor =
quantity /
([57, 62, 67].includes(num(row.LinkIndex)) || ["%", "%"].includes(row.Unite) ? 100 : 1);
if ([62, 67].includes(num(row.LinkIndex))) {
const selected = segments[prefix].filter(
(item) =>
num(item.CalcIndex) < 2 &&
(num(row.LinkIndex) !== 62 ||
(num(item.SKind) > 0 &&
String(row.Code || "")
.toUpperCase()
.includes(String.fromCharCode(64 + num(item.SKind))))),
);
const base = EstRows.baseKind(row),
target = { 1: "GC", 2: "NC", 3: "JC", 4: "SC" }[num(row.SCheck)] || "JC";
if (base)
row[prefix + target] = EstMath.even(
(selected.reduce((n, item) => n + num(item[prefix + base]), 0) * num(row.GN)) / 100,
);
else
components.forEach(
(field) =>
(row[prefix + field] = EstMath.even(
(selected.reduce((n, item) => n + num(item[prefix + field]), 0) * num(row.GN)) /
100,
)),
);
quantity = num(row.GN);
} else if (mode === "amount") {
distribute(amount, row, digits).forEach(
(value, i) => (row[prefix + components[i]] = value),
);
if (separated && !sum(row) && amount)
errors.push(`${row.Name || id}: 도급 비목 금액이 없어 비목별 배분을 할 수 없습니다.`);
} else if (mode !== "components") {
const fixed = units.every((field) => !num(row[field])) && num(row.TC) !== 0;
if (fixed) {
const share = mode === "ratio" ? value / 100 : num(row.GN) ? quantity / num(row.GN) : 0;
components.forEach(
(field) => (row[prefix + field] = EstMath.fixed(num(row[field]) * share, digits)),
);
if (!separated) standaloneAmount = EstMath.fixed(num(row.TC) * share, digits);
} else
components.forEach(
(field, i) =>
(row[prefix + field] = EstMath.fixed(num(row[units[i]]) * factor, digits)),
);
}
row[prefix + "GN"] = quantity;
row[prefix + "TC"] =
!separated && (mode === "amount" || standaloneAmount !== undefined)
? mode === "amount"
? amount
: standaloneAmount
: components.reduce((n, field) => n + num(row[prefix + field]), 0);
row[prefix + "Ratio"] = num(row.TC)
? floating((row[prefix + "TC"] / num(row.TC)) * 100, 2, 1)
: 0;
if (num(row.CalcIndex) < 2) segments[prefix].push(row);
}
});
const boundary = rows.findIndex((row) => num(row.LinkIndex) === 54);
const direct = rows
.slice(0, boundary < 0 ? rows.length : boundary)
.filter((row) => ordinary(row) && num(row.CalcIndex) < 2);
const contract = direct.reduce((n, row) => n + num(row.TC), 0);
function costsFor(prefix) {
const current = direct.reduce((n, row) => n + num(row[prefix + "TC"]), 0),
ratio = contract ? current / contract : 0;
const working = EstNative.empty();
working.options = { ...options, SourceRefresh: false, TotalFix: false };
working.tables.EstMainDat = rows.map((row) => {
const copy = { ...row, CalcIndex: num(row.CalcIndex) >= 2 ? 3 : 1 };
components.forEach((field) => (copy[field] = num(row[prefix + field])));
copy.TC = num(row[prefix + "TC"]);
return copy;
});
working.tables.Wonga = structuredClone(source.wonga);
for (const row of working.tables.Wonga) {
if (!separated && row.BaseField === "TC") {
row.Cost = current;
row.BaseField = "";
row.CalcIndex = 1;
} else if (row.Youl && !String(row.Name).replace(/\s/g, "").includes("부가")) {
row.Cost = Math.trunc(num(row.Cost) * ratio);
row.VSusic = "";
row.CalcIndex = 1;
} else if (
!row.VSusic &&
!row.BaseField &&
![1, 3].includes(num(row.CalcIndex)) &&
!/관급/.test(String(row.Name))
)
row.Cost = Math.trunc(num(row.Cost) * ratio);
}
const calculated = EstNative.calculate(working);
if (!separated) calculated.calculation.direct.TC = current;
errors.push(...calculated.calculation.errors);
const names = new Map(
calculated.tables.Wonga.filter((row) => num(row.CalcIndex) < 2).map((row) => [
EstNative.normalize(row.Name),
row,
]),
);
for (const row of rows) {
if (num(row.LinkIndex) === 54) {
for (const field of [...components, "TC"])
row[prefix + field] = num(calculated.calculation.direct[field]);
} else if (
[55, 58].includes(num(row.LinkIndex)) &&
names.has(EstNative.normalize(row.Name))
) {
row[prefix + "TC"] = num(names.get(EstNative.normalize(row.Name)).Cost);
}
row[prefix + "Ratio"] = num(row.TC)
? floating((num(row[prefix + "TC"]) / num(row.TC)) * 100, 2, 1)
: 0;
}
return {
rows: calculated.tables.Wonga,
total: calculated.tables.Wonga.length ? num(calculated.calculation.total) : current,
};
}
const gum = costsFor("Gum"),
sa = costsFor("Sa"),
accepted = round.approved ? "Sa" : "Gum";
for (const row of rows)
for (const field of ["GN", "TC", "Ratio", ...components])
row["Total" + field] = num(row["Jun" + field]) + num(row[accepted + field]);
for (let i = rows.length - 1; i >= 0; i--) {
const row = rows[i];
if (num(row.Level) <= 0) continue;
const children = [];
for (
let j = i + 1;
j < rows.length &&
num(rows[j].Level) < num(row.Level) &&
![54, 59].includes(num(rows[j].LinkIndex));
j++
)
if (num(rows[j].Level) === 0 && num(rows[j].CalcIndex) < 2) children.push(rows[j]);
for (const prefix of ["Jun", "Gum", "Sa", "Total"])
for (const field of [...components, "TC"])
row[prefix + field] = children.reduce((n, child) => n + num(child[prefix + field]), 0);
}
const acceptedCost = round.approved ? sa : gum;
const costKeys = (items) => {
const occurrences = new Map();
return items.map((row) => {
const identity = JSON.stringify(row.Gubun ? ["code", row.Gubun] : ["name", row.Name || ""]);
const occurrence = occurrences.get(identity) || 0;
occurrences.set(identity, occurrence + 1);
return JSON.stringify([identity, occurrence]);
});
};
const oldCosts = previous?.costs || previous?.wonga || [];
const priorCosts = new Map(costKeys(oldCosts).map((key, index) => [key, oldCosts[index]]));
const currentKeys = costKeys(acceptedCost.rows);
const costs = acceptedCost.rows.map((row, index) => {
const previous = priorCosts.get(currentKeys[index]);
const jun = num(previous?.TotalCost ?? previous?.Cost);
return {
...row,
ContractCost: num(source.wonga[index]?.Cost),
JunCost: jun,
GumCost: num(gum.rows[index]?.Cost),
SaCost: num(sa.rows[index]?.Cost),
TotalCost: jun + num(row.Cost),
};
});
return {
rows,
wonga: acceptedCost.rows,
costs,
gumWonga: gum.rows,
saWonga: sa.rows,
total: acceptedCost.total,
ratio: contract
? floating(
(direct.reduce((n, row) => n + num(row[accepted + "TC"]), 0) / contract) * 100,
2,
1,
)
: 0,
errors: [...new Set(errors)],
};
}
function lock(project, id) {
const next = structuredClone(project),
round = next.progress.find((item) => item.id === id);
if (!round) throw Error("기성 회차 없음");
if (round.locked) throw Error("이미 확정한 회차입니다.");
round.contract = {
rows: structuredClone(project.tables.EstMainDat),
wonga: structuredClone(project.tables.Wonga),
options: { ...project.options },
};
const result = calculate(next, id);
if (result.errors.length) throw Error(result.errors.join("\n"));
round.result = result;
round.locked = true;
return next;
}
function unlock(project, id) {
const next = structuredClone(project),
at = next.progress.findIndex((item) => item.id === id);
if (at < 0) throw Error("기성 회차 없음");
for (const round of next.progress.slice(at)) {
round.locked = false;
delete round.result;
delete round.contract;
}
return next;
}
function rebuild(project) {
for (const round of project.progress || [])
if (round.locked && round.contract) {
const result = calculate(project, round.id);
if (result.errors.length) throw Error(result.errors.join("\n"));
round.result = result;
}
return project;
}
return { calculate, lock, unlock, rebuild, ambiguousKeys };
})();