- 코드 등록·삭제·변경·병합·데이타병합·일괄변경·검색·동일코드·풀어넣기 끝전 등(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>
350 lines
14 KiB
JavaScript
350 lines
14 KiB
JavaScript
// 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,
|
||
};
|
||
}
|