Files
Aislo/tools/est-test/native-wonga.js
T
eomsangdonandClaude Opus 5.5 d38d2a2893 feat(est-test): 엑셀형 수량산출서 계산·편집과 원가 행 자리수
- 엑셀형 산출서 BIFF 수식 해석·다시 계산·수식줄 편집·4방향 합·수량으로 지정(가져올 때 값이 같은 합계 셀 추정 연결), 셀 편집 시 ESTX 저장 거부
- 원가 행 계산을 원본 c9d278 끝부분대로: 소수 4자리 반올림 뒤 원 미만 절사, Diget>0 행 자리맞춤(0x11293e8)
- 원가계산서 「선택 행 자릿수 설정」을 원본 선택항목 절하하기 대화로(도급액 행 거부, 입력 행 원금액 기준)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:24:06 +09:00

313 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 };
})();