Files
Aislo/tools/stmate-test/scr_edit_san.js
T

423 lines
20 KiB
JavaScript

/* -*- 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 `
<div class="win-form">
<div class="win-form-header">
<span>${code[0] === "D" ? "단가산출근거 수정" : code[0] === "X" ? "중기사용료 수정" : "일위대가표 수정"} [ ${esc(code)}: ${esc(row.NAME || "")} ${esc(row.SIZE || "")} ]</span>
<span>${esc(row.UNIT || "")} · 저장 단가 재 ${fmt(num(row.MAMT1))} / 노 ${fmt(num(row.LAMT1))} / 경 ${fmt(num(row.SAMT1))} = ${fmt(num(row.TAMT1))}원${num(row.NAGCD) ? ` · 낙찰율 ${num(row.NAGCD)}%` : ""}</span>
</div>
<div class="win-subbar">
${FILTERS.map(([k, l]) => `<button class="win-btn ilwi-sub-btn ${f === k ? "active" : ""}" data-sub="${k}">${l}</button>`).join("")}
<button class="win-btn" id="san-btn-eq" style="margin-left:auto;">표준중기공식 편집</button>
</div>
${cyc.length ? `<div style="color:#b91c1c;padding:3px 8px;">순환참조 ${cyc.length}건 — ${esc(cyc.slice(0, 3).join(" · "))}</div>` : ""}
<div class="split-horiz">
<div class="pane-box" style="width:400px;">
<div class="pane-title"><span>대가·산근·중기 목록</span><span>${rows.length}건</span></div>
<div class="grid-scroll"><table class="dbgrid">
<thead><tr><th style="width:62px">코드</th><th>명칭</th><th style="width:90px">규격</th><th style="width:78px">합계단가</th><th style="width:34px">식</th></tr></thead>
<tbody>${rows
.map((r) => {
const c = String(r.CODE).trim();
return `<tr class="ilwi-master-row ${c === code ? "selected" : ""}" data-code="${esc(c)}" style="cursor:pointer;"><td class="center"><strong>${esc(c)}</strong></td><td>${esc(r.NAME)}</td><td>${esc(r.SIZE)}</td><td class="num">${fmt(num(r.TAMT1))}</td><td class="center">${memos[c] ? "산식" : /^[BX]/.test(c) ? "구성" : "-"}</td></tr>`;
})
.join("")}</tbody>
</table></div>
</div>
<div class="split-vert" style="flex:1;padding:0;">
${code ? (isText ? renderText(ctx, app, code, row) : `<div class="pane-box" style="flex:1;">${COMP().render(ctx, code, app)}</div>`) : "<div class='pane-box'>항목 없음</div>"}
</div>
</div>
</div>`;
}
// 산출근거 편집기
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) =>
`<select id="${id}">${opts.map(([v, l]) => `<option value="${esc(v)}" ${String(cur) === String(v) ? "selected" : ""}>${esc(l)}</option>`).join("")}</select>`;
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 `
<div class="win-subbar" style="flex-wrap:wrap;gap:4px;">
<button class="win-btn primary" id="san-calc">계산(F9) · 단가 반영</button>
<button class="win-btn" id="san-strip">금액결과 삭제</button>
<button class="win-btn ${u.sanHelp ? "active" : ""}" id="san-help">문법설명 보기</button>
<button class="win-btn ${u.sanHilite === false ? "" : "active"}" id="san-hilite">문법 강조</button>
${sel("san-func", [["", "각종 지원함수"]].concat(X().FUNC_LIST.map((f) => [f, f])), "")}
${sel("san-mark", marks, "")}
${sel("san-qty", qopts, "")}
${sel(
"san-img",
[["", "그림코드(Ctrl+I)"]].concat(
((ctx.dbfData && ctx.dbfData.costn_rows) || [])
.filter((r) => /^I\d{5}$/.test(String(r.CODE).trim()))
.map((r) => [String(r.CODE).trim(), `${String(r.CODE).trim()} ${r.NAME || ""}`]),
),
"",
)}
${sel("san-eq", [["", eq.length ? "중기공식 불러오기" : "등록된 중기공식 없음"]].concat(eq.map((e, i) => [String(i), `${e.name} (${e.ins_x ? "공식+코드" : "공식"})`])), "")}
</div>
<div class="win-subbar" style="flex-wrap:wrap;gap:4px;">
<input id="san-code" list="san-code-list" placeholder="코드(F7)" style="width:80px" value="${esc(u.sanCode || "")}">
<datalist id="san-code-list">${((ctx.dbfData && ctx.dbfData.costn_rows) || []).map((r) => `<option value="${esc(String(r.CODE).trim())}">${esc(r.NAME)} ${esc(r.SIZE)}</option>`).join("")}</datalist>
${sel(
"san-trans",
L().TRANS.map((t, i) => [String(i), t]),
u.sanTrans ?? "1",
)}
<span>중기비 코드</span>${sel(
"san-op",
[
["/", "/"],
["*", "*"],
],
u.sanOp || "/",
)}${sel(
"san-qv",
["Q", "A", "T", "N"].map((v) => [v, v]),
u.sanQv || "Q",
)}<span>(으)로 입력</span>
<label><input type="checkbox" id="san-skip0" ${u.sanSkip0 ? "checked" : ""}> 단가가 0원인 비목은 입력안함</label>
<button class="win-btn" id="san-ins">코드 삽입</button>
<span style="margin-left:auto;">결과적용 비목 ${sel("san-sbmok", SB, String(row.SBMOK || "").trim())}</span>
<span title="Ed_NAGCD — 낙찰율(%) · 0 = 없음">낙찰율 <input id="san-nag" type="number" step="0.001" style="width:70px" value="${num(row.NAGCD) || ""}">%</span>
<label title="소수점을 정리한 결과값에 낙찰율을 적용하여 금액을 산출"><input type="checkbox" id="san-uc" ${String(row.UC || "").trim() === "T" ? "checked" : ""}> 결과값에 낙찰율적용</label>
<label title="인쇄시 낙찰율이 표시되지 않고 『계약단가』로 인쇄"><input type="checkbox" id="san-cont" ${String(row.BCONT || "").trim() === "T" ? "checked" : ""}> 계약단가</label>
<label><input type="checkbox" id="san-adj" ${adjOn ? "checked" : ""}> 결과금액조정</label>
${adjOn ? ["M", "L", "S"].map((k) => `${k}:x <input class="san-adjv" data-k="${k}" type="number" step="0.00001" style="width:64px" value="${num(row[`ADJ_${k}`]) || 1}">`).join(" ") : ""}
</div>
${u.sanHelp ? `<div class="pane-box" style="max-height:170px;overflow:auto;font-size:12px;padding:4px 8px;white-space:pre;">${L().GRAMMAR_HELP.map(esc).join("\n")}</div>` : ""}
<div class="pane-box" style="flex:1;min-height:180px;">
<textarea id="smt-formula-textarea" class="smt-editor-area" data-code="${esc(code)}" spellcheck="false">${esc(text)}</textarea>
</div>
<div class="pane-box" style="max-height:110px;overflow:auto;">
<div class="pane-title"><span>문법 오류 ${errs.length}건</span></div>
${errs.map((e) => `<div class="san-err" data-line="${e.lineNo}" style="color:#b91c1c;cursor:pointer;font-size:12px;">${e.lineNo ? `${e.lineNo}번째줄: ` : ""}${esc(e.msg)}</div>`).join("") || "<div style='font-size:12px;color:#15803d;'>오류 없음</div>"}
</div>
<div class="pane-box" style="flex:1;">
<div class="pane-title"><span>계산결과(Alt+F9) — 변수 ${esc(
Object.entries(ev.vars)
.map(([k, v]) => `${k}=${v}`)
.join(", ") || "없음",
)}</span>
<span>재 ${fmt(ev.sumM)} + 노 ${fmt(ev.sumL)} + 경 ${fmt(ev.sumS)} = <strong>${fmt(ev.sumT)}원</strong></span></div>
<div class="grid-scroll"><table class="dbgrid">
<thead><tr><th style="width:36px">줄</th><th>산식</th><th style="width:140px">대입·금액변수</th><th style="width:150px">참조</th><th style="width:86px">참조단가</th><th style="width:90px">금액</th></tr></thead>
<tbody>${lines
.map(
(
ln,
) => `<tr style="${ln.errors.length ? "background:#fee2e2;" : ln.excluded ? "color:#6b7280;" : ""}"><td class="center">${ln.lineNo}</td>
<td style="font-family:Consolas,monospace;">${u.sanHilite === false ? esc(ln.raw) : L().highlight(ln.raw)}</td>
<td>${esc(ln.assigned || "")}${ln.excluded ? " (제외)" : ""}</td>
<td>${ln.refInfo ? `${esc(ln.refInfo.refCode)}[${ln.refInfo.compTag}] ${esc(ln.refInfo.refName)}` : ""}</td>
<td class="num">${ln.refInfo ? fmt(ln.refInfo.baseUnitPrice) : ""}</td>
<td class="num">${ln.refInfo ? fmt(ln.refInfo.calcAmt, 1) : ""}</td></tr>`,
)
.join("")}</tbody>
</table></div>
</div>`;
}
/**
* 낙찰율(Ed_NAGCD) · 결과값에 낙찰율적용(Ck_UC → COSTN UC) — 26 §3·§4 · REA 30 TWM_EDIT_SAN
* 처음 걸 때 지금 1칸 단가를 2칸(원단가) 자리로 옮기고, 지우면 2칸 원단가를 1칸으로 되돌림
* 다시 셈(계산완료)은 smt_engine — 2칸 = 산근 결과 · 1칸 = 비목별 원단가 × 낙찰율 버림(실무 7건 UC=T ⇔ NAGCD>0)
*/
function setBidRate(row, rate, uc = true) {
const r = num(rate),
had = num(row.NAGCD) > 0;
const cols = ["T", "M", "L", "S"];
if (r > 0 && !had) cols.forEach((k) => (row[`${k}AMT2`] = String(row[`${k}AMT1`] || "0")));
if (!(r > 0) && had) cols.forEach((k) => (row[`${k}AMT1`] = String(row[`${k}AMT2`] || "0")));
row.NAGCD = String(r > 0 ? r : 0);
row.UC = r > 0 && uc ? "T" : "F";
return row;
}
// 순환 — 이 산근이 부르는 코드가 다시 이 산근에 닿는지(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());
if (e.ctrlKey && (e.key === "i" || e.key === "I")) (e.preventDefault(), $("san-img").focus());
};
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));
// 그림 넣기 — 「IMG(I00001)」 한 줄(문법설명 (5) · 그림코드는 한 줄에 하나)
pick("san-img", (v) => insertLines(area, [`IMG(${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());
const bid = () => (
keep(),
setBidRate(row, $("san-nag").value, $("san-uc").checked),
app.cascadeAndRender()
);
$("san-nag").onchange = bid;
$("san-uc").onchange = bid;
// 계약단가 표시(BCONT) — 인쇄용 · 저장 묶음에 덧붙임(app.js decorateSavePayload)
$("san-cont").onchange = (e) => {
row.BCONT = e.target.checked ? "T" : "F";
(u.costnExtra = u.costnExtra || {})[code] = { BCONT: row.BCONT };
};
$("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, setBidRate };
if (typeof module !== "undefined" && module.exports) module.exports = global.STMATE_EDIT_SAN;
})(typeof window !== "undefined" ? window : globalThis);