- 엑셀형 산출서 BIFF 수식 해석·다시 계산·수식줄 편집·4방향 합·수량으로 지정(가져올 때 값이 같은 합계 셀 추정 연결), 셀 편집 시 ESTX 저장 거부 - 원가 행 계산을 원본 c9d278 끝부분대로: 소수 4자리 반올림 뒤 원 미만 절사, Diget>0 행 자리맞춤(0x11293e8) - 원가계산서 「선택 행 자릿수 설정」을 원본 선택항목 절하하기 대화로(도급액 행 거부, 입력 행 원금액 기준) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
313 lines
11 KiB
JavaScript
313 lines
11 KiB
JavaScript
// REA c9f8ec / c9d278 / c9f0e4: 수식 참조, 원 미만 절사, 도급액 고정 반복.
|
||
// 및 TFRMWONGABASE popupTotal: 공사비역산 자리수 맞추기 (단단위 절하 후 도급액 일치 배분)
|
||
// 행 자리맞춤 0x11293e8: Diget k>0 = 10^k 단위 · −k = 소수 k 자리, DigetIndex 0 처리 없음 · 1 사사오입 · 2 절하 · 3 절상 · 4+ 절하.
|
||
const EstWonga = (() => {
|
||
const { num } = EstMath;
|
||
const compact = (text) => String(text || "").replace(/\s/g, "");
|
||
|
||
const simpleRoundTo = (v, e) => {
|
||
const f = 10 ** e;
|
||
return (v < 0 ? Math.trunc(v / f - 0.5) : Math.trunc(v / f + 0.5)) * f;
|
||
};
|
||
function roundHalfEven(v) {
|
||
const r = Math.round(v);
|
||
return Math.abs(v % 1) === 0.5 && r % 2 ? r - 1 : r;
|
||
}
|
||
function digetRound(row, v) {
|
||
const d = num(row.Diget);
|
||
let di = num(row.DigetIndex),
|
||
n = Math.abs(d),
|
||
dec = false;
|
||
if (d === 0) {
|
||
dec = true;
|
||
n = 1;
|
||
if (di === 0) di = 2;
|
||
} else if (d < 0) {
|
||
dec = true;
|
||
n += 1;
|
||
}
|
||
const method = di === 0 ? 3 : di === 1 ? 0 : di === 3 ? 2 : 1;
|
||
v = simpleRoundTo(v, -7);
|
||
if (n === 0 || method === 3) return v;
|
||
const e = dec ? -(n - 1) : n,
|
||
p = 10 ** Math.abs(e);
|
||
if (method === 0) return simpleRoundTo(v, e);
|
||
let q = simpleRoundTo(e > 0 ? v / p : v * p, -7);
|
||
q = q >= 0 === (method === 1) ? Math.floor(q) : Math.ceil(q);
|
||
return simpleRoundTo(e > 0 ? q * p : q / p, -7);
|
||
}
|
||
// 행 계산 끝 (c9d278 0xc9d607): 소수 4자리 반올림 뒤 원 미만 절사, 도급액(55) 아니고 Diget > 0 이면 자리맞춤.
|
||
function rowCost(row, raw) {
|
||
const v = Math.trunc(roundHalfEven(raw * 1e4) / 1e4);
|
||
return num(row.LinkIndex) !== 55 && num(row.Diget) > 0 ? digetRound(row, v) : v;
|
||
}
|
||
|
||
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 &&
|
||
num(row.LinkIndex) !== 66 &&
|
||
!/^원금액\s*:/.test(row.VSusic) &&
|
||
![1, 3].includes(num(row.CalcIndex))
|
||
) {
|
||
row.Cost = rowCost(
|
||
row,
|
||
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 = [],
|
||
adjustments = [];
|
||
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;
|
||
adjustments.push({ difference: delta });
|
||
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,
|
||
adjustments,
|
||
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, digetRound, rowCost };
|
||
})();
|