Files
Aislo/tools/stmate-test/ui_code.js
T
eomsangdonandClaude Opus 5.5 086750b6d4 feat(stmate-test): 2차 화면·코드관리 합침 · % 줄(조달수수료) 계산·저장 재계산 · 줄 고정 단가 보존 · 장부
- 코드 등록·삭제·변경·병합·데이타병합·일괄변경·검색·동일코드·풀어넣기 끝전 등(ui_code · ui_merge · api_ui_code)
- 코드 붙은 % 줄 = 같은 상위 공종 앞 말단 줄 비목 합 × 율(server pct_row_base · calc.js) · 저장 때 다시 셈(stc_save _pct_rows) — 실무 7건 저장값 그대로
- 내역 줄 단가 ≠ 단가목록이면 줄 고정 단가(fixedUnit)
- 메뉴 94개 눌러 오류 0 · 시험 502 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 20:10:16 +09:00

495 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* STMATE w 8.0 — 코드 관리 순수 계산 (ui_code.js) · 신규등록 · 삭제 · 코드변경 · +α · 항목교체 · 동일항목
* 근거: DFM 원문(C:/경영정보(주)/System/ST_CPM) — ST_U_ChgCode(uM_ChgCode 현재코드→바뀔코드 · uM_Chg_Plus 현재최종+α
* · uM_ReplCode 현재코드→대상코드 · 동일항목(명칭, 규격... 단가) 대상코드 자동입력) · ST_U_DelCode(uM_DelCode 선택 ·
* 코드일괄삭제(중기비제외/포함) · 사용안된 "천원" "인" 일위대가 · *[USE_] 사용표시) · STmate.Cpm wM_Boxa
* Act_CopyNew(선택된 코드를 복사하여 새로운코드 생성)
* env = { d: dbfData, state: sim_state, memo: uiState.editedSmtMap } — 저장 원천(d)과 계산 원천(state)을 함께 고침
* 저장 표시: d.ui_dirty · 새 행 _new(_from = 복사원 _recno) · 지운 원본 d.costn_deleted · 다른 표 코드 r._bd0
* 구성표는 원래 행 그대로 바꿔 둠 — 저장은 기본 묶음의 bdqty_rows(stc_save_extra.save_bdqty)
*/
(function (global) {
"use strict";
const CODE_RE = /^[A-Z]\d{5}$/;
const KIND_NAME = { L: "노무비", M: "재료비", S: "경비", B: "일위대가", X: "중기비" };
Object.assign(KIND_NAME, { D: "단가산출", W: "견적단가", I: "그림코드", A: "실적단가" });
const txt = (v) => String(v == null ? "" : v).trim();
const num = (v) => {
const n = parseFloat(txt(v).replace(/,/g, ""));
return Number.isFinite(n) ? n : 0;
};
const codeOf = (r) => txt(r && r.CODE);
const BDK = ["BDCODE1", "BDCODE2", "BDCODE3", "BDCODE4"];
// 내역 표 행 — 열린 표(boq_rows)와 표 묶음(boq_tables)이 같은 배열이 아닐 때도 한 번씩
function boqRowSets(d) {
const out = [];
const seen = new Set();
const add = (table, rows) => {
if (!rows || seen.has(rows)) return;
seen.add(rows);
out.push([table, rows]);
};
add(d.boq_table_name || "", d.boq_rows);
Object.entries(d.boq_tables || {}).forEach(([t, rows]) => add(t, rows));
return out;
}
const memoText = (env, c) => {
const m = env.memo || {};
if (Object.prototype.hasOwnProperty.call(m, c)) return m[c] || "";
const x = ((env.d && env.d.costn_memos) || {})[c];
return (x && x.text) || "";
};
const REF_RE = /&([A-Z]\d{5})([LMS]?(?::[LMS])?)&/g;
const memoRefs = (t) => [...String(t || "").matchAll(REF_RE)].map((m) => m[1]);
/** 사용표시(*) — 내역서(모든 표 · 1~4차 코드) · 구성표(BDQTY) · 산출근거 식 참조 */
function usedSet(env) {
const d = env.d,
used = new Set();
boqRowSets(d).forEach(([, rows]) =>
rows.forEach((r) => BDK.forEach((k) => txt(r[k]) && used.add(txt(r[k])))),
);
(d.bdqty_rows || []).forEach((r) => txt(r.CODE) && used.add(txt(r.CODE)));
(d.costn_rows || []).forEach((r) =>
memoRefs(memoText(env, codeOf(r))).forEach((c) => used.add(c)),
);
return used;
}
// 현재최종 — 종류(첫 글자)별 가장 큰 코드
function lastCodes(rows) {
const out = {};
(rows || []).forEach((r) => {
const c = codeOf(r);
if (CODE_RE.test(c) && (!out[c[0]] || c > out[c[0]])) out[c[0]] = c;
});
return out;
}
const codeNum = (c) => parseInt(String(c).slice(1), 10) || 0;
const mkCode = (k, n) => `${k}${String(n).padStart(5, "0")}`;
function nextCode(rows, kind, taken) {
const last = lastCodes(rows)[kind];
let n = last ? codeNum(last) + 1 : 1;
while (taken && taken.has(mkCode(kind, n))) n += 1;
return n > 99999 ? "" : mkCode(kind, n);
}
/** 동일항목 — 명칭+규격+단위+단가(계) 같은 종류 안 묶음 · 첫 코드(작은 번호)가 대표 */
function sameItems(rows, kinds) {
const g = new Map();
(rows || []).forEach((r) => {
const c = codeOf(r);
if (!CODE_RE.test(c) || (kinds && !kinds.includes(c[0]))) return;
const k = [c[0], txt(r.NAME), txt(r.SIZE), txt(r.UNIT), num(r.TAMT1)].join("\u0001");
g.set(k, (g.get(k) || []).concat(c));
});
return [...g.values()].filter((a) => a.length > 1).map((a) => a.slice().sort());
}
// ── 계산 원천(state) 코드 바꿈 — id·code·refId·machineId · 합성 자원 「코드_M/L/S」 ──
function stateRename(state, map) {
if (!state) return;
const sw = (v) => {
if (typeof v !== "string") return v;
if (map[v]) return map[v];
const m = v.match(/^([A-Z]\d{5})_([MLS])$/);
return m && map[m[1]] ? `${map[m[1]]}_${m[2]}` : v;
};
const fix = (o) =>
["id", "code", "refId", "machineId"].forEach((k) => k in o && (o[k] = sw(o[k])));
["resources", "machines", "sanItems", "ilwiItems", "boqItems"].forEach((k) =>
(state[k] || []).forEach((o) => {
fix(o);
(o.lines || []).forEach(fix);
(o.smtRefs || []).forEach(fix);
}),
);
}
// 참조만 바꿈(내역 · 구성표 · 식) — 항목교체·코드변경 공통
function remapRefs(env, map, opt) {
const d = env.d;
const o = Object.assign({ boq: true, comp: true, memo: true, items: true }, opt || {});
const n = { boq: 0, comp: 0, memo: 0 };
if (o.boq)
boqRowSets(d).forEach(([, rows]) =>
rows.forEach((r) =>
BDK.forEach((k) => {
const c = txt(r[k]);
if (!map[c]) return;
if (!r._bd0) r._bd0 = Object.fromEntries(BDK.map((x) => [x, r[x]]));
r[k] = map[c];
n.boq += 1;
}),
),
);
if (o.comp)
(d.bdqty_rows || []).forEach((r) => {
const c = txt(r.CODE);
if (map[c]) ((r.CODE = map[c]), (n.comp += 1));
});
if (o.memo) {
env.memo = env.memo || {};
(d.costn_rows || []).forEach((r) => {
const c = codeOf(r),
t = memoText(env, c);
if (!t) return;
const t2 = t.replace(REF_RE, (all, code, tail) =>
map[code] ? `&${map[code]}${tail}&` : all,
);
if (t2 !== t) ((env.memo[c] = t2), (n.memo += 1));
});
}
(env.state && env.state.boqItems ? env.state.boqItems : []).forEach((it) => {
if (o.boq && o.items && map[it.code])
Object.assign(it, { code: map[it.code], refId: map[it.code] });
});
d.ui_dirty = true;
return n;
}
/**
* 코드변경 검사 — 바뀔코드 = 같은 종류 글자 + 5자리 · 묶음 안 겹침 없음 · 남는 코드와 겹침 없음
* map = { 현재코드: 바뀔코드 } (빈 칸·같은 코드는 뺌)
*/
function checkChange(rows, map) {
const errs = [];
const all = new Set((rows || []).map(codeOf));
const pairs = Object.entries(map || {}).filter(([a, b]) => txt(b) && txt(a) !== txt(b));
const moving = new Set(pairs.map(([a]) => a));
const target = new Map();
pairs.forEach(([a, b]) => {
if (!all.has(a)) errs.push(`${a}: 없는 코드`);
if (!CODE_RE.test(b)) errs.push(`${a} → ${b}: 코드 꼴(글자+5자리) 아님`);
else if (b[0] !== a[0])
errs.push(`${a} → ${b}: 종류(${KIND_NAME[a[0]] || a[0]}) 글자가 다름`);
if (target.has(b)) errs.push(`${b}: ${target.get(b)}·${a} 둘이 같은 코드로`);
target.set(b, a);
if (all.has(b) && !moving.has(b))
errs.push(`${b}: 이미 있는 코드(항목교체·코드병합을 쓰세요)`);
});
return { pairs, errs };
}
/** 코드변경 실행(Act_Exc_Change) — COSTN 코드 · 메모 열쇠 · 모든 참조 · 계산 원천 */
function changeCodes(env, map) {
const d = env.d;
const chk = checkChange(d.costn_rows, map);
if (chk.errs.length) return { ok: false, errors: chk.errs };
const m = Object.fromEntries(chk.pairs);
if (!chk.pairs.length) return { ok: false, errors: ["바뀔코드가 없음"] };
(d.costn_rows || []).forEach((r) => {
const c = codeOf(r);
if (m[c]) r.CODE = m[c];
});
(d.bdqty_rows || []).forEach((r) => {
const c = txt(r.BDCODE);
if (m[c]) r.BDCODE = m[c];
});
const memos = d.costn_memos || {},
edits = (env.memo = env.memo || {});
const oldM = Object.assign({}, memos),
oldE = Object.assign({}, edits);
chk.pairs.forEach(([a]) => (delete memos[a], delete edits[a]));
chk.pairs.forEach(([a, b]) => {
if (oldM[a]) memos[b] = Object.assign({}, oldM[a], { code: b });
if (a in oldE) edits[b] = oldE[a];
});
const n = remapRefs(env, m, { items: false }); // 계산 원천은 아래 한 번에(연쇄 +α 겹침 방지)
stateRename(env.state, m);
return { ok: true, count: chk.pairs.length, refs: n, map: m };
}
/** 코드번호 변경(현재코드 + α) — 종류별 α 만큼 모든 코드를 밀어 냄 */
function plusMap(rows, alpha) {
const map = {};
(rows || []).forEach((r) => {
const c = codeOf(r),
a = parseInt((alpha || {})[c[0]], 10) || 0;
if (CODE_RE.test(c) && a) map[c] = mkCode(c[0], codeNum(c) + a);
});
return map;
}
/** 항목교체(Act_Exc_Replace) — 현재코드를 쓰던 자리(내역·구성·식)를 대상코드로 · 현재코드는 남음 */
function replaceCodes(env, map) {
const d = env.d;
const all = new Set((d.costn_rows || []).map(codeOf));
const errs = [];
const pairs = Object.entries(map || {}).filter(([a, b]) => txt(b) && a !== txt(b));
pairs.forEach(([a, b]) => {
if (!all.has(b)) errs.push(`${a} → ${b}: 대상코드가 단가목록에 없음`);
if (map[b] && txt(map[b]) !== b) errs.push(`${b}: 대상코드가 다시 교체됨`);
});
if (errs.length) return { ok: false, errors: errs };
if (!pairs.length) return { ok: false, errors: ["대상코드가 없음"] };
const m = Object.fromEntries(pairs);
const n = remapRefs(env, m);
// 계산 원천 — 내역 항목만 대상 단가로(단가 원천 자원은 그대로)
return { ok: true, count: pairs.length, refs: n, map: m };
}
/** 동일항목 대상코드 자동입력 — 묶음의 대표(첫 코드)로 */
function autoReplaceMap(rows, kinds) {
const map = {};
sameItems(rows, kinds).forEach((g) => g.slice(1).forEach((c) => (map[c] = g[0])));
return map;
}
// ── 지우기 ──
/** 코드일괄삭제 대상 — 사용표시 없는 코드(중기비 포함 여부) */
function unusedCodes(env, withX) {
const used = usedSet(env);
return (env.d.costn_rows || [])
.map(codeOf)
.filter((c) => CODE_RE.test(c) && !used.has(c) && (withX || c[0] !== "X"));
}
// 사용안된 "천원" "인" 일위대가
const unusedBUnit = (env) => {
const used = usedSet(env);
return (env.d.costn_rows || [])
.filter(
(r) => codeOf(r)[0] === "B" && !used.has(codeOf(r)) && /^(천원|인)$/.test(txt(r.UNIT)),
)
.map(codeOf);
};
/** 선택코드 삭제실행 — 쓰이는 코드는 지우지 않음(원본 Chk_True_Del_1 자리) */
function deleteCodes(env, codes) {
const d = env.d;
const want = new Set((codes || []).map(txt));
const used = usedSet(env);
const kept = [...want].filter((c) => used.has(c));
const kill = new Set([...want].filter((c) => !used.has(c)));
if (!kill.size)
return {
ok: false,
errors: kept.length ? [`쓰이는 코드라 못 지움: ${kept.join(", ")}`] : ["지울 코드 없음"],
};
d.costn_deleted = d.costn_deleted || [];
d.costn_rows = (d.costn_rows || []).filter((r) => {
if (!kill.has(codeOf(r))) return true;
if (!r._new && r._recno != null) d.costn_deleted.push(Number(r._recno));
return false;
});
d.bdqty_rows = (d.bdqty_rows || []).filter((r) => !kill.has(txt(r.BDCODE)));
kill.forEach((c) => {
if (d.costn_memos) delete d.costn_memos[c];
if (env.memo) delete env.memo[c];
});
const st = env.state;
if (st) {
const gone = (o) => kill.has(o.id) || kill.has(String(o.id || "").replace(/_[MLS]$/, ""));
["resources", "machines", "sanItems", "ilwiItems"].forEach(
(k) => st[k] && (st[k] = st[k].filter((o) => !gone(o))),
);
}
d.costn_count = d.costn_rows.length;
d.ui_dirty = true;
return { ok: true, count: kill.size, deleted: [...kill], kept };
}
// ── 신규등록 ──
function blankCostn(tmpl) {
const row = {};
Object.keys(tmpl || {}).forEach((k) => {
if (k.startsWith("_") || k.endsWith("_HEX") || k === "IFORM") return;
row[k] = /AMT|^PG_\d$|NAGCD|^RXX$/.test(k) ? "0" : "";
});
return row;
}
// 계산 원천 항목 하나 — 서버 convert_dbf_to_sim_state 와 같은 꼴(기초단가 자원 · 그 밖 일위대가류)
function stateEntry(state, r) {
const c = codeOf(r),
k = c[0];
const name = txt(r.NAME) || c,
spec = txt(r.SIZE),
unit = txt(r.UNIT);
const t = num(r.TAMT1);
if ("MLS".includes(k)) {
const cands = [1, 2, 3, 4, 5].map((n) => num(r[`AMT_${n}1`]));
state.resources.push({
id: c,
code: c,
name,
spec,
unit,
kind: k === "L" ? "노무비" : k === "S" ? "경비" : "재료비",
candidates: cands.some(Boolean) ? cands : [t, 0, 0, 0, 0],
adoptMode: "manual",
manualPrice: t,
convertHourly: false,
isUsd: false,
jdCode: txt(r.JDCODE),
uc: txt(r.UC) || "F",
});
return;
}
const lines = [];
[
["M", "재료비", num(r.MAMT1)],
["L", "노무비", num(r.LAMT1)],
["S", "경비", num(r.SAMT1)],
].forEach(([tag, kind, v]) => {
if (!(v > 0)) return;
const id = `${c}_${tag}`;
state.resources.push({
id,
code: id,
name: `${name} (${kind})`,
spec,
unit,
kind,
candidates: [v, 0, 0, 0, 0],
adoptMode: "manual",
manualPrice: v,
convertHourly: false,
isUsd: false,
isSynthetic: true,
});
lines.push({ refId: id, qty: 1.0, halPercent: 0, devd: false });
});
const base = { id: c, code: c, spec, unit, fixedM: num(r.MAMT1), fixedL: num(r.LAMT1) };
Object.assign(base, { fixedS: num(r.SAMT1), fixedT: t });
state.ilwiItems.push(
Object.assign({ name: `[${k}] ${name}`, rawName: name, prefix: k, lines }, base),
);
}
/**
* 신규등록(Act_CopyNew) — 선택 코드를 복사해 새 코드 · 구성표·산출근거 식도 복사
* from 없으면 빈 행(종류 kind) · fields = { NAME, SIZE, UNIT, … } 덮어씀
*/
function addCode(env, o) {
const d = env.d;
const rows = (d.costn_rows = d.costn_rows || []);
const src = o.from ? rows.find((r) => codeOf(r) === o.from) : null;
if (o.from && !src) return { ok: false, errors: [`복사할 코드 없음: ${o.from}`] };
const kind = src ? codeOf(src)[0] : txt(o.kind).toUpperCase();
if (!KIND_NAME[kind]) return { ok: false, errors: [`코드 종류 글자 아님: ${kind}`] };
const all = new Set(rows.map(codeOf));
const code = txt(o.code) || nextCode(rows, kind, all);
if (!CODE_RE.test(code) || code[0] !== kind)
return { ok: false, errors: [`코드 꼴 아님: ${code}`] };
if (all.has(code)) return { ok: false, errors: [`이미 있는 코드: ${code}`] };
const row = src
? Object.fromEntries(
Object.entries(src).filter(
([k]) => !k.startsWith("_") && !k.endsWith("_HEX") && k !== "IFORM",
),
)
: blankCostn(rows[0]);
Object.assign(row, o.fields || {}, { CODE: code, _new: true, _deleted: false });
if (src && src._recno != null) row._from = src._new ? src._from : src._recno;
const at = src ? rows.indexOf(src) + 1 : rows.length;
rows.splice(at, 0, row);
if (src) {
const comps = (d.bdqty_rows || []).filter((r) => txt(r.BDCODE) === codeOf(src));
if (comps.length) {
const all2 = d.bdqty_rows;
const last = all2.lastIndexOf(comps[comps.length - 1]);
const copies = comps.map((r) => {
return Object.assign({}, r, { BDCODE: code });
});
all2.splice(last + 1, 0, ...copies);
}
const t = memoText(env, codeOf(src));
if (t) {
env.memo = env.memo || {};
env.memo[code] = t;
d.costn_memos = d.costn_memos || {};
const m = d.costn_memos[codeOf(src)];
d.costn_memos[code] = Object.assign({}, m || { blockNo: 1 }, { code, text: t });
}
}
if (env.state) stateEntry(env.state, row);
d.costn_count = rows.length;
d.ui_dirty = true;
return { ok: true, code, row };
}
// ── 저장 묶음(api_ui_code.py /api/ui/save) ──
const EXTRA_COL = [
"BQCODE",
"EQCODE",
"JDCODE",
"WQCODE",
"EDT_DATE",
"NAME2",
"SIZE2",
"PP",
"CONTCODE",
];
/** 구조 덧붙임 — 원래 행(코드·덧붙임 칸) · 새 행 · 지운 원본 · 다른 표 코드 · 구성표 */
function structPayload(d) {
const cur = d.boq_table_name || "";
const costn_struct = (d.costn_rows || []).map((r) => {
const u = { CODE: codeOf(r) };
if (r._new)
Object.assign(u, { _new: true, _from: r._from, NAME: r.NAME, SIZE: r.SIZE, UNIT: r.UNIT });
if (r._new && r._from == null)
u._vals = Object.fromEntries(
Object.entries(r).filter(
([k, v]) =>
!k.startsWith("_") && !k.endsWith("_HEX") && k !== "IFORM" && typeof v !== "object",
),
);
else u._recno = r._recno;
EXTRA_COL.forEach((k) => k in r && (u[k] = r[k]));
return u;
});
const boq_code_rows = [];
boqRowSets(d).forEach(([t, rows]) => {
if (t === cur || rows === d.boq_rows) return;
rows.forEach(
(r) =>
r._bd0 &&
boq_code_rows.push(
Object.assign(
{ table: t, _recno: r._recno },
Object.fromEntries(BDK.map((k) => [k, r[k]])),
),
),
);
});
// 구성표(bdqty_rows)는 기본 저장 묶음이 함께 보냄(stc_save_extra.save_bdqty)
return { costn_struct, costn_deleted: d.costn_deleted || [], boq_code_rows };
}
const needsUiSave = (d) => Boolean(d && d.ui_dirty);
global.STMATE_UI_CODE = {
CODE_RE,
KIND_NAME,
EXTRA_COL,
codeOf,
boqRowSets,
memoText,
memoRefs,
usedSet,
lastCodes,
nextCode,
mkCode,
codeNum,
sameItems,
stateRename,
stateEntry,
remapRefs,
checkChange,
changeCodes,
plusMap,
replaceCodes,
autoReplaceMap,
unusedCodes,
unusedBUnit,
deleteCodes,
addCode,
structPayload,
needsUiSave,
};
if (typeof module !== "undefined" && module.exports) module.exports = global.STMATE_UI_CODE;
})(typeof window !== "undefined" ? window : globalThis);