// REA c90bd4/c91100/c914d4/c9261c/c9199c, c98928, ca4814/ca5e98. const EstProgress = (() => { const { num, components, units, floating, sum } = EstMath; const precision = (value) => { const [mantissa, exponent] = String(value ?? 0) .toLowerCase() .split("e"); return Math.max(0, (mantissa.split(".")[1]?.length || 0) - Number(exponent || 0)); }; const ordinary = (row) => num(row.Level) === 0 && ![54, 55, 58, 59].includes(num(row.LinkIndex)); function ambiguousKeys(rows) { const counts = new Map(); rows.forEach((row, index) => { const key = EstBusiness.rowKey(row, index); counts.set(key, (counts.get(key) || 0) + 1); }); return new Set([...counts].filter(([, count]) => count > 1).map(([key]) => key)); } function distribute(amount, row, digits) { const parts = components.map((field) => num(row[field])); const base = sum(row); const output = parts.map((part) => (base ? EstMath.fixed((amount * part) / base, digits) : 0)); if (base) { const index = parts.map(Math.abs).indexOf(Math.max(...parts.map(Math.abs))); output[index] += amount - output.reduce((a, b) => a + b, 0); } return output; } function calculate(project, roundId) { const round = project.progress.find((item) => item.id === roundId); if (!round) throw Error("기성 회차 없음"); const source = round.contract || { rows: project.tables.EstMainDat, wonga: project.tables.Wonga, options: project.options, }; const rows = structuredClone(source.rows), options = source.options || project.options; const errors = [], position = project.progress.indexOf(round), key = EstBusiness.rowKey; const ambiguous = ambiguousKeys(rows); const earlier = project.progress.slice(0, position); if (earlier.some((item) => !item.locked)) errors.push("전회 기성 회차를 먼저 확정하세요."); const previous = earlier.at(-1)?.result, oldRows = new Map((previous?.rows || []).map((row, index) => [key(row, index), row])); if (earlier.at(-1)?.locked && !Array.isArray(previous?.rows)) errors.push("확정된 전회 기성 결과가 없습니다. 전회 결과를 확인하세요."); if ( earlier.at(-1)?.locked && source.wonga?.length && !Array.isArray(previous?.costs) && !Array.isArray(previous?.wonga) ) errors.push("확정된 전회 기성 원가 결과가 없습니다. 전회 결과를 확인하세요."); const segments = { Gum: [], Sa: [] }; const separated = round.separateComponents !== false; rows.forEach((row, index) => { const id = key(row, index), input = ambiguous.has(id) ? {} : round.inputs?.[id] || {}, old = ambiguous.has(id) ? null : oldRows.get(id); if (ambiguous.has(id) && (round.inputs?.[id] || oldRows.has(id))) errors.push(`기성 내역 식별자가 중복됩니다: ${id}`); for (const field of ["GN", "TC", "Ratio", ...components]) row["Jun" + field] = num(old?.["Total" + field]); if (num(row.Level) > 0 || [54, 59].includes(num(row.LinkIndex))) { segments.Gum = []; segments.Sa = []; } for (const prefix of ["Gum", "Sa"]) { components.forEach((field) => (row[prefix + field] = 0)); row[prefix + "GN"] = 0; row[prefix + "TC"] = 0; row[prefix + "Ratio"] = 0; if (!ordinary(row)) continue; const mode = input[prefix + "Mode"] || "quantity", value = num(input[prefix]), digits = input.AmountDigits ?? round.amountDigits ?? 0; if (!["quantity", "amount", "ratio", "components"].includes(mode)) { errors.push(`${row.Name || id}: 지원하지 않는 기성 입력방법`); continue; } let quantity = value, amount, standaloneAmount; if (mode === "amount") { amount = value; quantity = num(row.TC) ? (amount / num(row.TC)) * num(row.GN) : 0; if (!num(row.TC) && amount) errors.push(`${row.Name || id}: 도급금액이 0인 항목은 비목 금액으로 입력하세요.`); } else if (mode === "components") { components.forEach((field) => (row[prefix + field] = num(input[prefix + field]))); amount = components.reduce((n, field) => n + row[prefix + field], 0); quantity = num(row.TC) ? (amount / num(row.TC)) * num(row.GN) : 0; } else if (mode === "ratio") quantity = floating( (num(row.GN) * value) / 100, input.QuantityDigits ?? precision(row.GN), 1, ); const factor = quantity / ([57, 62, 67].includes(num(row.LinkIndex)) || ["%", "%"].includes(row.Unite) ? 100 : 1); if ([62, 67].includes(num(row.LinkIndex))) { const selected = segments[prefix].filter( (item) => num(item.CalcIndex) < 2 && (num(row.LinkIndex) !== 62 || (num(item.SKind) > 0 && String(row.Code || "") .toUpperCase() .includes(String.fromCharCode(64 + num(item.SKind))))), ); const base = EstRows.baseKind(row), target = { 1: "GC", 2: "NC", 3: "JC", 4: "SC" }[num(row.SCheck)] || "JC"; if (base) row[prefix + target] = EstMath.even( (selected.reduce((n, item) => n + num(item[prefix + base]), 0) * num(row.GN)) / 100, ); else components.forEach( (field) => (row[prefix + field] = EstMath.even( (selected.reduce((n, item) => n + num(item[prefix + field]), 0) * num(row.GN)) / 100, )), ); quantity = num(row.GN); } else if (mode === "amount") { distribute(amount, row, digits).forEach( (value, i) => (row[prefix + components[i]] = value), ); if (separated && !sum(row) && amount) errors.push(`${row.Name || id}: 도급 비목 금액이 없어 비목별 배분을 할 수 없습니다.`); } else if (mode !== "components") { const fixed = units.every((field) => !num(row[field])) && num(row.TC) !== 0; if (fixed) { const share = mode === "ratio" ? value / 100 : num(row.GN) ? quantity / num(row.GN) : 0; components.forEach( (field) => (row[prefix + field] = EstMath.fixed(num(row[field]) * share, digits)), ); if (!separated) standaloneAmount = EstMath.fixed(num(row.TC) * share, digits); } else components.forEach( (field, i) => (row[prefix + field] = EstMath.fixed(num(row[units[i]]) * factor, digits)), ); } row[prefix + "GN"] = quantity; row[prefix + "TC"] = !separated && (mode === "amount" || standaloneAmount !== undefined) ? mode === "amount" ? amount : standaloneAmount : components.reduce((n, field) => n + num(row[prefix + field]), 0); row[prefix + "Ratio"] = num(row.TC) ? floating((row[prefix + "TC"] / num(row.TC)) * 100, 2, 1) : 0; if (num(row.CalcIndex) < 2) segments[prefix].push(row); } }); const boundary = rows.findIndex((row) => num(row.LinkIndex) === 54); const direct = rows .slice(0, boundary < 0 ? rows.length : boundary) .filter((row) => ordinary(row) && num(row.CalcIndex) < 2); const contract = direct.reduce((n, row) => n + num(row.TC), 0); function costsFor(prefix) { const current = direct.reduce((n, row) => n + num(row[prefix + "TC"]), 0), ratio = contract ? current / contract : 0; const working = EstNative.empty(); working.options = { ...options, SourceRefresh: false, TotalFix: false }; working.tables.EstMainDat = rows.map((row) => { const copy = { ...row, CalcIndex: num(row.CalcIndex) >= 2 ? 3 : 1 }; components.forEach((field) => (copy[field] = num(row[prefix + field]))); copy.TC = num(row[prefix + "TC"]); return copy; }); working.tables.Wonga = structuredClone(source.wonga); for (const row of working.tables.Wonga) { if (!separated && row.BaseField === "TC") { row.Cost = current; row.BaseField = ""; row.CalcIndex = 1; } else if (row.Youl && !String(row.Name).replace(/\s/g, "").includes("부가")) { row.Cost = Math.trunc(num(row.Cost) * ratio); row.VSusic = ""; row.CalcIndex = 1; } else if ( !row.VSusic && !row.BaseField && ![1, 3].includes(num(row.CalcIndex)) && !/관급/.test(String(row.Name)) ) row.Cost = Math.trunc(num(row.Cost) * ratio); } const calculated = EstNative.calculate(working); if (!separated) calculated.calculation.direct.TC = current; errors.push(...calculated.calculation.errors); const names = new Map( calculated.tables.Wonga.filter((row) => num(row.CalcIndex) < 2).map((row) => [ EstNative.normalize(row.Name), row, ]), ); for (const row of rows) { if (num(row.LinkIndex) === 54) { for (const field of [...components, "TC"]) row[prefix + field] = num(calculated.calculation.direct[field]); } else if ( [55, 58].includes(num(row.LinkIndex)) && names.has(EstNative.normalize(row.Name)) ) { row[prefix + "TC"] = num(names.get(EstNative.normalize(row.Name)).Cost); } row[prefix + "Ratio"] = num(row.TC) ? floating((num(row[prefix + "TC"]) / num(row.TC)) * 100, 2, 1) : 0; } return { rows: calculated.tables.Wonga, total: calculated.tables.Wonga.length ? num(calculated.calculation.total) : current, }; } const gum = costsFor("Gum"), sa = costsFor("Sa"), accepted = round.approved ? "Sa" : "Gum"; for (const row of rows) for (const field of ["GN", "TC", "Ratio", ...components]) row["Total" + field] = num(row["Jun" + field]) + num(row[accepted + field]); for (let i = rows.length - 1; i >= 0; i--) { const row = rows[i]; if (num(row.Level) <= 0) continue; const children = []; for ( let j = i + 1; j < rows.length && num(rows[j].Level) < num(row.Level) && ![54, 59].includes(num(rows[j].LinkIndex)); j++ ) if (num(rows[j].Level) === 0 && num(rows[j].CalcIndex) < 2) children.push(rows[j]); for (const prefix of ["Jun", "Gum", "Sa", "Total"]) for (const field of [...components, "TC"]) row[prefix + field] = children.reduce((n, child) => n + num(child[prefix + field]), 0); } const acceptedCost = round.approved ? sa : gum; const costKeys = (items) => { const occurrences = new Map(); return items.map((row) => { const identity = JSON.stringify(row.Gubun ? ["code", row.Gubun] : ["name", row.Name || ""]); const occurrence = occurrences.get(identity) || 0; occurrences.set(identity, occurrence + 1); return JSON.stringify([identity, occurrence]); }); }; const oldCosts = previous?.costs || previous?.wonga || []; const priorCosts = new Map(costKeys(oldCosts).map((key, index) => [key, oldCosts[index]])); const currentKeys = costKeys(acceptedCost.rows); const costs = acceptedCost.rows.map((row, index) => { const previous = priorCosts.get(currentKeys[index]); const jun = num(previous?.TotalCost ?? previous?.Cost); return { ...row, ContractCost: num(source.wonga[index]?.Cost), JunCost: jun, GumCost: num(gum.rows[index]?.Cost), SaCost: num(sa.rows[index]?.Cost), TotalCost: jun + num(row.Cost), }; }); return { rows, wonga: acceptedCost.rows, costs, gumWonga: gum.rows, saWonga: sa.rows, total: acceptedCost.total, ratio: contract ? floating( (direct.reduce((n, row) => n + num(row[accepted + "TC"]), 0) / contract) * 100, 2, 1, ) : 0, errors: [...new Set(errors)], }; } function lock(project, id) { const next = structuredClone(project), round = next.progress.find((item) => item.id === id); if (!round) throw Error("기성 회차 없음"); if (round.locked) throw Error("이미 확정한 회차입니다."); round.contract = { rows: structuredClone(project.tables.EstMainDat), wonga: structuredClone(project.tables.Wonga), options: { ...project.options }, }; const result = calculate(next, id); if (result.errors.length) throw Error(result.errors.join("\n")); round.result = result; round.locked = true; return next; } function unlock(project, id) { const next = structuredClone(project), at = next.progress.findIndex((item) => item.id === id); if (at < 0) throw Error("기성 회차 없음"); for (const round of next.progress.slice(at)) { round.locked = false; delete round.result; delete round.contract; } return next; } function rebuild(project) { for (const round of project.progress || []) if (round.locked && round.contract) { const result = calculate(project, round.id); if (result.errors.length) throw Error(result.errors.join("\n")); round.result = result; } return project; } return { calculate, lock, unlock, rebuild, ambiguousKeys }; })();