/* -*- coding: utf-8 -*- */
/**
* 대가표·산근 편집 화면 (scr_edit_san.js) — wM_Edit_iLWi 자리 바꿔 끼움
* 왼쪽 B·D·X 목록 · 오른쪽 산출근거 편집기(D · 산식 있는 코드) 또는 구성행 편집(B · X, scr_edit_comp.js)
* 산출근거 편집기: 계산(F9) · 문법 오류 줄 표시 · 함수 입력 · 계표시 · 코드 입력(꼴 5택 · 연산 / * · 변수 Q A T N ·
* 0원 비목 입력안함) · 수량변수(~Q00001.QTY1~) · 중기공식 불러오기 · 금액결과 삭제 · 결과적용 비목 · 결과금액조정 ·
* 문법 강조 · 문법설명
* 근거: REA 30 TWM_EDIT_SAN · TWM_EDIT_EQ · 지식DB 26 §3 · 28 §1·§4 · 23
*/
(function (global) {
"use strict";
const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || {
screens: {},
actions: {},
menus: [],
});
const num = (v) => Number(v) || 0;
const L = () => global.STMATE_SMT_LANG;
const X = () => global.STMATE_SMT_EXPR;
const ENG = () => global.STMATE_ENGINE;
const COMP = () => global.STMATE_EDIT_COMP;
const esc = (s) => L().esc(s);
const SB = [
["", "0 각 해당비목 적용"],
["M", "1 재료비로 적용"],
["L", "2 노무비로 적용"],
["S", "3 경 비로 적용"],
];
const FILTERS = [
["ALL_BDX", "전체 대가·산근·중기"],
["SMT_ONLY", "산식 보유"],
["D", "단가산출 D"],
["B", "일위대가 B"],
["X", "중기사용료 X"],
];
const memoText = (ctx, code) => {
const ed = ctx.uiState.editedSmtMap || {};
if (ed[code] !== undefined) return ed[code];
const m = ((ctx.dbfData && ctx.dbfData.costn_memos) || {})[code];
return m ? m.text : "";
};
// 수량코드·중기공식 — 처음 한 번 서버에서 받아 둠
function preload(ctx, app) {
const d = ctx.dbfData;
if (d && !d.qcode_rows && !ctx.uiState.qcodeLoading && ctx.currentSourcePath) {
ctx.uiState.qcodeLoading = true;
app
.postJson("/api/qty/qcode", { source_path: ctx.currentSourcePath })
.then((r) => ((d.qcode_rows = r.rows || []), app.renderWorkspace()))
.catch(() => (d.qcode_rows = []));
}
if (!ctx.uiState.eqItems && !ctx.uiState.eqLoading) {
ctx.uiState.eqLoading = true;
fetch("/api/qty/eq")
.then((r) => r.json())
.then((r) => ((ctx.uiState.eqItems = r.items || []), app.renderWorkspace()))
.catch(() => (ctx.uiState.eqItems = []));
}
}
function listRows(ctx) {
const memos = (ctx.dbfData && ctx.dbfData.costn_memos) || {};
const f = ctx.uiState.ilwiSubFilter || "ALL_BDX";
return ((ctx.dbfData && ctx.dbfData.costn_rows) || []).filter((r) => {
const c = String(r.CODE || "").trim();
if (!c) return false;
if (f === "ALL_BDX") return /^[BDX]/.test(c) || Boolean(memos[c]);
if (f === "SMT_ONLY") return Boolean(memos[c]);
return c[0] === f;
});
}
function render(ctx, app) {
preload(ctx, app);
const fmt = app.fmt;
const rows = listRows(ctx);
const memos = (ctx.dbfData && ctx.dbfData.costn_memos) || {};
let code = ctx.uiState.selectedIlwiCode;
if (!rows.some((r) => String(r.CODE).trim() === code))
code = rows.length ? String(rows[0].CODE).trim() : "";
ctx.uiState.selectedIlwiCode = code;
const row = rows.find((r) => String(r.CODE).trim() === code) || {};
const hasComp = ((ctx.dbfData && ctx.dbfData.bdqty_rows) || []).some((r) => r.BDCODE === code);
const isText = code[0] === "D" || Boolean(memos[code]) || !/^[BX]/.test(code) || !hasComp;
const f = ctx.uiState.ilwiSubFilter || "ALL_BDX";
const cyc = ctx.uiState.cycleErrors || [];
return `
`;
}
// 산출근거 편집기
function renderText(ctx, app, code, row) {
const fmt = app.fmt;
const text = memoText(ctx, code);
const ev = ENG().evaluateSmtFormulaText(text, ctx, code);
const cyc = cycleErrors(ctx, code);
const errs = ev.errors.concat(cyc.map((msg) => ({ lineNo: 0, msg })));
const u = ctx.uiState;
const sel = (id, opts, cur) =>
``;
const marks = [["", "계표시(F5)"]].concat(
L().MARK_NAMES.map((n, i) => [L().marker(i + 1, false), `${n} ${L().marker(i + 1, false)}`]),
L().MARK_NAMES.map((n, i) => [
L().marker(i + 1, true),
`${n}(제외금액) ${L().marker(i + 1, true)}`,
]),
);
const qrows = (ctx.dbfData && ctx.dbfData.qcode_rows) || [];
const qopts = [["", qrows.length ? "수량코드" : "수량코드 없음"]];
for (const q of qrows) {
const c = String(q.CODE).trim();
for (let k = 0; k < 10; k++)
if (String(q[`QTY${k}`] ?? "").trim())
qopts.push([`~${c}.QTY${k}~`, `${c}.QTY${k} = ${q[`QTY${k}`]} ${q.NAME || ""}`]);
for (const ln of String(q.XCODE || "").split(/\r?\n/))
if (ln.includes("="))
qopts.push([`~${c}.${ln.split("=")[0].trim()}~`, `${c}.${ln.trim()}`]);
}
const eq = u.eqItems || [];
const adjOn = String(row.S_ADJ || "").trim() === "T";
const lines = ev.linesOut;
return `
${sel("san-func", [["", "각종 지원함수"]].concat(X().FUNC_LIST.map((f) => [f, f])), "")}
${sel("san-mark", marks, "")}
${sel("san-qty", qopts, "")}
${sel("san-eq", [["", eq.length ? "중기공식 불러오기" : "등록된 중기공식 없음"]].concat(eq.map((e, i) => [String(i), `${e.name} (${e.ins_x ? "공식+코드" : "공식"})`])), "")}
${sel(
"san-trans",
L().TRANS.map((t, i) => [String(i), t]),
u.sanTrans ?? "1",
)}
중기비 코드${sel(
"san-op",
[
["/", "/"],
["*", "*"],
],
u.sanOp || "/",
)}${sel(
"san-qv",
["Q", "A", "T", "N"].map((v) => [v, v]),
u.sanQv || "Q",
)}(으)로 입력
결과적용 비목 ${sel("san-sbmok", SB, String(row.SBMOK || "").trim())}
${adjOn ? ["M", "L", "S"].map((k) => `${k}:x `).join(" ") : ""}
${u.sanHelp ? `${L().GRAMMAR_HELP.map(esc).join("\n")}
` : ""}
문법 오류 ${errs.length}건
${errs.map((e) => `
${e.lineNo ? `${e.lineNo}번째줄: ` : ""}${esc(e.msg)}
`).join("") || "
오류 없음
"}
계산결과(Alt+F9) — 변수 ${esc(
Object.entries(ev.vars)
.map(([k, v]) => `${k}=${v}`)
.join(", ") || "없음",
)}
재 ${fmt(ev.sumM)} + 노 ${fmt(ev.sumL)} + 경 ${fmt(ev.sumS)} = ${fmt(ev.sumT)}원
`;
}
// 순환 — 이 산근이 부르는 코드가 다시 이 산근에 닿는지(REA 30 mode1 순환참조 검사)
function cycleErrors(ctx, code) {
const C = COMP();
const deps = C.depsOf(ctx, code);
const cyc = ENG().findCycles(deps, new Set([code, ...C.reach(deps, code)]));
return cyc.filter((p) => p.includes(code)).map((p) => `순환참조: ${p.join(" → ")}`);
}
function insertAt(area, text) {
const s = area.selectionStart ?? area.value.length,
e = area.selectionEnd ?? s;
area.value = area.value.slice(0, s) + text + area.value.slice(e);
area.selectionStart = area.selectionEnd = s + text.length;
area.focus();
}
// 줄 머리에 끼움 — 커서가 있는 줄 다음에 새 줄로
function insertLines(area, lines) {
const v = area.value,
at = v.indexOf("\n", area.selectionStart ?? v.length);
const pos = at < 0 ? v.length : at + 1;
const add = (at < 0 && v && !v.endsWith("\n") ? "\r\n" : "") + lines.join("\r\n") + "\r\n";
area.value = v.slice(0, pos) + add + v.slice(pos);
area.selectionStart = area.selectionEnd = pos + add.length;
area.focus();
}
function bind(ctx, app) {
const $ = (id) => document.getElementById(id);
const code = ctx.uiState.selectedIlwiCode;
const u = ctx.uiState;
u.editedSmtMap = u.editedSmtMap || {};
document
.querySelectorAll(".ilwi-sub-btn")
.forEach(
(b) => (b.onclick = () => ((u.ilwiSubFilter = b.dataset.sub), app.renderWorkspace())),
);
document
.querySelectorAll(".ilwi-master-row")
.forEach(
(tr) =>
(tr.onclick = () => (
(u.selectedIlwiCode = tr.dataset.code),
(u.compSel = []),
app.renderWorkspace()
)),
);
if ($("san-btn-eq"))
$("san-btn-eq").onclick = () => ((u.activeScreen = "wM_Edit_EQ"), app.renderWorkspace());
const area = $("smt-formula-textarea");
if (!area) return COMP().bind(ctx, code, app);
const keep = () => (u.editedSmtMap[code] = area.value);
area.oninput = keep;
const redo = () => (keep(), app.renderWorkspace());
area.onkeydown = (e) => {
if (e.key === "F9") (e.preventDefault(), $("san-calc").click());
};
const pick = (id, fn) =>
$(id) &&
($(id).onchange = (e) => {
if (e.target.value === "") return;
fn(e.target.value);
e.target.value = "";
redo();
});
pick("san-func", (v) => insertAt(area, v));
pick("san-mark", (v) => insertLines(area, [v]));
pick("san-qty", (v) => insertAt(area, v));
pick("san-eq", (v) => {
const it = (u.eqItems || [])[Number(v)];
if (!it) return;
// 「공식」 = 계산식 줄만 · 「공식+코드」 = 단가코드 줄까지
const ls = String(it.text || "").split(/\r?\n/);
insertLines(area, it.ins_x ? ls : ls.filter((l) => !/&[^&]+&/.test(l)));
});
const keepOpt = (id, key) =>
$(id) &&
($(id).onchange = (e) =>
(u[key] = e.target.type === "checkbox" ? e.target.checked : e.target.value));
[
["san-trans", "sanTrans"],
["san-op", "sanOp"],
["san-qv", "sanQv"],
["san-skip0", "sanSkip0"],
["san-code", "sanCode"],
].forEach(([i, k]) => keepOpt(i, k));
$("san-ins").onclick = () => {
const c = $("san-code").value.trim().toUpperCase();
const r = ENG().costnLookup(ctx).byCode[c];
if (!r) return alert(`${c || "(빈 코드)"} 는 등록된 단가코드가 아님`);
if (c === code) return alert("자기 자신은 넣을 수 없음(순환참조)");
insertLines(
area,
L().codeLines(c, r, {
trans: $("san-trans").value,
op: $("san-op").value,
qtyVar: $("san-qv").value,
skipZero: $("san-skip0").checked,
}),
);
redo();
};
$("san-strip").onclick = () => ((area.value = L().stripResults(area.value)), redo());
$("san-help").onclick = () => ((u.sanHelp = !u.sanHelp), app.renderWorkspace());
$("san-hilite").onclick = () => ((u.sanHilite = u.sanHilite === false), app.renderWorkspace());
document.querySelectorAll(".san-err").forEach(
(el) =>
(el.onclick = () => {
const n = Number(el.dataset.line);
if (!n) return;
const ls = area.value.split("\n");
const s = ls.slice(0, n - 1).join("\n").length + (n > 1 ? 1 : 0);
area.focus();
area.setSelectionRange(s, s + (ls[n - 1] || "").length);
}),
);
const row = ENG().costnLookup(ctx).byCode[code] || {};
$("san-sbmok").onchange = (e) => ((row.SBMOK = e.target.value), redo());
$("san-adj").onchange = (e) => {
row.S_ADJ = e.target.checked ? "T" : "F";
for (const k of "MLS") if (!num(row[`ADJ_${k}`])) row[`ADJ_${k}`] = "1";
redo();
};
document
.querySelectorAll(".san-adjv")
.forEach(
(el) =>
(el.onchange = () => ((row[`ADJ_${el.dataset.k}`] = String(num(el.value))), redo())),
);
$("san-calc").onclick = () => {
keep();
const txt = area.value;
const ev = ENG().evaluateSmtFormulaText(txt, ctx, code);
const cyc = cycleErrors(ctx, code);
const n = ev.errors.length + cyc.length;
if (
n &&
!confirm(
`문법 오류 ${n}건 — 그래도 계산 결과를 단가에 반영할까요?\n${ev.errors
.slice(0, 5)
.map((e) => `${e.lineNo}번째줄: ${e.msg}`)
.concat(cyc)
.join("\n")}`,
)
)
return app.renderWorkspace();
const memos = ctx.dbfData.costn_memos || (ctx.dbfData.costn_memos = {});
memos[code] = Object.assign({ code, blockNo: 0 }, memos[code], {
text: txt,
byteLen: txt.length,
});
global.STMATE_VIEW_PRJ_BOQ.syncCostnUnitPrice(ctx, code, ev.sumM, ev.sumL, ev.sumS);
app.cascadeAndRender();
};
}
P.screens.wM_Edit_iLWi = { render, bind };
global.STMATE_EDIT_SAN = { render, bind, cycleErrors, insertLines };
})(window);