270 lines
9.1 KiB
JavaScript
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 };
|
|
})();
|