// 단가 목록 도구(spec_pb2): 중복 항목 정리(TfrmStuffc popupSamecheck 0xf61d94), 단가정의 엑셀변환(TfrmSiljuk popupGunguToExcel), // 할증·낙찰·복원(TfrmSetDiget 모드 51·52, btnRestor 0x110919c), 바꾸기(barbtnChange → TfrmChangeValue: Main Data 로 이 표만 갱신), // SUM(barbtnSum → 항목선택계산 LinkIndex 62 줄, TfrmSetGabBiIndex SUM 모드). const EstPriceTools = (() => { const num = (v) => Number(v) || 0; const key = (s) => String(s ?? "").replace(/[ |]/g, ""); const TABLE_KIND = { StuffcDat: 5, LaborcDat: 6, SilJukDat: 21, EstiMateDat: 101 }; // 같은 품명·규격·단위(빈칸·| 무시) 묶음에서 TC 가장 큰 행 하나만(같으면 뒤 행) 남김. 묻지 않음, 다시 셈 없음. function samecheck(rows) { const keep = new Map(); rows.forEach((row, i) => { const k = `${key(row.Name)}\u0000${key(row.Size)}\u0000${key(row.Unite)}`, prev = keep.get(k); if (prev == null || !(num(rows[prev].TC) > num(row.TC))) keep.set(k, i); }); const kept = new Set(keep.values()); const removed = rows.length - kept.size; return { rows: rows.filter((_, i) => kept.has(i)), removed }; } // 표준시장단가 EXPLEAN 이 있는 행만 「코드 / 번호. 품명 (규격) [단위]」 + 설명 줄 function gungu(rows) { const out = []; for (const r of rows) { const text = String(r.EXPLEAN ?? "").trim(); if (!text) continue; out.push([ String(r.Code ?? ""), `${r.Index ?? ""}. ${r.Name ?? ""} (${r.Size ?? ""}) [${r.Unite ?? ""}]`, ]); for (const line of text.split(/\r?\n/)) out.push(["", line]); out.push(["", ""]); } return out; } // 할증(Ratio)·낙찰(NachalRatio) 율 쓰기. v = 100 이면 원래값(그 율 칸 100, 자리 해제), 복원 = 0. function setRate(rows, indices, field, v) { const list = indices.length >= 2 ? indices : rows.map((_, i) => i); for (const i of list) { const row = rows[i]; if (!num(row.TC) && !String(row.Name ?? "").trim()) continue; if (!num(row.OTC)) row.OTC = row.TC; for (const f of [field].flat()) row[f] = v; if (v === 100) Object.assign(row, { Diget: 0, DigetIndex: 0 }); } return list.length; } // 항목선택계산(62) 줄 function sumRow(view, row, o) { const banned = view === "EstMainDat" ? [ 5, 6, 21, 51, 52, 53, 54, 56, 57, 58, 59, 61, 101, 103, 104, 105, 211, 212, 213, 214, 215, 217, 218, ] : [5, 6, 21, 51, 52, 53, 61, 101, 103, 104, 105, 211, 212, 213, 214, 215, 217, 218]; if ( (view === "EstMainDat" && num(row.Level) > 0) || (view === "HeadMainDat" && num(row.Index) > 0) || (banned.includes(num(row.LinkIndex)) && (String(row.Name ?? "").trim() || String(row.Size ?? "").trim())) ) throw Error("현재 라인에는 합계(SUM)를 할수가 없습니다."); if ((o.base === 0) !== (o.target === 0)) throw Error("비목별항목은 비목별적용만 가능합니다."); if (!o.picked.length) throw Error("더할항목의 색인들을 선택하세요"); if (o.picked.length > 5) throw Error("더할항목의 색인들은 6개이상 선택할수없습니다"); Object.assign(row, { Name: o.name, Size: o.size, LinkIndex: 62, Code: `A${o.picked.map((n) => String(n).padStart(2, "0")).join("")}`, GN: o.rate, Biul: o.rate / 100, Unite: "%", OCheck: o.base, SCheck: o.target, }); } function nextSKind(rows, at, view) { let start = at, end = at; const head = (r) => (view === "EstMainDat" ? num(r.Level) > 0 : num(r.Index) > 0); while (start > 0 && !head(rows[start - 1])) start--; while (end + 1 < rows.length && !head(rows[end + 1])) end++; return Math.max(0, ...rows.slice(start, end + 1).map((r) => num(r.SKind))) + 1; } async function run(name, state) { const view = state.view, rows = state.project.tables[view], picked = [...state.selection].filter((i) => rows?.[i]).sort((a, b) => a - b); if (name === "price-samecheck") { if (view !== "StuffcDat") throw Error("자재단가 화면에서 쓰세요."); let removed = 0; EstApp.change((project) => { const out = samecheck(project.tables.StuffcDat); project.tables.StuffcDat = out.rows; removed = out.removed; }); state.notice = `중복 항목 ${removed}개를 지웠습니다.`; } else if (name === "price-gungu-excel") { const list = gungu(state.project.tables.SilJukDat || []); if (!list.length) throw Error("단가정의(설명)가 있는 표준시장단가가 없습니다."); const response = await fetch("/api/export-report-xlsx", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "단가정의", columns: ["코드", "단가정의"], rows: list, errors: [], }), }); if (!response.ok) throw Error((await response.json()).error); EstApp.download( `${state.project.name || "공사"}_단가정의.xlsx`, await response.blob(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ); } else if (name === "price-ratio" || name === "price-nachal" || name === "price-restore") { if (!TABLE_KIND[view]) throw Error("자재·노임·표준시장·견적 단가 화면에서 쓰세요."); // 복원(원본은 할증·낙찰 창 안 단추): 두 율 모두 0 const field = name === "price-nachal" ? "NachalRatio" : name === "price-ratio" ? "Ratio" : ["Ratio", "NachalRatio"]; let v = 0; if (name !== "price-restore") { const input = prompt( name === "price-nachal" ? "낙찰율을 입력하세요(%)" : "할증율을 입력하세요(%)", "", ); if (input == null) return; v = Number(input); if (!Number.isFinite(v)) throw Error("율은 수치로 입력하세요."); } if ( !confirm( v === 100 || v === 0 ? "원래값으로 복원하시겠습니까?" : "설정값을 적용하시겠습니까?", ) ) return; let n = 0; EstApp.change((project) => (n = setRate(project.tables[view], picked, field, v))); state.notice = `${n}개 행에 적용했습니다.`; } else if (name === "price-change") { const kind = TABLE_KIND[view]; if (!kind) throw Error("자재·노임·표준시장·견적 단가 화면에서 쓰세요."); if ( !confirm( "선택한 Main데이타의 단가로 바꾸시겠습니까?\n바꾸기후 전체재계산을 해야 금액이 바뀝니다.", ) ) return; const source = await EstApp.api("/api/main-data"); let missing = 0; EstApp.change((project) => { missing = EstDataUpdate.table(project.tables[view], source.tables[view] || [], kind); }); state.notice = missing ? `${missing} 개의 찾지못한 데이타가 있습니다. 확인란에 체크된 데이타를 검토하세요.` : "Main 데이타의 단가로 바꿨습니다."; } else if (name === "price-sum") { if (!["EstMainDat", "HeadMainDat"].includes(view) || !picked.length) throw Error("내역서·일위대가에서 SUM 줄로 바꿀 행을 고르세요."); const row = rows[picked[0]]; const letters = prompt("더할 색인(A~Z, 최대 5개, 예 AC)", "")?.toUpperCase(); if (letters == null) return; const rate = Number(prompt("적용율(%)", "100")); const o = { name: prompt("명칭", row.Name || "잡재료비") ?? "", size: row.Size ?? "", rate: Number.isFinite(rate) ? rate : 0, base: num(row.OCheck), target: num(row.SCheck), picked: [...new Set(letters.replace(/[^A-Z]/g, ""))].map((c) => c.charCodeAt(0) - 64), }; sumRow(view, structuredClone(row), o); EstApp.change((project) => { const list = project.tables[view]; sumRow(view, list[picked[0]], o); list[picked[0]].SKind = nextSKind(list, picked[0], view); }); } else return false; EstApp.render(); return true; } const names = new Set([ "price-samecheck", "price-gungu-excel", "price-ratio", "price-nachal", "price-restore", "price-change", "price-sum", ]); function handle(name, state) { if (!names.has(name)) return false; run(name, state).catch((error) => { state.notice = error.message; EstApp.render(); }); return true; } return { samecheck, gungu, setRate, sumRow, handle }; })();