Files

270 lines
9.1 KiB
JavaScript

// REA c9f8ec / c9d278 / c9f0e4: 수식 참조, 원 미만 절사, 도급액 고정 반복.
// 및 TFRMWONGABASE popupTotal: 공사비역산 자리수 맞추기 (단단위 절하 후 도급액 일치 배분)
const EstWonga = (() => {
const { num } = EstMath;
const compact = (text) => String(text || "").replace(/\s/g, "");
function buildContext(rows, options = {}, bases = {}) {
const key = (row) =>
String(options.VerFormat === false ? row.NumStr || row.Gubun : row.Gubun || row.NumStr || "");
const keys = new Map(rows.map((row, index) => [key(row), index]));
keys.delete("");
rows.forEach((row, index) => {
if (compact(row.Name)) keys.set(compact(row.Name), index);
});
const defaultBases = {
NC: 0,
GC: 0,
JC: 0,
SC: 0,
TC: 0,
노무비: 0,
재료비: 0,
경비: 0,
실적: 0,
직접공사비: 0,
...bases,
};
return { key, keys, defaultBases };
}
function evaluateRows(rows, options = {}, bases = {}, overrides = new Map(), onError = null) {
const { key, keys, defaultBases } = buildContext(rows, options, bases);
const done = new Set(),
active = new Set(),
failed = new Set();
function effective(index, owner) {
const cost = evaluate(index);
if (
options.GangubExceptJodalRatio &&
/산업안전|안전보건/.test(compact(rows[owner]?.Name)) &&
compact(rows[index].Name).includes("관급")
)
return cost - num(rows[index].NC);
return cost;
}
function resolve(name, owner) {
if (Object.hasOwn(defaultBases, name)) return defaultBases[name];
const index = keys.get(name) ?? keys.get(compact(name));
return index === undefined ? undefined : effective(index, owner);
}
function range(start, end, owner) {
let a = keys.get(start),
b = keys.get(end);
if (a === undefined || b === undefined) throw Error(`범위 참조 없음: ${start}:${end}`);
if (a > b) [a, b] = [b, a];
let sum = 0;
for (let i = a; i <= b; i++)
if ([0, 1, 4].includes(num(rows[i].CalcIndex))) sum += effective(i, owner);
return sum;
}
function evaluate(index) {
const row = rows[index];
if (overrides.has(index)) return (row.Cost = overrides.get(index));
if (done.has(index)) return num(row.Cost);
if (active.has(index)) throw Error(`원가 수식 순환 참조: ${key(row)}`);
active.add(index);
try {
if (row.BaseField && Object.hasOwn(defaultBases, row.BaseField)) {
row.Cost = defaultBases[row.BaseField];
} else if (
row.VSusic &&
!/^원금액\s*:/.test(row.VSusic) &&
![1, 3].includes(num(row.CalcIndex))
) {
row.Cost = Math.trunc(
EstExpression.evaluate(row.VSusic, (name) => resolve(name, index), {
numericReferences: true,
range: (a, b) => range(a, b, index),
}),
);
} else {
row.Cost = num(row.Cost);
}
} catch (err) {
if (onError) {
failed.add(index);
onError(index, row, err);
row.Cost = num(row.Cost);
} else {
throw err;
}
} finally {
active.delete(index);
}
done.add(index);
return row.Cost;
}
for (let i = 0; i < rows.length; i++) evaluate(i);
return { done, failed };
}
function calculate(input, options = {}, bases = {}) {
const rows = structuredClone(input || []),
errors = [];
const overrides = new Map();
function pass() {
evaluateRows(rows, options, bases, overrides, null);
}
const targetIndex = rows.findIndex((row) => num(row.LinkIndex) === 55);
const target = targetIndex >= 0 ? num(rows[targetIndex].Cost) : 0;
try {
pass();
if (options.TotalFix && targetIndex >= 0) {
const profit = rows.findIndex(
(row) => compact(row.Name).includes("이윤") && ![1, 3].includes(num(row.CalcIndex)),
);
const vat = rows.findIndex((row) => compact(row.Name).includes("부가"));
if (profit < 0) throw Error("도급액 고정: 조정할 이윤 행이 없습니다.");
for (let iteration = 0; iteration < 100; iteration++) {
const delta = num(rows[targetIndex].Cost) - target;
if (delta === 0) break;
overrides.set(profit, num(rows[profit].Cost) - delta);
pass();
if (iteration === 99 && vat >= 0) {
overrides.set(vat, num(rows[vat].Cost) + target - num(rows[targetIndex].Cost));
pass();
}
}
if (num(rows[targetIndex].Cost) !== target)
throw Error("도급액 고정 계산이 수렴하지 않았습니다.");
}
} catch (error) {
errors.push(error.message);
}
return {
rows,
errors,
total: targetIndex >= 0 ? num(rows[targetIndex].Cost) : num(rows.at(-1)?.Cost),
};
}
function scan(input, options = {}, bases = {}) {
const rows = structuredClone(input || []),
errors = [];
const reported = new Set();
evaluateRows(rows, options, bases, new Map(), (index, row, err) => {
if (!reported.has(index)) {
reported.add(index);
errors.push({
table: "Wonga",
index,
code: row.Gubun || row.NumStr || "",
message: err.message,
});
}
});
return errors;
}
function alignTotal(rows, targetCost, unitDigits = 1, options = {}, bases = {}) {
// REA TFRMWONGABASE popupTotal: 공사비역산 자리수 맞추기
// unitDigits: 1=십원(10), 2=백원(100), 3=천원(1000), 4=만원(10000), 0=원단위(1)
const target = Number(targetCost);
if (!Number.isFinite(target) || target <= 0) {
throw Error("목표 공사금액을 0보다 큰 숫자로 입력하세요.");
}
const divisor = Math.pow(10, Math.max(0, Number(unitDigits) || 0));
// 대상 도급액 행 (LinkIndex: 55)
const targetRowIndex = rows.findIndex((r) => num(r.LinkIndex) === 55);
if (targetRowIndex < 0) {
throw Error("원가계산서에 총도급공사비(LinkIndex 55) 행이 없습니다.");
}
// 조정 대상 행: 이윤 행
const profitIndex = rows.findIndex(
(r) => compact(r.Name).includes("이윤") && ![1, 3].includes(num(r.CalcIndex)),
);
if (profitIndex < 0) {
throw Error("단단위 조정을 반영할 이윤 행을 찾을 수 없습니다.");
}
// 도급액을 divisor 단위로 절하한 목표값 설정
const roundedTarget = Math.floor(target / divisor) * divisor;
// 실패 시 기존 rows 상태를 100% 보존하기 위해 복제본으로 먼저 계산
const testRows = structuredClone(rows);
testRows[targetRowIndex].Cost = roundedTarget;
// 실제 calculate(TotalFix: true) 수행하여 이윤·부가세·전체 원가 명세와 목표 금액을 일치시킴
const calcResult = calculate(testRows, { ...options, TotalFix: true }, bases);
if (calcResult.errors && calcResult.errors.length) {
throw Error(`원가역산 계산 오류: ${calcResult.errors.join("; ")}`);
}
// 수렴 실패 또는 목표 불일치 시 기존 rows 변경 없이 에러 차단
if (num(calcResult.rows[targetRowIndex]?.Cost) !== roundedTarget) {
throw Error("원가역산 도급액 고정 계산이 목표 금액에 수렴하지 못했습니다.");
}
// 계산이 성공하고 목표에 완전히 도달한 경우에만 일관성 있는 Cost 결과를 원본 rows에 반영
for (let i = 0; i < rows.length; i++) {
if (calcResult.rows[i]) {
rows[i].Cost = calcResult.rows[i].Cost;
}
}
return {
targetIndex: targetRowIndex,
profitIndex,
roundedTarget,
divisor,
finalTotal: num(rows[targetRowIndex].Cost),
finalProfit: num(rows[profitIndex].Cost),
};
}
function resetWonga(rows, options = {}, bases = {}) {
// REA TFRMWONGABASE popupInit: 원가계산서 항목 초기화 (TotalFix 해제, 수렴 override 초기화, 당초 수식 및 Cost 복원 후 재계산)
if (!Array.isArray(rows) || !rows.length) {
throw Error("원가계산서 행 자료가 없습니다.");
}
// 1) 당초 보존값(VSusic_, Cost_, CalcIndex_)이 있는 경우 복원
const restored = structuredClone(rows);
for (const r of restored) {
if (r.VSusic_ !== undefined) {
r.VSusic = r.VSusic_;
}
if (r.Cost_ !== undefined) {
r.Cost = num(r.Cost_);
}
if (r.CalcIndex_ !== undefined) {
r.CalcIndex = r.CalcIndex_;
}
}
// 2) TotalFix 옵션 해제 및 일반 재계산 수행
const newOptions = { ...options, TotalFix: false };
const calcResult = calculate(restored, newOptions, bases);
if (calcResult.errors && calcResult.errors.length) {
throw Error(`원가계산서 초기화 재계산 오류: ${calcResult.errors.join("; ")}`);
}
for (let i = 0; i < rows.length; i++) {
if (calcResult.rows[i]) {
Object.assign(rows[i], calcResult.rows[i]);
}
}
return {
rows,
total: calcResult.total,
};
}
return { calculate, scan, alignTotal, resetWonga };
})();