/* -*- 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 `
`;
}
// 탭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 ? `검사 통과
` : ""}
${
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("")}
변수셀 찾기(셀별명)
${
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}건
내역↔BDQTY 수량 연결(자원 소요량) ${res.length}건
`;
}
// 내역 반영 — 코드가 같은 행 수량 바꿈 · 없으면 끝에 새 행(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);