// 잡다한 원본 대화(spec_pb1·pb2): 전회기성 불러오기(TfrmAccomJunLoad — 완료 회차 아무것이나 전회로), 조달수수료 입력(TfrmJodalBiul, Ctrl+J), // 원가계산에 내역 금액 넣기(TfrmEstCostToWonga — 끌어 놓기 대신 줄 고르기), 원가 구성비 모드(popupBiulMode — 표시 전용 「비율」 열). const EstMiscTools = (() => { const num = (v) => Number(v) || 0; const plain = (s) => String(s ?? "").replace(/\s/g, ""); // 구성비(ca19d8·ca06c4): 기준 행(CalcBase, 없으면 도급액 줄) 대비 대상 비목 Cost 비율, 0.01% 절사 뒤 최대 나머지로 100% 맞춤 function biul(rows) { let base = rows .map((r, i) => (r.CalcBase ? i : -1)) .filter((i) => i >= 0) .at(-1); if (base == null) base = rows.findIndex((r) => num(r.LinkIndex) === 55 || /도급액/.test(plain(r.Name))); if (base < 0 || !num(rows[base].Cost)) return null; const total = num(rows[base].Cost), target = new Set(); let sum = 0; rows.slice(0, base).forEach((r, i) => { if (!num(r.Cost) || num(r.CalcIndex) >= 2) return; const end = num(r.EndLineIndex); if (i === 0 || end === 0 || end === 3 || (end === 1 && String(r.Youl ?? "").includes("%"))) { target.add(i); sum += num(r.Cost); } }); for (let i = base - 1; i >= 0 && sum < total; i--) { const r = rows[i]; if ( num(r.Cost) && !target.has(i) && num(r.EndLineIndex) === 1 && !String(r.VSusic ?? "").trim() ) { target.add(i); sum += num(r.Cost); } } const exact = new Map([...target].map((i) => [i, num(rows[i].Cost) / total])), cut = new Map([...exact].map(([i, v]) => [i, Math.trunc(v * 10000) / 10000])); let t = [...cut.values()].reduce((a, b) => a + b, 0); const bumped = new Set(); for (let n = Math.round((1 - t) * 10000); n > 0 && t < 1; n--) { const pick = [...exact.keys()] .filter((i) => !bumped.has(i)) .sort((a, b) => exact.get(b) - cut.get(b) - (exact.get(a) - cut.get(a)))[0]; if (pick == null) break; cut.set(pick, cut.get(pick) + 0.0001); bumped.add(pick); t += 0.0001; } return rows.map((_, i) => i === base ? "100%" : cut.has(i) ? `${(cut.get(i) * 100).toFixed(2)}%` : "", ); } function jodal(state) { if (state.view !== "EstMainDat") throw Error("내역서에서 쓰세요."); const rows = state.project.tables.EstMainDat, at = [...state.selection].sort((a, b) => a - b)[0], row = rows[at]; if (!row) throw Error("조달수수료를 넣을 줄을 고르세요."); if ( (num(row.Level) > 0 && plain(row.Name) && !plain(row.Name).includes("수수")) || ![0, 57, 64].includes(num(row.LinkIndex)) ) throw Error("현재 라인에는 조달수수료를 입력할수 없습니다."); const edit = num(row.LinkIndex) === 57; EstActions.form( "조달수수료", [ { name: "name", label: "명 칭", value: edit ? row.Name : "조달수수료" }, { name: "biul", label: "비 율(%)", type: "number", value: edit ? num(row.GN) : 0.54 }, { name: "o", label: "계산할 비목", value: edit ? num(row.OCheck) || 1 : 1, choices: [ [1, "재료비의"], [2, "노무비의"], [3, "경비의"], [4, "합계의"], ], }, { name: "s", label: "계산결과 저장위치", value: edit ? num(row.SCheck) || 1 : 1, choices: [ [1, "재료비에"], [3, "경비에"], ], }, { name: "m", label: "자리수 방법", value: edit ? num(row.Index) || 2 : 2, choices: [ [1, "사사오입"], [2, "절하"], [3, "절상"], ], }, { name: "d", label: "자리수", type: "number", value: edit ? num(row.Gangub) : 0 }, ], (v) => { if (!Number.isFinite(v.biul)) throw Error("비율은 수치로 입력하세요."); EstApp.change((project) => Object.assign(project.tables.EstMainDat[at], { Code: "AJJ01", Name: String(v.name).trim(), GN: v.biul, Unite: "%", LinkIndex: 57, Index: Number(v.m), Gangub: v.d, OCheck: Number(v.o), SCheck: Number(v.s), }), ); }, ); } function costToWonga(state) { if (state.view !== "Wonga") throw Error("원가계산서에서 넣을 줄을 고르세요."); const at = [...state.selection].sort((a, b) => a - b)[0], target = state.project.tables.Wonga[at]; if (!target) throw Error("원가계산서에서 넣을 줄을 고르세요."); const est = state.project.tables.EstMainDat; EstActions.form( "원가계산에 입력할 비목선택", [ { name: "row", label: "내역 줄", value: "", choices: est .map((r, i) => [i, `${i + 1}. ${r.Name || r.Code || ""} ${num(r.TC).toLocaleString()}`]) .filter(([i]) => est[i].Name || est[i].Code), }, { name: "kind", label: "입력할 비목", value: -5, choices: [ [-5, "합계 금액"], [-1, "노무비 금액"], [-2, "재료비 금액"], [-3, "경비 금액"], ], }, ], (v) => { const src = est[Number(v.row)]; if (!src) throw Error("내역 줄을 고르세요."); if (plain(target.Name) && !confirm(`${target.Name}에 덮어쓰시겠습니까?`)) return; const field = { "-5": "TC", "-1": "NC", "-2": "GC", "-3": "JC" }[v.kind]; EstApp.change((project) => Object.assign(project.tables.Wonga[at], { CalcIndex: 0, LinkIndex: 1, Youl: "", VSusic: "", BaseField: "", Name: src.Name, TC: num(src.TC), NC: num(src.NC), GC: num(src.GC), JC: num(src.JC), SC: num(src.SC), OCheck: Number(v.kind), Cost: num(src[field]), }), ); }, ); } function junLoad(state) { const project = state.project, round = project.progress?.find((r) => r.id === state.roundId); if (!round) throw Error("기성 회차를 고르세요."); const done = project.progress.filter((r) => r.locked && r.result); if (!done.length) throw Error("전회없슴"); EstActions.form( "전회기성 불러오기", [ { name: "id", label: "불러올 차수선택", value: round.junFrom || "", choices: done.map((r) => [r.id, `${r.name || r.id}`]), }, ], (v) => { if (v.id === round.id) throw Error("현재작업중인 회차를 전회로 불러올수 없습니다."); EstApp.change((p) => (p.progress.find((r) => r.id === round.id).junFrom = v.id), false); }, ); } // 인쇄양식 불러오기·저장하기(mnuPrintFormatLoad·Save 0x10ed318·0x10ed000): .pfot = 인쇄정보표 한 벌(원본 이진 그대로). // 불러오면 공사 인쇄표(printCatalog)를 그 값으로, 저장은 지금 인쇄표(불러온 원본 행 우선)를 .pfot 로. .gfot(격자 배치)는 다루지 않음. async function pfot(name, state) { if (name === "pfot-load") { const file = await new Promise((resolve) => { const picker = document.createElement("input"); picker.type = "file"; picker.accept = ".pfot"; picker.addEventListener("change", () => resolve(picker.files[0])); picker.click(); }); if (!file) return; const rows = await EstApp.api("/api/pfot-read", { method: "POST", body: await file.arrayBuffer(), }); EstApp.change((project) => { project.printFormat = rows; project.printCatalog = rows.map((r) => Object.fromEntries(Object.entries(r).filter(([k, v]) => k !== "__nulls" && !v?.binary)), ); }, false); state.notice = `인쇄양식 「${file.name}」(서식 ${rows.length}개)을 불러왔습니다.`; } else { const defaults = await EstApp.api("/api/print-catalog"), saved = new Map((state.project.printCatalog || []).map((r) => [r.Index, r])), full = new Map((state.project.printFormat || []).map((r) => [r.Index, r])); const rows = defaults.map((r) => ({ ...r, ...full.get(r.Index), ...saved.get(r.Index), __nulls: full.get(r.Index)?.__nulls, })); const response = await fetch("/api/pfot-write", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rows }), }); if (!response.ok) throw Error((await response.json()).error); EstApp.download( `${state.project.name || "인쇄양식"}.pfot`, await response.blob(), "application/octet-stream", ); } EstApp.render(); } // 사용위치 검색 창(TfrmUsesDataSerch btnSerch·btnAllDisplay): 현재 표 항목을 품명·규격(빈칸·|·괄호 무시)으로 거르고 골라 사용위치 보기 function usesList(state, word) { const rows = state.project.tables[state.view]; if (!rows) throw Error("자료 화면에서 쓰세요."); const norm = (x) => String(x ?? "").replace(/[\s|()]/g, ""), w = norm(word), { esc } = EstApp; const list = rows .map((r, i) => [r, i]) .filter( ([r]) => (r.Name || r.Code) && (!w || norm(r.Name).includes(w) || norm(r.Size).includes(w)), ) .slice(0, 300); EstApp.showDialog( "데이터 사용위치 검색", `
${list.map(([r, i]) => ``).join("")}
${esc(r.Code ?? "")}${esc(r.Name ?? "")}${esc(r.Size ?? "")}
`, ); } function handle(name, state, node) { if (name === "uses-list" || name === "uses-list-all") { usesList(state, ""); return true; } if (name === "uses-list-search") { usesList(state, document.querySelector("[data-uses-word]")?.value || ""); return true; } if (name === "uses-list-pick") { EstExtra.showUsesSearchDialog(state.project.tables[state.view][Number(node.dataset.i)]); return true; } if (name === "pfot-load" || name === "pfot-save") { pfot(name, state).catch((error) => { state.notice = error.message; EstApp.render(); }); return true; } if (name === "jodal-fee") jodal(state); else if (name === "cost-to-wonga") costToWonga(state); else if (name === "jun-load") junLoad(state); else if (name === "wonga-biul-mode") EstApp.change( (project) => (project.options.WongaBiulMode = !project.options.WongaBiulMode), false, ); else return false; return true; } if (typeof document !== "undefined" && document.addEventListener) document.addEventListener("keydown", (event) => { if ( event.ctrlKey && !event.altKey && event.key.toLowerCase() === "j" && EstApp.state.view === "EstMainDat" ) { event.preventDefault(); try { jodal(EstApp.state); } catch (error) { EstApp.state.notice = error.message; EstApp.render(); } } }); return { biul, handle }; })();