feat(est): complete four business workflows

This commit is contained in:
2026-11-06 05:51:38 +09:00
parent 4e08509700
commit 74ab66b00e
17 changed files with 1262 additions and 286 deletions
+293
View File
@@ -0,0 +1,293 @@
// 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 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 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]));
const segments = { Gum: [], Sa: [] };
const separated = round.separateComponents !== false;
rows.forEach((row, index) => {
const id = key(row, index),
input = round.inputs?.[id] || {},
old = oldRows.get(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: 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 priorCosts = new Map(
(previous?.costs || previous?.wonga || []).map((row) => [row.Gubun ?? row.Name, row]),
);
const costs = acceptedCost.rows.map((row, index) => {
const previous = priorCosts.get(row.Gubun ?? row.Name);
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 };
})();