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>
This commit is contained in:
2026-11-13 20:10:16 +09:00
co-authored by Claude Opus 5.5
parent d07d65683c
commit 086750b6d4
24 changed files with 3956 additions and 87 deletions
+494
View File
@@ -0,0 +1,494 @@
/**
* 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);