feat(est): complete four business workflows
This commit is contained in:
@@ -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 };
|
||||
})();
|
||||
Reference in New Issue
Block a user