250 lines
13 KiB
JavaScript
250 lines
13 KiB
JavaScript
/* -*- coding: utf-8 -*- */
|
|
/**
|
|
* 일위대가(B)·중기(X) 구성행 편집 (scr_edit_comp.js) — BDQTY 구성행 보기·고치기·행 추가/삭제/이동 ·
|
|
* 나누기수량 → 일반수량 · 노임산출계수(RXX) · 손료 갈래(E0~E3) · 결과적용 비목 · 계산 반영 · 당초내역 재생
|
|
* 근거: REA 30 TWM_EDIT_ILWI(wcBx_RXX 7택 · cBx_SBMOK 4택 · Act_Devd2Qty · Act_Up/Dn · Act_Undo 당초내역 재생 ·
|
|
* Act_CalcBD 재계산) · divisor_to_ordinary_quantity_rules · 지식DB 17(성분 절사) · 30 §4(E0~E3 · H0~H5)
|
|
* 계산 = smt_eval.js evaluateComposition · 설정 = smt_engine.js compositionOptions
|
|
*/
|
|
(function (global) {
|
|
"use strict";
|
|
const num = (v) => Number(v) || 0;
|
|
const esc = (s) =>
|
|
String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
const RXX = ["초기값설정 개별적용", "기 본 노임산출계수", "기 타 노임산출계수", "기타-1노임산출계수", "기타-2노임산출계수", "기타-3노임산출계수", "기타-4노임산출계수"];
|
|
const SHIFT = [["E0", "주간"], ["E1", "야간"], ["E2", "주야간 2교대"], ["E3", "주야간 3교대"]];
|
|
const SB = [["", "0 각 해당비목 적용"], ["M", "1 재료비로 적용"], ["L", "2 노무비로 적용"], ["S", "3 경 비로 적용"]];
|
|
const COMP = { 1: "재", 2: "노", 3: "경", 4: "계" };
|
|
|
|
const E = () => global.STMATE_SMT_EVAL;
|
|
const ENG = () => global.STMATE_ENGINE;
|
|
const byCode = (ctx) => ENG().costnLookup(ctx).byCode;
|
|
|
|
// 편집 사본 — 고르면 BDQTY 구성행을 복사해 둠(반영 전까지 원본 그대로)
|
|
function draft(ctx, code) {
|
|
const u = ctx.uiState;
|
|
u.compEdit = u.compEdit || {};
|
|
if (!u.compEdit[code])
|
|
u.compEdit[code] = ((ctx.dbfData && ctx.dbfData.bdqty_rows) || [])
|
|
.filter((r) => r.BDCODE === code)
|
|
.map((r) => ({ ...r }));
|
|
return u.compEdit[code];
|
|
}
|
|
|
|
// 행 금액 미리보기 — 단가 × 수량(소수 1자리 버림) · 시간 환산 직종은 시간당 노임
|
|
function rowView(ctx, code, row, opts) {
|
|
const c = String(row.CODE || "").trim();
|
|
const r = byCode(ctx)[c];
|
|
const q = ["T", "t", "1", true].includes(row.DEVD1) ? (num(row.QTY1) ? 1 / num(row.QTY1) : 0) : num(row.QTY1);
|
|
if (!r) return { name: c === "PR" || c === "RR" ? "비율 줄" : "", p: null, q };
|
|
let p = { M: num(r.MAMT1), L: num(r.LAMT1), S: num(r.SAMT1) };
|
|
if (num(r.NAGCD) > 0) p = { M: num(r.MAMT2), L: num(r.LAMT2), S: num(r.SAMT2) };
|
|
if (opts.labor && opts.labor.has(c)) {
|
|
const rx = opts.labor instanceof Map ? opts.labor.get(c) : opts.rx;
|
|
p = { M: 0, L: E().hourlyWage(p.L, rx), S: 0 };
|
|
}
|
|
const amt = {};
|
|
for (const k of "MLS") amt[k] = Math.trunc(q * p[k] * 10 + 1e-9) / 10;
|
|
return { name: r.NAME, size: r.SIZE, unit: r.UNIT, p, q, amt };
|
|
}
|
|
|
|
function evalDraft(ctx, code) {
|
|
const row = byCode(ctx)[code] || {};
|
|
const opts = ENG().compositionOptions(ctx, code, row);
|
|
return { opts, ev: E().evaluateComposition(draft(ctx, code), ENG().costnLookup(ctx).lookup, opts) };
|
|
}
|
|
|
|
function render(ctx, code, app) {
|
|
const fmt = app.fmt;
|
|
const rows = draft(ctx, code);
|
|
const row = byCode(ctx)[code] || {};
|
|
const { opts, ev } = evalDraft(ctx, code);
|
|
const sel = new Set(ctx.uiState.compSel || []);
|
|
const sys = (ctx.state && ctx.state.sysConfig) || {};
|
|
const opt = (list, cur) => list.map(([v, l]) => `<option value="${v}" ${String(cur) === String(v) ? "selected" : ""}>${l}</option>`).join("");
|
|
const isX = code[0] === "X";
|
|
const errs = checkDraft(ctx, code);
|
|
return `
|
|
<div class="win-subbar" style="flex-wrap:wrap;gap:4px;">
|
|
<button class="win-btn" id="comp-add">행 추가</button>
|
|
<button class="win-btn" id="comp-del">항목삭제</button>
|
|
<button class="win-btn" id="comp-up">위로 이동</button>
|
|
<button class="win-btn" id="comp-dn">아래로 이동</button>
|
|
<button class="win-btn" id="comp-devd">나누기수량 → 일반수량</button>
|
|
<button class="win-btn" id="comp-undo">당초내역 재생</button>
|
|
<button class="win-btn primary" id="comp-apply">재계산(C) · 단가 반영</button>
|
|
${isX ? `<label>노임산출계수 <select id="comp-rxx">${opt(RXX.map((l, i) => [i, l]), num(row.RXX))}</select></label>` : ""}
|
|
${isX ? `<label>손료 <select id="comp-shift">${opt(SHIFT.map(([v, l]) => [v, `${v} ${l}`]), row.SHIFT || sys.shiftMode || "E0")}</select></label>` : ""}
|
|
${isX ? `<label>계수 <input id="comp-shift-f" type="number" step="0.0001" style="width:70px" value="${esc(((sys.shiftFactors || {})[row.SHIFT || sys.shiftMode || "E0"]) || "")}" title="E1~E3 손료 계수 — 품셈 값 입력(빈칸 = 1)"></label>` : ""}
|
|
<label>결과적용 비목 <select id="comp-sbmok">${opt(SB, String(row.SBMOK || "").trim())}</select></label>
|
|
</div>
|
|
${errs.length ? `<div class="err-box" style="color:#b91c1c;padding:4px 8px;">${errs.map(esc).join("<br>")}</div>` : ""}
|
|
<div class="grid-scroll" style="flex:1;">
|
|
<table class="dbgrid">
|
|
<thead><tr><th style="width:24px"></th><th style="width:30px">No</th><th style="width:72px">코드</th><th>명칭</th><th>규격</th><th style="width:40px">단위</th>
|
|
<th style="width:90px">수량</th><th style="width:34px" title="나누기수량(DEVD1)">÷</th><th style="width:60px" title="비율 줄 밑수·대상(PRIN→PROUT) · 표시(PRPT)">비율</th>
|
|
<th style="width:80px">재료비</th><th style="width:80px">노무비</th><th style="width:80px">경비</th></tr></thead>
|
|
<tbody>
|
|
${rows
|
|
.map((r, i) => {
|
|
const v = rowView(ctx, code, r, opts);
|
|
const c = String(r.CODE || "").trim();
|
|
const pr = c === "PR" || c === "RR";
|
|
const amt = (k) => (v.amt ? fmt(v.amt[k], 1) : "");
|
|
return `<tr class="${sel.has(i) ? "selected" : ""}">
|
|
<td class="center"><input type="checkbox" class="comp-sel" data-i="${i}" ${sel.has(i) ? "checked" : ""}></td>
|
|
<td class="center">${i + 1}</td>
|
|
<td><input class="cell-input comp-code" data-i="${i}" value="${esc(c)}" style="width:66px"></td>
|
|
<td>${esc(v.name || "")}</td><td>${esc(v.size || "")}</td><td class="center">${esc(v.unit || "")}</td>
|
|
<td><input class="cell-input num comp-qty" data-i="${i}" value="${esc(String(r.QTY1 ?? "").replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, ""))}" style="width:84px"></td>
|
|
<td class="center"><input type="checkbox" class="comp-devd" data-i="${i}" ${["T", true].includes(r.DEVD1) ? "checked" : ""}></td>
|
|
<td class="center">${pr ? `${COMP[r.PRIN] || "-"}→${COMP[r.PROUT] || "-"}` : String(r.PRPT || "0").trim() !== "0" && String(r.PRPT || "").trim() ? "밑수" : ""}</td>
|
|
<td class="num">${amt("M")}</td><td class="num">${amt("L")}</td><td class="num">${amt("S")}</td>
|
|
</tr>`;
|
|
})
|
|
.join("")}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="win-subbar"><span>재계산: 재 <strong>${fmt(ev.M)}</strong> · 노 <strong>${fmt(ev.L)}</strong> · 경 <strong>${fmt(ev.S)}</strong> = <strong>${fmt(ev.T)}원</strong></span>
|
|
<span style="margin-left:auto;">저장 단가: 재 ${fmt(num(row.MAMT1))} · 노 ${fmt(num(row.LAMT1))} · 경 ${fmt(num(row.SAMT1))} = ${fmt(num(row.TAMT1))}원 ${opts.totalFirst ? "· 총액우선" : "· 비목우선"}</span></div>`;
|
|
}
|
|
|
|
// 구성 검사 — 없는 코드 · 자기참조 · 순환(하위가 다시 이 호표를 부름)
|
|
function checkDraft(ctx, code) {
|
|
const by = byCode(ctx);
|
|
const errs = [];
|
|
const deps = depsOf(ctx, code);
|
|
draft(ctx, code).forEach((r, i) => {
|
|
const c = String(r.CODE || "").trim();
|
|
if (!c || /^(PR|RR|T\d|C\d|E\d)$/.test(c)) return;
|
|
if (!by[c]) errs.push(`${i + 1}행 ${c} 는 등록된 단가코드가 아님`);
|
|
if (c === code) errs.push(`${i + 1}행 ${c} 자기 자신을 참조함(순환참조)`);
|
|
});
|
|
const cyc = ENG().findCycles(deps, new Set([code, ...reach(deps, code)]));
|
|
cyc.filter((p) => p.includes(code)).forEach((p) => errs.push(`순환참조: ${p.join(" → ")}`));
|
|
return [...new Set(errs)];
|
|
}
|
|
// 참조 그래프 — 편집 중 사본 · BDQTY 구성 · 산근 글
|
|
function depsOf(ctx, code) {
|
|
const groups = {};
|
|
for (const r of (ctx.dbfData && ctx.dbfData.bdqty_rows) || [])
|
|
if (/^[BX]/.test(r.BDCODE || "")) (groups[r.BDCODE] = groups[r.BDCODE] || []).push(String(r.CODE || "").trim());
|
|
const u = ctx.uiState.compEdit || {};
|
|
for (const k of Object.keys(u)) groups[k] = u[k].map((r) => String(r.CODE || "").trim());
|
|
const memos = (ctx.dbfData && ctx.dbfData.costn_memos) || {};
|
|
const edited = ctx.uiState.editedSmtMap || {};
|
|
return (c) =>
|
|
groups[c] ||
|
|
[...String(edited[c] ?? (memos[c] && memos[c].text) ?? "").matchAll(/&([A-Z]\d{4,5})[LMS]?(?::[LMS])?&/g)].map((m) => m[1]);
|
|
}
|
|
function reach(deps, start) {
|
|
const seen = new Set(),
|
|
st = [start];
|
|
while (st.length) for (const d of deps(st.pop())) if (!seen.has(d)) seen.add(d), st.push(d);
|
|
return seen;
|
|
}
|
|
|
|
function bind(ctx, code, app) {
|
|
const rows = draft(ctx, code);
|
|
const $ = (id) => document.getElementById(id);
|
|
const redraw = () => app.renderWorkspace();
|
|
const sel = () => (ctx.uiState.compSel || []).slice().sort((a, b) => a - b);
|
|
const row = byCode(ctx)[code] || {};
|
|
document.querySelectorAll(".comp-sel").forEach(
|
|
(el) =>
|
|
(el.onchange = () => {
|
|
const i = Number(el.dataset.i);
|
|
const s = new Set(ctx.uiState.compSel || []);
|
|
el.checked ? s.add(i) : s.delete(i);
|
|
ctx.uiState.compSel = [...s];
|
|
}),
|
|
);
|
|
document.querySelectorAll(".comp-code").forEach(
|
|
(el) =>
|
|
(el.onchange = () => {
|
|
rows[Number(el.dataset.i)].CODE = el.value.trim().toUpperCase();
|
|
redraw();
|
|
}),
|
|
);
|
|
document.querySelectorAll(".comp-qty").forEach(
|
|
(el) =>
|
|
(el.onchange = () => {
|
|
rows[Number(el.dataset.i)].QTY1 = String(num(el.value));
|
|
redraw();
|
|
}),
|
|
);
|
|
document.querySelectorAll(".comp-devd").forEach(
|
|
(el) =>
|
|
(el.onchange = () => {
|
|
rows[Number(el.dataset.i)].DEVD1 = el.checked ? "T" : "F";
|
|
redraw();
|
|
}),
|
|
);
|
|
const on = (id, fn) => $(id) && ($(id).onclick = fn);
|
|
on("comp-add", () => {
|
|
const at = sel().length ? sel().pop() + 1 : rows.length;
|
|
rows.splice(at, 0, { BDCODE: code, CODE: "", QTY1: "0", DEVD1: "F", PRPT: "0", PRIN: "0", PROUT: "0" });
|
|
ctx.uiState.compSel = [at];
|
|
redraw();
|
|
});
|
|
on("comp-del", () => {
|
|
const s = new Set(sel());
|
|
if (!s.size) return alert("지울 행을 고르세요");
|
|
rows.splice(0, rows.length, ...rows.filter((_r, i) => !s.has(i)));
|
|
ctx.uiState.compSel = [];
|
|
redraw();
|
|
});
|
|
const move = (d) => {
|
|
const s = sel();
|
|
if (!s.length || s[0] + d < 0 || s[s.length - 1] + d >= rows.length) return;
|
|
const order = d < 0 ? s : s.slice().reverse();
|
|
for (const i of order) [rows[i], rows[i + d]] = [rows[i + d], rows[i]];
|
|
ctx.uiState.compSel = s.map((i) => i + d);
|
|
redraw();
|
|
};
|
|
on("comp-up", () => move(-1));
|
|
on("comp-dn", () => move(1));
|
|
on("comp-devd", () => {
|
|
const by = byCode(ctx);
|
|
for (const i of sel()) {
|
|
const r = rows[i];
|
|
if (!["T", true].includes(r.DEVD1)) continue;
|
|
const unit = (by[String(r.CODE).trim()] || {}).UNIT;
|
|
Object.assign(r, { QTY1: String(E().devdToOrdinary(r.QTY1, unit)), DEVD1: "F" });
|
|
}
|
|
redraw();
|
|
});
|
|
on("comp-undo", () => {
|
|
delete ctx.uiState.compEdit[code];
|
|
ctx.uiState.compSel = [];
|
|
redraw();
|
|
});
|
|
const setRow = (k, v) => Object.assign(row, { [k]: v });
|
|
const onc = (id, fn) => $(id) && ($(id).onchange = (e) => (fn(e.target.value), redraw()));
|
|
onc("comp-rxx", (v) => setRow("RXX", v));
|
|
onc("comp-sbmok", (v) => setRow("SBMOK", v));
|
|
onc("comp-shift", (v) => setRow("SHIFT", v));
|
|
onc("comp-shift-f", (v) => {
|
|
const sys = ctx.state.sysConfig;
|
|
sys.shiftFactors = Object.assign({}, sys.shiftFactors, { [row.SHIFT || sys.shiftMode || "E0"]: num(v) || "" });
|
|
});
|
|
on("comp-apply", () => {
|
|
const errs = checkDraft(ctx, code);
|
|
if (errs.length && !confirm(`검사 오류 ${errs.length}건 — 그래도 반영할까요?\n${errs.slice(0, 5).join("\n")}`)) return;
|
|
const all = ctx.dbfData.bdqty_rows || (ctx.dbfData.bdqty_rows = []);
|
|
const at = all.findIndex((r) => r.BDCODE === code);
|
|
const rest = all.filter((r) => r.BDCODE !== code);
|
|
const fresh = rows.map((r) => ({ ...r, BDCODE: code }));
|
|
rest.splice(at < 0 ? rest.length : at, 0, ...fresh);
|
|
all.splice(0, all.length, ...rest);
|
|
delete ctx.uiState.compEdit[code];
|
|
app.cascadeAndRender();
|
|
const cyc = ctx.uiState.cycleErrors || [];
|
|
if (cyc.length) alert(`순환참조 ${cyc.length}건 — 저장 단가로 이어 셈\n${cyc.slice(0, 5).join("\n")}`);
|
|
});
|
|
}
|
|
|
|
global.STMATE_EDIT_COMP = { render, bind, draft, checkDraft, depsOf, reach, rowView };
|
|
})(typeof window !== "undefined" ? window : globalThis);
|