326 lines
14 KiB
JavaScript
326 lines
14 KiB
JavaScript
// 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 };
|
||
})();
|