// 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; } // 도급내역 선택(TfrmAccomLoadMethodEst 0xc8cd50 · 덮어쓰기 c8e5e8): 설계변경 내역서로 회차를 처음 만들 때 당초를 고르면 // 행마다 당초 칸(_)으로 — Index, 품명·규격·단위(당초가 비면 지금 값), 수량·단가·금액. 원본은 GC 를 빠뜨리지만 웹은 함께 옮김. function originRows(rows) { return rows.map((row) => { const out = { ...row }; if (row.Index_ != null) out.Index = row.Index_; for (const k of ["Name", "Size", "Unite"]) if (String(row[k + "_"] ?? "").trim()) out[k] = row[k + "_"]; for (const k of ["GN", "TU", "TC", ...units, ...components]) if (row[k + "_"] != null) out[k] = row[k + "_"]; return out; }); } const contractRows = (project, round) => round.loadMethod === "origin" ? originRows(project.tables.EstMainDat) : project.tables.EstMainDat; function calculate(project, roundId) { const round = project.progress.find((item) => item.id === roundId); if (!round) throw Error("기성 회차 없음"); const source = round.contract || { rows: contractRows(project, round), 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("전회 기성 회차를 먼저 확정하세요."); // 전회기성 불러오기(TfrmAccomJunLoad): 고른 완료 회차가 있으면 그 누계를 전회로 const chosen = round.junFrom && project.progress.find((r) => r.id === round.junFrom && r.locked && r !== round), prior = chosen || earlier.at(-1); const previous = prior?.result, oldRows = new Map((previous?.rows || []).map((row, index) => [key(row, index), row])); if (prior?.locked && !Array.isArray(previous?.rows)) errors.push("확정된 전회 기성 결과가 없습니다. 전회 결과를 확인하세요."); if ( prior?.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); // 기성 행 자리맞춤(ca6a6c): 도급액 아니고 Diget>0 이면 원 미만 절사 뒤 자리맞춤 const scaled = (row, v) => num(row.LinkIndex) !== 55 && num(row.Diget) > 0 ? EstWonga.digetRound(row, Math.trunc(v)) : Math.trunc(v); 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(round.wonga || 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 = scaled(row, 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 = scaled(row, num(row.Cost) * ratio); } let calculated = EstNative.calculate(working); const fixed = working.tables.Wonga.findIndex((row) => num(row.LinkIndex) === 55), unit = 10 ** num(round.wongaTotalDigits); if (unit > 1 && fixed >= 0) { // 기성 공사비역산 자리수(popupTotal): 도급액을 10^n 미만 절하해 고정하고 이윤으로 역산 working.tables.Wonga[fixed].Cost = Math.trunc(num(calculated.calculation.total) / unit) * unit; working.options.TotalFix = true; // 기성 이윤은 비율로 고정된 값(CalcIndex 1)이라 역산 대상이 되도록 되돌림 for (const row of working.tables.Wonga) if (/이윤/.test(String(row.Name).replace(/\s/g, "")) && num(row.CalcIndex) === 1) row.CalcIndex = 0; 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), contractKeys = costKeys(source.wonga || []), contractCosts = new Map(contractKeys.map((k, i) => [k, num(source.wonga[i]?.Cost)])); const costs = acceptedCost.rows.map((row, index) => { const previous = priorCosts.get(currentKeys[index]); const jun = num(previous?.TotalCost ?? previous?.Cost); return { ...row, ContractCost: contractCosts.get(currentKeys[index]) ?? 0, 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(contractRows(project, round)), 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, originRows }; })();