// 원본 원가계산서 보조 계산(TfrmWongaDiget) — 기타경비 계산(popGita 모드 4, 0xd0bc80·확인 0xd09c89)과 // 산업안전보건관리비 비교(popAnjun 모드 1, 식 0xd0b298·확인 0xd09973). 요율·기초금액은 사용자가 넣는다(내장 요율표 없음). // 기타경비: 식1 = (직접공사비 × 재료비율) + 직접노무비 + 간접노무비, 식2 = 직접공사비 × 기초금액상한율 — 작은 기초금액 × 요율. // 안전관리비: 대상액 T(직접공사비, 없으면 재료비 소계 + 비과세 + 직접노무비), 관급 = 「관급자재」 행. // 기초금액 켬: Up (T × r + B) × 1.2 · Down (T + 관급/1.1) × r + B / 끔: Up T × r × 1.2 · Down (T + 관급/1.1) × r — 작은 쪽. // 고르지 않은 식은 「비교 표시」를 켜면 바로 아래 계산 제외 행(CalcIndex 2)으로 남긴다. const EstWongaTools = (() => { const num = (value) => Number(value) || 0; const plain = (text) => String(text ?? "").replace(/\s+/g, ""); const key = (row) => row?.Gubun || row?.NumStr || ""; const find = (rows, ...words) => rows.find((r) => num(r.CalcIndex) !== 2 && words.every((w) => plain(r.Name).includes(w))); const decimal = (value) => String(Number(value.toFixed(10))); function evaluate(rows, expression) { const costs = new Map(rows.filter((r) => key(r)).map((r) => [String(key(r)), num(r.Cost)])); return EstExpression.evaluate(expression, (name) => costs.get(String(name)), { numericReferences: true, }); } function compareRow(rows, target, expression, show) { const at = rows.indexOf(target), existing = rows.findIndex( (r, i) => i > at && num(r.CalcIndex) === 2 && plain(r.Name) === plain(target.Name), ); if (!show) { if (existing >= 0) rows.splice(existing, 1); return; } const row = existing >= 0 ? rows[existing] : (rows.splice(at + 1, 0, { ...target }), rows[at + 1]); Object.assign(row, { Gubun: "", NumStr: "", Bimog1: "", Bimog2: "", VSusic: expression, CalcIndex: 2, Checked: false, LinkIndex: 0, }); } function gita(project, { gcRatio, baseRatio, youl, compare }) { const rows = project.tables.Wonga || []; if (![gcRatio, baseRatio].every((v) => Number.isFinite(v) && v >= 0 && v <= 1)) throw Error("1 이하의 수치값으로 입력 하세요"); if (!(Number.isFinite(youl) && youl >= 0 && youl <= 10)) throw Error("기타경비요율은 0~10% 입니다."); const target = find(rows, "기타", "경비"), a = find(rows, "직접공사비"), b = find(rows, "직접노무비"), c = find(rows, "간접노무비"); if (!target) throw Error("기타경비 비목이 없습니다."); if (!a) throw Error("직접공사비 비목이 없어 기타경비를 계산할 수 없습니다."); const first = [`(${key(a)} × ${decimal(gcRatio)})`, key(b), key(c)].filter(Boolean).join(" + "), second = `${key(a)} × ${decimal(baseRatio)}`; const v1 = evaluate(rows, first), v2 = evaluate(rows, second), rate = decimal(youl / 100); const up = `(${first}) × ${rate}`, down = `(${second}) × ${rate}`, [chosen, other] = v2 >= v1 ? [up, down] : [down, up]; target.VSusic = chosen; target.Youl = `${decimal(youl)}%`; compareRow(rows, target, other, compare); project.options.GitaGCRatio = gcRatio; project.options.GitaBaseRatio = baseRatio; return { chosen, other, values: [v1, v2] }; } function anjunBase(rows) { const direct = find(rows, "직접공사비"); if (direct) return key(direct); const dm = find(rows, "직접재료비"), im = find(rows, "간접재료비"); const subtotal = rows.find( (r) => dm && String(r.VSusic ?? "").includes(key(dm)) && (!im || String(r.VSusic ?? "").includes(key(im))), ); let t = subtotal ? key(subtotal) : [dm, im].filter(Boolean).map(key).join(" + "); const free = find(rows, "비과세") || find(rows, "면세"); if (free) t += ` + ${key(free)}`; const labor = find(rows, "직접노무비"); if (labor) t = `(${t} + ${key(labor)})`; if (!t) throw Error("산업안전보건관리비 대상액 비목을 찾을 수 없습니다."); return t; } function anjun(project, { youl, base, addBase, compare }) { const rows = project.tables.Wonga || []; if (!(Number.isFinite(youl) && youl >= 0 && youl <= 15)) throw Error("요율은 0~15% 입니다."); const target = find(rows, "산업안전", "보건"); if (!target) throw Error("산업안전보건관리비 비목이 없습니다."); const t = anjunBase(rows), gov = find(rows, "관급자재"), r = decimal(youl / 100), b = addBase ? ` + ${Math.round(base)}` : ""; const up = addBase ? `(${t} × ${r}${b}) × 1.2` : `${t} × ${r} × 1.2`; const down = gov && num(gov.Cost) ? `(${t} + ${key(gov)}/1.1) × ${r}${b}` : `${t} × ${r}${b}`; const vu = evaluate(rows, up), vd = evaluate(rows, down), [chosen, other] = vd > vu ? [up, down] : [down, up]; target.VSusic = chosen; target.Youl = `${decimal(youl)}%`; compareRow(rows, target, other, compare); project.options.AnjunBaseCost = base; return { chosen, other, values: [vu, vd] }; } function dialog(state, kind) { const rows = state.project.tables.Wonga || [], o = state.project.options, rate = (row) => Number(String(row?.Youl ?? "").split("%")[0]) || 0; const fields = kind === "gita" ? [ { name: "gcRatio", label: "재료비율(0~1)", type: "number", value: o.GitaGCRatio ?? "" }, { name: "baseRatio", label: "기초금액상한율(0~1)", type: "number", value: o.GitaBaseRatio ?? "", }, { name: "youl", label: "기타경비요율(%)", type: "number", value: rate(find(rows, "기타", "경비")), }, ] : [ { name: "youl", label: "안전관리비 요율(%)", type: "number", value: rate(find(rows, "산업안전", "보건")), }, { name: "addBase", label: "기초금액 더하기", value: "no", choices: [ ["no", "더하지 않음"], ["yes", "더함"], ], }, { name: "base", label: "기초금액(원)", type: "number", value: o.AnjunBaseCost ?? 5349000, }, ]; fields.push({ name: "compare", label: "비교 식 표시", value: "yes", choices: [ ["yes", "아래 줄에 표시"], ["no", "표시 안 함"], ], }); EstActions.form( kind === "gita" ? "기타경비 계산" : "산업안전보건관리비 비교", fields, (values) => { const input = { ...values, compare: values.compare === "yes", addBase: values.addBase === "yes", }; const run = (project) => (kind === "gita" ? gita(project, input) : anjun(project, input)); run(structuredClone(state.project)); let out; EstApp.change((project) => (out = run(project))); state.notice = `작은 쪽 식을 골랐습니다: ${out.chosen}`; }, ); } // 선택항목 절하하기(popupPosition 모드 0, 확인 0xd094dc): 행 Diget·DigetIndex 기록, 도급액(55) 행 거부. // 수식 행은 재계산이 자리맞춤, 입력 행은 원금액(TC, 없으면 현재 금액)을 자리맞춤해 Cost 로. 자리수는 다음 기본값으로 기억. // accom = 기성 원가(도급액 행 거부 검사 없음). function position(project, indices, method, digits, accom = false) { const rows = project.tables.Wonga || []; if (![0, 1, 2, 3].includes(method) || !Number.isInteger(digits) || digits < 0 || digits > 6) throw Error("자리수는 0~6이고 처리방식을 골라야 합니다."); if (indices.some((i) => !rows[i])) throw Error("절하할 줄을 고르세요."); if (!accom && indices.some((i) => num(rows[i].LinkIndex) === 55)) throw Error("도급액 역산 자리수 지정한 라인은자리수를 별도로 지정할수 없습니다."); for (const i of indices) { const row = rows[i]; row.Diget = method ? digits : 0; row.DigetIndex = method; const formula = row.VSusic && ![1, 3].includes(num(row.CalcIndex)) && !row.BaseField; if (formula) continue; const base = num(row.TC) || num(row.Cost); if (!base) continue; row.TC = base; row.Cost = method ? EstWonga.digetRound(row, base) : base; } project.options.WongaLastDiget = digits; } function positionDialog(state, indices) { const rows = state.project.tables.Wonga, unit = ["원", "십원", "백원", "천원", "만원", "십만원", "백만원"]; EstActions.form( "선택항목 절하하기", [ { name: "method", label: "처리방식", value: num(rows[indices[0]].DigetIndex) || 2, choices: [ [1, "사사오입"], [2, "절하"], [3, "절상"], [0, "초기화"], ], }, { name: "digits", label: "자리 (0 원 · 1 십원 … 6 백만원)", type: "number", value: num(state.project.options.WongaLastDiget), }, ], (values) => { const method = Number(values.method), digits = Number(values.digits); position(structuredClone(state.project), indices, method, digits); EstApp.change((project) => position(project, indices, method, digits)); state.notice = method ? `${unit[digits]} 단위로 ${["", "사사오입", "절하", "절상"][method]}했습니다.` : "자리수 지정을 풀었습니다."; }, ); } // 산출근거 약어로 표시(popupSNameShow 0xd0e494 · 약어 생성 0xc97790): 산출근거 식의 기호(영문 시작 또는 2자 이하, // 범위 기호 뒤 제외)를 그 행의 약어(SName, 없으면 빈칸 뺀 이름)로, 숫자×100 이 그 행 요율과 같으면 요율 글자로, 「:」 는 「~」 로. // 화면·인쇄에서 산출근거 대신 보이고 그동안 산출근거 칸은 잠근다(원본 VGungu). 저장 값은 바꾸지 않는다. function abbreviate(rows, options = {}) { const key = (r) => String(options.VerFormat === false ? (r.NumStr ?? r.Index ?? "") : (r.Gubun ?? "")).trim(); const keys = new Map(); rows.forEach((r) => { const k = key(r); if (k && !keys.has(k)) keys.set(k, r); }); const label = (r) => String(r.SName || r.Name || "").replace(/\s/g, ""); return rows.map((row) => { const s = String(row.VSusic ?? "").trim(); if (!s) return ""; const youl = String(row.Youl ?? "") .replace("%", "") .trim(); const tokens = s.match(/\d+(?:\.\d+)?|[A-Za-z가-힣_][A-Za-z0-9가-힣_]*|\S/g) || []; return tokens .map((t, i) => { const prev = tokens[i - 1]; if (t === ":") return "~"; const numeric = /^\d+(?:\.\d+)?$/.test(t); if ( (/^[A-Z]/.test(t) || (t.length <= 2 && /^[A-Za-z0-9가-힣]+$/.test(t))) && prev !== ":" && prev !== "~" ) { const hit = keys.get(t); if (hit) return label(hit); } if (numeric && youl && Number(youl) === Number((Number(t) * 100).toPrecision(12))) return String(row.Youl).trim(); return t; }) .join(" "); }); } function handle(name, state) { if (name === "wonga-total-input") { // 순공사비 입력(가로양식 TfrmWongaH popTotalInput 0xe7fbf8): 연결 54 줄이 있으면 거부, 없으면 맨 위에 // 「순 공 사 비」(연결 54, 금액 = 직접공사비 TC) 줄을 넣고 다시 계산. 원본은 이어서 원가 전체 초기화로 // 모든 줄 자리수·도급액 고정을 지우나, 웹은 사용자 설정을 지우지 않는다. const rows = state.project.tables.Wonga || []; const at = rows.findIndex((r) => num(r.LinkIndex) === 54); if (at >= 0) { state.selection.clear(); state.selection.add(at); throw Error("순공사비 항목이 있습니다"); } EstApp.change((project) => { (project.tables.Wonga ||= []).unshift({ Gubun: "", Name: "순 공 사 비", LinkIndex: 54, CalcIndex: 0, BaseField: "TC", Checked: false, }); }); return true; } if (name === "wonga-content-del") { // 내용 삭제(popupContentDel A7): 연결·금액·비율·요율·산출근거·자리 비움, 이름·기호·계산방식 유지, 재계산 없음 const rows = state.project.tables.Wonga || [], picked = [...state.selection].filter((i) => rows[i]); if (state.view !== "Wonga" || !picked.length) throw Error("원가계산서에서 지울 줄을 고르세요."); EstApp.change((project) => { for (const i of picked) Object.assign(project.tables.Wonga[i], { LinkIndex: 0, Cost: 0, Biul: "", Youl: "", VSusic: "", Diget: 0, Checked: false, }); }, false); return true; } if (name === "wonga-sname") { EstApp.change( (project) => (project.options.WongaSNameShow = !project.options.WongaSNameShow), false, ); return true; } if (name === "wonga-gita") dialog(state, "gita"); else if (name === "wonga-anjun") dialog(state, "anjun"); else return false; return true; } return { gita, anjun, anjunBase, position, positionDialog, abbreviate, handle }; })();