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
+349
View File
@@ -0,0 +1,349 @@
// 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,
};
}
+47
View File
@@ -0,0 +1,47 @@
"""STMATE 재현 앱 저장 — % 줄(조달수수료 M%·T% · 비율 줄 PR) 다시 셈.
밑수 줄 수량을 고치면 % 줄 단가 칸(밑수)과 금액(밑수 × 율 원 미만 절사)이 따라 바뀜.
손대지 않은 % 줄 재계산 = 원본 저장값(실무 STC 7건) 그대로.
"""
import sys
from decimal import ROUND_DOWN, Decimal
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tools/stmate-test"))
from dbf_rw import DbfTable # noqa: E402
from stc_archive import files_of, read_entries # noqa: E402
from stc_save import SaveLog, _pct_rows # noqa: E402
DOCS = ROOT / "resources/knowledge/original/실무문서"
STCS = sorted(DOCS.rglob("*.STC"))
def _boq(p):
f = files_of(read_entries(p.read_bytes()))
return DbfTable(f["BOQ11.DBF"])
@pytest.mark.parametrize("stc", STCS, ids=[p.stem[:20] for p in STCS])
def test_pct_rows_recompute_equals_stored(stc):
bt = _boq(stc)
log = SaveLog()
_pct_rows(bt, "BOQ11.DBF", "1", log)
assert not log.items, f"{stc.stem} % 줄 재계산이 저장값과 다름 {log.items[:4]}"
def test_pct_row_follows_base_edit():
stc = next(p for p in STCS if "현동" in p.stem)
bt = _boq(stc)
i = next(k for k in range(len(bt)) if bt.get(k, "UNIT1").strip() == "M%")
j = next(k for k in range(i - 1, -1, -1) if float(bt.get(k, "M_AMT1") or 0) > 0)
base0 = Decimal(bt.get(i, "MAMT1"))
bt.set(j, "M_AMT1", Decimal(bt.get(j, "M_AMT1")) + 1000)
_pct_rows(bt, "BOQ11.DBF", "1", SaveLog())
assert Decimal(bt.get(i, "MAMT1")) == base0 + 1000
q = Decimal(bt.get(i, "QTY1"))
want = ((base0 + 1000) * q / 100).quantize(Decimal(1), rounding=ROUND_DOWN)
assert Decimal(bt.get(i, "M_AMT1")) == want
+194
View File
@@ -0,0 +1,194 @@
"""STMATE 재현 앱 코드 관리(신규등록·코드변경·+α·항목교체·삭제) · 단가 일괄 손질 지킴 시험.
계산 = tools/stmate-test/ui_code.js · ui_code_amt.js · ui_merge.js(node)
저장 = api_ui_code.py(/api/ui/save) → 작업본 다시 불러 대조.
자료 = 실무 STC 7건(server.api_load) — 정답은 원본 저장값(참조 개수·구성행·직공비)에서 읽음.
"""
import json
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tools/stmate-test"))
import api_ui_code # noqa: E402
import server # noqa: E402
HELPER = Path(__file__).with_name("helper_stmate_ui_code.cjs")
STCS = sorted((ROOT / "resources/knowledge/original/실무문서").rglob("*.STC"))
IDS = [p.stem[:16] for p in STCS]
_LOAD: dict = {}
def load(stc):
if stc not in _LOAD:
_LOAD[stc] = server.api_load(server.LoadReq(source_path=str(stc)))
return _LOAD[stc]
def node(data, mode, **extra):
raw = json.dumps(dict({"load": data, "mode": mode}, **extra), ensure_ascii=False, default=str)
out = subprocess.check_output(["node", str(HELPER)], input=raw, text=True, encoding="utf-8")
return json.loads(out)
@pytest.fixture
def work(tmp_path, monkeypatch):
monkeypatch.setattr(server, "WORK_DBF_ROOT", tmp_path)
return tmp_path
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_code_ops_keep_direct_cost_and_refs(stc):
r = node(load(stc), "ops")
a, c, rp = r["add"], r["change"], r["replace"]
assert a["ok"] and a["comps"][0] == a["comps"][1] > 0 and a["t"] == r["t0"]
assert c["ok"] and c["bad"] and c["t"] == r["t0"]
for (old, new), b in zip(c["map"].items(), c["before"].values()):
assert c["after"][new] == b and sum(b.values()) > 0 # 참조 개수 그대로 옮김
assert all(sum(g.values()) == 0 for g in c["gone"]) # 옛 코드는 어디에도 안 남음
assert rp["ok"] and rp["t"] == r["t0"] and rp["refused"] and rp["del"]
assert r["plus"]["ok"] and r["plus"]["same"] and r["plus"]["compOk"]
assert r["unused"]["plus"] >= r["unused"]["minus"] and r["needs"]
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_ui_save_writes_structure_and_reloads(stc, work):
data = load(stc)
r = node(data, "ops")
p = r["payload"]
res = api_ui_code.ui_save(api_ui_code.UiSaveReq(**p))
back = server.api_load(server.LoadReq(source_path=res["saved_dir"]))
codes = {x["CODE"].strip() for x in back["costn_rows"]}
new_b = r["add"]["code"]
assert new_b in codes and r["add"]["src"] not in codes # 복사본 남고 원래 것 지움
for old, new in r["change"]["map"].items():
assert new in codes and old not in codes
boq = sum(1 for x in back["boq_rows"] if x["BDCODE1"].strip() == new)
comp = sum(1 for x in back["bdqty_rows"] if x.get("CODE", "").strip() == new)
b = r["final"][new] # 항목교체·삭제 뒤 화면 자료의 참조 개수
assert (boq, comp) == (b["boq"], b["comp"])
memo = [k for k, m in back["costn_memos"].items() if f"&{new}" in m["text"]]
assert len(memo) == b["memo"]
assert (
sum(1 for x in back["bdqty_rows"] if x.get("BDCODE", "").strip() == new_b)
== r["finalComps"]
)
assert node(back, "direct")["direct"] == r["t0"] # 다시 불러도 직공비 그대로
@pytest.mark.parametrize("stc", STCS[:3], ids=IDS[:3])
def test_ui_save_without_struct_changes_nothing(stc, work):
data = load(stc)
files = server.read_project_files(str(stc))
struct = [{"_recno": r["_recno"], "CODE": r["CODE"]} for r in data["costn_rows"]]
p = {"source_path": str(stc), "costn_struct": struct, "costn_deleted": [], "boq_code_rows": []}
out, log = api_ui_code.apply_struct(files, p)
assert log == [] and all(out[k] is files[k] for k in files) # 코드·칸 그대로면 원래 바이트
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_amount_tools(stc):
r = node(load(stc), "amt")
ch = r["chg"]
assert ch["nagSkipped"] and ch["idem"] == 0
if ch["first"]:
assert ch["first"][0] == ch["first"][1]
assert (
r["hal"]["twice"] == 0 and r["hal"]["back"] == r["hal"]["n"]
) # 기준단가 기준 — 같은 할증 두 번 = 한 번
assert r["init"]["cleared"] == r["init"]["filled"] and r["jdLeft"] == 0
assert all(r["jd"]) # 같은 명칭·규격 자료에서 원래 호환규정코드를 다시 찾음
assert (
r["mrg"]["same"] and r["mrg"]["n"] >= r["mrg"]["uniq"]
) # 자료파일 업데이트 — 조달코드 되살림
for q, s, q2 in r["loss"]:
assert abs(s - round(s)) < 1e-6 and abs(q2 - q) < 1e-9 # 계(10^-7) 정수 · 왕복 같음
assert all(n > 0 for n in r["special"]) and len(r["special"]) == 14
PAIRS = [(0, 1), (1, 4), (4, 0)] # 복사처 ← 복사원(다른 원설계 프로젝트)
@pytest.mark.parametrize("a,b", PAIRS)
def test_merge_code_from_other_project(a, b, work):
dst, src = load(STCS[a]), load(STCS[b])
r = node(dst, "merge", src=src)
assert (
r["steps"] == r["n"] > 0 and r["kidsOk"] and r["t1"] == r["t0"]
) # 가져와도 내역 직공비 그대로
assert r["acts"]["new"] + r["acts"]["map"] == r["steps"]
res = api_ui_code.ui_save(api_ui_code.UiSaveReq(**r["payload"]))
back = server.api_load(server.LoadReq(source_path=res["saved_dir"]))
top = r["top"]
row = next(x for x in back["costn_rows"] if x["CODE"].strip() == top["dest"])
assert row["NAME"].strip() and abs(float(row["TAMT1"] or 0) - r["price"][1]) < 1
kids = [x for x in back["bdqty_rows"] if x.get("BDCODE", "").strip() == top["dest"]]
assert len(kids) == sum(
1 for x in src["bdqty_rows"] if x.get("BDCODE", "").strip() == r["pick"]
)
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_expand_with_surcharge_rounding_and_loss_row(stc):
r = node(load(stc), "expand")
if r.get("skip"):
pytest.skip("구성 있는 일위대가 내역 행 없음")
assert r["ok"] and r["body"] == r["want"] # 비목별 끝전·노무 할증 1.2
assert r["prS"] == r["prWant"] > 0 # 공구손료 = 풀린 줄 전체 금액 × 3% → 경비(원 미만 절사)
def test_project_data_search_finds_names_across_projects(monkeypatch):
paths = [str(p) for p in STCS[:3]]
projs = [{"name": p.stem, "path": str(p)} for p in STCS[:3]]
monkeypatch.setattr(server, "discover_sources", lambda: {"projects": projs})
a = load(STCS[0])
row = next(r for r in a["costn_rows"] if r["CODE"].startswith("L") and r["NAME"].strip())
words = row["NAME"].split()
res = api_ui_code.ui_search(api_ui_code.SearchReq(kind="LMS", kw=" ".join(words), paths=paths))
hits = {(r["project"], r["CODE"]) for r in res["rows"]}
assert (STCS[0].stem, row["CODE"].strip()) in hits and not res["errors"]
assert all(
all(w.lower() in f"{r['NAME']} {r['SIZE']}".lower() for w in words) for r in res["rows"]
)
boq = api_ui_code.ui_search(api_ui_code.SearchReq(kind="BOQ", kw=words[0], paths=paths))
assert all(words[0].lower() in f"{r['NAME1']} {r['SIZE1']}".lower() for r in boq["rows"])
jd = next(r for r in a["costn_rows"] if r["JDCODE"].strip())
res = api_ui_code.ui_search(
api_ui_code.SearchReq(
kind=jd["CODE"][0] in "LMS" and "LMS" or "XBD",
kw=jd["JDCODE"].strip(),
field="jdcode",
paths=paths[:1],
)
)
assert jd["CODE"].strip() in {r["CODE"] for r in res["rows"]}
OTHER_BOQ = [
(stc, str(r["PCODE"]).strip())
for stc in STCS
for r in server.api_load(server.LoadReq(source_path=str(stc)))["prjlst_all"]
if str(r["PCODE"]).strip() != "11"
and f"BOQ{str(r['PCODE']).strip()}.DBF" in server.read_project_files(str(stc))
]
@pytest.mark.parametrize("stc,pcode", OTHER_BOQ, ids=[f"{s.stem[:10]}-{p}" for s, p in OTHER_BOQ])
def test_open_other_boq_table(stc, pcode):
d = api_ui_code.ui_load_boq(api_ui_code.LoadBoqReq(source_path=str(stc), pcode=pcode))
assert d["boq_table_name"] == f"BOQ{pcode}.DBF" and d["prjlst"]["PCODE"].strip() == pcode
assert d["sim_state"]["projectMeta"]["pcode"].strip() == pcode
t = node(d, "direct")["direct"]
assert abs(t - float(d["prjlst"]["TCOST1"] or 0)) <= 1 # 그 공사 저장 직접비(TCOST1)
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_unit_rounding_per_kind(stc):
r = node(load(stc), "units")
assert (
r["bad"] == 0 and r["changed"] == r["want"]
) # 노무비만 10원 미만 절사 · 재료비·경비 그대로
+26 -25
View File
@@ -19,6 +19,7 @@
- `test_stmate_kanjub_opts.py` · `test_stmate_kanjub_rate.py` — 제잡비 선택지 손셈 대조 · 요율표 조회로 저장 요율 재현
- `test_stmate_smt_lang.py` · `test_stmate_qty_sheet.py` — 식 함수·변수·문법 검사 · 수량산출서·QCODE
- `test_stmate_ui.py` — 메뉴·내역서 편집·도구 창 순수 계산
- `test_stmate_conv_*.py` · `test_stmate_file_pct.py` · `test_stmate_ui_code.py` — 엑셀·XML 가져오기 왕복 · 분리형 변경 STC · % 줄 저장 재계산 · 코드 관리 왕복
- `test_stmate_file_*.py` — 실무 STC 7건 무손실 왕복(손대지 않은 저장 = 원본 바이트) · 고친 칸만 바뀜 · 새 공사·복사등록·해당분 압축
- `test_stmate_stage_*.py` — 설계변경(영덕·소광 변경 BOQ·엑셀 대조) · 계약 변환(울진 BOQ12 계약내역·총공사비 1원) · 기성·실행(앞뒤 맞춤만)
- `test_stmate_ilwi_eval.py` — 일위대가·중기 구성 재계산(원설계 5건 + 영덕 변경) 1원 · 연쇄 재계산(기초단가 → 원가계산서) 원설계 5건 전 단가·총공사비 그대로
@@ -27,16 +28,16 @@
## 진행률
- 합계: 전체 246 · 구현 149 · 부분 78 · 없음 19 → 진행률 76.4%((149 + 78×0.5) / 246) · 구현만 세면 60.6%
- 합계: 전체 246 · 구현 167 · 부분 74 · 없음 5 → 진행률 82.9%((167 + 74×0.5) / 246) · 구현만 세면 67.9%
### G1 화면·메뉴 (구현 16 · 부분 23 · 없음 15) — 50.9%
### G1 화면·메뉴 (구현 34 · 부분 19 · 없음 1) — 80.6%
- [x] F1-01 메인 메뉴·툴바·하단 금액 집계줄 — STmate.Cpm 메인 · 현재: 구현(원본 메뉴 구조·툴바 차례·단축키 12·체크 항목·최근 폴더(test_stmate_ui))
- [ ] F1-02 공사목록표 목록·선택·불러오기 — wPrjList · 현재: 부분(공사목록표·등록정보 수정 · 다른 내역서 열기 없음)
- [x] F1-02 공사목록표 목록·선택·불러오기 — wPrjList · 현재: 구현(공사목록표 · 다른 내역서 열기(저장 직접비 1원 일치))
- [ ] F1-03 프로젝트 신규 등록·삭제 — wM_Add_Prj · ST_U_DelPrj · 현재: 부분(stc_project 새 공사(빈 상태) · 삭제 화면 없음)
- [ ] F1-04 프로젝트 등록정보 수정 — wPrjList · 현재: 부분(공사목록표·등록정보 수정 · 다른 내역서 열기 없음)
- [x] F1-04 프로젝트 등록정보 수정 — wPrjList · 현재: 구현(등록정보 수정)
- [ ] F1-05 복사등록(차수·단계 변환) — wPrjList · ST_PList · 현재: 부분(stc_project 복사등록 차수 인계(K_ORD·직공비·원가 설정) 실물 대조 · 계약 변환 실물 대조 없음)
- [ ] F1-06 프로젝트 DATA 검색(노무~내역서 8종) — wSrch_PL · 현재: 없음
- [x] F1-06 프로젝트 DATA 검색(노무~내역서 8종) — wSrch_PL · 현재: 구현(등록 프로젝트 전수 검색·찾아가기·가져오기)
- [x] F1-07 공종목록표 계층 내역 그리드(T/E/C 짝 집계) — wBoq · 현재: 구현(view_prj_boq 37~145)
- [x] F1-08 내역 칸 편집 — wBoq · 현재: 구현(내역서 칸 편집·행 넣기·지우기·이동·복사·붙여넣기 · 직공비 증감 정확(test_stmate_ui))
- [x] F1-09 내역 행 추가·끼워넣기 — wBoq · 현재: 구현(내역서 칸 편집·행 넣기·지우기·이동·복사·붙여넣기 · 직공비 증감 정확(test_stmate_ui))
@@ -45,46 +46,46 @@
- [x] F1-12 계표시 레벨 상향·하향, 주석 → 계시작 변환 — wBoq · 현재: 구현(레벨 거르기·계표시·레벨 올리기/내리기·주공종 집계)
- [x] F1-13 내역 찾기 — wM_FindBoq · 현재: 구현(app.js 344)
- [x] F1-14 호표 코드 → 대가표·단가표 이동 — wBoq · 현재: 구현(코드 → 단가목록표·대가표 이동)
- [ ] F1-15 공종 목록(코드·명칭·분류 정렬, 대공종 필터) — wKongJong · 현재: 부분(공종목록에서 코드 넣기)
- [x] F1-15 공종 목록(코드·명칭·분류 정렬, 대공종 필터) — wKongJong · 현재: 구현(공종목록 코드 넣기 · 대공종 옵션)
- [x] F1-16 주공종 집계표(레벨별 계표시 확인) — wMBoq · 현재: 구현(레벨 거르기·계표시·레벨 올리기/내리기·주공종 집계)
- [ ] F1-17 공종번호 자동입력(머리·끝 글자·번호형식·자리수) — wITNum · 현재: 부분(공종번호 자동입력 · 원본 번호 빈 자리 앞까지 재현(봉화·영월))
- [ ] F1-18 내역 할증율 적용(전체/비목별) — wBoqRate · 현재: 부분(내역 할증율 계산 반영(calcBoq) · 근거 등급 C)
- [ ] F1-19 비목별 일괄 보정(절사/반올림 × 소수 0~4 · 범위) — wM_Rate · 현재: 부분(수량 단수 보정만)
- [x] F1-19 비목별 일괄 보정(절사/반올림 × 소수 0~4 · 범위) — wM_Rate · 현재: 구현(비목별 단가 끝전 · 줄 고정 단가(server fixedUnit)로 다시 불러도 유지)
- [x] F1-20 일위대가표 풀기 — wExpnd · 현재: 구현(일위대가 풀기 = 구성표 × 내역 수량)
- [ ] F1-21 풀어넣기 비목별 끝전·가산비목 지정 — wM_Expnd · wM_Expnd10 · 현재: 없음
- [ ] F1-22 내역 동일코드 처리 — wSameCode · 현재: 없음
- [x] F1-21 풀어넣기 비목별 끝전·가산비목 지정 — wM_Expnd · wM_Expnd10 · 현재: 구현(풀어넣기 비목별 끝전·할증·손료 비율 줄 · 손료 줄 금액 7건 대조)
- [x] F1-22 내역 동일코드 처리 — wSameCode · 현재: 구현(동일코드 행 모아보기 · 행 이동)
- [x] F1-23 내역보관함 (1)(2)(3) — wBoq · 현재: 구현(보관함 (1)(2)(3))
- [ ] F1-24 단가목록표 코드종류 필터(10종) — wM_JabPum cBoxCode · 현재: 부분(코드 종류 10종 · 그림코드 앞 글자 근거 없음)
- [x] F1-24 단가목록표 코드종류 필터(10종) — wM_JabPum cBoxCode · 현재: 구현(코드 종류 10종 · 그림코드 = I 코드(원본 화면 탭))
- [x] F1-25 단가 찾기(명칭·규격 / 산근내용) — wM_JabPum cBox_IFORM · 현재: 구현(단가 찾기·사용처 찾기)
- [ ] F1-26 기초단가 직접 수정(재·노·경) — wM_JabPum · 현재: 부분(기초단가 직접 수정·기준단가 수정·후보 채택 · 보호표시 칸 확인 안 됨)
- [ ] F1-27 코드 신규 등록·삭제 — wM_JabPum · ST_U_DelCode · 현재: 없음
- [ ] F1-28 코드 변경·치환(일괄·선택) — ST_U_ChgCode · 현재: 없음
- [x] F1-27 코드 신규 등록·삭제 — wM_JabPum · ST_U_DelCode · 현재: 구현(ui_code 신규등록(복사)·삭제 · 저장·다시 불러 참조 개수·직공비 그대로 7건(test_stmate_ui_code))
- [x] F1-28 코드 변경·치환(일괄·선택) — ST_U_ChgCode · 현재: 구현(코드변경 선택·일괄·+α · 항목교체 · 7건 왕복)
- [ ] F1-29 기준단가 비율 일괄수정(보호표시·0원 제외) — wM_Edt_Hal · 현재: 부분(기초단가 직접 수정·기준단가 수정·후보 채택 · 보호표시 칸 확인 안 됨)
- [ ] F1-30 할증 금액·율 두 갈래 편집 — ST_Jabpum Edt_Hal_Amt/Rate · 현재: 없음
- [x] F1-30 할증 금액·율 두 갈래 편집 — ST_Jabpum Edt_Hal_Amt/Rate · 현재: 구현(할증 율·금액(기준단가 기준 · 두 번 해도 같은 값))
- [ ] F1-31 가격지 후보 비교(1~5단가·최소단가) — wM_Boxa RG_MComp · 현재: 부분(기초단가 직접 수정·기준단가 수정·후보 채택 · 보호표시 칸 확인 안 됨)
- [ ] F1-32 자재·공종 분류 필터, 불러간 목록 — wM_Boxa rg_FLT · wM_Box_H · 현재: 없음
- [x] F1-32 자재·공종 분류 필터, 불러간 목록 — wM_Boxa rg_FLT · wM_Box_H · 현재: 구현(속성·명칭·불러간 목록 필터)
- [x] F1-33 대가표 목록·선택(B/D/X) — wM_iLWi_San · 현재: 구현(view_dan_ilwi 236~)
- [ ] F1-34 일위대가 구성행 표 편집·노임계수·저장비목 선택 — wM_Edit_iLWi · 현재: 부분(일위대가·중기 구성행 편집 화면 · BDQTY 저장 없음)
- [x] F1-34 일위대가 구성행 표 편집·노임계수·저장비목 선택 — wM_Edit_iLWi · 현재: 구현(구성행 편집 · 저장 = BDQTY 바뀐 호표만 암호로 다시 씀(stc_save_extra))
- [ ] F1-35 단가산출근거 편집 + 계산(F9) — wM_Edit_San · 현재: 부분(글 편집·즉시 계산, 함수·문법검사 없음)
- [ ] F1-36 손료 계산(F6)·시간당 손료계수 수정 — wM_Edit_San · wM_Edit_XRate · 현재: 부분(손료 계산기 규칙(REA 30) · 반올림 방향 미확정)
- [ ] F1-37 사용자 단가산출식 편집 — wM_Edit_USER · 현재: 없음
- [ ] F1-38 실적단가 → 일식단가 변환등록 — wM_Conv_A2W · 현재: 부분(calc_a2w 비목율 정리(REA 27) · 금액 비목 나눔 근거 없음)
- [ ] F1-39 호환규정코드 찾기·입력 — wM_JDCode · 현재: 없음
- [ ] F1-36 손료 계산(F6)·시간당 손료계수 수정 — wM_Edit_San · wM_Edit_XRate · 현재: 부분(손료계수(수량 = 계 × 10⁻⁴ 정수 확인) · calc_xrate · 반올림 방향 미확정)
- [x] F1-37 사용자 단가산출식 편집 — wM_Edit_USER · 현재: 구현(사용자문자 편집 목록)
- [ ] F1-38 실적단가 → 일식단가 변환등록 — wM_Conv_A2W · 현재: 부분(calc_a2w 실적→일식 · 7건 실적단가 자료 없음)
- [ ] F1-39 호환규정코드 찾기·입력 — wM_JDCode · 현재: 부분(조달청 코드 목록 암호 파일 · 다른 프로젝트 값 후보)
- [x] F1-40 사용처 찾기 — ST_FindUse wM_FindUse · 현재: 구현(단가 찾기·사용처 찾기)
- [ ] F1-41 코드 병합(4택)·데이터 병합(비목별 복사처/복사원/신규) — wM_Stmate_Merge_Code · uM_Merge · 현재: 없음
- [x] F1-41 코드 병합(4택)·데이터 병합(비목별 복사처/복사원/신규) — wM_Stmate_Merge_Code · uM_Merge · 현재: 구현(ui_merge 코드병합 4택 · 데이타병합(복사처/복사원/신규 · 동일항목 3택 · W코드) · 저장·다시 불러 대조)
- [ ] F1-42 자료파일 업데이트(매칭 5단계·명칭/단가/조달코드) — wM_Mrg_BSC · 현재: 없음(기준자료(BSC) 암호 ZIP·난독 DBF 로 못 읽음)
- [ ] F1-43 단가 일괄변경·물가변동 — ST_U_ChgAmt · 현재: 없음
- [x] F1-43 단가 일괄변경·물가변동 — ST_U_ChgAmt · 현재: 구현(ui_code_amt 일괄변경·물가변동 · 당초⇔변경 단가 옮기기)
- [x] F1-44 초기값 입력 및 수정 — wM_Set_Sys · 현재: 구현(초기값 화면 전체 항목 · 저장 = Start8x.Ini 바뀐 키)
- [ ] F1-45 프로젝트 계산 초기값 — wM_Set_Init · 현재: 없음
- [x] F1-45 프로젝트 계산 초기값 — wM_Set_Init · 현재: 구현(프로젝트 계산 초기값 5칸 × 8종)
- [ ] F1-46 직종·노임 설정 — wM_Set_JLab · 현재: 부분(조건 직종 코드 입력만)
- [ ] F1-47 제잡비 계산 화면(4형식 탭) — wM_KanJub · 현재: 부분(차수·방식·공사종류·기간·고용등급·퇴직 6칸 처리 없음)
- [ ] F1-48 제비율표 수정·취소·저장·출력 — wM_KAN_RATE · 현재: 부분(보기만, 고칠 칸 없음 view_kanjub_prt 152)
- [ ] F1-49 요율표 불러오기·선택 — wM_KAN_RATE · 현재: 부분(RATE_R 탭만 바뀜, 원가 요율 미연결 app.js 510)
- [ ] F1-50 잡비 금액 불러오기(타 프로젝트 금액을 밑수로) — wM_GetAMT · 현재: 없음
- [x] F1-50 잡비 금액 불러오기(타 프로젝트 금액을 밑수로) — wM_GetAMT · 현재: 구현(잡비 금액 불러오기 → 제잡비 직접입력(C_ 칸))
- [ ] F1-51 인쇄 메뉴 옵션(글꼴·코드필드·수량위치·양식·방향) — PM_Prt · 현재: 부분(scr_report_opts 인쇄 메뉴 옵션 화면)
- [ ] F1-52 보고서 인쇄순서·인쇄 공종 집계수준 선택 — PM_Prn_Ord · PM_Sel_St · 현재: 부분(인쇄순서·집계수준 화면)
- [ ] F1-53 인쇄영역 선택(당초/변경/분할·레벨·변경 1~8차) — wM_Sel_Prn · 현재: 부분(인쇄영역(당초/변경/분할·레벨·차수) 화면)
- [ ] F1-54 특수문자·그림코드·메모 입력 — mMinMsg_ · wInDlgImg · wInDlgMemo · 현재: 없음
- [ ] F1-54 특수문자·그림코드·메모 입력 — mMinMsg_ · wInDlgImg · wInDlgMemo · 현재: 부분(특수문자·메모 · 그림 넣기 없음)
### G2 계산 엔진 (구현 51 · 부분 9 · 없음 1) — 91.0%
@@ -108,7 +109,7 @@
- [x] F2-18 비목우선/총액우선 차액 보정(OPTNM) — REA 15단계 · 현재: 구현(CHNNM 비목우선 = 실무 7건 차수별 내역 줄 금액 1원 · 총액우선 = OPTIMIZ 끝전 배분(손셈 대조) · 비율 줄(test_stmate_kanjub_boq))
- [x] F2-19 계층 합산(SUM_TOT·T/E 짝) — 16번 §4.1 · 현재: 구현(같은 코드 여러 줄이면 짝 오류 위험 view_prj_boq 66)
- [x] F2-20 제외금액 C* 구역 — 16번 §4.2 · 현재: 구현
- [x] F2-21 관급자재·조달수수료 분리, 관급 천원 올림 — 16번 §4.3·§4.7 · 현재: 구현(kanjub_calc·kanjub_parts 원본 계산 순서 · 실무 7건 저장 결과키 전수 1원(test_kanjub_every_stored_key) · 표본 밖 선택지 손셈 대조(test_stmate_kanjub_opts))
- [x] F2-21 관급자재·조달수수료 분리, 관급 천원 올림 — 16번 §4.3·§4.7 · 현재: 구현(관급·조달수수료 · 코드 붙은 % 줄 = 같은 상위 공종 앞 줄 합 × 율 계산·저장 재계산(test_stmate_file_pct · kanjub_stc))
- [x] F2-22 요율표 규모·기간 구간 조회 — 12번 §8~10 · 06번 §2 · 현재: 구현(kanjub_calc·kanjub_parts 원본 계산 순서 · 실무 7건 저장 결과키 전수 1원(test_kanjub_every_stored_key) · 표본 밖 선택지 손셈 대조(test_stmate_kanjub_opts))
- [x] F2-23 요율 기본값 처리 — calc.js · 현재: 구현(num 기본값 · 4건 시험)
- [ ] F2-24 공사종류·적용기준(행자부 RateM 포함)·공사기간 축 — 12번 §10.1 · 현재: 부분(공사종류·기간 축 요율 조회 7건 재현 · 행자부 적용기준 근거 부족)
+297
View File
@@ -0,0 +1,297 @@
# -*- coding: utf-8 -*-
"""코드 관리·자료 병합 저장 끼워 넣기 API.
코드 신규·삭제·변경·항목교체·병합 뒤 저장 · 프로젝트 DATA 검색 · 다른 내역서 열기.
근거: ST_U_ChgCode.CPM(uM_ChgCode · uM_Chg_Plus · uM_ReplCode) · ST_U_DelCode.CPM(uM_DelCode)
· ST_U_Merge.CPM(uM_Merge) · ST_Merge_Code.CPM(wM_Stmate_Merge_Code · wSrch_PL)
— DFM 원문(C:/경영정보(주)/System)
저장 = 구조 손질(COSTN 행 끼움·지움·코드 · 다른 내역 표 코드)
→ stc_save.apply_save(바뀐 칸만 · 구성표 bdqty_rows 포함)
요청 덧붙임 키:
costn_struct [{_recno | _new+_from|_vals, CODE, 덧붙임 칸…}] — 있는 행은 코드·덧붙임 칸만,
새 행은 _from 행 복사 뒤 코드 · _vals(다른 프로젝트에서 가져온 행의 칸 값)
costn_deleted [_recno…] — 지움 표시(*) · 원래 자리 유지
boq_code_rows [{table, _recno, BDCODE1~4}] — 열린 표 밖 내역 표의 코드 바꿈
"""
from __future__ import annotations
import pathlib
from dbf_rw import DbfTable
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, ConfigDict
from smt_parser import parse_dbf_bytes
from stc_project import safe_name, write_output
from stc_save import apply_save, pick_boq_table
router = APIRouter()
# 덧붙임 칸 — 기본 저장(COSTN_SAVE_COL) 밖이나 코드 관리 화면이 고치는 칸
EXTRA_COL = ("BQCODE", "EQCODE", "JDCODE", "WQCODE", "EDT_DATE", "NAME2", "SIZE2", "PP", "CONTCODE")
def _txt(v) -> str:
return "" if v is None else str(v).strip()
def _same(tbl: DbfTable, i: int, name: str, value) -> bool:
"""뜻으로 같은가 — 숫자 칸이 깨진 값이어도 글자로 먼저 견줌."""
if _txt(tbl.get(i, name)) == _txt(value):
return True
try:
return tbl.same_value(i, name, value)
except ValueError:
return False
def _put(tbl: DbfTable, i: int, name: str, value, log: list, table: str) -> None:
if name not in tbl.fields or tbl.fields[name].type == "M" or _same(tbl, i, name, value):
return
old = tbl.get(i, name)
tbl.set_raw(i, name, tbl.encode_value(name, value))
log.append({"table": table, "recno": i, "field": name, "old": old, "new": _txt(value)})
def _costn(files: dict, req: dict, log: list) -> None:
struct = req.get("costn_struct") or []
dele = {int(x) for x in (req.get("costn_deleted") or [])}
if not (struct or dele) or "COSTN.DBF" not in files:
return
ct = DbfTable(files["COSTN.DBF"])
n0 = len(ct)
for i in sorted(dele):
if 0 <= i < n0 and not ct.deleted(i):
ct.recs[i][0:1] = b"*"
ct.dirty = True
log.append(
{
"table": "COSTN.DBF",
"recno": i,
"field": "_delete",
"old": ct.get(i, "CODE"),
"new": "",
}
)
codes = {ct.get(i, "CODE") for i in range(n0) if not ct.deleted(i)}
for u in struct:
code = _txt(u.get("CODE"))
if not code:
continue
r = u.get("_recno")
if not u.get("_new") and r is not None and 0 <= int(r) < n0:
i = int(r)
if i in dele:
continue
else:
if code in codes:
raise ValueError(f"COSTN 에 이미 있는 코드: {code}")
src = u.get("_from")
tmpl = int(src) if src is not None and 0 <= int(src) < n0 else None
i = ct.insert(len(ct), {}, template=tmpl)
if "IFORM" in ct.fields: # 산출근거 메모는 새 자리로(복사원 메모 덮어쓰기 방지)
ct.set_raw(i, "IFORM", b" " * ct.fields["IFORM"].len)
log.append(
{"table": "COSTN.DBF", "recno": i, "field": "_insert", "old": "", "new": code}
)
if tmpl is None:
for k in ("NAME", "SIZE", "UNIT"):
_put(ct, i, k, u.get(k, ""), log, "COSTN.DBF")
_put(ct, i, "CODE", code, log, "COSTN.DBF")
codes.add(code)
for k, v in (
u.get("_vals") or {}
).items(): # 다른 프로젝트에서 가져온 새 행 — 원래 칸 값 그대로
if k in ct.fields and k not in ("CODE", "IFORM") and not k.endswith("_HEX"):
try:
_put(ct, i, k, v, log, "COSTN.DBF")
except ValueError: # 칸 폭·꼴이 다른 값은 건너뜀(원래 틀 값 유지)
continue
for k in EXTRA_COL:
if k in u:
_put(ct, i, k, u[k], log, "COSTN.DBF")
if ct.dirty:
files["COSTN.DBF"] = ct.to_bytes()
def _boq_codes(files: dict, req: dict, log: list) -> None:
by: dict[str, list] = {}
for u in req.get("boq_code_rows") or []:
by.setdefault(str(u.get("table", "")).upper(), []).append(u)
for name, ups in by.items():
if name not in files:
continue
bt = DbfTable(files[name])
for u in ups:
i = int(u.get("_recno", -1))
if not (0 <= i < len(bt)):
continue
for k in ("BDCODE1", "BDCODE2", "BDCODE3", "BDCODE4"):
if k in u:
_put(bt, i, k, u[k], log, name)
if bt.dirty:
files[name] = bt.to_bytes()
def apply_struct(files: dict[str, bytes], req: dict) -> tuple[dict[str, bytes], list]:
"""구조 손질만 — 새 files 와 바뀐 목록(코드 칸·끼움·지움)."""
out = dict(files)
log: list = []
_costn(out, req, log)
_boq_codes(out, req, log)
return out, log
class UiSaveReq(BaseModel):
"""/api/save 요청 그대로(덧붙은 키는 apply_save 로 넘김) + 구조 덧붙임 키."""
model_config = ConfigDict(extra="allow")
source_path: str
target_folder_name: str = "stmate_edited"
costn_struct: list[dict] = []
costn_deleted: list[int] = []
boq_code_rows: list[dict] = []
def _server():
import server # 실행 중 바뀔 수 있는 작업 폴더·원천 열기(시험은 임시 폴더로 바꿈)
return server
@router.post("/api/ui/save")
def ui_save(req: UiSaveReq):
"""구조 손질 → 바뀐 칸 저장 → work_dbf/<이름>/ · <이름>.STC (원본은 손대지 않음)."""
srv = _server()
src = srv.open_source(req.source_path)
body = req.model_dump()
try:
files, struct_log = apply_struct(src.files, body)
files, changes = apply_save(files, body)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
res = write_output(
srv.WORK_DBF_ROOT, safe_name(req.target_folder_name), files, src.names, src.entries
)
res["changed_files"] = sorted(k for k in files if files[k] is not src.files.get(k))
allc = struct_log + changes
res["changes"] = allc[:500]
res["change_count"] = len(allc)
return res
# ── 프로젝트 DATA 검색(wSrch_PL) — 등록된 모든 프로젝트의 단가·내역에서 명칭·규격 찾기 ──
_CACHE: dict[str, tuple[float, dict]] = {}
COSTN_COLS = (
"CODE",
"NAME",
"SIZE",
"UNIT",
"BQCODE",
"EQCODE",
"TAMT1",
"LAMT1",
"MAMT1",
"SAMT1",
)
COSTN_COLS += ("JDCODE", "EDT_DATE")
BOQ_COLS = ("ITNUM", "NAME1", "SIZE1", "UNIT1", "QTY1", "T_AMT1", "BDCODE1", "BQCODE")
def _project_rows(path: str) -> dict:
p = pathlib.Path(path)
key = str(p)
mt = p.stat().st_mtime if p.exists() else 0.0
hit = _CACHE.get(key)
if hit and hit[0] == mt:
return hit[1]
files = _server().open_source(path).files
costn = parse_dbf_bytes(files["COSTN.DBF"])[1] if "COSTN.DBF" in files else []
boq_name = pick_boq_table(files)
boq = parse_dbf_bytes(files[boq_name])[1] if boq_name else []
data = {
"costn": [{k: _txt(r.get(k)) for k in COSTN_COLS} for r in costn if _txt(r.get("CODE"))],
"boq": [dict({k: _txt(r.get(k)) for k in BOQ_COLS}, table=boq_name) for r in boq],
}
_CACHE[key] = (mt, data)
return data
SRCH_KINDS = {"LMS": "LMS", "XBD": "XBD", "W": "W", "BOQ": ""}
class SearchReq(BaseModel):
kind: str = "LMS" # LMS · XBD · W · BOQ (wcBx_Type 4택)
kw: str = "" # 띄어 쓴 낱말 모두 든 것
field: str = "name" # name(명칭·규격) · jdcode(호환규정코드) · code(코드번호)
paths: list[str] = [] # 비면 등록된 모든 프로젝트
limit: int = 500
def match_rows(rows: list[dict], kind: str, kw: str, field: str) -> list[dict]:
words = [w.lower() for w in kw.split() if w]
pre = SRCH_KINDS.get(kind, "")
out = []
for r in rows:
if kind == "BOQ":
if not r.get("BDCODE1") or len(r["BDCODE1"]) < 3:
continue
hay = f"{r['NAME1']} {r['SIZE1']}"
else:
if r["CODE"][:1] not in pre:
continue
hay = {"jdcode": r["JDCODE"], "code": r["CODE"]}.get(field, f"{r['NAME']} {r['SIZE']}")
if all(w in hay.lower() for w in words):
out.append(r)
return out
@router.post("/api/ui/search")
def ui_search(req: SearchReq):
srv = _server()
projs = srv.discover_sources()["projects"]
if req.paths:
want = set(req.paths)
projs = [p for p in projs if p["path"] in want]
found, errs = [], []
for p in projs:
try:
data = _project_rows(p["path"])
except Exception as e: # 열 수 없는 원천은 건너뛰고 알림
errs.append({"project": p["name"], "error": str(e)[:120]})
continue
rows = data["boq"] if req.kind == "BOQ" else data["costn"]
for r in match_rows(rows, req.kind, req.kw, req.field):
found.append(dict(r, project=p["name"], path=p["path"]))
if len(found) >= req.limit:
return {"rows": found, "truncated": True, "errors": errs}
return {"rows": found, "truncated": False, "errors": errs}
# ── 다른 내역서 열기(wPrjList — 공사 행마다 BOQ{PCODE}.DBF) ──
class LoadBoqReq(BaseModel):
source_path: str
pcode: str
@router.post("/api/ui/load-boq")
def ui_load_boq(req: LoadBoqReq):
"""불러오기 결과를 고른 공사(PRJLST 행)의 내역 표·제잡비 설정으로 바꿔 돌려줌."""
srv = _server()
data = srv.api_load(srv.LoadReq(source_path=req.source_path))
pc = _txt(req.pcode)
name = f"BOQ{pc}.DBF"
if name not in data["boq_tables"]:
raise HTTPException(status_code=404, detail=f"내역 표 없음: {name}")
prj = next((r for r in data["prjlst_all"] if _txt(r.get("PCODE")) == pc), None)
if prj is None:
raise HTTPException(status_code=404, detail=f"PRJLST 에 PCODE {pc} 공사 없음")
files = srv.read_project_files(req.source_path)
smt_kv = srv.parse_prjlst_smt(files.get("PRJLST.SMT", b""), prj.get("PRJ_MEMO_HEX", ""))
rows = data["boq_tables"][name]
sim = srv.convert_dbf_to_sim_state(data["costn_rows"], rows, smt_kv, prj, data["costn_memos"])
for k in ("condCodes", "rxFormula"):
sim["sysConfig"][k] = data["sim_state"]["sysConfig"].get(k, sim["sysConfig"].get(k))
data.update(boq_table_name=name, boq_rows=rows, boq_count=len(rows), prjlst=prj)
data.update(prjlst_smt=smt_kv, sim_state=sim)
return data
+15 -5
View File
@@ -161,10 +161,7 @@
const kj = ctx.computed.kanjubComputed,
bt = ctx.computed.boqComputed.totals;
try {
const saved = await postJson(
"/api/save",
decorateSavePayload(ViewPrjBoq.buildSavePayload(ctx)),
);
const saved = await postSave(decorateSavePayload(ViewPrjBoq.buildSavePayload(ctx)));
renderWorkspace();
alert(
`[계산완료(Act_Calc_All) 및 DBF/SMT/STC 저장 성공]\n• 5단 연쇄재계산: 중기(X) ${stat.xCount}건 · 산근(D) ${stat.dCount}건 · 일위대가(B) ${stat.bCount}건\n• 폴더: ${saved.saved_dir}\n• 아카이브: ${saved.saved_stc} (${fmt(saved.stc_size)}B)\n• 직공비: ${fmt(bt.directT)}원 / 총공사비: ${fmt(kj.GRAND_TOTAL)}원`,
@@ -174,6 +171,14 @@
}
}
// 저장 길 — 코드 관리(지움·코드 바꿈·다른 표 코드)가 있으면 /api/ui/save(구조 덧붙임 · api_ui_code.py) · 아니면 /api/save
function postSave(p) {
const UC = global.STMATE_UI_CODE;
if (UC && UC.needsUiSave(ctx.dbfData))
return postJson("/api/ui/save", Object.assign(p, UC.structPayload(ctx.dbfData)));
return postJson("/api/save", p);
}
// 저장 묶음 덧붙임 — 초기값(start_ini) · 공사목록 전체 행(prjlst_rows) · 서버 반영은 파일 담당
function decorateSavePayload(p) {
const d = ctx.dbfData;
@@ -437,7 +442,12 @@
Object.assign(API, { renderWorkspace, recompute, openModal, postJson, downloadBlob });
Object.assign(API, { cascadeAndRender, loadProjectFromServer, insertBoqRow });
Object.assign(API, { saveAll: saveAllToDbfServer, decorateSavePayload, openMappedModal });
Object.assign(API, {
saveAll: saveAllToDbfServer,
decorateSavePayload,
openMappedModal,
postSave,
});
async function initStmateApp() {
onClick("modal-close-btn", closeModal);
+15 -1
View File
@@ -358,6 +358,7 @@ const StmateCalc = (() => {
govFeeTotal = 0;
// 내역 비율 줄(PR) 밑수 — 표시(PRPT) 줄 금액 누적 · PR 줄을 지나면 비움(영월 조달수수료 4줄 대조)
let prBase = { M: 0, L: 0, S: 0 };
const doneById = {}; // 코드 붙은 % 줄 밑수용 — 앞 줄 금액
const rows = (data.boqItems || []).map((row) => {
const pr = prRow(row, slot, prBase);
if (pr) {
@@ -397,9 +398,21 @@ const StmateCalc = (() => {
L = roundDown(rawL, 0),
S = roundDown(rawS, 0),
T = M + L + S;
// 코드 붙은 % 줄(조달수수료 M%·T%) = 같은 상위 공종 안 앞 말단 줄 비목 합 × 율 → 제 비목(server pct_row_base)
if (row.pctBase) {
const k = row.pctComp,
base = row.pctBase.reduce((a, id) => a + ((doneById[id] || {})[k] || 0), 0);
const own = /^L/.test(row.code) ? "L" : /^S/.test(row.code) ? "S" : "M";
((M = 0), (L = 0), (S = 0));
const amt = roundDown((base * num((row.prQtys || [])[slot - 1])) / 100, 0);
if (own === "L") L = amt;
else if (own === "S") S = amt;
else M = amt;
T = M + L + S;
}
let optimizDiff = 0,
optimizTarget = "";
if (chnMode === "total") {
if (chnMode === "total" && !row.pctBase) {
// 총액우선(CHNNM=0) — REA 15 OPTIMIZ: 총액 절사 후 남는 1원을 소수부 큰 성분부터 하나씩
// (동률은 OPTNM 순서 앞쪽) · 산근·일위대가(smt_eval finish)와 같은 규칙
const got = optimize({ M: rawM, L: rawL, S: rawS }, rawT, optOrder);
@@ -413,6 +426,7 @@ const StmateCalc = (() => {
((M = got.M), (L = got.L), (S = got.S), (T = M + L + S));
}
}
doneById[row.id] = { M, L, S, T };
const aggM = roundHalfUp(rawM, 0),
aggL = roundHalfUp(rawL, 0),
aggS = roundHalfUp(rawS, 0);
+5
View File
@@ -53,6 +53,8 @@
<script src="calc.js"></script>
<script src="smt_eval.js"></script>
<script src="smt_engine.js"></script>
<script src="calc_xrate.js"></script>
<script src="calc_a2w.js"></script>
<script src="view_prj_boq.js"></script>
<script src="view_dan_ilwi.js"></script>
<script src="view_kanjub_prt.js"></script>
@@ -63,6 +65,9 @@
<script src="ui_boq_tools.js"></script>
<script src="ui_sys.js"></script>
<script src="ui_dan.js"></script>
<script src="ui_code.js"></script>
<script src="ui_code_amt.js"></script>
<script src="ui_merge.js"></script>
<script src="plugins_manifest.js"></script>
<script src="app.js"></script>
</body>
+7
View File
@@ -187,6 +187,11 @@
null,
["mboq", "주공종 집계표 (wMBoq)"],
["qtyList", "수량산출서 (wQtyList)"],
null,
["openSame", "동일코드 추려보기 (wSameCode)"],
["openMemo", "메모 입력 (wInDlgMemo)"],
["openSpChar", "특수문자 (mMinMsg_)"],
["openUser", "사용자문자 (wM_Edit_USER)"],
],
],
["보관함", [["pocketPut", "선택 행 보관함에 담기"]]],
@@ -320,6 +325,8 @@
const D = global.STMATE_UI_BOQ_DLG;
if (D && D[act])
return D[act](ctx, api, { model, after, sel, selIdx, costnByCode, insertAt });
const X = global.STMATE_UI_TEXT; // 추려보기·메모·특수문자·사용자문자 — scr_ui_text.js
if (X && X[act]) return X[act](ctx, api);
};
document
.querySelectorAll("#boq-form [data-boq-act]")
+202 -21
View File
@@ -24,7 +24,6 @@
.join("");
const LEVELS = (ini) =>
[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => [n, `${n} 레벨 (${B.dispName(ini, n)})`]);
const DOTS = [4, 3, 2, 1, 0].map((d) => [d, `소수 ${d}위값을 적용`]);
const KINDS = [
["BD", "일위대가/산근전체"],
["ALL", "모든단가"],
@@ -37,10 +36,31 @@
["W", "견적단가"],
];
// 공종 목록(wKongJong) — RdoIndex(코드번호 순/명칭 순/분류번호 순) · 코드 골라 수량과 함께 넣기
// 공종 목록(wKongJong) — 대공종(KONGJONG.DBF) · RdoLevel · RdoIndex · RdoFilt · 전송될 항목(대공종명·번호·계표시 · 세부공종번호)
// 「대공종 해당목록만」 = 분류번호가 대공종 번호로 시작하는 항목(원본 연결 칸 미확인 — 추정)
function kongjong(ctx, api, x) {
const st = (ctx.uiState.kongjong = ctx.uiState.kongjong || { kind: "BD", sort: "C", kw: "" });
Object.assign(
st,
Object.assign(
{
big: "",
lv: 3,
filt: false,
trans: false,
ibrd: false,
sno: "bq",
first: "-",
start: 1,
left: 0,
},
st,
),
);
const costn = ctx.dbfData.costn_rows || [];
const bigs = (ctx.dbfData.kongjong_rows || []).filter((r) =>
String(r.NAME || "").replace(/[\]\s]/g, ""),
);
const draw = () => {
const kw = st.kw.trim().toLowerCase();
let list = costn.filter((r) => {
@@ -48,6 +68,14 @@
p = c[0];
if (!c) return false;
if (st.kind === "BD" ? !/[BD]/.test(p) : st.kind !== "ALL" && p !== st.kind) return false;
if (
st.filt &&
st.big &&
!String(r.BQCODE || "")
.trim()
.startsWith(st.big)
)
return false;
return !kw || `${c} ${r.NAME || ""} ${r.SIZE || ""}`.toLowerCase().includes(kw);
});
const key = {
@@ -77,6 +105,29 @@
st.sort,
)}</select>
<input id="kj-kw" class="win-input" placeholder="명칭·규격·코드" value="${esc(st.kw)}" /><span id="kj-count"></span></div>
<div class="stm-row"><label>대공종</label><select id="kj-big" class="win-select"><option value="">(고르지 않음)</option>${bigs.map((r) => `<option value="${esc(String(r.CODE).trim())}" ${st.big === String(r.CODE).trim() ? "selected" : ""}>${esc(String(r.CODE).trim())} ${esc(r.NAME)}</option>`).join("")}</select>
<label><input type="checkbox" id="kj-filt" ${st.filt ? "checked" : ""} /> 대공종 해당목록만</label><label>[대공종 레벨 설정]</label><select id="kj-lv" class="win-select">${opt(
[1, 2, 3, 4, 5, 6].map((n) => [
n,
`${B.dispName(ctx.dbfData.start_ini, n)} (+${"=".repeat(n)})`,
]),
st.lv,
)}</select></div>
<div class="stm-row"><label><input type="checkbox" id="kj-trans" ${st.trans ? "checked" : ""} /> 대공종명,번호,계표시 전송</label><label><input type="checkbox" id="kj-ibrd" ${st.ibrd ? "checked" : ""} /> 세부공종번호 전송</label>
<select id="kj-sno" class="win-select">${opt(
[
["bq", "분류번호로 전송"],
["seq", "일련번호로 전송"],
],
st.sno,
)}</select><label>일련번호 첫자</label><select id="kj-first" class="win-select">${opt(
[
["-", "- 입력"],
["", "없슴"],
],
st.first,
)}</select>
<label>시작번호</label><input id="kj-start" type="number" class="win-input" value="${st.start}" style="width:50px;" /><label>좌측여백</label><input id="kj-left" type="number" class="win-input" value="${st.left}" style="width:40px;" /></div>
<div class="grid-scroll" style="max-height:340px;"><table class="dbgrid"><thead><tr><th></th><th>코드</th><th>명칭</th><th>규격</th><th>단위</th><th>분류번호</th></tr></thead><tbody id="kj-list"></tbody></table></div>
<div class="stm-row"><label>수량</label><input id="kj-qty" class="win-input" type="number" value="0" style="width:90px;" />
<span class="stm-note">넣을 곳: ${ctx.uiState.boqGrpTo === "pocket" ? `열린 보관함 (${ctx.uiState.activePocket || 1})` : "내역서 선택 행 앞"}</span>
@@ -86,6 +137,15 @@
$("kj-kind").onchange = (e) => ((st.kind = e.target.value), draw());
$("kj-sort").onchange = (e) => ((st.sort = e.target.value), draw());
$("kj-kw").oninput = (e) => ((st.kw = e.target.value), draw());
$("kj-big").onchange = (e) => ((st.big = e.target.value), draw());
$("kj-filt").onchange = (e) => ((st.filt = e.target.checked), draw());
$("kj-lv").onchange = (e) => (st.lv = Number(e.target.value));
$("kj-trans").onchange = (e) => (st.trans = e.target.checked);
$("kj-ibrd").onchange = (e) => (st.ibrd = e.target.checked);
$("kj-sno").onchange = (e) => (st.sno = e.target.value);
$("kj-first").onchange = (e) => (st.first = e.target.value);
$("kj-start").onchange = (e) => (st.start = Number(e.target.value) || 1);
$("kj-left").onchange = (e) => (st.left = Number(e.target.value) || 0);
$("kj-ok").onclick = () => {
const codes = [...document.querySelectorAll(".kj-pick:checked")].map((c) => c.value);
if (!codes.length) return alert("넣을 코드를 고르세요");
@@ -93,6 +153,7 @@
cbc = x.costnByCode(ctx),
qty = B.num(val("kj-qty"));
let rec = B.nextRecno(m);
const rows0 = [];
const rows = codes.map((c) => {
const cr = cbc[c];
const r = B.makeRow(m, {
@@ -103,17 +164,46 @@
QTY1: String(qty),
});
r._recno = rec--;
if (st.ibrd)
r.ITNUM =
" ".repeat(st.left) +
(st.sno === "bq"
? String(cr.BQCODE || "").trim()
: `${st.first}${st.start + rows0.length}`);
rows0.push(r);
return r;
});
const big = bigs.find((b) => String(b.CODE).trim() === st.big);
let pairs;
if (st.trans && big) {
const head = B.makeRow(m, {
BDCODE1: `T${st.lv}`,
NAME1: String(big.NAME || "").trim(),
ITNUM: String(big.CODE).trim(),
});
head._recno = rec--;
const tail = B.makeRow(m, {
BDCODE1: `E${st.lv}`,
ITNUM: B.dispName(ctx.dbfData.start_ini, st.lv),
});
tail._recno = rec--;
rows.unshift(head);
rows.push(tail);
pairs = [[head, tail]];
}
api.closeModal();
if (ctx.uiState.boqGrpTo === "pocket") {
const u = ctx.uiState,
n = u.activePocket || 1;
u.boqPockets = u.boqPockets || { 1: [], 2: [], 3: [] };
u.boqPockets[n].push({ title: rows[0].NAME1, rows, pairs: [] });
u.boqPockets[n].push({
title: rows[0].NAME1,
rows,
pairs: pairs ? [[0, rows.length - 1]] : [],
});
return api.renderWorkspace();
}
x.after(ctx, api, B.insertRows(m, x.insertAt(ctx), rows));
x.after(ctx, api, B.insertRows(m, x.insertAt(ctx), rows, pairs));
};
}
@@ -307,9 +397,56 @@
[0, 1, 2, 3, 4].map((d) => [d, `${d} 소수 ${d}위값을 적용`]),
2,
)}</select></div>
<p class="stm-warn">비목별 금액 보정(wcBx_Pnt1_M·_L·_X·_W, rd_Amt1·rd_Amt2)은 내역 행 단가 고정 칸이 계산 엔진에 없어 대기</p>
<div class="stm-row"><button class="win-btn primary" id="mr-ok">수량 보정</button></div></div>`,
<div class="stm-row"><button class="win-btn primary" id="mr-ok">수량 보정</button></div>
<strong>비목별 단가 끝전(wcBx_Pnt1_M·_L … · rd_Amt1)</strong>
<div class="stm-row"><label>단가 범위</label><select id="mr-arange" class="win-select">${opt(
[
["all", "내역서 전체"],
["sel", "선택된 항목만"],
],
"all",
)}</select></div>
${[
["M", "재료비"],
["L", "노무비"],
["S", "경비"],
]
.map(
([k, t]) =>
`<div class="stm-row"><label><input type="checkbox" id="mr-on-${k}" checked /> ${t}</label><select id="mr-m-${k}" class="win-select">${opt(
[
["trunc", "0 절사하여"],
["round", "1 반올림 하여"],
],
"trunc",
)}</select><select id="mr-d-${k}" class="win-select">${opt(
[0, 1, 2, 3, 4].map((d) => [d, `${d} 소수 ${d}위값을 적용`]),
0,
)}</select></div>`,
)
.join("")}
<div class="stm-row"><button class="win-btn primary" id="mr-aok">단가 보정</button></div></div>`,
);
$("mr-aok").onclick = () => {
const recs = val("mr-arange") === "sel" ? x.sel(ctx) : null;
if (recs && !recs.length) return alert("선택된 항목 없음");
const units = {};
(ctx.computed.boqComputed.rows || []).forEach(
(r) =>
r._recno !== undefined &&
(units[Number(r._recno)] = { M: r.mUnit, L: r.lUnit, S: r.sUnit }),
);
const per = {};
["M", "L", "S"].forEach(
(k) =>
$(`mr-on-${k}`).checked &&
(per[k] = { mode: val(`mr-m-${k}`), digits: Number(val(`mr-d-${k}`)) }),
);
const res = T.roundUnits(x.model(ctx), recs, units, per);
api.closeModal();
ctx.uiState.boqMsg = `단가 ${res.changed}행 보정`;
x.after(ctx, api, { ok: true }, true);
};
$("mr-ok").onclick = () => {
const recs = val("mr-range") === "sel" ? x.sel(ctx) : null;
if (recs && !recs.length) return alert("선택된 항목 없음");
@@ -320,36 +457,80 @@
};
}
// 일위대가표 풀기(wExpnd) — RGrpToT · CMBoxUpDn · CMBoxDot
// 일위대가표 풀기 — wM_Expnd10(계표시·할증·손료 추가입력) + wM_Expnd(비목별 끝전 M·L·X·W)
function expnd(ctx, api, x) {
const r = ctx.dbfData.boq_rows.find((q) => Number(q._recno) === x.sel(ctx)[0]);
if (!r || B.codeOf(r)[0] !== "B") return alert("풀 일위대가(B 코드) 행 하나를 고르세요");
const UPDN = [
["trunc", "0 절사하여"],
["round", "1 반올림 하여"],
];
const DOT5 = [0, 1, 2, 3, 4].map((d) => [d, `${d} 소수 ${d}위값을 적용`]);
const PIN = [
[1, "재료비의"],
[2, "노무비의"],
[3, "경비의"],
[4, "전체의"],
];
const POUT = [
[1, "재료비로 계산"],
[2, "노무비로 계산"],
[3, "경비로 계산"],
[4, "해당비목 계산"],
];
const per = [
["M", "재료비코드(M)"],
["L", "노무비코드(L)"],
["X", "중장비코드(X)"],
["W", "기 타 코드(W)"],
]
.map(
([k, t]) =>
`<div class="stm-row"><label style="width:120px;">${t}</label><select id="ex-m-${k}" class="win-select">${opt(UPDN, "trunc")}</select><select id="ex-d-${k}" class="win-select">${opt(DOT5, 4)}</select></div>`,
)
.join("");
const add = (n) =>
`<div class="stm-row"><label><input type="checkbox" id="ex-a${n}" /> 항목 추가입력</label><input id="ex-an${n}" class="win-input" value="${n === 1 ? "공구손료" : "잡재료"}" style="width:90px;" /><select id="ex-ai${n}" class="win-select">${opt(PIN, 2)}</select><input id="ex-ap${n}" type="number" class="win-input" value="${n === 1 ? 3 : 2}" style="width:60px;" /> % 를<select id="ex-ao${n}" class="win-select">${opt(POUT, n === 1 ? 3 : 1)}</select></div>`;
api.openModal(
"일위대가표 풀기 (wExpnd)",
"일위대가표 풀기 (wM_Expnd10 · wM_Expnd)",
`<div class="stm-form"><p>${esc(B.codeOf(r))} ${esc(r.NAME1)} ${esc(r.SIZE1)} — 수량 ${B.num(r.QTY1)} ${esc(r.UNIT1)}</p>
<div class="stm-row"><label>집계(RGrpToT)</label><select id="ex-lv" class="win-select">${opt(
<p class="stm-note">내역서에 일위대가표 내용을 풀어내어 입력하고 현 일위대가표는 내역서에서 삭제 됩니다.</p>
<div class="stm-row"><label>1. 공종레벨 지정</label><select id="ex-lv" class="win-select">${opt(
[
[0, "입력안함"],
[1, "소계(=)"],
[2, "계(==)"],
[3, "합계(===)"],
],
1,
)}</select></div>
<div class="stm-row"><label>수량 끝전</label><select id="ex-mode" class="win-select">${opt(
[
["trunc", "절삭하여"],
["round", "반올림하여"],
],
"trunc",
)}</select><select id="ex-dot" class="win-select">${opt(DOTS, 4)}</select></div>
<p class="stm-note">구성 수량 = 구성표(BDQTY) 수량 × 내역 수량 · 비율(PR)·%단위·구분 줄은 풀지 않음</p>
<div class="stm-row"><button class="win-btn primary" id="ex-ok">풀기</button></div></div>`,
)}</select><label><input type="checkbox" id="ex-name" checked /> 공종의 명칭/규격 입력</label></div>
<div class="stm-row"><label>2. 일위대가표 기본수량 x 할 증 x 내역물량 — 노무비 :</label><input id="ex-hl" type="number" class="win-input" value="1" style="width:60px;" /><label>재료비 :</label><input id="ex-hm" type="number" class="win-input" value="1" style="width:60px;" /></div>
<strong>수량 처리방법(비목별 끝전)</strong>${per}
<strong>3. 손료계산 추가입력</strong>${add(1)}${add(2)}
<p class="stm-note">비율(PR)·%단위·구분 구성 줄은 풀지 않음 · 추가 줄 = 풀린 줄 합의 % (내역 비율 줄 PR)</p>
<div class="stm-row"><button class="win-btn primary" id="ex-ok">풀어넣기(O)</button></div></div>`,
);
$("ex-ok").onclick = () => {
const perOpt = Object.fromEntries(
["M", "L", "X", "W"].map((k) => [
k,
{ mode: val(`ex-m-${k}`), digits: Number(val(`ex-d-${k}`)) },
]),
);
const adds = [1, 2]
.filter((n) => $(`ex-a${n}`).checked)
.map((n) => ({
name: val(`ex-an${n}`),
prin: Number(val(`ex-ai${n}`)),
pct: Number(val(`ex-ap${n}`)),
prout: Number(val(`ex-ao${n}`)),
}));
const o = {
level: Number(val("ex-lv")),
mode: val("ex-mode"),
digits: Number(val("ex-dot")),
keepName: $("ex-name").checked,
hal: { L: Number(val("ex-hl")) || 1, M: Number(val("ex-hm")) || 1 },
per: perOpt,
adds,
};
const res = T.expandIlwi(
x.model(ctx),
+295
View File
@@ -0,0 +1,295 @@
/**
* STMATE w 8.0 — 코드 관리 창 끼워 넣기 (scr_ui_code.js) · 코드변경 · 항목교체 · 코드삭제 · +α · 신규등록
* 근거: DFM 원문 — uM_ChgCode(현재코드 · 바뀔코드 · * 사용 · 코드변환실행 · 코드일괄변환 · 현재이후 자동입력 · 바뀔코드 초기화)
* · uM_ReplCode(대상코드 · 항목교체실행 · 동일항목(명칭, 규격... 단가) 대상코드 자동입력) · uM_DelCode(선 택 · 선택코드
* 삭제실행 · 코드일괄삭제(중기비제외/포함) · 사용안된 "천원" "인" 일위대가 전체선택 · 동일항목 자동선택)
* · uM_Chg_Plus(종류별 현재최종 + α) · wM_Boxa Act_CopyNew(신규등록) · Act_DEL_CODE · Act_CH_CODE · Act_RP_CODE
* 계산 = ui_code.js · 저장 = app.js postSave(/api/ui/save · api_ui_code.py)
*/
(function (global) {
"use strict";
const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || {
screens: {},
actions: {},
menus: [],
});
const UC = global.STMATE_UI_CODE;
const esc = (s) =>
String(s == null ? "" : s).replace(
/[&<>"]/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
);
const $ = (id) => document.getElementById(id);
const KINDS = ["L", "M", "S", "B", "X", "D", "W"];
const env = (ctx) => ({
d: ctx.dbfData,
state: ctx.state,
memo: ctx.uiState.editedSmtMap || (ctx.uiState.editedSmtMap = {}),
});
const need = (ctx) => (ctx.dbfData ? true : (alert("열린 프로젝트가 없음"), false));
const done = (ctx, api, msg) => {
ctx.uiState.danMsg = msg;
api.closeModal();
api.cascadeAndRender();
};
const fail = (res) => alert((res.errors || [res.error || "실패"]).slice(0, 12).join("\n"));
const TITLE = {
change: "코드변경 [uM_ChgCode] — 현재코드 → 바뀔코드",
replace: "항목교체(내역포함) [uM_ReplCode] — 현재코드 → 대상코드",
delete: "코드삭제 [uM_DelCode]",
};
function st(ctx, mode) {
const u = ctx.uiState;
if (!u.codeMgr || u.codeMgr.mode !== mode)
u.codeMgr = { mode, kind: "L", map: {}, sel: {}, kw: "" };
return u.codeMgr;
}
function priceOf(r, k) {
const f = { L: "LAMT1", M: "MAMT1", S: "SAMT1" }[k] || "TAMT1";
return Number(r[f]) || 0;
}
function mgrHtml(ctx, api) {
const s = ctx.uiState.codeMgr,
e = env(ctx),
used = UC.usedSet(e);
const rows = (ctx.dbfData.costn_rows || []).filter((r) => {
const c = UC.codeOf(r);
if (c[0] !== s.kind) return false;
const kw = String(s.kw || "")
.trim()
.toLowerCase();
return (
!kw || `${c} ${r.NAME || ""} ${r.SIZE || ""} ${r.BQCODE || ""}`.toLowerCase().includes(kw)
);
});
const cnt = (k) => (ctx.dbfData.costn_rows || []).filter((r) => UC.codeOf(r)[0] === k).length;
const tabs = KINDS.map(
(k) =>
`<button class="win-btn cm-kind${s.kind === k ? " primary" : ""}" data-k="${k}">${UC.KIND_NAME[k]}(${cnt(k)})</button>`,
).join("");
const col = s.mode === "delete" ? "선 택" : s.mode === "replace" ? "대상코드" : "바뀔코드";
const body = rows
.map((r) => {
const c = UC.codeOf(r);
const cell =
s.mode === "delete"
? `<input type="checkbox" class="cm-sel" data-c="${c}" ${s.sel[c] ? "checked" : ""} />`
: `<input type="checkbox" class="cm-sel" data-c="${c}" ${s.sel[c] ? "checked" : ""} /> <input class="cell-input cm-map" data-c="${c}" value="${esc(s.map[c] || "")}" style="width:70px;" />`;
return `<tr><td class="center"><strong>${c}</strong></td><td class="center">${cell}</td><td>${esc(r.NAME)}</td><td>${esc(r.SIZE)}</td><td class="center">${esc(r.UNIT)}</td><td class="center">${esc(r.BQCODE || "")}</td><td class="center">${esc(r.EQCODE || "")}</td><td class="num">${api.fmt(priceOf(r, s.kind))}</td><td class="center">${used.has(c) ? "*" : ""}</td></tr>`;
})
.join("");
const btn = (id, t) => `<button class="win-btn" id="${id}">${t}</button>`;
const acts = {
change: [
btn("cm-run-sel", "코드변환실행(선택)"),
btn("cm-run-all", "코드일괄변환"),
btn("cm-auto", "현재이후 자동입력"),
btn("cm-clear", "바뀔코드 초기화"),
btn("cm-plus", "현재코드 + α"),
],
replace: [
btn("cm-run-all", "항목교체실행"),
btn("cm-same", "동일항목 대상코드 자동입력"),
btn("cm-clear", "대상코드 초기화"),
],
delete: [
btn("cm-del", "선택코드 삭제실행"),
btn("cm-del-x", "코드일괄삭제 (중기비제외)"),
btn("cm-del-t", "코드일괄삭제 (중기비포함)"),
btn("cm-1000", '사용안된 "천원" "인" 일위대가 선택'),
btn("cm-same", "동일항목 자동선택"),
btn("cm-clear", "선택코드 초기화"),
],
}[s.mode];
return `<div class="stm-form"><div class="stm-row">${tabs}</div>
<div class="stm-row"><input id="cm-kw" class="win-input" value="${esc(s.kw)}" placeholder="코드,명칭,분류번호" style="width:180px;" />${acts.join("")}</div>
<div class="grid-scroll" style="max-height:52vh;"><table class="dbgrid"><thead><tr><th>현재코드</th><th>${col}</th><th>명칭</th><th>규격</th><th>단위</th><th>분류번호</th><th>시설코드</th><th>단가</th><th>*</th></tr></thead><tbody>${body}</tbody></table></div>
<p class="stm-note">* = 내역서·구성표·산출근거 식에 쓰임 · 쓰이는 코드는 지우지 않음 · 바꾼 뒤 [계산 완료] 저장 때 작업본에 씀</p></div>`;
}
function openMgr(ctx, api, mode) {
if (!need(ctx)) return;
const s = st(ctx, mode);
api.openModal(TITLE[mode], mgrHtml(ctx, api));
const re = () => openMgr(ctx, api, mode);
document
.querySelectorAll(".cm-kind")
.forEach((b) => (b.onclick = () => ((s.kind = b.getAttribute("data-k")), re())));
$("cm-kw").onchange = (e) => ((s.kw = e.target.value), re());
document
.querySelectorAll(".cm-map")
.forEach(
(i) =>
(i.onchange = () => (s.map[i.getAttribute("data-c")] = i.value.trim().toUpperCase())),
);
document
.querySelectorAll(".cm-sel")
.forEach((i) => (i.onchange = () => (s.sel[i.getAttribute("data-c")] = i.checked)));
const on = (id, fn) => $(id) && ($(id).onclick = fn);
const pick = (onlySel) =>
Object.fromEntries(Object.entries(s.map).filter(([c, v]) => v && (!onlySel || s.sel[c])));
const run = (onlySel) => {
const m = pick(onlySel);
const res = mode === "replace" ? UC.replaceCodes(env(ctx), m) : UC.changeCodes(env(ctx), m);
if (!res.ok) return fail(res);
s.map = {};
s.sel = {};
done(
ctx,
api,
`${mode === "replace" ? "항목교체" : "코드변경"} ${res.count}건 · 내역 ${res.refs.boq} · 구성 ${res.refs.comp} · 식 ${res.refs.memo}곳`,
);
};
on("cm-run-sel", () => run(true));
on("cm-run-all", () => run(false));
on("cm-clear", () => {
const k = s.kind;
Object.keys(s.map).forEach((c) => c[0] === k && delete s.map[c]);
Object.keys(s.sel).forEach((c) => c[0] === k && delete s.sel[c]);
re();
});
on("cm-auto", () => {
const list = (ctx.dbfData.costn_rows || []).map(UC.codeOf).filter((c) => c[0] === s.kind);
const from = list.find((c) => s.sel[c]) || list[0];
const start = prompt(`${from} 부터 차례로 넣을 첫 바뀔코드`, from);
if (!start || !UC.CODE_RE.test(start.trim().toUpperCase())) return;
let n = UC.codeNum(start.trim().toUpperCase());
list.slice(list.indexOf(from)).forEach((c) => (s.map[c] = UC.mkCode(s.kind, n++)));
re();
});
on("cm-plus", () => openPlus(ctx, api));
on("cm-same", () => {
const m = UC.autoReplaceMap(ctx.dbfData.costn_rows, s.kind);
if (mode === "delete") Object.keys(m).forEach((c) => (s.sel[c] = true));
else Object.assign(s.map, m);
if (!Object.keys(m).length) alert("동일항목 없음");
re();
});
on(
"cm-1000",
() => (UC.unusedBUnit(env(ctx)).forEach((c) => (s.sel[c] = true)), (s.kind = "B"), re()),
);
const del = (codes, what) => {
if (!codes.length) return alert(`${what} 없음`);
if (
!confirm(
`${what} ${codes.length}건을 지움\n${codes.slice(0, 20).join(", ")}${codes.length > 20 ? " …" : ""}`,
)
)
return;
const res = UC.deleteCodes(env(ctx), codes);
if (!res.ok) return fail(res);
s.sel = {};
done(
ctx,
api,
`코드삭제 ${res.count}건${res.kept.length ? ` · 쓰이는 코드 ${res.kept.length}건은 남김` : ""}`,
);
};
on("cm-del", () =>
del(
Object.keys(s.sel).filter((c) => s.sel[c]),
"선택코드",
),
);
on("cm-del-x", () => del(UC.unusedCodes(env(ctx), false), "안 쓰이는 코드(중기비제외)"));
on("cm-del-t", () => del(UC.unusedCodes(env(ctx), true), "안 쓰이는 코드(중기비포함)"));
}
// 코드번호 변경(현재코드 + α)
function openPlus(ctx, api) {
if (!need(ctx)) return;
const last = UC.lastCodes(ctx.dbfData.costn_rows);
const kinds = ["L", "M", "S", "B", "X", "D", "W"];
api.openModal(
"코드번호 변경 ( 현재코드 + α ) [uM_Chg_Plus]",
`<div class="stm-form">${kinds
.map(
(k) =>
`<div class="stm-row"><label style="width:260px;">${UC.KIND_NAME[k]} ( 현재최종 : ${last[k] || "-"} ) +</label><input id="cp-${k}" type="number" class="win-input" value="0" style="width:90px;" /></div>`,
)
.join(
"",
)}<div class="stm-row"><button class="win-btn primary" id="cp-ok">코드번호 변경(O)</button><button class="win-btn" id="cp-no">취 소(C)</button></div></div>`,
);
$("cp-no").onclick = api.closeModal;
$("cp-ok").onclick = () => {
const alpha = Object.fromEntries(kinds.map((k) => [k, Number($(`cp-${k}`).value) || 0]));
const res = UC.changeCodes(env(ctx), UC.plusMap(ctx.dbfData.costn_rows, alpha));
if (!res.ok) return fail(res);
done(ctx, api, `코드번호 + α ${res.count}건`);
};
}
// ── 단가목록표 한 코드 동작(신규등록 · 현재코드 삭제 · 코드번호 변경 · 항목교체) ──
function copyNew(ctx, api, code) {
if (!need(ctx)) return;
const kind = code
? code[0]
: (prompt("새 코드 종류 글자(L M S B X D W)", "M") || "").trim().toUpperCase();
if (!kind) return;
const def = UC.nextCode(ctx.dbfData.costn_rows, kind);
const nc = (prompt(code ? `${code} 를 복사한 새 코드` : "새 코드", def) || "")
.trim()
.toUpperCase();
if (!nc) return;
const fields = code
? {}
: {
NAME: prompt("명칭", "") || "",
SIZE: prompt("규격", "") || "",
UNIT: prompt("단위", "") || "",
};
const res = UC.addCode(env(ctx), { from: code || null, kind, code: nc, fields });
if (!res.ok) return fail(res);
const s = ctx.uiState.dan || (ctx.uiState.dan = {});
Object.assign(s, { code: res.code, kind: "ALL", kw: res.code });
ctx.uiState.danMsg = `${res.code} 신규등록${code ? ` (${code} 복사)` : ""}`;
api.cascadeAndRender();
}
function delOne(ctx, api, code) {
if (!code || !confirm(`${code} 삭제?`)) return;
const res = UC.deleteCodes(env(ctx), [code]);
if (!res.ok) return fail(res);
ctx.uiState.dan.code = "";
ctx.uiState.danMsg = `${code} 삭제`;
api.cascadeAndRender();
}
function chgOne(ctx, api, code, replace) {
if (!code) return;
const to = (
prompt(replace ? `${code} 를 쓰는 자리를 바꿀 대상코드` : `${code} → 바뀔코드`, "") || ""
)
.trim()
.toUpperCase();
if (!to) return;
const res = replace
? UC.replaceCodes(env(ctx), { [code]: to })
: UC.changeCodes(env(ctx), { [code]: to });
if (!res.ok) return fail(res);
ctx.uiState.dan.code = replace ? code : to;
ctx.uiState.danMsg = `${code} → ${to} ${replace ? "항목교체" : "코드변경"} · 내역 ${res.refs.boq} · 구성 ${res.refs.comp} · 식 ${res.refs.memo}곳`;
api.cascadeAndRender();
}
Object.assign(P.actions, {
Act_Chg_Code_Sel: (ctx, api) => openMgr(ctx, api, "change"),
Act_Chg_Code_All: (ctx, api) => openMgr(ctx, api, "change"),
Act_Chg_Plus: (ctx, api) => openPlus(ctx, api),
Act_Del_Code_Sel: (ctx, api) => openMgr(ctx, api, "delete"),
Act_Del_Code_All_Minus: (ctx, api) => (
openMgr(ctx, api, "delete"),
$("cm-del-x") && $("cm-del-x").click()
),
Act_Del_Code_All_Plus: (ctx, api) => (
openMgr(ctx, api, "delete"),
$("cm-del-t") && $("cm-del-t").click()
),
Act_Chg_Item: (ctx, api) => openMgr(ctx, api, "replace"),
});
global.STMATE_UI_CODE_SCR = { openMgr, openPlus, copyNew, delOne, chgOne, env };
})(window);
+41 -1
View File
@@ -47,13 +47,20 @@
const ini = ctx.dbfData.start_ini || {};
const slots = U.slotNames(ini);
const sort = s.sort || prefs().dflIndex || "C";
const list = U.filterRows(rows, {
let list = U.filterRows(rows, {
kind: s.kind,
kw: s.kw,
mode: s.mode,
sort,
memos: ctx.dbfData.costn_memos || {},
});
const UA = global.STMATE_UI_CODE_AMT;
if (UA && s.pp) list = list.filter((r) => UA.ppMatch(r, s.pp)); // 속성 추려보기(rg_FLT_*_PP)
if (s.nm) list = list.filter((r) => String(r.NAME || "").trim() === s.nm); // 명칭 추려보기
if (UA && s.called) {
const called = UA.calledCodes(ctx.dbfData); // 불러간 목록(wM_Box_H)
list = list.filter((r) => called.has(U.codeOf(r)));
}
const cnt = U.counts(rows);
const n2 = prefs().useName2;
const nm = (r, k) => (n2 && String(r[`${k}2`] || "").trim() ? r[`${k}2`] : r[k]) || "";
@@ -114,6 +121,7 @@
<button class="win-btn" id="dan-use" ${s.code ? "" : "disabled"}>사용처 찾기${s.code ? ` (${esc(s.code)})` : ""}</button>
<span class="stm-note">${esc(ctx.uiState.danMsg || "")}</span>
</div>
${toolBar(ctx, s)}
<div class="split-vert"><div class="pane-box" style="flex:1;"><div class="grid-scroll" id="dan-grid"><table class="dbgrid"><thead><tr>
<th>No</th><th>코드</th><th>명칭</th><th>규격</th><th>단위</th><th>재료비</th><th>노무비</th><th>경비</th><th>${esc(slots.applied)}</th>
${slots.list.map((x) => `<th title="${esc(x.name)}">${esc(x.name)}</th>`).join("")}<th>채택</th><th>분류번호</th><th>시설코드</th><th></th>
@@ -121,6 +129,20 @@
</div>`;
}
// 코드 관리·단가 손질 단추줄 — scr_ui_code.js · scr_ui_dan2.js · 추려보기(속성·명칭·불러간 목록)
function toolBar(ctx, s) {
const UA = global.STMATE_UI_CODE_AMT;
if (!UA || !global.STMATE_UI_CODE_SCR) return "";
const pp = s.kind === "B" ? UA.PP_B : s.kind === "M" ? UA.PP_M : [];
const names = UA.nameList(ctx.dbfData.costn_rows, s.kind === "ALL" ? "" : s.kind, "NAME");
const b = (id, t, on) =>
`<button class="win-btn" id="${id}" ${on === false ? "disabled" : ""}>${t}</button>`;
return `<div class="win-subbar">${b("dan-new", "신규등록(A)")}${b("dan-del", "현재코드 삭제", Boolean(s.code))}${b("dan-chg", "코드번호 변경", Boolean(s.code))}${b("dan-rp", "현재코드 항목교체(내역포함)", Boolean(s.code))}${b("dan-jd", "호환규정코드 검색")}${b("dan-hal", "적용단가 일괄할증")}${b("dan-chgamt", "단가 일괄변경")}${b("dan-xr", "손료계수(F6)")}
${pp.length ? `<select id="dan-pp" class="win-select"><option value="">속성: 모 두 보 임</option>${pp.map((x) => `<option ${s.pp === x ? "selected" : ""}>${esc(x)}</option>`).join("")}</select>` : ""}
<select id="dan-nm" class="win-select" style="max-width:180px;"><option value="">명칭 추려보기: 모두보기</option>${names.map(([n, k]) => `<option value="${esc(n)}" ${s.nm === n ? "selected" : ""}>${esc(n)} (${k})</option>`).join("")}</select>
<label><input type="checkbox" id="dan-called" ${s.called ? "checked" : ""} /> 불러간 목록만</label></div>`;
}
function boxa(ctx, api) {
const slots = U.slotNames(ctx.dbfData.start_ini);
const s = st(ctx);
@@ -200,6 +222,24 @@
$("dan-boxa").onclick = () => boxa(ctx, api);
$("dan-edthal").onclick = () => edtHal(ctx, api);
$("dan-use").onclick = () => findUse(ctx, api);
const C = global.STMATE_UI_CODE_SCR,
T2 = global.STMATE_UI_DAN2;
const on = (id, fn) => $(id) && ($(id).onclick = fn);
if (C) {
on("dan-new", () => C.copyNew(ctx, api, s.code));
on("dan-del", () => C.delOne(ctx, api, s.code));
on("dan-chg", () => C.chgOne(ctx, api, s.code, false));
on("dan-rp", () => C.chgOne(ctx, api, s.code, true));
}
if (T2) {
on("dan-jd", () => T2.openJD(ctx, api));
on("dan-hal", () => T2.openHal(ctx, api));
on("dan-chgamt", () => T2.openChgAmt(ctx, api));
on("dan-xr", () => T2.openXRate(ctx, api, s.code));
}
if ($("dan-pp")) $("dan-pp").onchange = (e) => ((s.pp = e.target.value), rr());
if ($("dan-nm")) $("dan-nm").onchange = (e) => ((s.nm = e.target.value), rr());
if ($("dan-called")) $("dan-called").onchange = (e) => ((s.called = e.target.checked), rr());
const grid = $("dan-grid");
if (s.scroll) grid.scrollTop = s.scroll;
grid.onscroll = () => (s.scroll = grid.scrollTop);
+374
View File
@@ -0,0 +1,374 @@
/**
* STMATE w 8.0 — 단가 손질 도구 창 끼워 넣기 (scr_ui_dan2.js)
* 단가 일괄변경·물가변동(uM_Chg_Amt · Act_1st_2nd/2nd_1st) · 적용단가 일괄할증(Edt_Hal_Rate/Amt) · 프로젝트 계산 초기값(wM_Set_Init)
* · 호환규정코드 찾기/입력(wM_JDCode) · 시간당 손료계수·수량계산기(wM_Edit_XRate · F6) · 사용자문자(wM_Edit_USER)
* · 실적단가 → 일식단가(wM_Conv_A2W) · 특수문자(mMinMsg_) · 메모(wInDlgMemo) · 동일코드 추려보기(wSameCode)
* 계산 = ui_code_amt.js · calc_xrate.js(STMATE_XRATE) · calc_a2w.js(STMATE_A2W) · 코드 등록 = ui_code.js
*/
(function (global) {
"use strict";
const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || {
screens: {},
actions: {},
menus: [],
});
const UA = global.STMATE_UI_CODE_AMT;
const UC = global.STMATE_UI_CODE;
const esc = (s) =>
String(s == null ? "" : s).replace(
/[&<>"]/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
);
const $ = (id) => document.getElementById(id);
const val = (id) => ($(id) ? $(id).value : "");
const num = (v) => Number(String(v == null ? "" : v).replace(/,/g, "")) || 0;
const opt = (list, cur) =>
list
.map(
([v, t]) =>
`<option value="${v}" ${String(v) === String(cur) ? "selected" : ""}>${esc(t)}</option>`,
)
.join("");
const need = (ctx) => (ctx.dbfData ? true : (alert("열린 프로젝트가 없음"), false));
const selCode = (ctx) => (ctx.uiState.dan || {}).code || "";
const KN = (k) => UC.KIND_NAME[k] || k;
const DOT = [
[-1, "10원 단위"],
[0, "1원 단위(소수 0)"],
[1, "소수 1자리"],
[2, "소수 2자리"],
[4, "소수 4자리"],
];
// 계산 원천 맞춤 — 1차 단가 바뀐 코드(기초단가 = 자원 · 그 밖 = 비목 합성 자원)
function syncState(ctx, codes) {
const rows = new Map((ctx.dbfData.costn_rows || []).map((r) => [UC.codeOf(r), r]));
codes.forEach((c) => {
const r = rows.get(c);
if (!r) return;
if (/^[MLS]/.test(c)) {
const res = (ctx.state.resources || []).find((x) => x.code === c && !x.isSynthetic);
if (res) Object.assign(res, { adoptMode: "manual", manualPrice: num(r.TAMT1) });
} else
global.STMATE_VIEW_PRJ_BOQ.syncCostnUnitPrice(
ctx,
c,
num(r.MAMT1),
num(r.LAMT1),
num(r.SAMT1),
);
});
}
const finish = (ctx, api, msg) => (
(ctx.uiState.danMsg = msg),
api.closeModal(),
api.cascadeAndRender()
);
// ── 단가 일괄변경·물가변동 ──
const SLOT = [
[1, "1 (당초·적용)"],
[2, "2 (변경)"],
[3, "3"],
[4, "4"],
];
function openChgAmt(ctx, api) {
if (!need(ctx)) return;
const c = selCode(ctx);
api.openModal(
"단가 일괄변경 · 물가변동 [uM_Chg_Amt]",
`<div class="stm-form"><div class="stm-row"><label>대상 비목</label>${["L", "M", "S", "W", "B", "D", "X"].map((k) => `<label><input type="checkbox" class="ca-k" value="${k}" ${"LMS".includes(k) ? "checked" : ""} /> ${KN(k)}</label>`).join("")}</div>
<div class="stm-row"><label>차수</label><select id="ca-from" class="win-select">${opt(SLOT, 1)}</select> 단가 ×<input id="ca-rate" type="number" class="win-input" value="100" style="width:80px;" /> % → <select id="ca-to" class="win-select">${opt(SLOT, 1)}</select> 단가</div>
<div class="stm-row"><label>끝전</label><select id="ca-dot" class="win-select">${opt(DOT, 0)}</select><select id="ca-up" class="win-select">${opt(
[
["trunc", "절사"],
["round", "반올림"],
],
"trunc",
)}</select><label><input type="checkbox" id="ca-z" checked /> 0원은 적용안함</label>${c ? `<label><input type="checkbox" id="ca-one" /> 선택 코드 ${esc(c)} 만</label>` : ""}</div>
<p class="stm-note">비목(재·노·경) 칸마다 따로 곱함 · 낙찰률 행은 2차 칸(100% 단가 자리)을 건드리지 않음 · 대가·산근·중기 1차는 [계산 완료] 때 다시 셈</p>
<div class="stm-row"><button class="win-btn primary" id="ca-ok">변경 실행</button></div></div>`,
);
$("ca-ok").onclick = () => {
const kinds = [...document.querySelectorAll(".ca-k:checked")].map((i) => i.value).join("");
const ch = UA.chgAmt(ctx.dbfData.costn_rows, {
kinds,
from: Number(val("ca-from")),
to: Number(val("ca-to")),
rate: num(val("ca-rate")),
digits: Number(val("ca-dot")),
mode: val("ca-up"),
skipZero: $("ca-z").checked,
codes: $("ca-one") && $("ca-one").checked ? [c] : null,
});
if (Number(val("ca-to")) === 1)
syncState(
ctx,
ch.map((x) => x.code),
);
finish(
ctx,
api,
`단가 일괄변경 ${ch.length}건 (${val("ca-from")}차 × ${val("ca-rate")}% → ${val("ca-to")}차)`,
);
};
}
const copySlot = (from, to) => (ctx, api) => {
if (
!need(ctx) ||
!confirm(`${from === 1 ? "당초단가 ⇒ 변경단가" : "변경단가 ⇒ 당초단가"} 로 모든 단가를 옮김`)
)
return;
const ch = UA.chgAmt(ctx.dbfData.costn_rows, {
kinds: "LMSWBDX",
from,
to,
rate: 100,
digits: 4,
skipZero: false,
});
if (to === 1)
syncState(
ctx,
ch.map((x) => x.code),
);
ctx.uiState.danMsg = `${from}차 → ${to}차 단가 ${ch.length}건`;
api.cascadeAndRender();
};
// ── 적용단가 일괄할증 ──
function openHal(ctx, api) {
if (!need(ctx)) return;
const c = selCode(ctx);
api.openModal(
"적용단가 일괄할증 [Edt_Hal_Rate · Edt_Hal_Amt]",
`<div class="stm-form"><div class="stm-row"><label>대상</label><select id="hl-k" class="win-select">${opt(
[
["M", "재료비"],
["L", "노무비"],
["S", "경비"],
["W", "견적단가"],
["MLS", "기초단가 전체"],
].concat(c ? [["one", `선택 코드 ${c}`]] : []),
"M",
)}</select></div>
<div class="stm-row"><label>할증</label><select id="hl-how" class="win-select">${opt(
[
["rate", "율(%) 할증"],
["amt", "금액(원) 가산"],
],
"rate",
)}</select><input id="hl-v" type="number" class="win-input" value="0" style="width:90px;" /></div>
<div class="stm-row"><label>끝전</label><select id="hl-dot" class="win-select">${opt(DOT, 0)}</select><select id="hl-up" class="win-select">${opt(
[
["trunc", "절사"],
["round", "반올림"],
],
"trunc",
)}</select></div>
<p class="stm-note">적용단가 = 기준단가(AMT_61, 없으면 지금 적용단가) × (1 + 율) 또는 + 금액 · 0 이면 기준단가로 되돌림</p>
<div class="stm-row"><button class="win-btn primary" id="hl-ok">할증 적용</button></div></div>`,
);
$("hl-ok").onclick = () => {
const k = val("hl-k");
const ch = UA.halAll(ctx.dbfData.costn_rows, {
kinds: k === "one" ? c[0] : k,
codes: k === "one" ? [c] : null,
how: val("hl-how"),
value: num(val("hl-v")),
digits: Number(val("hl-dot")),
mode: val("hl-up"),
});
syncState(
ctx,
ch.map((x) => x.code),
);
finish(ctx, api, `일괄할증 ${ch.length}건`);
};
}
// ── 프로젝트 계산 초기값(분류번호·적용기준일·시설코드·수공분류코드·호환규정코드 초기화) ──
const INIT_G = [
["BQ", "분류번호"],
["ED", "적용기준일"],
["EQ", "시설코드"],
["WQ", "수공분류코드"],
["JD", "호환규정코드"],
];
function openSetInit(ctx, api) {
if (!need(ctx)) return;
const box = (g) =>
`<fieldset style="display:inline-block;vertical-align:top;margin:4px;"><legend>${g[1]} 초기화</legend>${UA.INIT_KINDS.map((k) => `<label style="display:block;"><input type="checkbox" class="si-c" data-g="${g[0]}" value="${k}" /> ${KN(k)} 초기화</label>`).join("")}<button class="win-btn si-all" data-g="${g[0]}">전체항목 선택 / 취소</button></fieldset>`;
api.openModal(
"프로젝트 분류번호 / 시설코드 / 호환규정코드 / 수공분류코드 / 적용기준일 초기화 [wM_Set_Init]",
`<div class="stm-form">${INIT_G.map(box).join("")}<div class="stm-row"><button class="win-btn primary" id="si-ok">선택항목 초기화(O)</button></div></div>`,
);
document.querySelectorAll(".si-all").forEach(
(b) =>
(b.onclick = () => {
const cs = [...document.querySelectorAll(`.si-c[data-g="${b.getAttribute("data-g")}"]`)];
const on = !cs.every((x) => x.checked);
cs.forEach((x) => (x.checked = on));
}),
);
$("si-ok").onclick = () => {
const pick = {};
document
.querySelectorAll(".si-c:checked")
.forEach(
(x) =>
(pick[x.getAttribute("data-g")] = (pick[x.getAttribute("data-g")] || "") + x.value),
);
if (!Object.keys(pick).length || !confirm("선택한 칸을 비움")) return;
const n = UA.initCols(ctx.dbfData.costn_rows, pick);
ctx.dbfData.ui_dirty = true;
finish(
ctx,
api,
`초기화 — ${
Object.entries(n)
.map(([k, v]) => `${k} ${v}건`)
.join(" · ") || "비울 칸 없음"
}`,
);
};
}
// ── 호환규정코드 찾기 / 입력 ──
function openJD(ctx, api) {
if (!need(ctx)) return;
const c = selCode(ctx);
const r = (ctx.dbfData.costn_rows || []).find((x) => UC.codeOf(x) === c);
const dup = UA.jdDup(ctx.dbfData.costn_rows);
api.openModal(
"공사원가 호환규정코드 찾기 / 입력 [wM_JDCode]",
`<div class="stm-form">${r ? `<p><strong>${c}</strong> ${esc(r.NAME)} ${esc(r.SIZE)} ${esc(r.UNIT)} — 지금 호환규정코드 <strong>${esc(r.JDCODE || "(없음)")}</strong></p>` : '<p class="stm-warn">단가목록표에서 코드를 먼저 고르세요(중복체크만 가능)</p>'}
<div class="stm-row"><label>기준</label><select id="jd-base" class="win-select">${opt(
[
["N", "명칭"],
["NS", "명칭/규격"],
["BQ", "분류번호"],
["EQ", "시설코드"],
],
"NS",
)}</select><button class="win-btn" id="jd-go" ${r ? "" : "disabled"}>등록된 프로젝트에서 찾기</button>
<input id="jd-in" class="win-input" value="${esc((r && r.JDCODE) || "")}" style="width:160px;" placeholder="호환규정코드" /><button class="win-btn primary" id="jd-set" ${r ? "" : "disabled"}>입력</button><button class="win-btn" id="jd-del" ${r ? "" : "disabled"}>호환규정코드 삭제</button></div>
<div id="jd-res"></div>
<p class="stm-note">조달청 호환규정코드 목록(JDCODE_*.BSC)은 암호 자료라 쓰지 않음 — 등록된 프로젝트의 같은 항목 코드를 후보로 보임</p>
<strong>호환규정코드 중복체크 ${dup.length}건</strong><div class="grid-scroll" style="max-height:20vh;"><table class="dbgrid"><tbody>${dup.map((x) => `<tr><td>${esc(x.jd)}</td><td>${x.codes.join(", ")}</td></tr>`).join("")}</tbody></table></div></div>`,
);
const set = (v) => {
r.JDCODE = v;
ctx.dbfData.ui_dirty = true;
finish(ctx, api, `${c} 호환규정코드 ${v || "삭제"}`);
};
if (!r) return;
$("jd-set").onclick = () => set(val("jd-in").trim());
$("jd-del").onclick = () => set("");
$("jd-go").onclick = async () => {
const kind = /^[LMS]/.test(c) ? "LMS" : c[0] === "W" ? "W" : "XBD";
const res = await api.postJson("/api/ui/search", {
kind,
kw: String(r.NAME || "").trim(),
limit: 2000,
});
const pool = res.rows.concat(
(ctx.dbfData.costn_rows || []).map((x) => Object.assign({ project: "(지금 프로젝트)" }, x)),
);
const cand = UA.jdCandidates(r, pool, val("jd-base"));
$("jd-res").innerHTML = cand.length
? `<table class="dbgrid"><thead><tr><th>호환규정코드</th><th>건수</th><th>단위</th><th>프로젝트</th><th></th></tr></thead><tbody>${cand.map((x) => `<tr><td>${esc(x.jd)}</td><td class="num">${x.n}</td><td>${esc(x.unit)}</td><td>${esc(x.src.slice(0, 3).join(" · "))}</td><td><button class="win-btn jd-pick" data-jd="${esc(x.jd)}">고름</button></td></tr>`).join("")}</tbody></table>`
: '<p class="stm-note">같은 기준의 후보 없음</p>';
document
.querySelectorAll(".jd-pick")
.forEach((b) => (b.onclick = () => ($("jd-in").value = b.getAttribute("data-jd"))));
};
}
// ── 시간당 손료계수 수정 · 노무/장비 수량계산기(F6) ──
function openXRate(ctx, api, code) {
if (!need(ctx)) return;
const c = code || selCode(ctx) || ctx.uiState.selectedIlwiCode || "";
const by = Object.fromEntries((ctx.dbfData.costn_rows || []).map((r) => [UC.codeOf(r), r]));
const rows = (ctx.dbfData.bdqty_rows || []).filter(
(q) =>
String(q.BDCODE || "").trim() === c &&
by[String(q.CODE || "").trim()] &&
String(by[String(q.CODE).trim()].UNIT || "").trim() === "천원",
);
const q0 = rows[0] ? num(rows[0].QTY1) : 0;
const X = global.STMATE_XRATE;
api.openModal(
"시간당 손료계수 수정 [wM_Edit_XRate]",
`<div class="stm-form">${rows.length ? `<p><strong>${esc(c)}</strong> ${esc((by[c] || {}).NAME)} — 기계가격 줄 ${esc(rows[0].CODE)} 수량 ${q0} (= 계 ${UA.lossSum(q0, 100)} × 10<sup>-7</sup> × 1,000)</p>` : `<p class="stm-warn">${esc(c || "(코드 없음)")} — 천원 단위 기계가격 구성 줄이 없음(중기 X 코드를 고르세요) · 아래 수량계산기만 씀</p>`}
<div class="stm-row"><label>계수입력방법</label><select id="xr-way" class="win-select">${opt(
[
["0", "계 입력"],
["1", "각계수 입력"],
],
"0",
)}</select>
<label>상각비 계수</label><input id="xr-a" type="number" class="win-input" style="width:80px;" /><label>정비비 계수</label><input id="xr-b" type="number" class="win-input" style="width:80px;" /><label>관리비 계수</label><input id="xr-c" type="number" class="win-input" style="width:80px;" /></div>
<div class="stm-row"><label>계 (10<sup>-7</sup>)</label><input id="xr-sum" type="number" class="win-input" value="${UA.lossSum(q0, 100)}" style="width:100px;" /><label>손료 적용율</label><input id="xr-rate" type="number" class="win-input" value="100" style="width:70px;" />%<button class="win-btn primary" id="xr-ok" ${rows.length ? "" : "disabled"}>확 인(O)</button></div>
<hr /><strong>수량계산기</strong>
<div class="stm-row"><label>기준 시공량</label><input id="xq-q1" type="number" class="win-input" style="width:90px;" /><label>유형 / 조건</label><input id="xq-cond" class="win-input" value="1" style="width:110px;" /><label>적용율(%)</label><input id="xq-h" type="number" class="win-input" value="100" style="width:70px;" /></div>
<div class="stm-row"><label>편성 수량</label><input id="xq-l" type="number" class="win-input" value="1" style="width:70px;" /><label>인력 / 장비</label><select id="xq-lab" class="win-select">${opt(
[
["1", "인력"],
["0", "장비"],
],
"1",
)}</select><label>소수 자리</label><input id="xq-p" type="number" class="win-input" value="3" style="width:50px;" /><label><input type="checkbox" id="xq-auto" /> 자동</label><button class="win-btn" id="xq-go">계산</button><span id="xq-out" class="stm-note"></span></div></div>`,
);
const sumNow = () =>
$("xr-way").value === "1"
? num(val("xr-a")) + num(val("xr-b")) + num(val("xr-c"))
: num(val("xr-sum"));
["xr-a", "xr-b", "xr-c"].forEach(
(id) => ($(id).oninput = () => $("xr-way").value === "1" && ($("xr-sum").value = sumNow())),
);
$("xr-ok").onclick = () => {
const q = UA.lossQty(sumNow(), num(val("xr-rate")));
rows.forEach((r) => (r.QTY1 = String(q)));
finish(ctx, api, `${c} 손료계수 계 ${sumNow()} × 적용율 ${val("xr-rate")}% → 수량 ${q}`);
};
$("xq-go").onclick = () => {
if (!X) return ($("xq-out").textContent = "수량계산기(calc_xrate.js) 없음");
const o = X.compute({
qty1: val("xq-q1"),
cond: val("xq-cond"),
hrat: val("xq-h"),
lqty: val("xq-l"),
labor: val("xq-lab") === "1",
point: val("xq-p"),
auto: $("xq-auto").checked,
});
$("xq-out").textContent = o.ok
? `일당 시공량 ${o.qday} · 적용 수량 ${o.cqty} (자리 ${o.point})`
: o.error;
};
}
P.screens = P.screens || {};
Object.assign(P.actions, {
Act_1st_2nd: copySlot(1, 2),
Act_2nd_1st: copySlot(2, 1),
Act_UI_ChgAmt: (ctx, api) => openChgAmt(ctx, api),
Act_UI_SetInit: (ctx, api) => openSetInit(ctx, api),
Act_UI_XRate: (ctx, api) => openXRate(ctx, api),
});
P.menus.push(
{ menu: "m1", act: "Act_UI_SetInit", label: "프로젝트 분류번호/시설코드/호환규정코드 초기화" },
{ menu: "m4", act: "Act_UI_ChgAmt", label: "단가 일괄변경 · 물가변동" },
{ menu: "m2", act: "Act_UI_XRate", label: "시간당 손료계수 수정 · 수량계산기 (F6)" },
);
// F6 — 대가표·단가목록표 화면에서 손료계수 창
document.addEventListener("keydown", (e) => {
const A = global.STMATE_APP;
if (e.key !== "F6" || !A || !/^(wM_Edit_iLWi|wM_JabPum)$/.test(A.ctx.uiState.activeScreen))
return;
e.preventDefault();
openXRate(A.ctx, A);
});
global.STMATE_UI_DAN2 = { openChgAmt, openHal, openSetInit, openJD, openXRate, syncState };
})(window);
+394
View File
@@ -0,0 +1,394 @@
/**
* STMATE w 8.0 — 다른 프로젝트 자료 창 끼워 넣기 (scr_ui_merge.js)
* 프로젝트 DATA검색(wSrch_PL) · 설계예산시스템 코드병합(wM_Stmate_Merge_Code) · 데이타병합(uM_Merge) · 잡비 금액 불러오기(wM_GetAMT)
* 근거: DFM 원문 — wSrch_PL(wcBx_Type 4택 · 검색결과 내 재검색 · 열: 프로젝트명·코드번호·명칭·규격·단위·분류번호·시설코드·계·노·재·경·
* 호환규정코드·적용기준일 · 프로젝트 찾아가기) · wM_Stmate_Merge_Code(rg_Set_Sys 4택 · 코드전송시 전송여부 묻기)
* · uM_Merge(병합기준선택 · 단가목록표 · 병합코드 확인/검토 · 최종확인 신규코드번호부여 · 내역서(Ctrl+N))
* · wM_GetAMT(공종 추려보기 6택 · 필터 · 복수선택 합산 · 금액전송)
* 계산 = ui_merge.js · 검색 = /api/ui/search(api_ui_code.py) · 복사원 자료 = /api/load
*/
(function (global) {
"use strict";
const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || {
screens: {},
actions: {},
menus: [],
});
const UM = global.STMATE_UI_MERGE;
const UC = global.STMATE_UI_CODE;
const B = global.STMATE_UI_BOQ;
const esc = (s) =>
String(s == null ? "" : s).replace(
/[&<>"]/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
);
const $ = (id) => document.getElementById(id);
const num = (v) => Number(String(v == null ? "" : v).replace(/,/g, "")) || 0;
const env = (ctx) => ({
d: ctx.dbfData,
state: ctx.state,
memo: ctx.uiState.editedSmtMap || (ctx.uiState.editedSmtMap = {}),
});
const others = (ctx) =>
(ctx.sourcesData.projects || []).filter((p) => p.path !== ctx.currentSourcePath);
const prjOpts = (ctx, cur) =>
others(ctx)
.map(
(p) =>
`<option value="${esc(p.path)}" ${p.path === cur ? "selected" : ""}>${esc(p.name)}</option>`,
)
.join("");
const SRC = {}; // 복사원 자료 — 경로별 한 번
async function srcData(api, path) {
if (!SRC[path]) SRC[path] = await api.postJson("/api/load", { source_path: path });
return SRC[path];
}
// ── 프로젝트 DATA검색 ──
const TYPES = [
["LMS", "노무비, 재료비, 경비"],
["XBD", "중기비, 일위대가, 단가산출"],
["W", "일식/표준시장"],
["BOQ", "내역서"],
];
function srchHtml(ctx, api) {
const s = ctx.uiState.srch;
const rows = s.rows || [];
const isBoq = s.kind === "BOQ";
const head = isBoq
? "<th>No</th><th>프로젝트명</th><th>공종</th><th>명칭</th><th>규격</th><th>수량</th><th>단위</th><th>계금액</th><th>코드</th><th></th>"
: "<th>No</th><th>프로젝트명</th><th>코드번호</th><th>명칭</th><th>규격</th><th>단위</th><th>분류번호</th><th>시설코드</th><th>계</th><th>노무비</th><th>재료비</th><th>경비</th><th>호환규정코드</th><th>적용기준일</th><th></th>";
const go = (i) =>
`<button class="win-btn sp-go" data-i="${i}">찾아가기</button>${isBoq ? "" : `<button class="win-btn sp-get" data-i="${i}">가져오기</button>`}`;
const body = rows
.map((r, i) =>
isBoq
? `<tr><td class="center">${i + 1}</td><td>${esc(r.project)}</td><td>${esc(r.ITNUM)}</td><td>${esc(r.NAME1)}</td><td>${esc(r.SIZE1)}</td><td class="num">${esc(r.QTY1)}</td><td class="center">${esc(r.UNIT1)}</td><td class="num">${api.fmt(num(r.T_AMT1))}</td><td class="center">${esc(r.BDCODE1)}</td><td>${go(i)}</td></tr>`
: `<tr><td class="center">${i + 1}</td><td>${esc(r.project)}</td><td class="center">${esc(r.CODE)}</td><td>${esc(r.NAME)}</td><td>${esc(r.SIZE)}</td><td class="center">${esc(r.UNIT)}</td><td>${esc(r.BQCODE)}</td><td>${esc(r.EQCODE)}</td><td class="num">${api.fmt(num(r.TAMT1))}</td><td class="num">${api.fmt(num(r.LAMT1))}</td><td class="num">${api.fmt(num(r.MAMT1))}</td><td class="num">${api.fmt(num(r.SAMT1))}</td><td>${esc(r.JDCODE)}</td><td>${esc(r.EDT_DATE)}</td><td>${go(i)}</td></tr>`,
)
.join("");
return `<div class="stm-form"><div class="stm-row"><select id="sp-kind" class="win-select">${TYPES.map(([k, t]) => `<option value="${k}" ${s.kind === k ? "selected" : ""}>${t}</option>`).join("")}</select>
<input id="sp-kw" class="win-input" value="${esc(s.kw)}" placeholder="명칭, 규격 (띄어 쓰면 모두 든 것)" style="width:240px;" />
<label><input type="checkbox" id="sp-re" ${s.re ? "checked" : ""} /> 검색결과 내 재검색</label><button class="win-btn primary" id="sp-go">검 색</button>
<span class="stm-note">${rows.length}건${s.truncated ? " (앞 500건)" : ""}${s.err ? ` · 못 연 프로젝트 ${s.err}` : ""}</span></div>
<div class="grid-scroll" style="max-height:60vh;"><table class="dbgrid"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div></div>`;
}
function openSrch(ctx, api) {
const s = (ctx.uiState.srch = ctx.uiState.srch || { kind: "LMS", kw: "", rows: [], re: false });
api.openModal("프로젝트 DATA검색(명칭, 규격) [wSrch_PL]", srchHtml(ctx, api));
$("sp-kind").onchange = (e) => (s.kind = e.target.value);
$("sp-kw").onchange = (e) => (s.kw = e.target.value);
$("sp-re").onchange = (e) => (s.re = e.target.checked);
$("sp-go").onclick = async () => {
s.kw = $("sp-kw").value;
if (s.re && s.rows.length) {
const w = s.kw.toLowerCase().split(/\s+/).filter(Boolean);
const hay = (r) => `${r.NAME || r.NAME1 || ""} ${r.SIZE || r.SIZE1 || ""}`.toLowerCase();
s.rows = s.rows.filter((r) => w.every((x) => hay(r).includes(x)));
} else {
const res = await api.postJson("/api/ui/search", { kind: s.kind, kw: s.kw });
Object.assign(s, {
rows: res.rows,
truncated: res.truncated,
err: (res.errors || []).length,
});
}
openSrch(ctx, api);
};
document.querySelectorAll(".sp-go").forEach(
(b) =>
(b.onclick = () => {
const r = s.rows[Number(b.getAttribute("data-i"))];
api.closeModal();
api.loadProjectFromServer(r.path, s.kind === "BOQ" ? "wBoq" : "wM_JabPum").then(() => {
if (s.kind !== "BOQ")
ctx.uiState.dan = Object.assign(ctx.uiState.dan || {}, {
kind: "ALL",
kw: r.CODE,
code: r.CODE,
});
api.renderWorkspace();
});
}),
);
document.querySelectorAll(".sp-get").forEach(
(b) =>
(b.onclick = () => {
const r = s.rows[Number(b.getAttribute("data-i"))];
openMergeCode(ctx, api, { path: r.path, codes: [r.CODE] });
}),
);
}
// ── 코드병합 · 데이타병합 ──
const KINDS = ["L", "M", "S", "B", "X", "D", "W"];
const POL_KINDS = ["L", "M", "S", "B", "D", "W"];
function mgHtml(ctx, api, m) {
const src = m.src;
const list = src
? src.costn_rows.filter(
(r) =>
UC.codeOf(r)[0] === m.kind &&
(!m.kw || `${r.CODE} ${r.NAME} ${r.SIZE}`.toLowerCase().includes(m.kw.toLowerCase())),
)
: [];
const tabs = KINDS.map(
(k) =>
`<button class="win-btn mg-kind${m.kind === k ? " primary" : ""}" data-k="${k}">${UC.KIND_NAME[k]}</button>`,
).join("");
const pol = m.data
? `<div class="stm-row">${POL_KINDS.map(
(k) =>
`<label>${UC.KIND_NAME[k]} <select class="win-select mg-pol" data-k="${k}">${UM.POLICY.map((t, i) => `<option value="${i}" ${Number(m.policy[k] || 0) === i ? "selected" : ""}>${t}</option>`).join("")}</select></label>`,
).join(" ")}</div>
<div class="stm-row"><label>동일항목 연결지정 <select id="mg-conn" class="win-select">${["1차:호환,2차:시설,3차:명칭..단위", "1차:호환,2차:명칭+규격+단위", "명칭+규격+단위로 동일항목 검색"].map((t, i) => `<option value="${i}" ${m.conn === i ? "selected" : ""}>${t}</option>`).join("")}${m.byCode ? '<option value="code" selected>코드번호로 인식</option>' : ""}</select></label>
<label><input type="checkbox" id="mg-bdw" ${m.bdW ? "checked" : ""} /> 대가,산근 → 일식단가(W코드)로 생성</label><label><input type="checkbox" id="mg-xw" ${m.xW ? "checked" : ""} /> 중기비 → 일식단가(W코드)로 생성</label></div>`
: "";
const plan = (m.plan || [])
.map(
(p) =>
`<tr><td class="center">${p.src}</td><td class="center">${{ map: "복사처적용", copy: "복사원적용", new: "신규등록", w: "일식단가(W)" }[p.act]}</td><td class="center"><strong>${p.dest}</strong></td><td>${esc((src.costn_rows.find((r) => UC.codeOf(r) === p.src) || {}).NAME)}</td></tr>`,
)
.join("");
return `<div class="stm-form"><div class="stm-row"><label>복사원</label><select id="mg-src" class="win-select" style="max-width:420px;">${prjOpts(ctx, m.path)}</select><button class="win-btn" id="mg-open">불러오기</button><span class="stm-note">복사처 = 지금 프로젝트</span></div>
${
src
? `<div class="stm-row">${tabs}<input id="mg-kw" class="win-input" value="${esc(m.kw || "")}" placeholder="코드·명칭" style="width:140px;" /></div>
<div class="grid-scroll" style="max-height:30vh;"><table class="dbgrid"><thead><tr><th>선택</th><th>코드</th><th>명칭</th><th>규격</th><th>단위</th><th>계</th><th>호환규정코드</th></tr></thead><tbody>${list
.map(
(r) =>
`<tr><td class="center"><input type="checkbox" class="mg-sel" data-c="${UC.codeOf(r)}" ${m.sel[UC.codeOf(r)] ? "checked" : ""} /></td><td class="center">${UC.codeOf(r)}</td><td>${esc(r.NAME)}</td><td>${esc(r.SIZE)}</td><td class="center">${esc(r.UNIT)}</td><td class="num">${api.fmt(num(r.TAMT1))}</td><td>${esc(r.JDCODE || "")}</td></tr>`,
)
.join("")}</tbody></table></div>
<div class="stm-row"><label>코드병합 내용 선택 <select id="mg-mode" class="win-select">${["선택코드만 병합", "선택코드+하위기초단가코드 병합", "모든코드(하위 산근,대가포함) 병합", "코드병합 취소"].map((t, i) => `<option value="${i}" ${m.mode === i ? "selected" : ""}>${t}</option>`).join("")}</select></label></div>${pol}
<div class="stm-row"><button class="win-btn" id="mg-check">병합코드 확인/검토</button><button class="win-btn primary" id="mg-ok" ${m.plan ? "" : "disabled"}>확 인(병합)</button><span class="stm-note">※ 단 복사처에 등록되지 않은코드 : 코드번호 생성(병합)</span></div>
${plan ? `<div class="grid-scroll" style="max-height:24vh;"><table class="dbgrid"><thead><tr><th>복사원코드</th><th>적용방법</th><th>복사처코드</th><th>명칭</th></tr></thead><tbody>${plan}</tbody></table></div>` : ""}`
: ""
}</div>`;
}
function openMergeCode(ctx, api, o) {
if (!ctx.dbfData) return alert("열린 프로젝트가 없음");
const prev = ctx.uiState.mg;
const m = (ctx.uiState.mg = Object.assign(
{ kind: "B", sel: {}, mode: 2, conn: 2, policy: {}, data: false, byCode: false },
prev && !(o && o.reset) ? prev : {},
o || {},
));
if (o && o.codes) o.codes.forEach((c) => ((m.sel[c] = true), (m.kind = c[0])));
if (o && o.path && (!m.src || m.src.source_path !== o.path))
return srcData(api, o.path).then(
(d) => ((m.src = d), (m.path = o.path), openMergeCode(ctx, api)),
);
const title = m.data
? `데이타 병합 【 복사처 <= 복사원 】 [uM_Merge${m.byCode ? " · 코드번호로 인식" : ""}]`
: "설계예산시스템 코드병합 [wM_Stmate_Merge_Code]";
api.openModal(title, mgHtml(ctx, api, m));
const re = () => openMergeCode(ctx, api);
$("mg-open").onclick = () =>
openMergeCode(ctx, api, { path: $("mg-src").value, sel: {}, plan: null });
if (!m.src) return;
document
.querySelectorAll(".mg-kind")
.forEach((b) => (b.onclick = () => ((m.kind = b.getAttribute("data-k")), re())));
document
.querySelectorAll(".mg-sel")
.forEach(
(i) =>
(i.onchange = () => ((m.sel[i.getAttribute("data-c")] = i.checked), (m.plan = null))),
);
document
.querySelectorAll(".mg-pol")
.forEach(
(s) =>
(s.onchange = () => (
(m.policy[s.getAttribute("data-k")] = Number(s.value)),
(m.plan = null)
)),
);
$("mg-kw").onchange = (e) => ((m.kw = e.target.value), re());
$("mg-mode").onchange = (e) => ((m.mode = Number(e.target.value)), (m.plan = null));
if ($("mg-conn"))
$("mg-conn").onchange = (e) => (
(m.conn = e.target.value === "code" ? "code" : Number(e.target.value)),
(m.plan = null)
);
if ($("mg-bdw")) $("mg-bdw").onchange = (e) => ((m.bdW = e.target.checked), (m.plan = null));
if ($("mg-xw")) $("mg-xw").onchange = (e) => ((m.xW = e.target.checked), (m.plan = null));
$("mg-check").onclick = () => {
if (m.mode === 3) return ((m.sel = {}), (m.plan = null), re()); // 코드병합 취소
const codes = Object.keys(m.sel).filter((c) => m.sel[c]);
if (!codes.length) return alert("병합할 코드를 고르세요");
m.plan = UM.plan(env(ctx), m.src, {
codes,
mode: m.mode,
conn: m.byCode ? "code" : m.conn,
policy: m.data ? m.policy : {},
bdW: m.data && m.bdW,
xW: m.data && m.xW,
});
re();
};
$("mg-ok").onclick = () => {
if (!m.plan) return;
const res = UM.apply(env(ctx), m.src, m.plan);
const c = res.count;
ctx.uiState.danMsg = `병합 — 신규 ${c.new} · 복사원적용 ${c.copy} · 복사처적용 ${c.map} · W ${c.w}${res.unresolved.length ? ` · 못 이은 하위코드 ${res.unresolved.join(",")}` : ""}`;
Object.assign(m, { plan: null, sel: {} });
api.closeModal();
api.cascadeAndRender();
};
}
// ── 잡비 금액 불러오기 ──
const LV = [
" 모두보기",
" 소 계(+=)",
" 계(+==)",
" 합 계(+===)",
" 총 계(+====)",
" 총합계(+=====)",
];
async function openGetAmt(ctx, api) {
if (!ctx.dbfData) return alert("열린 프로젝트가 없음");
const g = (ctx.uiState.getAmt = ctx.uiState.getAmt || { path: "", lv: 0, flt: "", sel: {} });
const rows = g.path
? (await srcData(api, g.path)).boq_rows
: global.STMATE_VIEW_PRJ_BOQ.evaluateFullBoqRows(ctx);
const words = g.flt.toLowerCase().split(/\s+/).filter(Boolean);
const list = g.lv
? B.groupTotals(rows, g.lv).map((x) => ({
key: `g${x.recno}`,
it: x.itnum,
name: x.name,
size: "",
qty: "",
unit: "",
amt: x.sum.T,
}))
: rows
.filter(B.isLeaf)
.map((r) => ({
key: `r${r._recno}`,
it: r.ITNUM || "",
name: r.NAME1 || "",
size: r.SIZE1 || "",
qty: r.QTY1,
unit: r.UNIT1 || "",
amt: num(r.T_AMT1),
}));
const shown = list.filter((x) =>
words.every((w) => `${x.name} ${x.size}`.toLowerCase().includes(w)),
);
const sum = list.filter((x) => g.sel[x.key]).reduce((a, x) => a + x.amt, 0);
const keys = Object.keys((ctx.state.kanjubConfig && ctx.state.kanjubConfig.keys) || {}).filter(
(k) => /^C_/.test(k),
);
api.openModal(
"잡비 금액 불러오기 [wM_GetAMT]",
`<div class="stm-form"><div class="stm-row"><select id="ga-prj" class="win-select" style="max-width:380px;"><option value="">(지금 프로젝트)</option>${prjOpts(ctx, g.path)}</select>
<select id="ga-lv" class="win-select">${LV.map((t, i) => `<option value="${i}" ${g.lv === i ? "selected" : ""}>${t}${i ? " 이상" : ""}</option>`).join("")}</select>
<input id="ga-flt" class="win-input" value="${esc(g.flt)}" placeholder="필터(명칭, 규격)" style="width:150px;" /></div>
<div class="grid-scroll" style="max-height:48vh;"><table class="dbgrid"><thead><tr><th></th><th>공종</th><th>명칭</th><th>규격</th><th>수량</th><th>단위</th><th>계금액</th></tr></thead><tbody>${shown
.map(
(x) =>
`<tr><td class="center"><input type="checkbox" class="ga-sel" data-k="${x.key}" ${g.sel[x.key] ? "checked" : ""} /></td><td>${esc(x.it)}</td><td>${esc(x.name)}</td><td>${esc(x.size)}</td><td class="num">${esc(x.qty)}</td><td class="center">${esc(x.unit)}</td><td class="num">${api.fmt(x.amt)}</td></tr>`,
)
.join("")}</tbody></table></div>
<div class="stm-row"><strong>선택 합산 ${api.fmt(sum)}원</strong><label>보낼 잡비 항목(직접입력 금액)</label><select id="ga-key" class="win-select">${keys.map((k) => `<option value="${k}">${k.slice(2)}</option>`).join("")}</select><button class="win-btn primary" id="ga-ok" ${keys.length ? "" : "disabled"}>금액전송</button></div></div>`,
);
const re = () => openGetAmt(ctx, api);
$("ga-prj").onchange = (e) => ((g.path = e.target.value), (g.sel = {}), re());
$("ga-lv").onchange = (e) => ((g.lv = Number(e.target.value)), (g.sel = {}), re());
$("ga-flt").onchange = (e) => ((g.flt = e.target.value), re());
document
.querySelectorAll(".ga-sel")
.forEach((i) => (i.onchange = () => ((g.sel[i.getAttribute("data-k")] = i.checked), re())));
$("ga-ok").onclick = () => {
const k = $("ga-key").value.slice(2);
const cfg = ctx.state.kanjubConfig;
cfg.keys["C_" + k] = Math.trunc(sum);
cfg.keys["D_" + k] = 1; // 고정(직접입력 금액 사용)
ctx.uiState.danMsg = `잡비 ${k} ← ${sum}원(금액전송)`;
api.closeModal();
api.renderWorkspace();
};
}
// ── 자료파일 업데이트(wM_Mrg_BSC) — 복사원 = 다른 프로젝트 단가목록(원본 기준자료 BSC 는 암호 자료) ──
const MKEY = [
["JD", "호환규정코드"],
["EQ", "시설코드"],
["NSU", "명칭/규격/단위"],
["JNSU", "호환+명+규+단"],
["NU", "명칭/단위"],
["", "설정안함"],
];
async function openMrgBsc(ctx, api) {
if (!ctx.dbfData) return alert("열린 프로젝트가 없음");
const b = (ctx.uiState.mrgBsc = ctx.uiState.mrgBsc || {
path: "",
keys: ["JD", "NSU", "NU", "", ""],
name: 0,
amt: 1,
jd: 0,
});
const sel = (id, list, cur) =>
`<select id="${id}" class="win-select">${list.map(([v, t]) => `<option value="${v}" ${String(v) === String(cur) ? "selected" : ""}>${t}</option>`).join("")}</select>`;
const UPD = [
[0, "업데이트 안함(복사처적용)"],
[1, "업데이트 (복사원적용)"],
];
api.openModal(
"자료파일 업데이트 [wM_Mrg_BSC]",
`<div class="stm-form"><div class="stm-row"><label>복사원</label><select id="mb-src" class="win-select" style="max-width:420px;">${prjOpts(ctx, b.path)}</select></div>
<div class="stm-row">${[0, 1, 2, 3, 4].map((i) => `<label>${i + 1}차</label>${sel(`mb-k${i}`, MKEY, b.keys[i])}`).join("")}</div>
<div class="stm-row"><label>명칭</label>${sel("mb-name", UPD, b.name)}<label>단가</label>${sel("mb-amt", UPD, b.amt)}<label>조달코드</label>${sel(
"mb-jd",
UPD.concat([
[2, "복사처 우선적용 업데이트"],
[3, "복사원 우선적용 업데이트"],
]),
b.jd,
)}</div>
<p class="stm-note">대상 = 노무·재료·경비·견적(W) · 원본 기준자료(ST_BSC)는 암호 자료라 다른 프로젝트 단가목록을 복사원으로 씀</p>
<div class="stm-row"><button class="win-btn primary" id="mb-ok">업데이트</button></div></div>`,
);
$("mb-ok").onclick = async () => {
Object.assign(b, {
path: $("mb-src").value,
keys: [0, 1, 2, 3, 4].map((i) => $(`mb-k${i}`).value),
name: Number($("mb-name").value),
amt: Number($("mb-amt").value),
jd: Number($("mb-jd").value),
});
const src = await srcData(api, b.path);
const ch = UM.mrgBsc(ctx.dbfData.costn_rows, src.costn_rows, b);
if (global.STMATE_UI_DAN2)
global.STMATE_UI_DAN2.syncState(
ctx,
ch.filter((x) => x.fields.some((f) => /AMT1$/.test(f))).map((x) => x.code),
);
if (ch.some((x) => x.fields.includes("JDCODE"))) ctx.dbfData.ui_dirty = true;
ctx.uiState.danMsg = `자료파일 업데이트 ${ch.length}건`;
api.closeModal();
api.cascadeAndRender();
};
}
Object.assign(P.actions, {
Act_UI_MrgBsc: (ctx, api) => openMrgBsc(ctx, api),
Act_Merge: (ctx, api) => openMergeCode(ctx, api, { data: true, byCode: false, reset: true }),
Act_Merge_C: (ctx, api) => openMergeCode(ctx, api, { data: true, byCode: true, reset: true }),
Act_UI_MergeCode: (ctx, api) =>
openMergeCode(ctx, api, { data: false, byCode: false, reset: true }),
Act_UI_SrchPL: (ctx, api) => openSrch(ctx, api),
Act_UI_GetAmt: (ctx, api) => openGetAmt(ctx, api),
});
P.menus.push(
{ menu: "m4", act: "Act_UI_MergeCode", label: "설계예산시스템 코드병합" },
{ menu: "m4", act: "Act_UI_SrchPL", label: "프로젝트 DATA검색(명칭, 규격)" },
{ menu: "m3", act: "Act_UI_GetAmt", label: "잡비 금액 불러오기" },
{ menu: "m2", act: "Act_UI_MrgBsc", label: "단가(명칭,규격, 호환규정코드..) 업데이트" },
);
global.STMATE_UI_MERGE_SCR = { openSrch, openMergeCode, openGetAmt, openMrgBsc };
})(window);
+28 -10
View File
@@ -163,16 +163,34 @@
api.renderWorkspace();
};
function needActive(ctx) {
const r = selRow(ctx);
if (r && String(r.PCODE).trim() !== activeCode(ctx)) {
alert(
`BOQ${String(r.PCODE).trim()}.DBF 내역서 열기는 서버 불러오기 대기 — 지금 열린 내역서는 BOQ${activeCode(ctx)}`,
);
// 다른 내역서 열기 — 고른 공사 행의 BOQ{PCODE}.DBF 로 바꿔 불러옴(/api/ui/load-boq · api_ui_code.py)
async function openBoq(ctx, api, pcode) {
const d = ctx.dbfData;
const dirty =
d && (d.ui_dirty || (d.boq_deleted || []).length || (d.boq_rows || []).some((r) => r._new));
if (dirty && !confirm("지금 내역서에 저장 안 한 손질이 있음 — 버리고 다른 내역서를 열까요?"))
return false;
try {
const data = await api.postJson("/api/ui/load-boq", {
source_path: ctx.currentSourcePath,
pcode,
});
ctx.dbfData = data;
ctx.state = data.sim_state;
Object.assign(ctx.uiState, { editedSmtMap: {}, boqSel: [], sysDraft: null, prjSel: pcode });
return true;
} catch (err) {
alert("내역서 열기 실패: " + err.message);
return false;
}
return true;
}
// 고른 공사가 열린 내역서가 아니면 먼저 연 뒤 이어서
const withActive = (fn) => (ctx, api) => {
const r = selRow(ctx);
const pc = r ? String(r.PCODE).trim() : "";
if (!r || pc === activeCode(ctx)) return fn(ctx, api);
openBoq(ctx, api, pc).then((ok) => ok && fn(ctx, api));
};
function editMeta(ctx, api) {
const r = selRow(ctx);
@@ -205,11 +223,11 @@
}
const H = {
ActEditBoq: (ctx, api) => needActive(ctx) && go(ctx, api, "wBoq"),
ActEditBoq: withActive((ctx, api) => go(ctx, api, "wBoq")),
ActHaeCost: (ctx, api) => go(ctx, api, "wM_JabPum"),
Act_ED_PNUM: editMeta,
Act_Ed_Jab: (ctx, api) => needActive(ctx) && go(ctx, api, "wM_KanJub"),
Act_CalBoq: (ctx, api) => needActive(ctx) && api.cascadeAndRender(),
Act_Ed_Jab: withActive((ctx, api) => go(ctx, api, "wM_KanJub")),
Act_CalBoq: withActive((ctx, api) => api.cascadeAndRender()),
Act_Calc_Hae: (ctx, api) => api.cascadeAndRender(),
Act_Prn: (ctx, api) => go(ctx, api, "PM_Prt"),
ActMkCont00: stage("Act_Stg_Contract"),
+324
View File
@@ -0,0 +1,324 @@
/**
* STMATE w 8.0 — 글자 입력 도우미·내역 보조 창 끼워 넣기 (scr_ui_text.js)
* 특수문자(mMinMsg_ · 14종 · 문자 입력후 종료 · 다중문자 동시입력) · 사용자문자 편집(wM_Edit_USER · 검색명칭/입력내용/적용)
* · 메모 입력(wInDlgMemo · Ok/Cancel/Load/Clear — 내역 행 DES1·DES2) · 동일코드 추려보기(wSameCode «추려보기» · N번 사용중 · 더블클릭 → 내역 위치)
* · 실적단가 → 일식단가 변환등록(wM_Conv_A2W · 항목선택 → 비목율 → 변환코드 → 변환/생성)
* 글자 넣는 자리 = 마지막으로 손댄 입력칸·글상자(커서 자리) · 사용자문자 저장 = 브라우저(localStorage, 원본 USR_SYNC.DBF 자리)
*/
(function (global) {
"use strict";
const P = (global.STMATE_PLUGINS = global.STMATE_PLUGINS || {
screens: {},
actions: {},
menus: [],
});
const UA = global.STMATE_UI_CODE_AMT;
const UC = global.STMATE_UI_CODE;
const esc = (s) =>
String(s == null ? "" : s).replace(
/[&<>"]/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
);
const $ = (id) => document.getElementById(id);
const num = (v) => Number(String(v == null ? "" : v).replace(/,/g, "")) || 0;
// 마지막 입력 자리 — 창(모달) 안 입력칸은 빼고 작업 화면 칸만 기억
let target = null;
document.addEventListener("focusin", (e) => {
const el = e.target;
if (
/^(INPUT|TEXTAREA)$/.test(el.tagName) &&
!el.closest("#stmate-modal-window") &&
el.type !== "checkbox"
)
target = el;
});
function insertText(s) {
const el = target;
if (!el || !document.body.contains(el))
return (navigator.clipboard && navigator.clipboard.writeText(s), false);
const a = el.selectionStart ?? el.value.length,
b = el.selectionEnd ?? el.value.length;
el.value = el.value.slice(0, a) + s + el.value.slice(b);
el.selectionStart = el.selectionEnd = a + s.length;
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
return true;
}
const where = () =>
target && document.body.contains(target)
? `넣을 자리: ${target.id || target.className || target.tagName}`
: "넣을 자리 없음 — 클립보드로 복사";
// ── 특수문자 ──
const dec = typeof TextDecoder !== "undefined" ? new TextDecoder("euc-kr") : null;
function openSpChar(ctx, api) {
const s = (ctx.uiState.spc = ctx.uiState.spc || { i: 0, close: true, multi: false, buf: "" });
const chars = dec ? UA.specialChars(s.i, (b) => dec.decode(b)) : [];
api.openModal(
"특수문자 [mMinMsg_]",
`<div class="stm-form" style="display:flex;gap:10px;"><select id="sc-kind" size="14" class="win-select">${UA.SPECIAL.map(([t], i) => `<option value="${i}" ${s.i === i ? "selected" : ""}>${t}</option>`).join("")}</select>
<div><div style="display:grid;grid-template-columns:repeat(16,26px);gap:2px;max-height:50vh;overflow:auto;">${chars.map((c) => `<button class="win-btn sc-ch" style="padding:2px;">${esc(c)}</button>`).join("")}</div>
<div class="stm-row"><label>입력될문자</label><input id="sc-buf" class="win-input" value="${esc(s.buf)}" style="width:160px;" /><label><input type="checkbox" id="sc-close" ${s.close ? "checked" : ""} /> 문자 입력후 종료</label><label><input type="checkbox" id="sc-multi" ${s.multi ? "checked" : ""} /> 다중문자 동시입력</label><button class="win-btn primary" id="sc-put">입력</button></div>
<p class="stm-note">${esc(where())}</p></div></div>`,
);
$("sc-kind").onchange = (e) => ((s.i = Number(e.target.value)), openSpChar(ctx, api));
$("sc-close").onchange = (e) => (s.close = e.target.checked);
$("sc-multi").onchange = (e) => (s.multi = e.target.checked);
const put = (t) => {
insertText(t);
s.buf = "";
if (s.close) api.closeModal();
};
document
.querySelectorAll(".sc-ch")
.forEach(
(b) =>
(b.onclick = () =>
s.multi
? (($("sc-buf").value += b.textContent), (s.buf = $("sc-buf").value))
: put(b.textContent)),
);
$("sc-put").onclick = () => put($("sc-buf").value);
}
// ── 사용자문자 편집 ──
const UKEY = "stmate_user_text";
const loadU = () => {
try {
return JSON.parse(global.localStorage.getItem(UKEY) || "[]");
} catch (_e) {
return [];
}
};
const saveU = (list) => {
try {
global.localStorage.setItem(UKEY, JSON.stringify(list));
} catch (_e) {
// 저장 공간 없음 — 이번 실행 동안만
}
};
function openUser(ctx, api) {
const list = loadU();
const u = (ctx.uiState.usr = ctx.uiState.usr || { kw: "", sel: 0 });
const shown = UA.userTextFind(
list.map((x, i) => Object.assign({ i }, x)),
u.kw,
).map((x) => x.i);
const all = u.kw ? shown : list.map((_x, i) => i);
api.openModal(
"사용자문자 편집 [wM_Edit_USER]",
`<div class="stm-form"><div class="stm-row"><input id="us-kw" class="win-input" value="${esc(u.kw)}" placeholder="검색 명칭" style="width:160px;" />
<button class="win-btn" id="us-add">항목추가</button><button class="win-btn" id="us-del">항목삭제</button><button class="win-btn" id="us-up">위로 이동</button><button class="win-btn" id="us-dn">아래로이동</button><button class="win-btn" id="us-init">초기화</button><button class="win-btn" id="us-sp">특수문자 불러오기</button></div>
<div class="grid-scroll" style="max-height:46vh;"><table class="dbgrid"><thead><tr><th></th><th>검 색 명 칭</th><th>입 력 내 용</th><th>적용</th><th></th></tr></thead><tbody>${all
.map(
(i) =>
`<tr class="${u.sel === i ? "boq-sel" : ""}"><td class="center"><input type="radio" name="us-r" class="us-r" data-i="${i}" ${u.sel === i ? "checked" : ""} /></td><td><input class="cell-input us-k" data-i="${i}" value="${esc(list[i].key)}" /></td><td><input class="cell-input us-t" data-i="${i}" value="${esc(list[i].text)}" style="width:320px;" /></td><td class="center"><input type="checkbox" class="us-on" data-i="${i}" ${list[i].on !== false ? "checked" : ""} /></td><td><button class="win-btn us-put" data-i="${i}">넣기</button></td></tr>`,
)
.join("")}</tbody></table></div><p class="stm-note">${esc(where())}</p></div>`,
);
const re = () => openUser(ctx, api);
const save = () => (saveU(list), re());
$("us-kw").onchange = (e) => ((u.kw = e.target.value), re());
document
.querySelectorAll(".us-r")
.forEach((r) => (r.onchange = () => (u.sel = Number(r.getAttribute("data-i")))));
document
.querySelectorAll(".us-k")
.forEach(
(r) =>
(r.onchange = () => (
(list[Number(r.getAttribute("data-i"))].key = r.value),
saveU(list)
)),
);
document
.querySelectorAll(".us-t")
.forEach(
(r) =>
(r.onchange = () => (
(list[Number(r.getAttribute("data-i"))].text = r.value),
saveU(list)
)),
);
document
.querySelectorAll(".us-on")
.forEach(
(r) =>
(r.onchange = () => (
(list[Number(r.getAttribute("data-i"))].on = r.checked),
saveU(list)
)),
);
document
.querySelectorAll(".us-put")
.forEach(
(b) =>
(b.onclick = () => (
insertText(list[Number(b.getAttribute("data-i"))].text || ""),
api.closeModal()
)),
);
$("us-add").onclick = () => (
list.splice(u.sel + 1, 0, { key: "", text: "", on: true }),
(u.sel += 1),
save()
);
$("us-del").onclick = () => list[u.sel] && (list.splice(u.sel, 1), save());
const mv = (d) => {
const j = u.sel + d;
if (!list[u.sel] || j < 0 || j >= list.length) return;
[list[u.sel], list[j]] = [list[j], list[u.sel]];
u.sel = j;
save();
};
$("us-up").onclick = () => mv(-1);
$("us-dn").onclick = () => mv(1);
$("us-init").onclick = () => confirm("사용자문자 목록을 비움") && (list.splice(0), save());
$("us-sp").onclick = () => openSpChar(ctx, api);
}
// ── 내역 메모(DES1·DES2) ──
const SC = () => global.STMATE_UI_BOQ_SCREEN;
const selRow = (ctx) =>
(ctx.dbfData.boq_rows || []).find((r) => Number(r._recno) === (ctx.uiState.boqSel || [])[0]);
function openMemo(ctx, api) {
const r = selRow(ctx);
if (!r) return alert("메모를 넣을 내역 행을 고르세요");
api.openModal(
`메모 입력 [wInDlgMemo] — ${esc(r.ITNUM || "")} ${esc(r.NAME1 || r.BDCODE1 || "")}`,
`<div class="stm-form"><textarea id="mm-t" class="win-input" style="width:520px;height:120px;">${esc([r.DES1, r.DES2].filter((x) => String(x || "").trim()).join("\n"))}</textarea>
<div class="stm-row"><button class="win-btn primary" id="mm-ok">Ok</button><button class="win-btn" id="mm-no">Cancel</button><input type="file" id="mm-file" accept=".txt" style="display:none;" /><button class="win-btn" id="mm-load">Load</button><button class="win-btn" id="mm-clr">Clear</button></div>
<p class="stm-note">줄 1 = 비고1(DES1) · 줄 2 = 비고2(DES2) — 내역 저장 때 함께 씀</p></div>`,
);
$("mm-no").onclick = api.closeModal;
$("mm-clr").onclick = () => ($("mm-t").value = "");
$("mm-load").onclick = () => $("mm-file").click();
$("mm-file").onchange = (e) => {
const f = e.target.files[0];
if (f) f.text().then((t) => ($("mm-t").value = t));
};
$("mm-ok").onclick = () => {
const [a, b] = $("mm-t").value.split(/\r?\n/);
Object.assign(r, { DES1: (a || "").trim(), DES2: (b || "").trim() });
api.closeModal();
SC().after(ctx, api, { ok: true }, true);
};
}
// ── 동일코드 추려보기 ──
function openSame(ctx, api) {
const r = selRow(ctx);
const c = r ? String(r.BDCODE1 || "").trim() : "";
if (!c || /^(T\d|E\d|C\d|:)$/.test(c)) return alert("단가코드 행을 고르세요");
const ev = global.STMATE_VIEW_PRJ_BOQ.evaluateFullBoqRows(ctx).filter(
(x) => String(x.BDCODE1 || "").trim() === c,
);
api.openModal(
`추려보기 [wSameCode] — ${c}`,
`<div class="stm-form"><p><strong>${ev.length}번 사용중</strong> · Enter/더블클릭 → 내역서의 해당위치로 이동</p><div class="grid-scroll" style="max-height:50vh;"><table class="dbgrid"><thead><tr><th>공종번호</th><th>명칭</th><th>규격</th><th>단위</th><th>수량</th><th>단가</th><th>금액</th><th>노무비</th><th>재료비</th><th>경비</th><th>비고</th></tr></thead><tbody>${ev
.map(
(x) =>
`<tr class="sm-go" data-r="${x._recno}" tabindex="0"><td>${esc(x.ITNUM)}</td><td>${esc(x.NAME1)}</td><td>${esc(x.SIZE1)}</td><td class="center">${esc(x.UNIT1)}</td><td class="num">${esc(x.QTY1)}</td><td class="num">${api.fmt(num(x.TAMT1))}</td><td class="num">${api.fmt(num(x.T_AMT1))}</td><td class="num">${api.fmt(num(x.L_AMT1))}</td><td class="num">${api.fmt(num(x.M_AMT1))}</td><td class="num">${api.fmt(num(x.S_AMT1))}</td><td>${esc(x.BDCODE1)}</td></tr>`,
)
.join("")}</tbody></table></div></div>`,
);
const go = (tr) => {
const rec = Number(tr.getAttribute("data-r"));
Object.assign(ctx.uiState, { boqSel: [rec], boqLevelMin: 0, boqScrollTo: rec });
api.closeModal();
api.renderWorkspace();
};
document
.querySelectorAll(".sm-go")
.forEach(
(tr) => (
(tr.ondblclick = () => go(tr)),
(tr.onkeydown = (e) => e.key === "Enter" && go(tr))
),
);
}
// ── 실적단가 → 일식단가 ──
function openA2W(ctx, api) {
if (!ctx.dbfData) return alert("열린 프로젝트가 없음");
const src = ctx.dbfData.costa_rows || [];
const A = global.STMATE_A2W;
const w = (ctx.uiState.a2w = ctx.uiState.a2w || {
sel: {},
L: 100,
M: 0,
own: false,
start: UC.nextCode(ctx.dbfData.costn_rows, "W"),
});
if (!src.length)
return api.openModal(
"실적단가를 일식단가로 변환등록 [wM_Conv_A2W]",
'<p class="stm-warn" style="padding:12px;">이 프로젝트에 실적단가(COSTA.DBF) 자료가 없음</p>',
);
const ed = A ? A.normalizeEditor(w.L, w.M, "L") : { L: w.L, M: w.M, E: 100 - w.L - w.M };
api.openModal(
"실적단가를 일식단가로 변환등록 [wM_Conv_A2W]",
`<div class="stm-form"><div class="grid-scroll" style="max-height:36vh;"><table class="dbgrid"><thead><tr><th>선택</th><th>코드</th><th>공종</th><th>명칭</th><th>규격</th><th>단위</th><th>단가</th><th>노무비율</th></tr></thead><tbody>${src
.map(
(r, i) =>
`<tr><td class="center"><input type="checkbox" class="aw-sel" data-i="${i}" ${w.sel[i] ? "checked" : ""} /></td><td>${esc(r.CODE)}</td><td>${esc(r.CODEA || "")}</td><td>${esc(r.NAME)}</td><td>${esc(r.SIZE)}</td><td>${esc(r.UNIT)}</td><td class="num">${api.fmt(num(r.AAMT1))}</td><td class="num">${esc(r.RATE_L || "")}</td></tr>`,
)
.join("")}</tbody></table></div>
<div class="stm-row"><label>비 목 율( 단가 => 노,재,경 ) 노무비율</label><input id="aw-l" type="number" class="win-input" value="${ed.L}" style="width:70px;" /><label>재료비율</label><input id="aw-m" type="number" class="win-input" value="${ed.M}" style="width:70px;" /><span>경 비 율 ${ed.E} %</span>
<label><input type="checkbox" id="aw-own" ${w.own ? "checked" : ""} /> 【 노무비율ː개별노무비율 】【 나머지ː재료비우선 】</label></div>
<div class="stm-row"><label>신규 시작코드</label><input id="aw-start" class="win-input" value="${esc(w.start)}" style="width:90px;" /><button class="win-btn primary" id="aw-ok">변환/생성</button></div></div>`,
);
document
.querySelectorAll(".aw-sel")
.forEach((i) => (i.onchange = () => (w.sel[i.getAttribute("data-i")] = i.checked)));
$("aw-ok").onclick = () => {
Object.assign(w, {
L: num($("aw-l").value),
M: num($("aw-m").value),
own: $("aw-own").checked,
start: $("aw-start").value.trim().toUpperCase(),
});
const rows = src.map((r, i) => ({ sel: Boolean(w.sel[i]), rateL: num(r.RATE_L) }));
const rates = A
? A.bulkApply(rows, { L: w.L, M: w.M, sender: "L" }, { individual: w.own })
: rows;
const pick = src.map((r, i) => Object.assign({}, r, rates[i])).filter((_r, i) => w.sel[i]);
if (!pick.length) return alert("변환할 항목을 고르세요");
const taken = new Set(ctx.dbfData.costn_rows.map(UC.codeOf));
const made = UA.a2w(pick, { start: w.start, taken });
const env = { d: ctx.dbfData, state: ctx.state, memo: ctx.uiState.editedSmtMap };
made.forEach((m) =>
UC.addCode(env, {
kind: "W",
code: m.CODE,
fields: {
NAME: m.NAME,
SIZE: m.SIZE,
UNIT: m.UNIT,
MAMT1: String(m.M),
LAMT1: String(m.L),
SAMT1: String(m.S),
TAMT1: String(m.T),
},
}),
);
ctx.uiState.danMsg = `일식단가(W) ${made.length}건 생성 ${made[0].CODE}~`;
w.sel = {};
api.closeModal();
api.cascadeAndRender();
};
}
Object.assign(P.actions, {
Act_UI_SpChar: (ctx, api) => openSpChar(ctx, api),
Act_UI_UserText: (ctx, api) => openUser(ctx, api),
Act_UI_A2W: (ctx, api) => openA2W(ctx, api),
});
P.menus.push(
{ menu: "m4", act: "Act_UI_SpChar", label: "특수문자" },
{ menu: "m4", act: "Act_UI_UserText", label: "사용자문자 편집" },
{ menu: "m2", act: "Act_UI_A2W", label: "실적단가(A) ⇒ 일식단가(W) 변환" },
);
global.STMATE_UI_TEXT = { insertText, openSpChar, openUser, openMemo, openSame, openA2W };
})(window);
+39
View File
@@ -122,6 +122,42 @@ def _make_syn_resource(res_id: str, name: str, spec: str, unit: str, kind: str,
}
def pct_row_base(boq_rows: list[dict], i: int) -> dict:
"""코드 붙은 % 줄(조달수수료 M%·T%) 밑수 — 같은 상위 공종 안 그 위 말단 줄들(닫힌 하위 공종 포함).
단가 칸 = 밑수 합 · 수량 칸 = 율(%) — 실무 봉화·울진·대흥 저장값 대조.
"""
ids, depth = [], []
for j in range(i - 1, -1, -1):
bd = (boq_rows[j].get("BDCODE1") or "").strip()
if re.match(r"^E\d$", bd):
depth.append(bd[1])
elif re.match(r"^T\d$", bd):
if depth and depth[-1] == bd[1]:
depth.pop()
else:
break
elif bd and not STRUCT_RE.match(bd):
ids.append(f"BOQ_{j + 1}")
unit = (boq_rows[i].get("UNIT1") or "").strip()
return {"pctComp": unit[0] if unit[:1] in ("M", "L", "S", "T") else "T", "pctBase": ids[::-1]}
def boq_fixed_unit(r: dict, crow: dict | None, is_pct: bool) -> dict:
"""내역 줄 단가가 단가목록(COSTN 1칸)과 다르면 줄 고정 단가(fixedUnit) — 단가 끝전 보정 등 줄 단위 수정 보존.
0원 줄(변경 차수에서만 쓰는 신규 공종의 당초 칸)은 제외.
"""
if is_pct or not crow:
return {}
unit = {c: fnum(r.get(f"{c}AMT1")) for c in "MLS"}
if not any(unit.values()) or all(
abs(unit[c] - fnum(crow.get(f"{c}AMT1"))) < 1e-6 for c in "MLS"
):
return {}
return {"fixedUnit": unit}
def convert_dbf_to_sim_state(
costn_rows: list[dict],
boq_rows: list[dict],
@@ -131,6 +167,7 @@ def convert_dbf_to_sim_state(
) -> dict:
"""원본 COSTN.DBF(전수) / COSTN.SMT / BOQ11~14.DBF(전수) / PRJLST.SMT를 무손실 변환."""
costn_memos = costn_memos or {}
costn_by_code = {(r.get("CODE") or "").strip(): r for r in costn_rows}
resources: list[dict] = []
machines: list[dict] = []
san_items: list[dict] = []
@@ -338,6 +375,8 @@ def convert_dbf_to_sim_state(
"PRIN": r.get("PRIN", ""),
"PROUT": r.get("PROUT", ""),
"prQtys": [fnum(r.get(f"QTY{n}")) for n in range(1, 5)],
**(pct_row_base(boq_rows, i) if is_pct_fee else {}),
**boq_fixed_unit(r, costn_by_code.get(bd), is_pct_fee),
}
)
+63
View File
@@ -95,6 +95,68 @@ def boq_spans(tbl: DbfTable) -> tuple[list[tuple[int, int, int, bool]], set[int]
return spans, excl
_PR_COMP = {"1": "M", "2": "L", "3": "S", "4": "T"}
def _pct_rows(tbl: DbfTable, name: str, s: str, log: SaveLog) -> None:
"""% 줄 다시 셈 — 단가 칸 = 밑수 · 금액 = 밑수 × 율(수량 칸, %) 원 미만 절사.
코드 붙은 줄(조달수수료 M%·T%) = 같은 상위 공종 안 앞 말단 줄 비목 합(server.pct_row_base)
PR 줄 = 앞 PR 줄 뒤 표시(PRPT) 줄의 밑수 비목(PRIN) 합 → 대상 비목(PROUT) (calc.js prRow)
"""
if not all(f in tbl.fields for f in (f"QTY{s}", f"MAMT{s}", f"M_AMT{s}", "UNIT1")):
return
bds = [tbl.get(i, "BDCODE1") for i in range(len(tbl))]
amt = lambda i, c: ( # noqa: E731
sum(_dec(tbl.get(i, f"{k}_AMT{s}")) for k in "MLS")
if c == "T"
else _dec(tbl.get(i, f"{c}_AMT{s}"))
)
marked = {"M": Decimal(0), "L": Decimal(0), "S": Decimal(0)}
for i in range(len(tbl)):
bd, unit = bds[i], tbl.get(i, "UNIT1").strip()
if not _leaf(bd):
continue
if bd == "PR" and "PRIN" in tbl.fields:
bk, ok = (
_PR_COMP.get(tbl.get(i, "PRIN").strip()),
_PR_COMP.get(tbl.get(i, "PROUT").strip()),
)
if bk and ok:
base = sum(marked.values()) if bk == "T" else marked[bk]
_set_pct(tbl, name, i, s, "M" if ok == "T" else ok, base, log)
marked = {k: Decimal(0) for k in marked}
continue
elif unit.endswith("%") and unit[:1] in "MLST":
base, depth = Decimal(0), []
for j in range(i - 1, -1, -1):
b = bds[j]
if re.match(r"^E\d$", b):
depth.append(b[1])
elif re.match(r"^T\d$", b):
if depth and depth[-1] == b[1]:
depth.pop()
else:
break
elif _leaf(b):
base += amt(j, unit[0])
own = "L" if bd[:1] == "L" else "S" if bd[:1] == "S" else "M"
_set_pct(tbl, name, i, s, own, base, log)
continue
if "PRPT" in tbl.fields and tbl.get(i, "PRPT").strip() not in ("", "0"):
for k in marked:
marked[k] += _dec(tbl.get(i, f"{k}_AMT{s}"))
def _set_pct(tbl: DbfTable, name: str, i: int, s: str, own: str, base: Decimal, log: SaveLog):
q = _dec(tbl.get(i, f"QTY{s}"))
a = (base * q / 100).quantize(Decimal(1), rounding=ROUND_DOWN)
for c in "MLS":
log.set(tbl, name, i, f"{c}AMT{s}", base if c == own else 0)
log.set(tbl, name, i, f"{c}_AMT{s}", a if c == own else 0)
log.set(tbl, name, i, f"T_AMT{s}", a)
def recompute_boq(tbl: DbfTable, name: str, slots: set[str], log: SaveLog) -> dict[str, list]:
"""바뀐 차수의 소계 행 다시 셈 · 차수별 직접비(재·노·경·계) 반환."""
spans, excl = boq_spans(tbl)
@@ -104,6 +166,7 @@ def recompute_boq(tbl: DbfTable, name: str, slots: set[str], log: SaveLog) -> di
cols = [f"{c}_AMT{s}" for c in "MLST"]
if not all(c in tbl.fields for c in cols):
continue
_pct_rows(tbl, name, s, log)
vals = [[_dec(tbl.get(i, c)) for c in cols] for i in range(len(tbl))]
for i, a, b, isx in spans:
tot = [Decimal(0)] * 4
+1 -1
View File
@@ -56,7 +56,7 @@
const base = String(payload.target_folder_name || "stmate").slice(0, 20);
payload.target_folder_name = `${base}_백업_${kind === "day" ? today() : nowTag()}`;
try {
const res = await API.postJson("/api/save", payload);
const res = await API.postSave(payload);
if (kind === "day") prefs.lastBackDay = today();
if (kind === "30") prefs.lastBack30 = Date.now();
savePrefs();
+100 -21
View File
@@ -1,5 +1,5 @@
/**
* STMATE w 8.0 — 내역서 도구 대화상자 순수 계산 (ui_boq_tools.js) · wITNum · wExpnd · wM_Rate · wBoqRate
* STMATE w 8.0 — 내역서 도구 대화상자 순수 계산 (ui_boq_tools.js) · wITNum · wExpnd(wM_Expnd·wM_Expnd10) · wM_Rate · wBoqRate
* 근거: 지식DB STmate/old/30_원자료/ui_form_catalog.txt
* wITNum RdoFirstChar(공란/./(/-/추가입력) · RdoLastChar(공란/./)) · CBoxType(가..하 / a..z) · CBoxLeft(여백 0~7)
* Fmt_Typ(0/00/000) · cBx_itNum_B(직전공종 번호 / 길이만큼 여백) · cBx_OnlyIT(대상) · cBx_Bl_Lev(초기화 레벨)
@@ -97,18 +97,25 @@
}
/**
* 일위대가 풀기(wExpnd) — B 코드 행 하나를 구성 자원 행으로 바꾸고 T{n}/E{n} 로 감쌈
* 구성 수량 = 구성표 수량 × 내역 수량 → 절삭/반올림 소수 d 위 · 비율(PR)·% 단위·구분(T/C) 줄은 풀지 않고 개수만 알림
* 일위대가 풀기(wExpnd · wM_Expnd · wM_Expnd10) — B 코드 행 하나를 구성 자원 행으로 바꾸고 T{n}/E{n} 로 감쌈
* 구성 수량 = 구성표 수량 × 할증(노무·재료) × 내역 수량 → 비목별(M·L·X·기타 W) 절삭/반올림 소수 d 위
* o = { level(0 = 입력안함 · 감싸지 않음), mode, digits, per: { M|L|X|W: { mode, digits } }, hal: { L, M },
* keepName(공종의 명칭/규격 입력), adds: [{ pct, prin, prout, name }](손료계산 추가입력 → PR 비율 줄) }
* 비율(PR)·% 단위·구분(T/C) 구성 줄은 풀지 않고 개수만 알림 · 추가 줄의 밑수 = 풀린 줄(PRPT 표시)
*/
function expandIlwi(model, recno, bdqtyRows, costnByCode, opts, ini) {
const o = Object.assign({ level: 1, mode: "trunc", digits: 4 }, opts || {});
const o = Object.assign(
{ level: 1, mode: "trunc", digits: 4, per: {}, hal: {}, keepName: true, adds: [] },
opts || {},
);
const row = model.rows.find((x) => num(x._recno) === num(recno));
if (!row || !isLeaf(row)) return { ok: false, error: "풀 일위대가 행을 고르세요" };
const code = codeOf(row);
if (code[0] !== "B") return { ok: false, error: `일위대가(B) 코드만 풀 수 있음 — ${code}` };
const comps = (bdqtyRows || []).filter((r) => String(r.BDCODE || "").trim() === code);
if (!comps.length) return { ok: false, error: `${code} 구성표(BDQTY) 없음` };
const Q = num(row.QTY1);
const Q = num(row.QTY1) || 1; // 실행수량 0 이면 1(wM_Expnd 안내)
const adds = (o.adds || []).filter((a) => num(a.pct) && a.prin && a.prout);
const made = [];
let skipped = 0;
comps.forEach((c) => {
@@ -116,36 +123,70 @@
cr = costnByCode[cc];
const unit = cr ? String(cr.UNIT || "").trim() : "";
if (!cr || cc === "PR" || /^[TC]\d$/.test(cc) || /%$/.test(unit)) return void (skipped += 1);
const k = "MLX".includes(cc[0]) ? cc[0] : "W";
const r = Object.assign({ mode: o.mode, digits: o.digits }, (o.per || {})[k] || {});
const h = num((o.hal || {})[cc[0]]) || 1;
made.push({
code: cc,
name: cr.NAME || "",
spec: cr.SIZE || "",
unit,
qty: roundTo(num(c.QTY1) * Q, o.digits, o.mode),
qty: roundTo(num(c.QTY1) * h * Q, r.digits, r.mode),
});
});
if (!made.length) return { ok: false, error: "풀 수 있는 구성 자원 없음" };
const at = model.rows.indexOf(row);
const head = B.makeRow(model, {
BDCODE1: `T${o.level}`,
NAME1: row.NAME1 || "",
SIZE1: row.SIZE1 || "",
});
head.ITNUM = row.ITNUM || "";
let rec = num(head._recno);
const wrap = Number(o.level) > 0;
const head = wrap
? B.makeRow(model, {
BDCODE1: `T${o.level}`,
NAME1: o.keepName ? row.NAME1 || "" : "",
SIZE1: o.keepName ? row.SIZE1 || "" : "",
})
: null;
if (head) head.ITNUM = row.ITNUM || "";
let rec = head ? num(head._recno) : B.nextRecno(model) + 1;
const body = made.map((m) => {
const r = B.makeRow(model, { BDCODE1: m.code, NAME1: m.name, SIZE1: m.spec, UNIT1: m.unit });
Object.assign(r, { QTY1: String(m.qty), _recno: (rec -= 1) });
if (adds.length) r.PRPT = "1";
return r;
});
const tail = B.makeRow(model, { BDCODE1: `E${o.level}`, ITNUM: B.dispName(ini, o.level) });
tail._recno = rec - 1;
const res = B.edit(model, (rows, links) => {
links.set(head, tail).set(tail, head);
rows.splice(at, 1, head, ...body, tail);
if (!row._new) model.deleted.push(num(row._recno));
return { count: body.length, skipped };
const prs = adds.map((a) => {
const r = B.makeRow(model, { BDCODE1: "PR", NAME1: a.name || "손료", UNIT1: "%" });
Object.assign(r, {
QTY1: String(num(a.pct)),
PRIN: String(a.prin),
PROUT: String(a.prout),
_recno: (rec -= 1),
});
return r;
});
const tail = wrap
? B.makeRow(model, { BDCODE1: `E${o.level}`, ITNUM: B.dispName(ini, o.level) })
: null;
if (tail) tail._recno = rec - 1;
const res = B.edit(model, (rows, links) => {
if (wrap) links.set(head, tail).set(tail, head);
rows.splice(at, 1, ...(wrap ? [head] : []), ...body, ...prs, ...(wrap ? [tail] : []));
if (!row._new) model.deleted.push(num(row._recno));
return { count: body.length + prs.length, skipped };
});
// 계산 원천 — 비율 줄·밑수 표시(calc.js prRow · PRPT)
if (res.ok && adds.length) {
const byRec = new Map((model.items || []).map((it) => [num(it._recno), it]));
body.forEach((r) => byRec.get(num(r._recno)) && (byRec.get(num(r._recno)).PRPT = "1"));
prs.forEach((r) => {
const it = byRec.get(num(r._recno));
if (it)
Object.assign(it, {
PRIN: r.PRIN,
PROUT: r.PROUT,
PRPT: "0",
prQtys: [num(r.QTY1), 0, 0, 0],
});
});
}
return res;
}
@@ -164,6 +205,36 @@
return { changed };
}
/**
* 비목별 단가 끝전 보정(wM_Rate wcBx_Pnt1_M·_L … · rd_Amt1/rd_Amt2 범위) — 지금 행 단가(재·노·경)를 비목마다 절사/반올림
* units = { recno: { M, L, S } }(계산 결과 행 단가) · 계산 원천 항목 fixedUnit · 저장 원천 MAMT1·LAMT1·SAMT1·TAMT1
*/
function roundUnits(model, recnos, units, per) {
const set = recnos ? new Set(recnos.map(num)) : null;
const byRec = new Map(model.items.map((it) => [num(it._recno), it]));
let changed = 0;
model.rows.forEach((r) => {
const u = (units || {})[num(r._recno)];
if (!isLeaf(r) || !u || (set && !set.has(num(r._recno)))) return;
const v = {};
["M", "L", "S"].forEach((c) => {
const o = (per || {})[c];
v[c] = o ? roundTo(num(u[c]), o.digits, o.mode) : num(u[c]);
});
if (["M", "L", "S"].every((c) => v[c] === num(u[c]))) return;
Object.assign(r, {
MAMT1: String(v.M),
LAMT1: String(v.L),
SAMT1: String(v.S),
TAMT1: String(v.M + v.L + v.S),
});
const it = byRec.get(num(r._recno));
if (it) it.fixedUnit = v;
changed += 1;
});
return { changed };
}
// 할증율 적용(wBoqRate) — 행 칸 RATE(전체) 또는 RM·RL·RS(비목별) · 계산 원천 항목에도 halRate 로 둠
function applyHalRate(model, recnos, rates, mode) {
const set = recnos ? new Set(recnos.map(num)) : null;
@@ -188,6 +259,14 @@
return { changed };
}
global.STMATE_UI_BOQ_TOOLS = { seqText, autoItnum, roundTo, expandIlwi, roundQty, applyHalRate };
global.STMATE_UI_BOQ_TOOLS = {
seqText,
autoItnum,
roundTo,
expandIlwi,
roundQty,
roundUnits,
applyHalRate,
};
if (typeof module !== "undefined" && module.exports) module.exports = global.STMATE_UI_BOQ_TOOLS;
})(typeof window !== "undefined" ? window : globalThis);
+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);
+334
View File
@@ -0,0 +1,334 @@
/**
* STMATE w 8.0 — 단가 일괄 손질 순수 계산 (ui_code_amt.js) · 화면·DOM 없음(node 시험 가능)
* 근거: DFM 원문(C:/경영정보(주)/System) — STmate.Cpm wM_Boxa(적용단가 일괄할증 · 기준단가[AMT_61] · 보호해제시 자동할증
* · rg_FLT_M_PP/rg_FLT_B_PP 속성 추려보기 · 명칭/규격 추려보기 · 호환규정코드 검색·중복체크) · ST_Jabpum Edt_Hal_Amt/Edt_Hal_Rate
* · ST_Sys wM_Set_Init(분류번호·적용기준일·시설코드·수공분류코드·호환규정코드 초기화 × 비목 8종)
* · ST_EdiLWi wM_Edit_XRate(상각비·정비비·관리비 계수 · 계(10^-7) · 손료 적용율) — 시공량은 calc_xrate.js
* · DP_MinMsg mMinMsg_(특수문자 14종) · 메뉴 Act_1st_2nd/Act_2nd_1st(당초단가 ⇔ 변경단가)
* 일괄변경 세부 필터는 원본 미확정(08번 §3) — 차수·비목·코드 범위·0원 제외를 명시 선택으로 둠
*/
(function (global) {
"use strict";
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 OWN = { M: "MAMT", L: "LAMT", S: "SAMT" };
// 반올림·절사 — 소수 d 자리(음수 = 10^-d 원 단위)
function roundTo(v, d, mode) {
const f = Math.pow(10, Number(d) || 0);
const x = Number((v * f).toFixed(6));
return (mode === "round" ? Math.round(x) : Math.trunc(x)) / f;
}
const slotVals = (r, s) => ({
M: num(r[`MAMT${s}`]),
L: num(r[`LAMT${s}`]),
S: num(r[`SAMT${s}`]),
});
function writeSlot(r, s, v) {
["M", "L", "S"].forEach((c) => (r[`${c}AMT${s}`] = String(v[c])));
r[`TAMT${s}`] = String(v.M + v.L + v.S);
}
/**
* 단가 일괄변경·물가변동(uM_Chg_Amt) — from 차수 단가 × rate% → to 차수 · 비목별 끝전
* o = { kinds: "MLSW", from: 1, to: 1, rate: 100, digits: 0, mode: "trunc", skipZero: true, codes: null }
* 낙찰률 행(NAGCD>0)은 2차 칸이 100% 단가 자리라 건너뜀 · 결과 = [{ code, from:{T}, to:{T} }]
*/
function chgAmt(rows, o) {
const opt = Object.assign(
{ kinds: "MLSW", from: 1, to: 1, rate: 100, digits: 0, mode: "trunc", skipZero: true },
o || {},
);
const only = opt.codes ? new Set(opt.codes) : null;
const out = [];
(rows || []).forEach((r) => {
const c = codeOf(r);
if (!c || !opt.kinds.includes(c[0]) || (only && !only.has(c))) return;
if (num(r.NAGCD) > 0 && (Number(opt.from) === 2 || Number(opt.to) === 2)) return;
const src = slotVals(r, opt.from);
const T0 = src.M + src.L + src.S;
if (opt.skipZero && !(T0 > 0)) return;
const v = {};
["M", "L", "S"].forEach(
(k) => (v[k] = roundTo((src[k] * num(opt.rate)) / 100, opt.digits, opt.mode)),
);
const before = slotVals(r, opt.to);
if (["M", "L", "S"].every((k) => before[k] === v[k])) return;
writeSlot(r, opt.to, v);
out.push({ code: c, from: before.M + before.L + before.S, to: v.M + v.L + v.S });
});
return out;
}
/**
* 적용단가 일괄할증(Edt_Hal_Rate · Edt_Hal_Amt) — 기준단가(AMT_61, 없으면 지금 적용단가) 기준
* how = "rate"(× (1 + v%)) · "amt"(+ v 원) · 같은 할증을 두 번 해도 결과 같음
* 기초단가(M·L·S)는 자기 비목 한 칸 · 견적단가(W)는 비목 비율 그대로 나눔
*/
function halAll(rows, o) {
const opt = Object.assign(
{ kinds: "M", how: "rate", value: 0, digits: 0, mode: "trunc" },
o || {},
);
const only = opt.codes ? new Set(opt.codes) : null;
const out = [];
(rows || []).forEach((r) => {
const c = codeOf(r);
if (!c || !opt.kinds.includes(c[0]) || (only && !only.has(c))) return;
const cur = slotVals(r, 1);
const curT = cur.M + cur.L + cur.S;
const base = num(r.AMT_61) || curT;
if (!(base > 0)) return;
const tgt = roundTo(
opt.how === "amt" ? base + num(opt.value) : base * (1 + num(opt.value) / 100),
opt.digits,
opt.mode,
);
if (!num(r.AMT_61)) r.AMT_61 = String(base);
let v;
if (OWN[c[0]]) v = Object.assign({ M: 0, L: 0, S: 0 }, { [c[0]]: tgt });
else {
const k = tgt / base;
v = {
M: roundTo(cur.M * k, opt.digits, opt.mode),
L: roundTo(cur.L * k, opt.digits, opt.mode),
};
v.S = tgt - v.M - v.L;
}
if (["M", "L", "S"].every((x) => cur[x] === v[x])) return;
writeSlot(r, 1, v);
out.push({ code: c, base, from: curT, to: tgt });
});
return out;
}
// 프로젝트 계산 초기값(wM_Set_Init) — 칸 무리 × 비목 글자
const INIT_COLS = { BQ: "BQCODE", ED: "EDT_DATE", EQ: "EQCODE", WQ: "WQCODE", JD: "JDCODE" };
const INIT_KINDS = ["L", "M", "S", "W", "B", "X", "D", "A"];
function initCols(rows, pick) {
const out = {};
Object.entries(pick || {}).forEach(([g, kinds]) => {
const col = INIT_COLS[g];
if (!col) return;
(rows || []).forEach((r) => {
const c = codeOf(r);
if (!c || !String(kinds).includes(c[0]) || !(col in r) || !txt(r[col])) return;
r[col] = "";
out[col] = (out[col] || 0) + 1;
});
});
return out;
}
// 속성(PP)·명칭/규격 추려보기(rg_FLT_*_PP · mBtn_Flt_*) — 원본 속성 목록(급수 공종)
const PP_M = [
"10 : 새들분수전",
"11 : K.P 자재",
"12 : 엘보",
"13 : 직관",
"14 : 소켓",
"15 : S.T.S 자재",
];
PP_M.push(
"16 : 니플",
"17 : 죠인트",
"18 : 계량기보호통",
"19 : 계량기",
"20 : 사급자재",
"25 : 무선원격검침비",
);
const PP_B = [
"30 : 토공",
"31 : 포장공",
"32 : 포장깨기/절단",
"30~32 : 토공사",
"40 : 분기공",
"41 : 관접합",
];
PP_B.push(
"42 : 관연결",
"43 : 죠인트 접합",
"44 : 계량기 설치",
"45 : 보호통 설치",
"40~45 : 관공사",
);
PP_B.push(
"50 : 부대공",
"60 : 폐기물( CON'C )",
"61 : 폐기물( ASP )",
"62 : 폐기물( BODO )",
"65 : 폐기물운반",
);
function ppMatch(r, opt) {
const v = parseInt(txt(r.PP), 10);
const m = String(opt || "").match(/^(\d+)(?:~(\d+))?/);
if (!m) return true;
const a = Number(m[1]),
b = m[2] ? Number(m[2]) : a;
return v >= a && v <= b;
}
function nameList(rows, kind, field) {
const f = field === "SIZE" ? "SIZE" : "NAME";
const seen = new Map();
(rows || []).forEach((r) => {
const c = codeOf(r);
if (c && (!kind || kind.includes(c[0])) && txt(r[f]))
seen.set(txt(r[f]), (seen.get(txt(r[f])) || 0) + 1);
});
return [...seen.entries()].sort((a, b) => a[0].localeCompare(b[0], "ko"));
}
// 불러간 목록(wM_Box_H) — 이 프로젝트 내역서·구성표에 불려 간 코드만
function calledCodes(d) {
const out = new Set();
Object.values((d && d.boq_tables) || {}).forEach((rows) =>
(rows || []).forEach((r) => txt(r.BDCODE1) && out.add(txt(r.BDCODE1))),
);
((d && d.boq_rows) || []).forEach((r) => txt(r.BDCODE1) && out.add(txt(r.BDCODE1)));
((d && d.bdqty_rows) || []).forEach((r) => txt(r.CODE) && out.add(txt(r.CODE)));
return out;
}
// 호환규정코드 찾기(wM_JDCode wCb_Base) — 기준(명칭 / 명칭·규격 / 분류번호 / 시설코드)이 같은 자료의 코드
const JD_BASE = {
N: (r) => txt(r.NAME),
NS: (r) => `${txt(r.NAME)}|${txt(r.SIZE)}`,
BQ: (r) => txt(r.BQCODE),
EQ: (r) => txt(r.EQCODE),
};
function jdCandidates(row, pool, base) {
const f = JD_BASE[base] || JD_BASE.NS;
const key = f(row);
const cnt = new Map();
if (!key || key === "|") return [];
(pool || []).forEach((p) => {
if (!txt(p.JDCODE) || f(p) !== key) return;
const x = cnt.get(txt(p.JDCODE)) || {
jd: txt(p.JDCODE),
n: 0,
unit: txt(p.UNIT),
src: new Set(),
};
x.n += 1;
if (p.project) x.src.add(p.project);
cnt.set(x.jd, x);
});
return [...cnt.values()]
.map((x) => ({ jd: x.jd, n: x.n, unit: x.unit, src: [...x.src] }))
.sort((a, b) => b.n - a.n || a.jd.localeCompare(b.jd));
}
// 호환규정코드 중복체크(Act_Chk_JDCode) — 한 호환코드를 둘 이상 코드가 씀
function jdDup(rows) {
const g = new Map();
(rows || []).forEach(
(r) => txt(r.JDCODE) && g.set(txt(r.JDCODE), (g.get(txt(r.JDCODE)) || []).concat(codeOf(r))),
);
return [...g.entries()].filter(([, a]) => a.length > 1).map(([jd, codes]) => ({ jd, codes }));
}
/**
* 시간당 손료계수(wM_Edit_XRate) — 계 = 상각비 + 정비비 + 관리비 계수(×10^-7)
* 구성 수량 = 계 × 10^-7 × 1,000(기계가격 천원 단위) × 적용율% · 「계 입력」이면 계만
*/
const lossQty = (sum, ratePct) =>
Number(((num(sum) * 1e-4 * num(ratePct === undefined ? 100 : ratePct)) / 100).toFixed(10));
const lossSum = (qty, ratePct) => {
const r = num(ratePct === undefined ? 100 : ratePct);
return r ? Number(((num(qty) * 1e4 * 100) / r).toFixed(6)) : 0;
};
// 시공량(기준·일당·적용 수량) 계산은 calc_xrate.js(STMATE_XRATE.compute)
// 특수문자(mMinMsg_ ListBox1 14종) — KS X 1001 특수문자 줄(A1~AC) 대응 · 분수/첨자·특수외국어는 A8·A9 일부
const SPECIAL = [
["기호1", [0xa1]],
["기호2", [0xa2]],
["영어/숫자", [0xa3]],
["한글자모", [0xa4]],
["그리스어", [0xa5]],
["선문자", [0xa6]],
["단위기호", [0xa7]],
["원문자", [0xa8], [0xb1, 0xf8]],
["괄호문자", [0xa9], [0xb1, 0xf6]],
["히라가다", [0xaa]],
["가다가나", [0xab]],
["러시아어", [0xac]],
["특수외국어", [0xa8, 0xa9], [0xa1, 0xb0]],
["분수/첨자", [0xa8, 0xa9], [0xf6, 0xfe]],
];
function specialChars(i, decode) {
const [, leads, range] = SPECIAL[i] || SPECIAL[0];
const [a, b] = range || [0xa1, 0xfe];
const out = [];
leads.forEach((lead) => {
for (let t = a; t <= b; t++) {
const ch = decode(new Uint8Array([lead, t]));
if (ch && ch !== "\ufffd" && !/^\s$/.test(ch)) out.push(ch);
}
});
return out;
}
// 사용자문자(wM_Edit_USER) — 검색명칭 → 입력내용 · 적용 표시된 것만
function userTextFind(list, kw) {
const k = txt(kw).toLowerCase();
return (list || []).filter(
(x) => x.on !== false && (!k || txt(x.key).toLowerCase().includes(k)),
);
}
/**
* 실적단가 → 일식단가 변환(wM_Conv_A2W) — 줄마다 비목율(L·M·E %, calc_a2w.js bulkApply 결과)로 단가를 나눔
* src = [{ CODE, NAME, SIZE, UNIT, AAMT1, L, M, E }] · start = 첫 W 코드 · 노무·재료 절사, 경비 = 나머지
*/
function a2w(src, o) {
const opt = Object.assign({ digits: 0, start: "W00001", taken: null }, o || {});
let n = parseInt(String(opt.start).slice(1), 10) || 1;
return (src || []).map((r) => {
const p = num(r.AAMT1 || r.TAMT1);
const L = roundTo((p * num(r.L)) / 100, opt.digits, "trunc");
const M = roundTo((p * num(r.M)) / 100, opt.digits, "trunc");
while (opt.taken && opt.taken.has(`W${String(n).padStart(5, "0")}`)) n += 1;
const code = `W${String(n++).padStart(5, "0")}`;
return {
from: codeOf(r),
CODE: code,
NAME: r.NAME,
SIZE: r.SIZE,
UNIT: r.UNIT,
M,
L,
S: p - L - M,
T: p,
};
});
}
global.STMATE_UI_CODE_AMT = {
roundTo,
chgAmt,
halAll,
INIT_COLS,
INIT_KINDS,
initCols,
PP_M,
PP_B,
ppMatch,
nameList,
calledCodes,
jdCandidates,
jdDup,
lossQty,
lossSum,
SPECIAL,
specialChars,
userTextFind,
a2w,
};
if (typeof module !== "undefined" && module.exports) module.exports = global.STMATE_UI_CODE_AMT;
})(typeof window !== "undefined" ? window : globalThis);
+2 -2
View File
@@ -3,7 +3,7 @@
* 근거: ui_form_catalog.txt wM_JabPum cBoxCode(10종)·cBox_IFORM · wM_Boxa RG_MComp(변동없음/1~5 단가/최소단가)
* · 22_UI_폼_분석 §3.1 wM_Edt_Hal(적용단가 = 기준단가 × % · 소수 자리 미만 절사/반올림 · 적용단가 0원 제외)
* · STC_필드사전_핵심(AMT_11~51 후보 5칸 · PG_1 = 채택 출처 · PG_11~51 페이지) · Start8x.Ini JMOK1~5·JMK10~50·JEMNM
* 그림코드는 실자료 표본에 없음 — 앞 글자 미확인이라 걸러지는 행 없음
* 그림코드 = I 코드(DFM uM_ChgCode·uM_DelCode·wM_Boxa 의 Tab_I 「그림코드(Ctrl+I)」 · Act_Sel_I) — 실자료 표본에는 없음
*/
(function (global) {
"use strict";
@@ -28,7 +28,7 @@
["D", "단가산출", (p) => p === "D"],
["W", "견적단가", (p) => p === "W"],
["BD", "대가,산근", (p) => p === "B" || p === "D"],
["G", "그림코드", () => false],
["G", "그림코드", (p) => p === "I"],
];
const OWN = { M: "MAMT", L: "LAMT", S: "SAMT" };
const isBase = (code) => /^[MLS]/.test(code);
+310
View File
@@ -0,0 +1,310 @@
/**
* STMATE w 8.0 — 코드병합·데이터병합 순수 계산 (ui_merge.js) · 복사처(지금 프로젝트) ← 복사원(다른 프로젝트)
* 근거: DFM 원문 — ST_Merge_Code.CPM wM_Stmate_Merge_Code(rg_Set_Sys 4택: 선택코드만 / 선택코드+하위기초단가코드 /
* 모든코드(하위 산근,대가포함) / 코드병합 취소 · 「복사처에 등록되지 않은코드 : 코드번호 생성(병합)」)
* · ST_U_Merge.CPM uM_Merge(RdoL~RdoW: 복사처적용(동일항목 코드치환) / 복사원적용(동일항목 내용복사) /
* 신규등록(단가다른코드 신규생성) · Rdo_BD·Rdo_X 일식단가(W코드)로 생성 · Rdo_Sel_Con 동일항목 연결 3택
* · 「동일항목 이란? - 명칭+규격+단위 가 복사처와 같은것으로 코드번호는 무관함」 · 메뉴 Act_Merge_C 코드번호로 인식)
*/
(function (global) {
"use strict";
const UC =
global.STMATE_UI_CODE || (typeof require !== "undefined" ? require("./ui_code.js") : null);
const txt = (v) => String(v == null ? "" : v).trim();
const num = (v) => Number(txt(v).replace(/,/g, "")) || 0;
const codeOf = (r) => txt(r && r.CODE);
const REF_RE = /&([A-Z]\d{5})([LMS]?(?::[LMS])?)&/g;
// 복사원 자료 — 코드 → 행 · 구성 · 식
function srcIndex(src) {
const by = new Map((src.costn_rows || []).map((r) => [codeOf(r), r]));
const comp = new Map();
(src.bdqty_rows || []).forEach((r) => {
const p = txt(r.BDCODE);
comp.set(p, (comp.get(p) || []).concat(r));
});
const memo = (c) => ((src.costn_memos || {})[c] || {}).text || "";
const kids = (c) => {
const a = (comp.get(c) || []).map((r) => txt(r.CODE));
return a.concat([...memo(c).matchAll(REF_RE)].map((m) => m[1])).filter((k) => by.has(k));
};
return { by, comp, memo, kids };
}
/** 병합할 코드 — mode 0 선택코드만 · 1 +하위 기초단가(M·L·S) · 2 모든 하위(산근·대가·중기 포함) */
function closure(src, codes, mode) {
const ix = srcIndex(src);
const out = new Set();
const walk = (c, deep) => {
if (!ix.by.has(c) || (out.has(c) && deep)) return;
out.add(c);
if (mode === 0) return;
ix.kids(c).forEach((k) => {
if (/^[MLS]/.test(k)) out.add(k);
else if (mode === 2) walk(k, true);
});
};
(codes || []).forEach((c) => walk(c, true));
return [...out];
}
// 동일항목 열쇠 — conn 0 「1차 호환, 2차 시설, 3차 명칭..단위」 · 1 「1차 호환, 2차 명칭+규격+단위」 · 2 명칭+규격+단위 · "code" 코드번호
const nsu = (r) => [txt(r.NAME), txt(r.SIZE), txt(r.UNIT)].join("\u0001");
function findSame(r, dest, conn) {
const k = codeOf(r)[0];
const pool = dest.filter((x) => codeOf(x)[0] === k);
if (conn === "code") return pool.find((x) => codeOf(x) === codeOf(r)) || null;
const tries = [];
if (conn === 0 || conn === 1)
tries.push((x) => txt(r.JDCODE) && txt(x.JDCODE) === txt(r.JDCODE));
if (conn === 0) tries.push((x) => txt(r.EQCODE) && txt(x.EQCODE) === txt(r.EQCODE));
// 명칭+규격+단위 — 단가까지 같은 항목 먼저
tries.push((x) => nsu(x) === nsu(r) && num(x.TAMT1) === num(r.TAMT1));
tries.push((x) => nsu(x) === nsu(r));
for (const f of tries) {
const hit = pool.find(f);
if (hit) return hit;
}
return null;
}
const POLICY = ["복사처적용", "복사원적용", "신규등록"];
const PRICE = ["TAMT1", "LAMT1", "MAMT1", "SAMT1", "TAMT2", "LAMT2", "MAMT2", "SAMT2"];
const COPY_KEEP = [
"NAME",
"SIZE",
"UNIT",
...PRICE,
"AMT_11",
"AMT_21",
"AMT_31",
"AMT_41",
"AMT_51",
];
COPY_KEEP.push(
"AMT_61",
"PG_1",
"PG_11",
"PG_21",
"PG_31",
"PG_41",
"PG_51",
"JDCODE",
"EDT_DATE",
"BQCODE",
"EQCODE",
);
/**
* 병합 계획 — 코드마다 { src, act: "map"|"copy"|"new"|"w", dest } (자식 먼저)
* o = { codes, mode(0~2), conn(0~2|"code"), policy: {L,M,S,B,D,W: 0~2}, bdW, xW, start: {kind: 코드} }
*/
function plan(env, src, o) {
const opt = Object.assign(
{ mode: 2, conn: 2, policy: {}, bdW: false, xW: false, start: {} },
o || {},
);
const d = env.d;
const ix = srcIndex(src);
const want = new Set(closure(src, opt.codes, opt.mode));
const order = [];
const seen = new Set();
const visit = (c) => {
if (seen.has(c) || !want.has(c)) return;
seen.add(c);
ix.kids(c).forEach(visit);
order.push(c);
};
[...want].forEach(visit);
const taken = new Set((d.costn_rows || []).map(codeOf));
const counters = {};
const newCode = (k) => {
const start = txt((opt.start || {})[k]);
if (counters[k] == null)
counters[k] =
start && UC.CODE_RE.test(start)
? UC.codeNum(start)
: UC.codeNum(UC.nextCode(d.costn_rows, k, taken) || `${k}00001`);
while (taken.has(UC.mkCode(k, counters[k]))) counters[k] += 1;
const c = UC.mkCode(k, counters[k]);
taken.add(c);
return c;
};
return order.map((c) => {
const r = ix.by.get(c);
let k = c[0];
const toW = (opt.bdW && (k === "B" || k === "D")) || (opt.xW && k === "X");
if (toW) k = "W";
const pol = Number((opt.policy || {})[toW ? "W" : c[0]] || 0);
const same = toW ? null : findSame(r, d.costn_rows || [], opt.conn);
if (same) {
if (pol === 2 && num(same.TAMT1) !== num(r.TAMT1))
return { src: c, act: "new", dest: newCode(k), same: codeOf(same) };
return { src: c, act: pol === 1 ? "copy" : "map", dest: codeOf(same) };
}
return { src: c, act: toW ? "w" : "new", dest: newCode(k) };
});
}
/** 병합 실행 — 계획대로 새 코드 등록·내용 복사·코드 치환(구성표·식의 복사원 코드 → 복사처 코드) */
function apply(env, src, steps) {
const d = env.d;
const ix = srcIndex(src);
const map = Object.fromEntries(steps.map((s) => [s.src, s.dest]));
const unresolved = new Set();
// 계획 밖 하위 코드 — 복사처의 같은 명칭·규격·단위 항목, 없으면 그대로 두고 알림
const resolve = (c) => {
if (map[c] || !ix.by.has(c)) return map[c] || c;
const same = findSame(ix.by.get(c), d.costn_rows || [], 2);
if (same) return (map[c] = codeOf(same));
unresolved.add(c);
return c;
};
env.memo = env.memo || {};
d.costn_memos = d.costn_memos || {};
d.bdqty_rows = d.bdqty_rows || [];
const remapText = (t) =>
String(t || "").replace(REF_RE, (all, c, tail) => `&${resolve(c)}${tail}&`);
// 구성행 칸 — 복사처 구성표가 읽는 칸만(프로젝트마다 복호되는 칸이 다름)
const keys = new Set(Object.keys(d.bdqty_rows[0] || { BDCODE: 1, CODE: 1, QTY1: 1 }));
const putBody = (srcCode, dest) => {
d.bdqty_rows = d.bdqty_rows.filter((q) => txt(q.BDCODE) !== dest);
(ix.comp.get(srcCode) || []).forEach((q) => {
const x = Object.fromEntries(
Object.entries(q).filter(([k]) => keys.has(k) && !k.startsWith("_")),
);
Object.assign(x, { BDCODE: dest, CODE: resolve(txt(q.CODE)) });
d.bdqty_rows.push(x);
});
const t = ix.memo(srcCode);
if (t) {
const t2 = remapText(t);
env.memo[dest] = t2;
d.costn_memos[dest] = Object.assign({}, d.costn_memos[dest] || { blockNo: 1 }, {
code: dest,
text: t2,
});
}
};
const n = { new: 0, copy: 0, map: 0, w: 0 };
steps.forEach((s) => {
const r = ix.by.get(s.src);
n[s.act] += 1;
if (s.act === "map") return;
if (s.act === "copy") {
const t = d.costn_rows.find((x) => codeOf(x) === s.dest);
COPY_KEEP.forEach((k) => k in r && (t[k] = r[k]));
if (!/^[MLSW]/.test(s.dest)) putBody(s.src, s.dest);
return;
}
const row = Object.fromEntries(
Object.entries(r).filter(
([k, v]) =>
!k.startsWith("_") && !k.endsWith("_HEX") && k !== "IFORM" && typeof v !== "object",
),
);
Object.assign(row, { CODE: s.dest, _new: true, _deleted: false });
d.costn_rows.push(row);
if (s.act !== "w") putBody(s.src, s.dest);
if (env.state) UC.stateEntry(env.state, row);
});
d.costn_count = d.costn_rows.length;
d.ui_dirty = true;
return { ok: true, map, count: n, unresolved: [...unresolved] };
}
/** 내역서 행 가져오기 — 복사원 내역 단가코드 행을 복사처 코드로 바꿔 새 행 값으로 */
function boqRowsFrom(srcRows, map) {
return (srcRows || []).map((r) => {
const c = txt(r.BDCODE1);
return {
BDCODE1: map[c] || c,
NAME1: r.NAME1 || "",
SIZE1: r.SIZE1 || "",
UNIT1: r.UNIT1 || "",
QTY1: String(num(r.QTY1)),
ITNUM: r.ITNUM || "",
};
});
}
/**
* 자료파일 업데이트(wM_Mrg_BSC) — 매칭 1~5차(cBx_Sel1~5) 차례로 복사원 같은 항목을 찾아 명칭·단가·조달코드 갱신
* keys: "JD"(호환규정코드) · "EQ"(시설코드) · "NSU"(명칭/규격/단위) · "JNSU"(호환+명+규+단) · "NU"(명칭/단위) · ""(설정안함)
* o = { keys, name: 0|1, amt: 0|1, jd: 0 안함 | 1 복사원 | 2 복사처 우선(비었을 때만) | 3 복사원 우선(복사원에 있을 때) , kinds }
*/
const MK = {
JD: (r) => txt(r.JDCODE),
EQ: (r) => txt(r.EQCODE),
NSU: (r) => (txt(r.NAME) ? nsu(r) : ""),
JNSU: (r) => (txt(r.JDCODE) && txt(r.NAME) ? `${txt(r.JDCODE)}${nsu(r)}` : ""),
NU: (r) => (txt(r.NAME) ? `${txt(r.NAME)}${txt(r.UNIT)}` : ""),
};
const AMT = [
"TAMT1",
"LAMT1",
"MAMT1",
"SAMT1",
"AMT_11",
"AMT_21",
"AMT_31",
"AMT_41",
"AMT_51",
"AMT_61",
"PG_1",
];
function mrgBsc(dest, src, o) {
const opt = Object.assign(
{ keys: ["JD", "NSU"], name: 0, amt: 1, jd: 0, kinds: "LMSW" },
o || {},
);
const keys = opt.keys.filter((k) => MK[k]);
const idx = keys.map((k) => {
const m = new Map();
(src || []).forEach((r) => {
const v = MK[k](r);
if (v && opt.kinds.includes(codeOf(r)[0]) && !m.has(`${codeOf(r)[0]}|${v}`))
m.set(`${codeOf(r)[0]}|${v}`, r);
});
return m;
});
const out = [];
(dest || []).forEach((r) => {
const c = codeOf(r);
if (!c || !opt.kinds.includes(c[0])) return;
let hit = null,
by = "";
keys.some((k, i) => {
const v = MK[k](r);
hit = v ? idx[i].get(`${c[0]}|${v}`) : null;
by = k;
return Boolean(hit);
});
if (!hit) return;
const ch = [];
if (opt.name)
["NAME", "SIZE", "UNIT"].forEach(
(k) => txt(r[k]) !== txt(hit[k]) && (ch.push(k), (r[k] = hit[k])),
);
if (opt.amt)
AMT.forEach((k) => k in hit && num(r[k]) !== num(hit[k]) && (ch.push(k), (r[k] = hit[k])));
const jd = txt(hit.JDCODE);
const takeJd = opt.jd === 1 || (opt.jd === 2 && !txt(r.JDCODE) && jd) || (opt.jd === 3 && jd);
if (takeJd && txt(r.JDCODE) !== jd) (ch.push("JDCODE"), (r.JDCODE = jd));
if (ch.length) out.push({ code: c, from: codeOf(hit), by, fields: ch });
});
return out;
}
global.STMATE_UI_MERGE = {
POLICY,
srcIndex,
closure,
findSame,
plan,
apply,
boqRowsFrom,
mrgBsc,
};
if (typeof module !== "undefined" && module.exports) module.exports = global.STMATE_UI_MERGE;
})(typeof window !== "undefined" ? window : globalThis);