// 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 }; })();