Files
Aislo/resources/tester/helper_stmate_ui_code.cjs
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

350 lines
14 KiB
JavaScript
Raw Permalink 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 재현 앱 코드 관리(ui_code.js)·단가 일괄 손질(ui_code_amt.js) 순수 계산 — stdin = { load, mode }
// mode "ops" = 신규등록·코드변경·+α·항목교체·삭제를 차례로 한 뒤 저장 요청(buildSavePayload + structPayload)과 사실값
// mode "direct" = 불러온 자료 그대로 직공비(다시 불러온 작업본 대조용)
// mode "amt" = 일괄변경·할증·초기화·손료계수·특수문자 수치
globalThis.window = globalThis;
const D = "../../tools/stmate-test/";
[
"kanjub_parts.js",
"kanjub_calc.js",
"calc.js",
"smt_eval.js",
"smt_engine.js",
"view_prj_boq.js",
].forEach((f) => require(D + f));
const UC = require(D + "ui_code.js");
const UA = require(D + "ui_code_amt.js");
const UM = require(D + "ui_merge.js");
const Calc = window.STMATE_CALC;
let buf = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (x) => (buf += x));
process.stdin.on("end", () => {
const { load, mode } = JSON.parse(buf);
const fresh = () => JSON.parse(JSON.stringify(load));
const direct = (d) => Calc.computeAll(d.sim_state).boqComputed.totals.directT;
const txt = (v) => String(v == null ? "" : v).trim();
if (mode === "direct") return process.stdout.write(JSON.stringify({ direct: direct(load) }));
if (mode === "amt") return process.stdout.write(JSON.stringify(amt(load)));
if (mode === "expand") return process.stdout.write(JSON.stringify(expand(load)));
if (mode === "units") return process.stdout.write(JSON.stringify(units(load)));
if (mode === "merge") return process.stdout.write(JSON.stringify(merge(JSON.parse(buf))));
const d = fresh();
const env = { d, state: d.sim_state, memo: {} };
const out = { t0: direct(d) };
const boqCodes = new Set(d.boq_rows.map((r) => txt(r.BDCODE1)));
const comps = (c) => d.bdqty_rows.filter((r) => txt(r.BDCODE) === c).length;
// 1. 신규등록(복사) — 내역에 쓰인 구성 있는 일위대가
const bsrc = d.costn_rows
.map(UC.codeOf)
.find((c) => c[0] === "B" && boqCodes.has(c) && comps(c) > 0);
const add = UC.addCode(env, { from: bsrc });
out.add = {
ok: add.ok,
src: bsrc,
code: add.code,
comps: [comps(bsrc), comps(add.code)],
t: direct(d),
};
// 2. 코드변경 — 쓰이는 재료비 하나 + 식 있는 산근 하나를 빈 번호로
const used = UC.usedSet(env);
const all = new Set(d.costn_rows.map(UC.codeOf));
const pickFree = (k) => UC.nextCode(d.costn_rows, k, all);
const mOld = d.costn_rows.map(UC.codeOf).find((c) => c[0] === "M" && used.has(c));
const dOld = d.costn_rows
.map(UC.codeOf)
.find(
(c) =>
c[0] === "D" &&
UC.memoText(env, c) &&
used.has(c) &&
UC.memoRefs(UC.memoText(env, c)).length,
);
const map = { [mOld]: pickFree("M") };
if (dOld) map[dOld] = pickFree("D");
const refCount = (c) => ({
boq: Object.values(d.boq_tables).reduce(
(n, rows) => n + rows.filter((r) => txt(r.BDCODE1) === c).length,
0,
),
comp: d.bdqty_rows.filter((r) => txt(r.CODE) === c).length,
memo: d.costn_rows.filter((r) => UC.memoRefs(UC.memoText(env, UC.codeOf(r))).includes(c))
.length,
});
const before = Object.fromEntries(Object.keys(map).map((c) => [c, refCount(c)]));
const bad = UC.checkChange(d.costn_rows, { [mOld]: bsrc }).errs.length > 0; // 다른 종류·있는 코드 거부
const ch = UC.changeCodes(env, map);
out.change = {
ok: ch.ok,
map,
before,
after: Object.fromEntries(Object.entries(map).map(([a, b]) => [b, refCount(b)])),
gone: Object.keys(map).map(refCount),
bad,
t: direct(d),
};
// 3. 항목교체 — 원래 일위대가 → 복사본(값 같음) · 원래 것은 안 쓰이게 됨 → 지움
const rp = UC.replaceCodes(env, { [bsrc]: add.code });
const refused = UC.deleteCodes(env, [add.code]); // 쓰이는 코드
const del = UC.deleteCodes(env, [bsrc]);
out.replace = {
ok: rp.ok,
t: direct(d),
refused: !refused.ok,
del: del.ok && del.deleted[0] === bsrc,
};
out.final = Object.fromEntries(Object.values(map).map((c) => [c, refCount(c)])); // 모든 손질 뒤 참조 개수
out.finalComps = comps(add.code);
// 4. +α — 견적·노무 아닌 종류 하나를 통째로 밀어 냄(겹침 없이)
const d2 = fresh(),
env2 = { d: d2, state: d2.sim_state, memo: {} };
const t2 = direct(d2);
const pm = UC.plusMap(d2.costn_rows, { X: 1000 });
const pch = UC.changeCodes(env2, pm);
out.plus = {
n: Object.keys(pm).length,
ok: pch.ok || !Object.keys(pm).length,
same: direct(d2) === t2,
};
out.plus.compOk = d2.bdqty_rows.every((r) => !/^X/.test(txt(r.BDCODE)) || !pm[txt(r.BDCODE)]);
// 5. 동일항목 · 일괄삭제 대상
out.same = UC.sameItems(d.costn_rows).length;
out.unused = { minus: UC.unusedCodes(env, false).length, plus: UC.unusedCodes(env, true).length };
const ctx = {
state: d.sim_state,
dbfData: d,
currentSourcePath: load.source_path,
uiState: { editedSmtMap: env.memo },
};
ctx.computed = Calc.calculateAll(ctx.state);
const p = window.STMATE_VIEW_PRJ_BOQ.buildSavePayload(ctx);
out.payload = Object.assign(p, UC.structPayload(d));
out.needs = UC.needsUiSave(d);
process.stdout.write(JSON.stringify(out));
});
function amt(load) {
const txt = (v) => String(v == null ? "" : v).trim();
const num = (v) => Number(txt(v)) || 0;
const rows = JSON.parse(JSON.stringify(load.costn_rows));
const out = {};
// 일괄변경 — 1차 × 110% → 2차(낙찰률 행 제외) · 다시 100% 로 1차 → 1차 무변화
const nag = new Set(rows.filter((r) => num(r.NAGCD) > 0).map((r) => txt(r.CODE)));
const ch = UA.chgAmt(rows, { kinds: "MLS", from: 1, to: 2, rate: 110, digits: 0, mode: "trunc" });
const r0 = load.costn_rows.find((r) => txt(r.CODE) === (ch[0] || {}).code) || {};
out.chg = {
n: ch.length,
nagSkipped: ch.every((c) => !nag.has(c.code)),
first: ch[0]
? [
ch[0].to,
Math.trunc(num(r0.MAMT1) * 1.1) +
Math.trunc(num(r0.LAMT1) * 1.1) +
Math.trunc(num(r0.SAMT1) * 1.1),
]
: null,
idem: UA.chgAmt(rows, { kinds: "MLS", from: 1, to: 1, rate: 100, digits: 4 }).length,
};
// 할증 — 율 두 번 = 한 번 · 금액 갈래
const h1 = UA.halAll(rows, { kinds: "M", how: "rate", value: 10, digits: 4 });
const h2 = UA.halAll(rows, { kinds: "M", how: "rate", value: 10, digits: 4 });
const h3 = UA.halAll(rows, { kinds: "M", how: "amt", value: 0, digits: 4 });
out.hal = { n: h1.length, twice: h2.length, back: h3.length };
// 초기화 — 호환규정코드(재료비)
const filled = rows.filter((r) => txt(r.CODE)[0] === "M" && txt(r.JDCODE)).length;
out.init = { filled, cleared: UA.initCols(rows, { JD: "M" }).JDCODE || 0 };
out.jdLeft = rows.filter((r) => txt(r.CODE)[0] === "M" && txt(r.JDCODE)).length;
// 호환규정코드 찾기 — 원본 행에서 지운 값을 같은 명칭·규격 자료에서 다시 찾음
const src = load.costn_rows.filter((r) => txt(r.JDCODE));
out.jd = src.slice(0, 40).map((r) => {
const c = UA.jdCandidates({ NAME: r.NAME, SIZE: r.SIZE }, src, "NS");
return c.length > 0 && c.some((x) => x.jd === txt(r.JDCODE));
});
out.jdDup = UA.jdDup(load.costn_rows).length;
// 자료파일 업데이트 — 호환규정코드 지운 사본 ← 원본(명칭/규격/단위 매칭 · 복사처 우선) · 명칭·규격·단위가 하나뿐인 행은 원래 코드로
const blank = JSON.parse(JSON.stringify(load.costn_rows));
blank.forEach((r) => (r.JDCODE = ""));
const up = UM.mrgBsc(blank, load.costn_rows, {
keys: ["NSU"],
name: 0,
amt: 0,
jd: 2,
kinds: "LMSW",
});
const key = (r) => `${txt(r.CODE)[0]}|${txt(r.NAME)}|${txt(r.SIZE)}|${txt(r.UNIT)}`;
const cnt = {};
load.costn_rows.forEach((r) => (cnt[key(r)] = (cnt[key(r)] || 0) + 1));
const uniq = load.costn_rows.filter(
(r) => "LMSW".includes(txt(r.CODE)[0]) && txt(r.NAME) && txt(r.JDCODE) && cnt[key(r)] === 1,
);
out.mrg = {
n: up.length,
uniq: uniq.length,
same: uniq.every(
(r) => txt(blank.find((b) => txt(b.CODE) === txt(r.CODE)).JDCODE) === txt(r.JDCODE),
),
};
// 손료계수 — 중기 구성의 천원 단위 기계가격 줄 수량 = 계 × 10^-7 × 1000
const by = Object.fromEntries(load.costn_rows.map((r) => [txt(r.CODE), r]));
const lossRows = (load.bdqty_rows || []).filter(
(r) =>
/^X/.test(txt(r.BDCODE)) &&
by[txt(r.CODE)] &&
txt(by[txt(r.CODE)].UNIT) === "천원" &&
num(r.QTY1) > 0,
);
out.loss = lossRows.map((r) => {
const s = UA.lossSum(num(r.QTY1), 100);
return [num(r.QTY1), s, UA.lossQty(s, 100)];
});
const dec = new TextDecoder("euc-kr");
out.special = UA.SPECIAL.map((_x, i) => UA.specialChars(i, (b) => dec.decode(b)).length);
out.special0 = UA.specialChars(6, (b) => dec.decode(b))
.slice(0, 6)
.join("");
return out;
}
// 코드병합 — 복사원 = 다른 프로젝트 · 내역에 쓰인 구성 있는 일위대가 하나를 모든 하위 포함으로 가져옴
function merge({ load, src }) {
const txt = (v) => String(v == null ? "" : v).trim();
const d = JSON.parse(JSON.stringify(load));
const env = { d, state: d.sim_state, memo: {} };
const t0 = Calc.computeAll(d.sim_state).boqComputed.totals.directT;
const used = new Set(src.boq_rows.map((r) => txt(r.BDCODE1)));
const pick = src.costn_rows
.map((r) => txt(r.CODE))
.find((c) => c[0] === "B" && used.has(c) && src.bdqty_rows.some((q) => txt(q.BDCODE) === c));
const want = UM.closure(src, [pick], 2);
const steps = UM.plan(env, src, { codes: [pick], mode: 2, conn: 2, policy: {} });
const res = UM.apply(env, src, steps);
const top = steps.find((s) => s.src === pick);
const comp = (rows, c) => rows.filter((q) => txt(q.BDCODE) === c).map((q) => txt(q.CODE));
const by = Object.fromEntries(d.costn_rows.map((r) => [txt(r.CODE), r]));
const srcBy = Object.fromEntries(src.costn_rows.map((r) => [txt(r.CODE), r]));
// 가져온 구성 자식 = 복사원 자식을 계획대로 바꾼 것 · 자식 명칭이 복사원과 같음
const kidsOk = comp(src.bdqty_rows, pick).every((c, i) => {
const dc = comp(d.bdqty_rows, top.dest)[i];
return (
dc === (res.map[c] || c) && (!srcBy[c] || !by[dc] || txt(by[dc].NAME) === txt(srcBy[c].NAME))
);
});
const ctx = {
state: d.sim_state,
dbfData: d,
currentSourcePath: load.source_path,
uiState: { editedSmtMap: env.memo },
};
if (window.STMATE_ENGINE) window.STMATE_ENGINE.cascadeRecalculateAll(ctx);
const tNew = Calc.num((by[top.dest] || {}).TAMT1);
const p = window.STMATE_VIEW_PRJ_BOQ.buildSavePayload(
Object.assign(ctx, { computed: Calc.calculateAll(ctx.state) }),
);
return {
pick,
n: want.length,
steps: steps.length,
acts: res.count,
unresolved: res.unresolved,
top,
kidsOk,
t0,
t1: Calc.computeAll(d.sim_state).boqComputed.totals.directT,
price: [Calc.num(srcBy[pick].TAMT1), tNew],
payload: Object.assign(p, UC.structPayload(d)),
};
}
// 일위대가 풀기(wM_Expnd10) — 할증·비목별 끝전·손료 추가입력(PR 비율 줄)
function expand(load) {
const B = require(D + "ui_boq.js");
const T = require(D + "ui_boq_tools.js");
const txt = (v) => String(v == null ? "" : v).trim();
const d = JSON.parse(JSON.stringify(load));
d.boq_deleted = [];
const m = { rows: d.boq_rows, items: d.sim_state.boqItems, deleted: d.boq_deleted };
const by = Object.fromEntries(d.costn_rows.map((r) => [txt(r.CODE), r]));
const comps = (c) => d.bdqty_rows.filter((q) => txt(q.BDCODE) === c);
const ok = (q) =>
by[txt(q.CODE)] && !/%$/.test(txt(by[txt(q.CODE)].UNIT)) && txt(q.CODE) !== "PR";
const row = d.boq_rows.find(
(r) => /^B/.test(txt(r.BDCODE1)) && Number(r.QTY1) > 0 && comps(txt(r.BDCODE1)).some(ok),
);
if (!row) return { skip: true };
const Q = Number(row.QTY1);
const o = {
level: 0,
hal: { L: 1.2, M: 1 },
per: {
L: { mode: "round", digits: 2 },
M: { mode: "trunc", digits: 3 },
X: { mode: "trunc", digits: 4 },
W: { mode: "trunc", digits: 4 },
},
adds: [{ name: "공구손료", pct: 3, prin: 4, prout: 3 }],
};
const res = T.expandIlwi(m, row._recno, d.bdqty_rows, by, o, d.start_ini);
const want = comps(txt(row.BDCODE1))
.filter(ok)
.map((q) => {
const c = txt(q.CODE),
k = "MLX".includes(c[0]) ? c[0] : "W";
return [
c,
T.roundTo(Number(q.QTY1) * (c[0] === "L" ? 1.2 : 1) * Q, o.per[k].digits, o.per[k].mode),
];
});
const comp = Calc.computeAll(d.sim_state).boqComputed.rows;
const pr = comp.find((r) => r.code === "PR" && r.name === "공구손료");
const at = d.boq_rows.findIndex((r) => txt(r.BDCODE1) === "PR" && r.NAME1 === "공구손료");
const body = d.boq_rows.slice(at - want.length, at).map((r) => [txt(r.BDCODE1), Number(r.QTY1)]);
const bodyL = comp
.filter((r) => body.some((b, i) => r._recno === d.boq_rows[at - want.length + i]._recno))
.reduce((a, r) => a + r.mAmt + r.lAmt + r.sAmt, 0);
return {
ok: res.ok,
want,
body,
prS: pr ? pr.sAmt : null,
prWant: Math.floor((bodyL * 3) / 100),
};
}
// 비목별 단가 끝전(wM_Rate) — 노무비 10원 미만 절사 · 재료비 그대로
function units(load) {
const B = require(D + "ui_boq.js");
const T = require(D + "ui_boq_tools.js");
const d = JSON.parse(JSON.stringify(load));
const m = { rows: d.boq_rows, items: d.sim_state.boqItems, deleted: [] };
const before = Calc.computeAll(d.sim_state).boqComputed.rows.filter(
(r) => r._recno !== undefined && r.code !== "PR",
);
const u = Object.fromEntries(
before.map((r) => [r._recno, { M: r.mUnit, L: r.lUnit, S: r.sUnit }]),
);
const res = T.roundUnits(m, null, u, { L: { mode: "trunc", digits: -1 } });
const after = new Map(Calc.computeAll(d.sim_state).boqComputed.rows.map((r) => [r._recno, r]));
const bad = before.filter((r) => {
const a = after.get(r._recno);
return (
a &&
!(a.lUnit === Math.trunc(r.lUnit / 10) * 10 && a.mUnit === r.mUnit && a.sUnit === r.sUnit)
);
}).length;
return {
changed: res.changed,
want: before.filter(
(r) => r.lUnit % 10 !== 0 && B.isLeaf(d.boq_rows.find((x) => x._recno === r._recno) || {}),
).length,
bad,
};
}