/* -*- coding: utf-8 -*- */ /** * 수량산출서 화면 (scr_qty_list.js) — wQtyList * 탭1 수량산출서 목록표(기본 수량변수 · QCODE) · 탭2 수량산출서 보기(확장 수량변수 · 셀별명 · 내역서로 가져가기) · * 탭3 코드 종류별 수량(9종) · 내역↔BDQTY 수량 연결(자원 역집계) * 근거: REA 28 TWQTYLIST(Tab1 · Tab2 · sBtnTrans 기본 수량변수 전송 · sBtnSaveToFile · ActDef* 필드 설정 · * ActSheetToBoqByName/ByCode · LBoxGoTo 변수셀 찾기 · LBoxQty 수량변수 산근에 전송 · cBoxCode 9종) · * 지식DB 28 §2 · 02 QCODE · 10 ST_U_ConBD · 계산 = scr_qty_sheet.js */ (function (global) { "use strict"; const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || { screens: {}, actions: {}, menus: [], }); const S = () => global.STMATE_QTY_SHEET; const num = (v) => Number(String(v ?? "").replace(/,/g, "")) || 0; const esc = (s) => String(s == null ? "" : s).replace( /[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c], ); const ROLES = [ ["name", "내역명칭"], ["size", "내역규격"], ["unit", "내역단위"], ["qty", "내역수량"], ["code", "내역코드"], ["mamt", "자재단가"], ["lamt", "노무단가"], ["samt", "경비단가"], ["no", "공종번호"], ]; const TABS = [ ["list", "수량산출서 목록표 (기본 수량변수)"], ["sheet", "수량산출서 보기 (확장 수량변수)"], ["code", "코드별 수량 · 자원 역집계"], ]; function state(ctx, app) { const u = ctx.uiState; const d = ctx.dbfData; if (d && !d.qcode_rows && !u.qcodeLoading && ctx.currentSourcePath) { u.qcodeLoading = true; app .postJson("/api/qty/qcode", { source_path: ctx.currentSourcePath }) .then((r) => ((d.qcode_rows = r.rows || []), app.renderWorkspace())) .catch(() => (d.qcode_rows = [])); } u.qtySheets = u.qtySheets || {}; const key = ctx.currentSourcePath || "-"; u.qtySheets[key] = u.qtySheets[key] || S().newSheet(); return { sheet: u.qtySheets[key], key, qrows: (d && d.qcode_rows) || [] }; } function render(ctx, app) { const { sheet, qrows } = state(ctx, app); const tab = ctx.uiState.qtyTab || "list"; const body = tab === "list" ? renderList(ctx, app, qrows) : tab === "sheet" ? renderSheet(ctx, app, sheet) : renderCode(ctx, app); return `
수량산출서 [wQtyList · QCODE.DBF ${qrows.length}건]${ctx.dbfData ? "" : "프로젝트를 먼저 여세요"}
${TABS.map(([k, l]) => ``).join("")}
${body}
`; } // 탭1 — 수량코드(기본 수량변수) function renderList(ctx, app, qrows) { const u = ctx.uiState; const sel = u.qtyQSel ?? -1; const f = (r, k) => ``; const errs = u.qtyCheck || []; return `
전송 → 내역
${errs.length ? `
${errs.map(esc).join("
")}
` : u.qtyCheck ? `
검사 통과
` : ""}
${Array.from({ length: 10 }, (_v, k) => ``).join("")}${qrows .map( ( r, i, ) => ` ${Array.from({ length: 10 }, (_v, k) => ``).join("")}`, ) .join("")}
수량코드명칭내역코드QTY${k}
${f(r, `QTY${k}`)}
${ sel >= 0 && qrows[sel] ? `
${esc(qrows[sel].CODE)} 연결식(IFORM · QTYn=셀별명 식)
확장 수량변수(XCODE · 이름=값)
수량변수 산근에 전송 — 산출근거에 넣을 글
${varTokens(qrows[sel]).map(esc).join("
") || "-"}
` : "" }
`; } const varTokens = (r) => { const c = String(r.CODE || "").trim(); const out = []; for (let k = 0; k < 10; k++) if (String(r[`QTY${k}`] ?? "").trim()) out.push(`~${c}.QTY${k}~`); for (const ln of String(r.XCODE || "").split(/\r?\n/)) if (ln.includes("=")) out.push(`~${c}.${ln.split("=")[0].trim()}~`); return out; }; // 탭2 — 시트(셀별명 · 필드 설정 · 내역서로 가져가기) function renderSheet(ctx, app, sheet) { const u = ctx.uiState; const ev = S().evalSheet(sheet); const cur = u.qtyCell || "A1"; const aliasAt = {}; for (const [n, a] of Object.entries(sheet.aliases)) aliasAt[a.toUpperCase()] = n; const roleOpt = (k) => ``; const kind = u.qtyPickKind || "ALL"; const test = (S().KINDS.find((k) => k[0] === kind) || S().KINDS[0])[2]; const picks = ((ctx.dbfData && ctx.dbfData.costn_rows) || []) .filter((r) => test(String(r.CODE).trim())) .slice(0, 400); return `
셀 ${esc(cur)} 시작줄끝줄 ${ROLES.map(([k, l]) => ``).join("")}
${Array.from( { length: sheet.cols }, (_v, c) => ``, ).join("")}${Array.from({ length: sheet.rows }, (_v, r) => { const inRange = r >= Math.min(sheet.start, sheet.end) && r <= Math.max(sheet.start, sheet.end); return `${Array.from( { length: sheet.cols }, (_w, c) => { const a = S().addr(r, c); const v = ev.values[a]; const shown = v === undefined ? "" : typeof v === "number" ? app.fmt(v, Number.isInteger(v) ? 0 : 3) : v; return ``; }, ).join("")}`; }).join("")}
${S().colName(c)}${Object.entries(sheet.roles) .filter(([, v]) => v === S().colName(c)) .map(([k]) => ` (${(ROLES.find((x) => x[0] === k) || [])[1]})`) .join("")}
${r + 1}${aliasAt[a] ? `${esc(aliasAt[a])} ` : ""}${esc(shown)}
변수셀 찾기(셀별명)
${ Object.entries(sheet.aliases) .map( ([n, a]) => `
${esc(n)} = ${esc(a)} → ${app.fmt(ev.aliasValues[n.toUpperCase()] || 0, 3)} ×
`, ) .join("") || "-" }
내역코드란에 단가코드 입력
${picks.map((r) => `
${esc(String(r.CODE).trim())} ${esc(r.NAME)} ${esc(r.SIZE)}
`).join("")}
`; } // 탭3 — 코드 종류별 내역 수량 · 내역↔BDQTY 자원 역집계 function renderCode(ctx, app) { const u = ctx.uiState; const kind = u.qtyCodeKind || "ALL"; const field = u.qtyCodeField || "QTY1"; const boq = (ctx.dbfData && ctx.dbfData.boq_rows) || []; const list = S().qtyByCode(boq, kind, field); const roll = S().rollup(boq, (ctx.dbfData && ctx.dbfData.bdqty_rows) || [], field); const test = (S().KINDS.find((k) => k[0] === kind) || S().KINDS[0])[2]; const by = Object.fromEntries( ((ctx.dbfData && ctx.dbfData.costn_rows) || []).map((r) => [String(r.CODE).trim(), r]), ); const res = Object.entries(roll.totals) .filter(([c]) => test(c)) .sort(([a], [b]) => a.localeCompare(b)); return `
${roll.cycles.length ? `순환참조 ${roll.cycles.length}건 — ${esc(roll.cycles[0])}` : ""}
내역 코드별 수량 ${list.length}건
${list.map((o) => ``).join("")}
코드명칭규격단위내역 행수량 계
${esc(o.code)}${esc(o.name)}${esc(o.size)}${esc(o.unit)}${o.rows}${app.fmt(o.qty, 3)}
내역↔BDQTY 수량 연결(자원 소요량) ${res.length}건
${res.map(([c, q]) => ``).join("")}
코드명칭규격단위소요 수량
${esc(c)}${esc((by[c] || {}).NAME)}${esc((by[c] || {}).SIZE)}${esc((by[c] || {}).UNIT)}${app.fmt(q, 4)}
`; } // 내역 반영 — 코드가 같은 행 수량 바꿈 · 없으면 끝에 새 행(STMATE_UI_BOQ 모델) function applyToBoq(ctx, items, dest) { const B = global.STMATE_UI_BOQ; const model = { rows: ctx.dbfData.boq_rows, items: ctx.state.boqItems, deleted: (ctx.dbfData.boq_deleted = ctx.dbfData.boq_deleted || []), }; const by = Object.fromEntries( (ctx.dbfData.costn_rows || []).map((r) => [String(r.CODE).trim(), r]), ); const used = new Set(); let upd = 0, add = 0; const fresh = []; for (const it of items) { const hit = it.code && model.rows.find((r) => !used.has(r) && String(r.BDCODE1 || "").trim() === it.code); if (hit) { used.add(hit); if (dest === "QTY1" && B) B.setCell(model, hit._recno, "QTY1", it.qty); else hit[dest] = String(it.qty); upd++; } else if (B) { const cr = by[it.code] || {}; fresh.push( B.makeRow(model, { BDCODE1: it.code, NAME1: it.name || cr.NAME || "", SIZE1: it.size || cr.SIZE || "", UNIT1: it.unit || cr.UNIT || "", [dest]: String(it.qty), ITNUM: it.itnum || "", }), ); add++; } } if (fresh.length) { const res = B.insertRows(model, model.rows.length, fresh); if (res && res.ok === false) alert(res.error); } return { upd, add }; } function bind(ctx, app) { const $ = (id) => document.getElementById(id); const u = ctx.uiState; const { sheet, key, qrows } = state(ctx, app); const redraw = () => app.renderWorkspace(); document .querySelectorAll(".qty-tab") .forEach((b) => (b.onclick = () => ((u.qtyTab = b.dataset.tab), redraw()))); $("qty-file-save").onclick = () => app.downloadBlob(S().serialize(sheet, qrows), "수량산출서.stq.json", "application/json"); $("qty-file-load").onclick = () => $("qty-file-input").click(); $("qty-file-input").onchange = (e) => { const f = e.target.files && e.target.files[0]; if (!f) return; const rd = new FileReader(); rd.onload = () => { const d = S().deserialize(String(rd.result)); if (d.error) return alert(d.error); u.qtySheets[key] = d.sheet; if (d.qcode.length && ctx.dbfData) ctx.dbfData.qcode_rows = d.qcode; redraw(); }; rd.readAsText(f); }; $("qty-file-reset").onclick = () => confirm("수량산출파일을 초기화할까요?") && ((u.qtySheets[key] = S().newSheet()), redraw()); const tab = u.qtyTab || "list"; if (tab === "list") bindList(ctx, app, qrows, sheet); else if (tab === "sheet") bindSheet(ctx, app, sheet); else { $("qty-code-kind").onchange = (e) => ((u.qtyCodeKind = e.target.value), redraw()); $("qty-code-field").onchange = (e) => ((u.qtyCodeField = e.target.value), redraw()); } } function bindList(ctx, app, qrows, sheet) { const $ = (id) => document.getElementById(id); const u = ctx.uiState; const redraw = () => app.renderWorkspace(); const d = ctx.dbfData; if (!d) return; document .querySelectorAll(".qty-q") .forEach( (el) => (el.onchange = () => ( (qrows[Number(el.dataset.i)][el.dataset.k] = el.value.trim()), (u.qtyCheck = null) )), ); document .querySelectorAll(".qty-q-sel") .forEach((el) => (el.onchange = () => ((u.qtyQSel = Number(el.dataset.i)), redraw()))); document .querySelectorAll(".qty-q-memo") .forEach((el) => (el.onchange = () => (qrows[u.qtyQSel][el.dataset.k] = el.value))); $("qty-q-add").onclick = () => { d.qcode_rows = qrows; qrows.push({ CODE: S().nextQcode(qrows), NAME: "", BQCODE: "", IFORM: "", XCODE: "" }); u.qtyQSel = qrows.length - 1; redraw(); }; $("qty-q-del").onclick = () => { if (!(u.qtyQSel >= 0) || !confirm("선택한 수량코드를 지울까요?")) return; qrows.splice(u.qtyQSel, 1); u.qtyQSel = -1; redraw(); }; $("qty-q-check").onclick = () => ( (u.qtyCheck = S().checkQcodes( qrows, new Set((d.costn_rows || []).map((r) => String(r.CODE).trim())), )), redraw() ); $("qty-q-assign").onclick = () => { const res = S().assignFromSheet(qrows, sheet); d.qcode_rows = res.rows; const se = Object.entries(res.sheetErrors); u.qtyCheck = res.errors .map((e) => `${e.code}: ${e.msg}`) .concat( se.length ? [`셀참조 에러가 있는 수량변수(셀별명) ${se.length}건 — ${se[0][0]} ${se[0][1]}`] : [], ); redraw(); }; $("qty-q-field").onchange = (e) => (u.qtyQField = e.target.value); $("qty-q-dest").onchange = (e) => (u.qtyQDest = e.target.value); $("qty-q-trans").onclick = () => { const fld = $("qty-q-field").value, dest = $("qty-q-dest").value; const items = qrows .filter((r) => String(r.BQCODE || "").trim()) .map((r) => ({ code: String(r.BQCODE).trim(), name: r.NAME, qty: num(r[fld]) })); if (!items.length) return alert("내역코드(BQCODE)가 있는 수량코드가 없음"); const r = applyToBoq(ctx, items, dest); app.cascadeAndRender(); alert(`기본 수량변수 전송 — 내역 ${r.upd}행 수량 바꿈 · ${r.add}행 새로 넣음`); }; $("qty-q-save").onclick = async () => { const errs = S().checkQcodes( qrows, new Set((d.costn_rows || []).map((r) => String(r.CODE).trim())), ); if (errs.length) return ((u.qtyCheck = errs), redraw()); try { const res = await app.postJson("/api/qty/qcode-save", { source_path: ctx.currentSourcePath, target_folder_name: (ctx.state.projectMeta.pname || "stmate_edited").slice(0, 28), rows: qrows, }); alert(`수량코드 ${res.count}건 저장 — ${res.saved_stc}`); } catch (err) { alert("수량코드 저장 실패: " + err.message); } }; } function bindSheet(ctx, app, sheet) { const $ = (id) => document.getElementById(id); const u = ctx.uiState; const redraw = () => app.renderWorkspace(); const cur = u.qtyCell || "A1"; document .querySelectorAll(".qty-cell") .forEach((td) => (td.onclick = () => ((u.qtyCell = td.dataset.a), redraw()))); $("qty-cell-edit").onchange = (e) => { const v = e.target.value; if (v === "") delete sheet.cells[cur]; else sheet.cells[cur] = v; const p = S().parseAddr(cur); if (p && p.r + 1 < sheet.rows) u.qtyCell = S().addr(p.r + 1, p.c); redraw(); }; $("qty-alias-set").onclick = () => { const n = $("qty-alias").value.trim(); for (const [k, a] of Object.entries(sheet.aliases)) if (a === cur) delete sheet.aliases[k]; if (n) { if (!/^[A-Za-z가-힣_][A-Za-z0-9가-힣_]*$/.test(n) || S().parseAddr(n)) return alert("셀별명은 글자로 시작 · 셀 주소 꼴은 안 됨"); sheet.aliases[n] = cur; } redraw(); }; document .querySelectorAll(".qty-goto") .forEach((el) => (el.ondblclick = el.onclick = () => ((u.qtyCell = el.dataset.a), redraw()))); document .querySelectorAll(".qty-alias-del") .forEach( (el) => (el.onclick = (e) => (e.stopPropagation(), delete sheet.aliases[el.dataset.n], redraw())), ); $("qty-row-add").onclick = () => ((sheet.rows += 10), redraw()); $("qty-col-add").onclick = () => ((sheet.cols += 1), redraw()); $("qty-start").onchange = (e) => ( (sheet.start = Math.max(0, num(e.target.value) - 1)), redraw() ); $("qty-end").onchange = (e) => ((sheet.end = Math.max(0, num(e.target.value) - 1)), redraw()); document .querySelectorAll(".qty-role") .forEach((el) => (el.onchange = () => ((sheet.roles[el.dataset.k] = el.value), redraw()))); $("qty-pick-kind").onchange = (e) => ((u.qtyPickKind = e.target.value), redraw()); $("qty-pick-name").onchange = (e) => (u.qtyPickName = e.target.checked); document.querySelectorAll(".qty-pick").forEach( (el) => (el.ondblclick = el.onclick = () => { const c = el.dataset.c; sheet.cells[cur] = c; const p = S().parseAddr(cur); if (u.qtyPickName && p) { const r = (ctx.dbfData.costn_rows || []).find((x) => String(x.CODE).trim() === c) || {}; [r.NAME, r.SIZE, r.UNIT].forEach( (v, k) => v && (sheet.cells[S().addr(p.r, p.c + k + 1)] = String(v).trim()), ); } if (p && p.r + 1 < sheet.rows) u.qtyCell = S().addr(p.r + 1, p.c); redraw(); }), ); const toBoq = (mode) => { const res = S().sheetToBoq(sheet, mode); if (res.error) return alert(res.error); if (!res.items.length) return alert("시작줄~끝줄 안에 가져갈 행이 없음"); const by = Object.fromEntries( (ctx.dbfData.costn_rows || []).map((r) => [String(r.CODE).trim(), r]), ); const bad = mode === "code" ? res.items.filter((it) => !by[it.code]) : []; if (bad.length) return alert( `단가목록에 없는 내역코드: ${bad.map((b) => `${b.row}행 ${b.code}`).join(", ")}`, ); const r = applyToBoq(ctx, res.items, "QTY1"); app.cascadeAndRender(); alert(`내역서로 가져가기 — ${r.upd}행 수량 바꿈 · ${r.add}행 새로 넣음`); }; $("qty-to-boq-code").onclick = () => toBoq("code"); $("qty-to-boq-name").onclick = () => toBoq("name"); } P.screens.wQtyList = { render, bind }; P.actions.Act_Qty_List = (ctx, app) => ( (ctx.uiState.activeScreen = "wQtyList"), app.renderWorkspace() ); if (!P.menus.some((m) => m.act === "Act_Qty_List")) P.menus.push({ menu: "m3", label: "수량산출서 (wQtyList)", act: "Act_Qty_List" }); global.STMATE_QTY_LIST = { applyToBoq }; })(window);