/* -*- coding: utf-8 -*- */ /** * 보고서 시트 잇기 (report_link.js) — 원본 STMATE 엑셀의 시트 간 참조 수식 · 바로가기(→) · 시트 이름표 수식 * 근거: 지식DB STmate/old/20_분석 24 §3(ST_P_XLS Prn_FMLA_INFO) · 19 §3(목차·안내 시트) · * 원본 출력 엑셀 5건 수식 실측(봉화·영월·대흥·울진·영덕 — openpyxl 수식 읽기) * 정답 대조: resources/tester/test_stmate_report_link.py(원본 수식 글 그대로) * 시트 모형 행에 붙이는 것: * _f {열: 값} — 수식 넣기 때만 쓰는 칸(수식 · 숨은 열쇠 글) · 값 칸은 계산 엔진 값 그대로 * _links {열: '시트!칸'} — 값 모드 바로가기(하이퍼링크) */ const StmateReportLink = (() => { const TOC = "〓 목 차 〓"; const GUIDE = "※※안내※※"; const INIT = "〓 INITIAL 〓"; const FILE_NAME = '=MID(CELL("filename",$A$1),FIND("]",CELL("filename",$A$1))+1,LEN(CELL("filename",$A$1)))'; // 시트 이름 — 한글·영문·숫자만이면 따옴표 없이(원본 꼴) const q = (n) => /^[가-힣A-Za-z_][가-힣A-Za-z0-9_]*$/.test(n) ? n : `'${n.replace(/'/g, "''")}'`; const colNum = (c) => c.split("").reduce((a, ch) => a * 26 + ch.charCodeAt(0) - 64, 0); const colName = (n) => (n > 26 ? String.fromCharCode(64 + Math.floor((n - 1) / 26)) : "") + String.fromCharCode(65 + ((n - 1) % 26)); const isCol = (k) => /^[A-Z]{1,2}$/.test(k); // 시트 이름표 칸 — 2행에서 시트 이름이 적힌 열 const tagOf = (sh) => sh.rows[1] ? Object.keys(sh.rows[1]).find((k) => isCol(k) && sh.rows[1][k] === sh.name) : null; // 목차 바로가기 열 — 숨은 도움 열이 있는 시트는 그 뒤(원본 실측) · 그 밖 이름표 다음 열 const LINK_COL = { 총괄내역서: "Q", 설계내역서: "AC", 일위대가표: "Z", 중기사용료: "Z", 단가산출근거: "Y", }; const NO_LINK = new Set([TOC, GUIDE, INIT, "환율및기초자료"]); function linkCol(sh) { const id = sh.id || sh.name; if (LINK_COL[id]) return LINK_COL[id]; const t = tagOf(sh); return t ? colName(colNum(t) + 1) : null; } const headRow = (sh) => { let h = 0; sh.rows.slice(0, 6).forEach((r, i) => r._k === "head" && (h = i + 1)); return h || 3; }; const setF = (row, col, v) => ((row._f = row._f || {}), (row._f[col] = v)); const setLink = (row, col, target, text) => { row[col] = text; row._links = Object.assign(row._links || {}, { [col]: target }); }; /** * 바로가기 수식 — =HYPERLINK("#"&시트!이름표2&"!열"&ROW(시트!열행),"글") * 이름표 칸 수식이 시트 이름을 돌려줘 시트 이름을 바꿔도 따라감(원본 꼴) */ function jump(target, col, row, text) { const n = q(target.name); return `=HYPERLINK("#"&${n}!${tagOf(target)}2&"!${col}"&ROW(${n}!${col}${row}),"${text}")`; } /** * 바로가기 글 — 코드 · 호표 비고 모드(SH_TYP1=1 · SH_TYP3=1)면 종류 이름 + 목록 차례 5자리(「자재 14」 · 대흥 실측) * 종류 이름 = Start8x.Ini COD_FMT_1~6(노무·자재·경비·대가·중기·산근) */ function codeLabel(by, c) { const ini = (by._dbf && by._dbf.start_ini) || {}; const hop = String(ini.SH_TYP1 ?? "0") !== "0" && String(ini.SH_TYP3 ?? "2") === "1"; const at = "LMSBXD".indexOf(c[0]); if (!hop || at < 0) return `${c} →`; const list = by[LIST_OF[c[0]]]; const n = list ? list.rows.filter((r) => r._k === "item").findIndex((r) => r._code === c) + 1 : 0; return n > 0 ? `${String(ini[`COD_FMT_${at + 1}`] || "")}${String(n).padStart(5)} →` : `${c} →`; } function putJump(row, at, target, col, r, text) { if (!at || !target || !tagOf(target)) return; setLink(row, at, `${q(target.name)}!${col}${r}`, text); setF(row, at, jump(target, col, r, text)); } // 시트 안 코드 → 행 번호(1부터) — kind 가 같은 행만 function rowsBy(sh, kind) { const m = {}; if (!sh) return m; sh.rows.forEach((r, i) => { if (r._code && (!kind || r._k === kind) && !(r._code in m)) m[r._code] = i + 1; }); return m; } // 머리 행의 비목 열 — 이름 → 열(합계·노무·재료·경비) · BMKNM 차례로 쓴 머리 글에서 찾음 function bimokCols(ini, head, cols) { const C = globalThis.STMATE_REPORT_CORE || require("./report_core.js"); const out = {}; for (const k of "TLMS") { const lbl = C.bimokLabel(ini, k); const c = cols.find((x) => head[x] === lbl); if (c) out[k] = c; } return out; } // 코드 → COSTN 단위(M%·L%·…) · 규격 function costnMap(by, key, field) { if (!by[key]) { by[key] = {}; for (const r of (by._dbf && by._dbf.costn_rows) || []) by[key][String(r.CODE || "").trim()] = String(r[field] || "").trim(); } return by[key]; } const unitOf = (by) => costnMap(by, "_unit", "UNIT"); const specOf = (by) => costnMap(by, "_spec", "SIZE"); // 코드 머리 → 목록 시트 id const LIST_OF = { M: "재료비목록표", L: "노무비목록표", S: "경비목록표", B: "일위대가목록표", D: "단가산출근거목록표", X: "중기목록표", W: "일식견적목록표", }; const DETAIL_OF = { 일위대가목록표: ["일위대가표", "parent"], 중기목록표: ["중기사용료", "parent"], 단가산출근거목록표: ["단가산출근거", "hopyo"], }; const COLL_OF = { 재료비수량금액집계표: "재료비목록표", 노무비수량금액집계표: "노무비목록표", 경비수량금액집계표: "경비목록표", 중기시간금액집계표: "중기목록표", 일위대가수량금액집계표: "일위대가목록표", 단가산출근거수량금액집계표: "단가산출근거목록표", 일식견적수량금액집계표: "일식견적목록표", }; /** * 목록 층 — 재료비목록표 단가 = 자재단가대비표 적용 단가 · 대가·중기·산근 목록 = 호표 합계 줄 · * 수량금액집계표 단가 = 목록 단가 · 비목 집계(중기·대가·산근) = ROUND(수량 × 목록 비목 단가) */ function linkLists(by, ini) { const pc = by.자재단가대비표; const mat = by.재료비목록표; if (pc && mat) { const at = rowsBy(pc, "item"); const lc = linkCol(mat); mat.rows.forEach((r) => { const pr = r._k === "item" && at[r._code]; if (!pr) return; setF(r, "E", `=${q(pc.name)}!O${pr}`); putJump(r, lc, pc, "A", pr, codeLabel(by, r._code)); }); } for (const [lid, [did, kind]] of Object.entries(DETAIL_OF)) { const list = by[lid]; const det = by[did]; if (!list || !det) continue; const tot = rowsBy(det, "total"); const top = rowsBy(det, kind); const lcols = bimokCols(ini, list.rows[2] || {}, ["E", "F", "G", "H"]); const dcols = did === "단가산출근거" ? bimokCols(ini, det.rows[2] || {}, ["C", "D", "E", "F"]) : bimokCols(ini, det.rows[2] || {}, ["E", "G", "I", "K"]); const lc = "K"; list.rows.forEach((r) => { if (r._k !== "item" || !tot[r._code]) return; for (const k of "TLMS") { if (!lcols[k] || !dcols[k]) continue; const dc = did === "단가산출근거" ? dcols[k] : colName(colNum(dcols[k]) + 1); setF(r, lcols[k], `=${q(det.name)}!${dc}${tot[r._code]}`); } const hr = kind === "parent" ? top[r._code] - 1 : top[r._code]; if (hr > 0) putJump(r, lc, det, "A", hr, codeLabel(by, r._code)); }); } for (const [cid, lid] of Object.entries(COLL_OF)) { const sh = by[cid]; const list = by[lid]; if (!sh || !list) continue; const at = rowsBy(list, "item"); const lc = linkCol(sh); const head = sh.rows.slice(0, 4).find((r) => r._k === "head") || {}; const ccols = bimokCols(ini, head, ["F", "G", "H", "I"]); const lcols = bimokCols(ini, list.rows[2] || {}, ["E", "F", "G", "H"]); const single = !ccols.L; // 자원 집계 — 단가 한 칸 sh.rows.forEach((r) => { const lr = r._k === "item" && at[r._code]; if (!lr) return; if (single) setF(r, "F", `=${q(list.name)}!E${lr}`); else { setF(r, ccols.T, `=${ccols.M}{r}+${ccols.L}{r}+${ccols.S}{r}`); for (const k of "LMS") setF(r, ccols[k], `=ROUND(D{r}*${q(list.name)}!${lcols[k]}${lr},0)`); } putJump(r, lc, list, "A", lr, codeLabel(by, r._code)); }); } } /** * 구성 호표(일위대가표 · 중기사용료) — 줄 단가 = 목록 단가(노임 시간 환산은 환율및기초자료) · * 줄 금액 = IF(수량=0,0,ROUNDDOWN(단가×수량,1)) · % 단위 = ÷100 · 합계 = 같은 열쇠 줄 SUMIF 원 미만 버림 * 총액우선(일위대가 · CHBNM=0) = 끝수 배분 도움 열 S~Y(소수 셋째 끝수 큰 비목부터 · 같으면 OPTNM 차례) * 숨은 열쇠 — O = 코드_1(줄) · T7_1(합계) · P = T7_1(줄이 속한 합계) */ function linkComposition(by, sh, ini) { if (!sh) return; const PAIR = { T: ["E", "F"], L: ["G", "H"], M: ["I", "J"], S: ["K", "L"] }; const C = globalThis.STMATE_REPORT_CORE || require("./report_core.js"); const order = C.bimokOrder(ini); ["E", "G", "I", "K"].forEach((u, i) => (PAIR[order[i]] = [u, colName(colNum(u) + 1)])); const tf = (sh.id || sh.name) === "일위대가표" && String(ini.CHBNM ?? "1") === "0"; const opt = String(ini.OPTNM || "SML") .toUpperCase() .split("") .filter((k) => "MLS".includes(k)); const nonT = order.filter((k) => k !== "T"); // 도움 열 S·T·U 차례 = 단가 열 차례 const help = {}; nonT.forEach((k, i) => (help[k] = { frac: "STU"[i], adj: "WXY"[i] })); const lab = by.환율및기초자료 ? rowsBy(by.환율및기초자료) : {}; const rowsOf = {}; const sum3 = (side) => `=${PAIR.M[side]}{r}+${PAIR.L[side]}{r}+${PAIR.S[side]}{r}`; const helpers = (row, base, d, step) => { for (const k of nonT) setF( row, help[k].frac, `=ROUND(ROUNDDOWN(${base(PAIR[k][0])},${d + 2})-ROUNDDOWN(${base(PAIR[k][0])},${d}),${d + 2})`, ); const parts = ["M", "L", "S"].map((k) => `ROUNDDOWN(${base(PAIR[k][0])},${d})`).join("+"); setF( row, "V", `=ROUNDDOWN(ROUND(ROUNDDOWN(${base(PAIR.T[0])},${d})-(${parts}),${d + 2}),${d})`, ); for (const k of nonT) { const me = help[k].frac; const ahead = opt .slice(0, opt.indexOf(k)) .map((j) => `+IF(${help[j].frac}{r}=${me}{r},1,0)`) .join(""); setF( row, help[k].adj, `=IF(V{r}<0,-1,1)*IF(COUNTIF(R{r}:U{r},IF(V{r}<0,"<",">")&${me}{r})${ahead}= 25; // 중기 % 코드 줄 중 규격 「주연료의 %」(잡품·잡유) = 앞 연료 줄 금액을 밑수 수식으로(대흥 「=J9」 실측) · // 그 밖 % 코드(재료비의 % 등)는 산식 없음(안내 시트 알림) const fuelPct = (c) => /주연료/.test(specOf(by)[c] || ""); const amounts = (r, pct) => { setF(r, PAIR.T[0], sum3(0)); setF(r, PAIR.T[1], sum3(1)); for (const k of nonT) { const [u, a] = PAIR[k]; const core = `ROUNDDOWN(${u}{r}*C{r}${pct ? "/100" : ""},1)`; setF(r, a, `=IF(C{r}=0,0,${core}${tf ? `+${help[k].adj}{r}` : ""})`); } if (tf) helpers(r, (u) => `${u}{r}*C{r}`, 1, 0.1); }; // 비율 줄 밑수 — 표시 줄(Q=1_01)의 밑수 비목 금액 합 · 25 판부터 칸 더하기(울진 실측) const prBase = (r, rn, a, marks, inK, outK) => { const inA = PAIR[inK === "T" ? "M" : inK][1]; const out = PAIR[outK === "T" ? "M" : outK][0]; if (!marks.length) return; if (v25) setF(r, out, "=" + marks.map((m) => `${inA}${m}`).join("+")); else setF(r, out, `=SUMIF(Q${a}:Q${rn - 1},"1_01",${inA}${a}:${inA}${rn - 1})`); }; let first = 0; let marks = []; const lc = linkCol(sh); sh.rows.forEach((r, i) => { const rn = i + 1; if (r._k === "parent") { setF(r, "O", r._code); first = rn + 1; marks = []; return; } if (r._k === "text" && first) { // 글 줄 — 합계 밖 열쇠 setF(r, "O", "_1"); setF(r, "P", "_1"); return; } if (r._k === "line" && r._code === "PR" && r._pr) { setF(r, "O", "PRA01_1"); setF(r, "P", "T7_1"); amounts(r, true); prBase(r, rn, first, marks, r._pr.in, r._pr.out); if (r._mark) { setF(r, "Q", "1_01"); marks.push(rn); } return; } if (r._k === "line" && r._code && r._code !== "PR") { const c = r._code; const pct = r.D === "%"; if (r._mark) { setF(r, "Q", "1_01"); marks.push(rn); } setF(r, "O", `${c}_1`); if (pct && isJ && fuelPct(c)) { // 표준 구성 % 줄 — 밑수 = 앞 줄 중 그 비목 금액이 있는 칸 더하기(대흥 「=J9」 실측) const k = (unitOf(by)[c] || "M%")[0]; const kk = k === "T" ? "M" : k; const mk = []; for (let j = first; j < rn; j++) { const x = sh.rows[j - 1]; if (x._k === "line" && Number(x[PAIR[kk][1]]) !== 0) mk.push(`${PAIR[kk][1]}${j}`); } if (mk.length) setF(r, PAIR[kk][0], "=" + mk.join("+")); } setF(r, "P", "T7_1"); amounts(r, pct); const labor = c[0] === "L" && lab[c] && isJ; const list = labor ? by.환율및기초자료 : by[LIST_OF[c[0]]]; if (!list) return; const lr = labor ? lab[c] : (rowsOf[list.name] = rowsOf[list.name] || rowsBy(list, "item"))[c]; if (!lr) return; if (!pct) { if (labor) setF(r, PAIR.L[0], `=${q(list.name)}!G${lr}`); else if ("MLS".includes(c[0])) setF(r, PAIR[c[0]][0], `=${q(list.name)}!E${lr}`); else { const lcols = bimokCols(ini, list.rows[2] || {}, ["E", "F", "G", "H"]); for (const k of nonT) setF(r, PAIR[k][0], `=${q(list.name)}!${lcols[k]}${lr}`); } } putJump(r, lc, list, "A", lr, codeLabel(by, c)); return; } if (r._k === "total" && first) { const a = first; const b = rn - 1; const toS = /경비/.test(String(r.A || "")); // 결과적용 비목 경비(SBMOK=S) — 세 비목 합을 경비 칸에 setF(r, "O", "T7_1"); const sumif = (col) => `SUMIF(P${a}:P${b},O{r},${col}${a}:${col}${b})`; const fin = (k) => `ROUNDDOWN(${sumif(PAIR[k][1])}${tf ? `+${help[k].adj}{r}` : ""},0)`; setF(r, PAIR.T[1], sum3(1)); for (const k of nonT) { const [u, am] = PAIR[k]; if (tf) setF(r, u, `=${sumif(am)}`); if (!toS) setF(r, am, `=${fin(k)}`); } if (toS) setF(r, PAIR.S[1], "=" + ["M", "L", "S"].map(fin).join("+")); if (tf) { setF(r, PAIR.T[0], sum3(0)); helpers(r, (u) => `${u}{r}`, 0, 1); } first = 0; } }); } /** * 설계내역서 — 묶음 금액 = SUMIF(아래 행 Q 열쇠 = 이 묶음 P 열쇠) · 품목 단가 = 목록 단가 · 금액 = ROUNDDOWN(단가×수량,0) * 열쇠 — 묶음 P = E단_1(제외 구역 C단_1 · 윗 묶음에 안 더함) · 품목 P = 코드_1(비율 줄 PR_1) · Q = 윗 묶음 열쇠 · * 빈 줄 P = CMT_1 · 비율 줄 밑수 표시 R = 1_2_01 · 끝 줄 = 맨 위 열쇠(E10_1) 합 */ function linkDesign(by, ini) { const sh = by.설계내역서; if (!sh) return; const C = globalThis.STMATE_REPORT_CORE || require("./report_core.js"); const PAIR = {}; const order = C.bimokOrder(ini); ["F", "H", "J", "L"].forEach((u, i) => (PAIR[order[i]] = [u, colName(colNum(u) + 1)])); const nonT = order.filter((k) => k !== "T"); const sum3 = (side) => `=${PAIR.M[side]}{r}+${PAIR.L[side]}{r}+${PAIR.S[side]}{r}`; const lc = linkCol(sh); const rowsOf = {}; let first = 0; let grpStart = 0; let last = 0; sh.rows.forEach((r, i) => { const rn = i + 1; if (/^T\d$/.test(r._k || "")) { if (!first) first = rn; grpStart = rn + 1; last = rn; setF(r, "P", `${r._ex ? "C" : "E"}${r._lv}_1`); if (!r._ex && r._pk) setF(r, "Q", r._pk); const end = r._end > rn ? r._end : rn; for (const k of order) { const am = PAIR[k][1]; if (end > rn) setF(r, am, `=SUMIF(Q${rn + 1}:Q${end},P{r},${am}${rn + 1}:${am}${end})`); } return; } if (r._k === "blank") { setF(r, "P", "CMT_1"); if (r._pk) setF(r, "Q", r._pk); last = rn; return; } if (r._k !== "item") return; last = rn; const c = r._code; setF(r, "P", c === "PR" ? "PR_1" : `${c}_1`); setF(r, "Q", r._pk); if (r._mark) setF(r, "R", "1_2_01"); const pct = r.E === "%"; if (pct) { setF(r, PAIR.T[0], sum3(0)); setF(r, PAIR.T[1], sum3(1)); for (const k of nonT) setF(r, PAIR[k][1], `=ROUNDDOWN(${PAIR[k][0]}{r}*D{r}/100,0)`); } if (c === "PR" && r._pr) { const inA = PAIR[r._pr.in === "T" ? "M" : r._pr.in][1]; const out = PAIR[r._pr.out === "T" ? "M" : r._pr.out][0]; if (grpStart < rn) setF( r, out, `=SUMIF(R${grpStart}:R${rn - 1},"1_2_01",${inA}${grpStart}:${inA}${rn - 1})`, ); return; } const list = by[LIST_OF[c[0]]]; if (!list) return; const lr = (rowsOf[list.name] = rowsOf[list.name] || rowsBy(list, "item"))[c]; if (!lr) return; if (!pct) { if ("MLS".includes(c[0])) setF(r, PAIR[c[0]][0], `=${q(list.name)}!E${lr}`); else { const lcols = bimokCols(ini, list.rows[2] || {}, ["E", "F", "G", "H"]); for (const k of nonT) if (lcols[k]) setF(r, PAIR[k][0], `=${q(list.name)}!${lcols[k]}${lr}`); } } putJump(r, lc, list, "A", lr, codeLabel(by, c)); }); const fin = sh.rows.findIndex((r) => r._k === "final"); if (fin >= 0 && first) for (const k of order) { const am = PAIR[k][1]; setF(sh.rows[fin], am, `=SUMIF(Q${first}:Q${last},"E10_1",${am}${first}:${am}${last})`); } } /** * 총괄내역서 공종 줄 · 순공사비 = 설계내역서 묶음 줄(끝 줄) 금액 참조 + 「내역 →」 바로가기 */ function linkSummary(by, ini) { const sm = by.총괄내역서; const ds = by.설계내역서; if (!sm || !ds) return; const C = globalThis.STMATE_REPORT_CORE || require("./report_core.js"); const order = C.bimokOrder(ini); const dcol = {}; ["G", "I", "K", "M"].forEach((c, i) => (dcol[order[i]] = c)); const scol = {}; ["D", "E", "F", "G"].forEach((c, i) => (scol[order[i]] = c)); // 설계 묶음 줄 — 같은 단·공종 번호·명칭이 차례로 나오는 행을 맞춤 const groups = ds.rows.map((r, i) => [r, i + 1]).filter(([r]) => /^T\d$/.test(r._k || "")); let gi = 0; const fin = ds.rows.findIndex((r) => r._k === "final") + 1; const lc = linkCol(sm); sm.rows.forEach((r) => { let target = 0; if (/^T\d$/.test(r._k || "")) { while (gi < groups.length) { const [g, rn] = groups[gi++]; if (g._k === r._k && g.A === r.A && g.B === r.B) { target = rn; break; } } } else if (r._k === "sub" && /가\./.test(String(r.A || "")) && fin > 0) target = fin; if (!target) return; for (const k of order) setF(r, scol[k], `=${q(ds.name)}!${dcol[k]}${target}`); putJump(r, lc, ds, "A", target, "내역 →"); }); } /** * 시트마다 — 이름표 칸 수식(파일 안 시트 이름) · 목차 바로가기(머리 끝 행 · 바로가기 열) */ function linkFrame(sheets) { for (const sh of sheets) { if (NO_LINK.has(sh.name) && sh.name !== "환율및기초자료") continue; const tag = tagOf(sh); if (tag) setF(sh.rows[1], tag, FILE_NAME); if (NO_LINK.has(sh.name)) continue; const lc = linkCol(sh); if (!lc) continue; const row = sh.rows[headRow(sh) - 1]; setLink(row, lc, `${q(TOC)}!B2`, "목차 →"); setF(row, lc, `=HYPERLINK("#${q(TOC)}!B2","목차 →")`); } } // 묶음 전체 잇기 — 시트 id 로 찾음(이름은 SET_REP 제목에 따라 바뀜) function linkBook(sheets, dbf) { const ini = (dbf && dbf.start_ini) || {}; const by = { _dbf: dbf }; for (const s of sheets) by[s.id || s.name] = s; linkFrame(sheets); linkLists(by, ini); linkComposition(by, by.일위대가표, ini); linkComposition(by, by.중기사용료, ini); linkDesign(by, ini); linkSummary(by, ini); return sheets; } return { linkBook, specOf, jump, tagOf, linkCol, headRow, q, colName, colNum, TOC, GUIDE, INIT }; })(); if (typeof window !== "undefined") window.STMATE_REPORT_LINK = StmateReportLink; if (typeof module !== "undefined" && module.exports) module.exports = StmateReportLink;