feat(stmate-test): 3차 화면·데이터 마무리 — 보호표시 · 공종번호 · 직종·노임 · 제비율표 편집 · 차수 칸 저장 · 계약 변환등록 = 울진 계약 STC · 장부 87.6%

- 시험 615 통과 · 브라우저 화면 13개·메뉴 90개 오류 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 21:00:14 +09:00
co-authored by Claude Opus 5.5
parent e8b76dd65b
commit ffc2401870
36 changed files with 1907 additions and 78 deletions
@@ -0,0 +1,26 @@
// STMATE 재현 앱 산근 편집기 낙찰율 입력(scr_edit_san.setBidRate) → 계산완료(연쇄 재계산) — stdin {load, codes, rate}
globalThis.window = globalThis;
require("../../tools/stmate-test/calc.js");
require("../../tools/stmate-test/smt_eval.js");
require("../../tools/stmate-test/smt_engine.js");
require("../../tools/stmate-test/view_prj_boq.js");
const San = require("../../tools/stmate-test/scr_edit_san.js");
let buf = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (d) => (buf += d));
process.stdin.on("end", () => {
const { load, codes, rate } = JSON.parse(buf);
const by = new Map(load.costn_rows.map((r) => [String(r.CODE).trim(), r]));
codes.forEach((c) => San.setBidRate(by.get(c), rate, true));
window.STMATE_ENGINE.cascadeRecalculateAll({ dbfData: load, state: load.sim_state, uiState: {} });
const cols = ["TAMT1", "MAMT1", "LAMT1", "SAMT1", "TAMT2", "MAMT2", "LAMT2", "SAMT2"];
const out = {};
codes.forEach(
(c) =>
(out[c] = Object.assign(
{ UC: by.get(c).UC },
...cols.map((k) => ({ [k]: Number(by.get(c)[k]) })),
)),
);
process.stdout.write(JSON.stringify(out));
});
+13
View File
@@ -73,6 +73,19 @@ const MODES = {
});
}
if (args.target) out.found = Contract.findRate(tree, args.opts, args.target, args.stage || 1);
if (args.register) {
out.register = Contract.registerRows(
conv,
load.costn_rows,
load.costn_memos,
load.bdqty_rows,
);
const cfg = Contract.defaultContractCfg(
state.kanjubStages[1] || state.kanjubConfig,
comp.stages[1].kanjub,
);
out.register.costw1 = Contract.contractKanjub(conv, args.kanjubCfg || cfg).grandTotal;
}
return out;
},
progress({ load, args }) {
@@ -0,0 +1,21 @@
// STMATE 재현 앱 차수 칸(2·3·4) 고침 → 저장 요청(buildSavePayload) — stdin {load, ops:[{recno, stage, qty?, mls?}]}
globalThis.window = globalThis;
const Calc = require("../../tools/stmate-test/calc.js");
require("../../tools/stmate-test/view_prj_boq.js");
const Change = require("../../tools/stmate-test/change_calc.js");
let buf = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (d) => (buf += d));
process.stdin.on("end", () => {
const { load, ops } = JSON.parse(buf);
const state = load.sim_state;
for (const op of ops || []) {
const it = state.boqItems.find((b) => b._recno === op.recno);
if (op.qty != null) Change.setQty(state, it.id, op.stage, op.qty);
if (op.mls) Change.setUnit(state, it.id, op.stage, op.mls);
}
const ctx = { state, dbfData: load, currentSourcePath: load.source_path };
ctx.uiState = { editedSmtMap: {} };
ctx.computed = Calc.calculateAll(state);
process.stdout.write(JSON.stringify(window.STMATE_VIEW_PRJ_BOQ.buildSavePayload(ctx)));
});
+32 -2
View File
@@ -229,11 +229,41 @@ process.stdin.on("end", () => {
const it = m.items.find((x) => x._recno === leaf._recno);
const q1 = [it.qty, it.qtys[0], Calc.num(leaf.QTY1)];
const r2 = T.roundQty(m, [leaf._recno], "round", 0);
const hal = T.applyHalRate(m, [leaf._recno], { M: 3, L: 0, S: 0 }, "each");
const hal = T.applyHalRate(m, [leaf._recno], { M: 103, L: 0, S: 0 }, "each");
const halOut = [hal.changed, leaf.RM, it.halRate.M];
// 0% = 100%(그대로) · 보호된 견적단가(W · UC=T) 건너뜀
const zero = T.applyHalRate(m, [leaf._recno], { all: 0 }, "all");
const halZero = Object.assign({}, it.halRate);
const w = { BDCODE1: "W00001", UC: "T", _recno: -77, SORTCODE: "1", ENDCODE: "0" };
const wm = { rows: [w], items: [] };
const kept = T.applyHalRate(wm, null, { all: 110 }, "all");
const free = T.applyHalRate(wm, null, { all: 110 }, "all", { notUC: false });
// 기준단가 × 할증율 → 행 1칸 단가(저장) = 계산 원천 단가(calc.js halRate)
const d2 = fresh(),
m2 = modelOf(d2);
const by = cbc(d2);
const lf = d2.boq_rows.find(
(r) =>
B.isLeaf(r) &&
by[B.codeOf(r)] &&
Calc.num(by[B.codeOf(r)].MAMT1) > 0 &&
!/%$/.test(String(r.UNIT1 || "").trim()),
);
const base = (c) =>
by[c] && { M: Calc.num(by[c].MAMT1), L: Calc.num(by[c].LAMT1), S: Calc.num(by[c].SAMT1) };
T.applyHalRate(m2, [lf._recno], { all: 107.5 }, "all", { base });
const cr = Calc.computeAll(d2.sim_state).boqComputed.rows.find((x) => x._recno === lf._recno);
out.halBase = {
row: [Calc.num(lf.MAMT1), Calc.num(lf.LAMT1), Calc.num(lf.SAMT1)],
want: ["M", "L", "S"].map((c) => Math.trunc((base(B.codeOf(lf))[c] * 107.5) / 100 + 1e-9)),
calc: cr ? [cr.mUnit, cr.lUnit, cr.sUnit] : null,
};
out.qty = {
first: [r1.changed, ...q1],
second: [r2.changed, Calc.num(leaf.QTY1)],
hal: [hal.changed, leaf.RM, it.halRate.M],
hal: halOut,
zero: [zero.changed, halZero.M, halZero.L, halZero.S],
w: [kept.changed, kept.kept, free.changed, w.RATE],
};
}
@@ -0,0 +1,77 @@
// STMATE 재현 앱 보호표시(UC) 일괄 손질 제외 · 코드종류별 성분 검사 — stdin = /api/load 결과 + {prot: [코드]}
// 결과 = 손질별 바뀐 코드(보호 없음 · 보호 있음) · 보호해제 자동할증 · 성분 검사 · 저장 요청 COSTN 칸
globalThis.window = globalThis;
const D = "../../tools/stmate-test/";
const Calc = require(D + "calc.js");
require(D + "view_prj_boq.js");
const UA = require(D + "ui_code_amt.js");
const U = require(D + "ui_dan.js");
const PA = require(D + "scr_edit_price.js");
const CK = require(D + "ui_code_check.js");
let buf = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (x) => (buf += x));
process.stdin.on("end", () => {
const src = JSON.parse(buf);
const prot = new Set(src.prot || []);
const rowsOf = (mark) => {
const rows = JSON.parse(JSON.stringify(src.costn_rows));
if (mark) rows.forEach((r) => prot.has(U.codeOf(r)) && (r.UC = "T"));
return rows;
};
const codes = (list) => list.map((x) => x.code).sort();
const ops = {
adoptAll: (rows) => U.adoptAll(rows, "min", { kinds: "MLS", keepUser: false }),
edtHal: (rows) => U.edtHal(rows, { kinds: "MLS", pct: 110 }),
halAll: (rows) => UA.halAll(rows, { kinds: "MLSW", how: "rate", value: 5 }),
chgAmt: (rows) => UA.chgAmt(rows, { kinds: "MLSW", from: 1, to: 1, rate: 103 }),
adoptCand: (rows) => PA.adoptCandidates(rows, { mode: "min", slot: 1 }).changed,
};
const out = { ops: {} };
for (const [k, fn] of Object.entries(ops)) {
const a = fn(rowsOf(false)),
rows = rowsOf(true),
b = fn(rows);
const untouched = [...prot].every((c) => {
const r0 = src.costn_rows.find((r) => U.codeOf(r) === c),
r1 = rows.find((r) => U.codeOf(r) === c);
return ["MAMT1", "LAMT1", "SAMT1", "TAMT1"].every((f) => r0[f] === r1[f]);
});
out.ops[k] = { plain: codes(a), marked: codes(b), untouched };
}
// 보호해제 자동할증 — 그 행만 일괄할증과 같은 값
{
const rows = rowsOf(true);
const c = [...prot][0];
const r = rows.find((x) => U.codeOf(x) === c);
const hal = { how: "rate", value: 5, digits: 0, mode: "trunc" };
const res = U.setProtect(r, false, { autoHal: hal });
const ref = UA.halAll(rowsOf(false), Object.assign({ kinds: c[0], codes: [c] }, hal));
out.autoHal = { uc: r.UC, got: res.hal && res.hal.to, want: ref[0] && ref[0].to };
}
out.bdProtected = src.costn_rows.filter(
(r) => /^[BD]/.test(U.codeOf(r)) && U.isProtected(r),
).length;
// 성분 검사 — 원본 그대로 · 일부러 어긋낸 행
{
out.check = CK.checkComponents(src.costn_rows);
out.checkBoq = Object.values(src.boq_tables || {}).flatMap((rows) => CK.checkBoq(rows));
const rows = rowsOf(false);
const m = rows.find((r) => /^M/.test(U.codeOf(r)) && Number(r.MAMT1) > 0);
const b = rows.find((r) => /^B/.test(U.codeOf(r)) && Number(r.TAMT1) > 0);
m.LAMT1 = "5";
b.TAMT1 = String(Number(b.TAMT1) + 1);
out.broken = { m: U.codeOf(m), b: U.codeOf(b), res: CK.checkComponents(rows) };
}
// 저장 요청 — 보호 켠 행만 COSTN UC 가 바뀜
{
const load = JSON.parse(JSON.stringify(src));
load.costn_rows.forEach((r) => prot.has(U.codeOf(r)) && U.setProtect(r, true));
const ctx = { state: load.sim_state, dbfData: load, currentSourcePath: load.source_path };
ctx.uiState = { editedSmtMap: {} };
ctx.computed = Calc.calculateAll(ctx.state);
out.payload = window.STMATE_VIEW_PRJ_BOQ.buildSavePayload(ctx);
}
process.stdout.write(JSON.stringify(out));
});
+79
View File
@@ -0,0 +1,79 @@
// STMATE 재현 앱 화면 순수 계산 3차(직종·노임 적용코드 wM_Set_JLab · 공종번호 자동입력 wITNum) — stdin {mode, load, ...}
globalThis.window = globalThis;
const D = "../../tools/stmate-test/";
["kanjub_parts.js", "kanjub_calc.js", "calc.js", "smt_eval.js"].forEach((f) => require(D + f));
const S = require(D + "ui_sys.js");
const B = require(D + "ui_boq.js");
const T = require(D + "ui_boq_tools.js");
const MODES = {
// 제잡비 요율 축 콤보 — 화면과 같은 길(setKey → 제비율표 다시 조회 → 다시 셈) · 축마다 총공사비가 바뀐 선택지 수
rateAxes({ load, axes }) {
["kanjub_parts.js", "kanjub_calc.js", "scr_kanjub_axes.js", "scr_kanjub_rate.js"].forEach((f) =>
require(D + f),
);
require(D + "scr_kanjub.js");
const K = window.STMATE_KANJUB_SCREEN,
R = window.StmateRate,
X = window.STMATE_KANJUB_AXES;
const Calc = window.STMATE_CALC;
const m = R.matrixFromRows(load.rate_tables["RATE_R.DBF"] || []);
const all = [...X.COMMON, ...X.OPTN];
const grand = (st) => Calc.calculateAll(st).kanjubOrig.grandTotal;
const g0 = grand(JSON.parse(JSON.stringify(load.sim_state)));
const out = {};
for (const key of axes) {
const ax = all.find((a) => a[0] === key);
out[key] = ax[2].map(([v]) => {
const st = JSON.parse(JSON.stringify(load.sim_state));
const cfg = st.kanjubConfig;
cfg.keys = cfg.keys || {};
K.setKey(cfg, key, v);
if (st.kanjubStages && st.kanjubStages[1]) st.kanjubStages[1] = cfg;
R.applyWithLoopCheck(cfg, m, () => Calc.calculateAll(st).kanjubOrig, { withTbugum: true });
return grand(st) - g0;
});
}
return out;
},
jlab({ load }) {
const ini = load.start_ini || {};
const hw = window.STMATE_SMT_EVAL.hourlyWage;
const j = S.jlab(load.costn_rows, ini, "CONLD", hw);
const add = j.source.length ? j.source[0].code : null;
const added = add ? S.jlabEdit(ini.CONLD, [add], []) : ini.CONLD;
return {
j,
add,
added,
back: S.jlabEdit(added, [], add ? [add] : []),
dup: S.jlabEdit(ini.CONLD, S.splitCodes(ini.CONLD), []),
jcd: Number(String(ini.JCDNM || "0").trim()) || 0,
};
},
itnum({ load, plans }) {
const out = {};
for (const [t, plan] of Object.entries(plans)) {
const rows = load.boq_tables[t].map((r) => Object.assign({}, r));
const keep = rows.map((r) => r.ITNUM);
rows.forEach((r) => (r.ITNUM = ""));
plan.forEach((o) => T.autoItnum(rows, o));
out[t] = rows.map((r, i) => ({
i,
kind: B.rowKind(r).kind,
n: B.rowKind(r).n,
got: rows[i].ITNUM,
want: keep[i],
}));
}
return out;
},
};
let buf = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (x) => (buf += x));
process.stdin.on("end", () => {
const inp = JSON.parse(buf);
process.stdout.write(JSON.stringify(MODES[inp.mode](inp)));
});
@@ -0,0 +1,287 @@
"""STMATE 재현 앱 설계 → 계약자료 변환등록 끝까지(화면 계산 → 저장 → 다시 읽기) — 소광 원설계 STC → 울진 2025 계약 STC.
같은 공사의 설계 원본(22-…소광.STC, PCODE 11 만)을 계약 변환·등록하면 실제 계약 STC(울진 2025)의
PCODE 12 행 · BOQ12 · COSTN 새 코드 행(NAGCD 낙찰율 · 1칸 계약단가 · 2칸 원단가 · 산근 글 · 구성표)과 같아야 함.
입력(사용자 선택) = 적용율(계약 STC COSTN NAGCD 최빈값) · 적용제외 머리(계약에서도 코드가 그대로인 공종의 머리).
근거: 27 §2 · 26 §4 · 16 §4.5 · ui_form_catalog wM_Conv_Mode.
"""
import json
import re
import subprocess
import sys
from collections import Counter
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tools/stmate-test"))
sys.path.insert(0, str(Path(__file__).parent))
import server # noqa: E402
from api_ui_contract import ContractRegReq, contract_register # noqa: E402
from bdqty import decode_bdqty # noqa: E402
from kanjub_cfg import build_kanjub_config # noqa: E402
from smt_parser import extract_costn_memos, parse_dbf_bytes, parse_prjlst_smt # noqa: E402
from test_stmate_kanjub_stc import DOCS, STCS # noqa: E402
HELPER = Path(__file__).with_name("helper_stmate_stage.cjs")
STRUCT = re.compile(r"^(T\d|E\d|C\d|:)$")
DESIGN = str(DOCS / STCS["소광원설계"])
TRUTH = str(DOCS / STCS["울진2025"])
def _f(v):
return float(v or 0)
def _leaves(rows):
return [r for r in rows if r.get("BDCODE1") and not STRUCT.match(r["BDCODE1"])]
@pytest.fixture(scope="module")
def case():
load = server.api_load(server.LoadReq(source_path=DESIGN))
truth = server.api_load(server.LoadReq(source_path=TRUTH))
t12 = truth["boq_tables"]["BOQ12.DBF"]
rate = float(
Counter(r["NAGCD"] for r in truth["costn_rows"] if _f(r["NAGCD"])).most_common(1)[0][0]
)
base = {}
for r in load["costn_rows"]:
m = re.match(r"^([A-Z])(\d+)$", r["CODE"].strip())
if m:
base[m[1]] = max(base.get(m[1], 0), int(m[2]))
kept = {r["BDCODE1"] for r in _leaves(t12)} & {r["BDCODE1"] for r in load["boq_rows"]}
heads, cur = [], None
for r in load["boq_rows"]:
bd = r.get("BDCODE1") or ""
if bd.startswith("T"):
cur = r
elif bd.startswith("E") or bd.startswith("C"):
cur = None
elif cur is not None and bd in kept and cur["_recno"] not in heads:
heads.append(cur["_recno"])
opts = {"rateL": rate, "rateM": rate, "rateS": rate, "excludeHeads": heads}
# 계약 원가설정 = 사용자가 계약 공사에 넣은 설정(계약 행 PRJLST.SMT) — 총공사비 대조용
p12 = next(p for p in truth["prjlst_all"] if p["PCODE"] == "12")
smt = parse_prjlst_smt(server.read_project_files(TRUTH)["PRJLST.SMT"], p12["PRJ_MEMO_HEX"])
args = {"opts": opts, "codeBase": base, "register": True}
args["kanjubCfg"] = build_kanjub_config(smt, 1)
raw = json.dumps({"load": load, "mode": "contract", "args": args}, ensure_ascii=False)
out = json.loads(subprocess.check_output(["node", str(HELPER)], input=raw.encode("utf-8")))
reg = out["register"]
req = ContractRegReq(
source_path=DESIGN,
src_pcode="11",
pname="계약",
boq_rows=reg["boq"],
costn_rows=reg["costn"],
costn_memos=reg["memos"],
bdqty_rows=reg["bdqty"],
costw1=reg["costw1"],
)
files, pcode, _log = contract_register(server.read_project_files(DESIGN), req)
design = {r["CODE"].strip() for r in load["costn_rows"]}
return {
"files": files,
"pcode": pcode,
"truth": truth,
"t12": t12,
"reg": reg,
"design": design,
}
def test_new_contract_table_matches_boq12(case):
assert case["pcode"] == "12"
rows = parse_dbf_bytes(case["files"]["BOQ12.DBF"])[1]
got = [r for r in rows if r.get("BDCODE1") and r["BDCODE1"] != ":"]
exp = [r for r in case["t12"] if r.get("BDCODE1") and r["BDCODE1"] != ":"]
assert len(got) == len(exp)
cols = ("BDCODE1", "QTY1", "MAMT1", "LAMT1", "SAMT1", "T_AMT1", "M_AMT1", "L_AMT1", "S_AMT1")
for g, e in zip(got, exp):
for k in cols:
a, b = g.get(k), e.get(k)
assert (a == b) if k == "BDCODE1" else _f(a) == _f(b), (k, e.get("NAME1"), a, b)
def test_new_prjlst_row_matches_contract_row(case):
prj = parse_dbf_bytes(case["files"]["PRJLST.DBF"])[1]
got = next(p for p in prj if p["PCODE"] == "12")
exp = next(p for p in case["truth"]["prjlst_all"] if p["PCODE"] == "12")
for k in ("CCODE", "PCLASS", "MCOST1", "LCOST1", "SCOST1", "TCOST1", "COSTW1"):
assert _f(got[k]) == _f(exp[k]), k
assert _f(got["COSTW1"]) == case["reg"]["costw1"] > 0
def test_new_costn_rows_nagcd_and_prices(case):
files = case["files"]
rows = {r["CODE"].strip(): r for r in parse_dbf_bytes(files["COSTN.DBF"])[1]}
truth = {r["CODE"].strip(): r for r in case["truth"]["costn_rows"]}
assert sorted(c for c in truth if c not in rows) == [] # 계약 STC 의 코드가 모두 생김
made = [c["CODE"] for c in case["reg"]["costn"]]
assert sorted(made) == sorted(set(truth) - case["design"]) # 새 코드 = 계약 STC 에만 있는 코드
cols = [f"{c}AMT{s}" for s in "12" for c in "TMLS"] + ["NAGCD"]
for c in made:
g, e = rows[c], truth[c]
for k in ("NAME", "SIZE", "UNIT"):
assert g[k] == e[k], (c, k)
for k in cols:
assert _f(g[k]) == _f(e[k]), (c, k, g[k], e[k])
for k in ("UC", "BCONT"):
assert (str(g[k]).strip() == "T") == (str(e[k]).strip() == "T"), (c, k)
memos = extract_costn_memos(files["COSTN.DBF"], files["COSTN.SMT"])
tm = case["truth"]["costn_memos"]
assert {c for c in made if c in memos} == {c for c in made if c in tm}
for c in made:
if c in tm:
assert memos[c]["text"] == tm[c]["text"], c
bq = decode_bdqty(files["BDQTY.DBF"])
tq = case["truth"]["bdqty_rows"]
for c in made:
g = [(q["CODE"], _f(q["QTY1"])) for q in bq if q["BDCODE"].strip() == c]
e = [(q["CODE"], _f(q["QTY1"])) for q in tq if q["BDCODE"].strip() == c]
assert g == e, c
def _conv(path, opts, stage=1):
load = server.api_load(server.LoadReq(source_path=path))
base = {}
for r in load["costn_rows"]:
m = re.match(r"^([A-Z])(\d+)$", r["CODE"].strip())
if m:
base[m[1]] = max(base.get(m[1], 0), int(m[2]))
args = {"opts": opts, "codeBase": base, "register": True, "stage": stage}
raw = json.dumps({"load": load, "mode": "contract", "args": args}, ensure_ascii=False)
return load, json.loads(
subprocess.check_output(["node", str(HELPER)], input=raw.encode("utf-8"))
)
def test_unchecked_options_keep_base_and_make_w_codes():
"""기초단가 적용율 끔 = M·L·S 코드·단가 그대로 · 일위/산근 생성 끔 = B·D·X → 계약단가 W코드(낙찰율 열 없음)."""
r = 87.745
_load, out = _conv(
DESIGN, {"rateL": r, "rateM": r, "rateS": r, "baseRate": False, "makeIlwi": False}
)
leaves = [x for x in out["rows"] if x["kind"] == "leaf" and not x["excluded"]]
base = [x for x in leaves if x["code"][0] in "MLS"]
comp = [x for x in leaves if x["code"][0] in "BDX" and x["dUnit"]]
assert base and comp
assert all(x["newCode"] == x["code"] and x["tUnit"] == x["dUnit"] for x in base)
assert all(x["newCode"][0] == "W" for x in comp)
assert any(x["tUnit"] < x["dUnit"] for x in comp)
rows = {c["CODE"]: c for c in out["register"]["costn"]}
assert {x["newCode"] for x in comp} <= set(rows)
for c in rows.values():
assert c["CODE"][0] == "W" and "NAGCD" not in c
assert [c[f"{k}AMT1"] for k in "TMLS"] == [c[f"{k}AMT2"] for k in "TMLS"]
assert out["register"]["memos"] == {} and out["register"]["bdqty"] == []
def test_change_stage_quantities_registered():
"""변경 수량/단가 적용(cBx_Sel_QTY) — 영덕 변경 2차수 수량·단가로 계약 공사 · 새 표 직접비 = 계약 직접비."""
path = str(DOCS / STCS["영덕변경"])
load, out = _conv(path, {"rateL": 90, "rateM": 90, "rateS": 90}, stage=2)
reg = out["register"]
req = ContractRegReq(
source_path=path,
boq_rows=reg["boq"],
costn_rows=reg["costn"],
costn_memos=reg["memos"],
bdqty_rows=reg["bdqty"],
)
files, pcode, _log = contract_register(server.read_project_files(path), req)
rows = {r["_recno"]: r for r in parse_dbf_bytes(files[f"BOQ{pcode}.DBF"])[1]}
leaves = [x for x in out["rows"] if x["kind"] == "leaf" and x["recno"] is not None]
src = {r["_recno"]: r for r in load["boq_rows"]}
moved = 0
for x in leaves:
g = rows[x["recno"]]
if not x["excluded"]:
assert g["BDCODE1"] == x["newCode"] and _f(g["QTY1"]) == x["qty"], x["name"]
moved += _f(src[x["recno"]]["QTY1"]) != _f(src[x["recno"]]["QTY2"])
assert moved > 0 # 당초와 다른 변경 수량이 실제로 1칸에 들어감
prj = next(p for p in parse_dbf_bytes(files["PRJLST.DBF"])[1] if p["PCODE"] == pcode)
assert _f(prj["TCOST1"]) == out["totals"]["directTotal"]
def test_editor_bid_rate_input_matches_contract_codes():
"""산근·대가 편집기 낙찰율 입력(Ed_NAGCD · Ck_UC) → 계산완료 — 설계 코드 1칸·2칸 = 계약 STC 새 코드(NAGCD) 1칸·2칸."""
design = server.api_load(server.LoadReq(source_path=DESIGN))
truth = server.api_load(server.LoadReq(source_path=TRUTH))
t11, t12 = truth["boq_tables"]["BOQ11.DBF"], truth["boq_tables"]["BOQ12.DBF"]
tc = {r["CODE"].strip(): r for r in truth["costn_rows"]}
pair = {
a["BDCODE1"]: b["BDCODE1"]
for a, b in zip(_leaves(t11), _leaves(t12))
if a["BDCODE1"] != b["BDCODE1"] and _f(tc[b["BDCODE1"]]["NAGCD"]) > 0
}
(rate,) = {_f(tc[c]["NAGCD"]) for c in pair.values()}
raw = json.dumps({"load": design, "codes": list(pair), "rate": rate}, ensure_ascii=False)
helper = Path(__file__).with_name("helper_stmate_edit_bid.cjs")
got = json.loads(subprocess.check_output(["node", str(helper)], input=raw.encode("utf-8")))
assert len(pair) >= 20
for d, c in pair.items():
for k in ("TAMT1", "MAMT1", "LAMT1", "SAMT1", "TAMT2", "MAMT2", "LAMT2", "SAMT2"):
assert got[d][k] == _f(tc[c][k]), (d, c, k)
assert got[d]["UC"] == "T"
def _prj(files):
return parse_dbf_bytes(files["PRJLST.DBF"])[1]
def test_delete_project_drops_row_and_table_only():
"""내역서 삭제(Act_Del_Prj) — 고른 공사 PRJLST 행·BOQ1n 만 빠지고 나머지는 그대로 · 마지막 공사는 거부."""
from api_ui_project import delete_project
files = server.read_project_files(TRUTH)
out, log = delete_project(files, "12")
assert "BOQ12.DBF" not in out and [p["PCODE"] for p in _prj(out)] == ["11"]
before = next(p for p in _prj(files) if p["PCODE"] == "11")
after = _prj(out)[0]
assert {k: v for k, v in after.items() if k != "_recno"} == {
k: v for k, v in before.items() if k != "_recno"
}
assert all(out[k] is files[k] for k in files if k not in ("PRJLST.DBF", "BOQ12.DBF"))
assert {x["table"] for x in log} == {"PRJLST.DBF", "BOQ12.DBF"}
with pytest.raises(ValueError):
delete_project(out, "11")
def test_stage_register_k_ord_matrix():
"""단계 등록 K_ORD — 원본 PCLASS·등록(6 기성 · 7 설계변경)·플래그(0 차수별 · 1 당초/변경) 표(REA 31) 그대로."""
from api_ui_project import new_k_ord, stage_register
files = server.read_project_files(TRUTH) # PCODE 12 = 계약(PCLASS 4 · K_ORD 0)
a, pa, ka = stage_register(files, "12", 7, 0)
ra = next(p for p in _prj(a) if p["PCODE"] == pa)
assert (ka, _f(ra["K_ORD"]), _f(ra["PCLASS"])) == (1, 1, 7)
b12 = parse_dbf_bytes(files["BOQ12.DBF"])[1]
ba = parse_dbf_bytes(a[f"BOQ{pa}.DBF"])[1]
assert [r["T_AMT1"] for r in ba] == [r["T_AMT1"] for r in b12] # 계약 내역이 당초
# 설계변경(PCLASS 7 · K 1) → 차수별 = K+1 · 당초/변경 = 1(마지막 차수 2칸 → 새 1·2칸)
b, pb, kb = stage_register(a, pa, 7, 0)
assert kb == 2 and _f(next(p for p in _prj(b) if p["PCODE"] == pb)["K_ORD"]) == 2
src = parse_dbf_bytes(a[f"BOQ{pa}.DBF"])[1]
c, pc, kc = stage_register(a, pa, 7, 1)
rows = parse_dbf_bytes(c[f"BOQ{pc}.DBF"])[1]
assert kc == 1
for s, n in zip(src, rows):
assert _f(n["QTY1"]) == _f(n["QTY2"]) == _f(s["QTY2"])
rc = next(p for p in _prj(c) if p["PCODE"] == pc)
from dbf_rw import DbfTable
from stc_save import _leaf, boq_spans
_sp, excl = boq_spans(DbfTable(a[f"BOQ{pa}.DBF"]))
leaf2 = sum(_f(r["T_AMT2"]) for i, r in enumerate(src) if _leaf(r["BDCODE1"]) and i not in excl)
assert (
_f(rc["TCOST1"]) == _f(rc["TCOST2"]) == leaf2
) # 새 당초·변경 직접비 = 원본 변경 칸 직접비
# 기성 — 계약에서 1 · 기성에서 K+1 · 설계변경에서 1
assert [new_k_ord(4, 6, 0, 3), new_k_ord(6, 6, 0, 3), new_k_ord(7, 6, 1, 3)] == [1, 4, 1]
assert [new_k_ord(4, 7, 0, 3), new_k_ord(6, 7, 0, 3), new_k_ord(7, 7, 0, 3)] == [1, 1, 4]
d, pd, kd = stage_register(files, "12", 6, 0)
assert kd == 1 and _f(next(p for p in _prj(d) if p["PCODE"] == pd)["PCLASS"]) == 6
@@ -0,0 +1,75 @@
"""STMATE 재현 앱 차수 4벌 칸(QTY·단가 2~4) 고침 → 저장 → 다시 읽기 — 고친 칸만 바뀌고 금액·소계·직접비가 따라감.
설계변경 화면의 차수 수량·변경 단가(change_calc setQty·setUnit) → buildSavePayload → stc_save.apply_save.
근거: 08 §2 · 24 §7(차수 4벌) · 16 §4(행 금액 = ROUNDDOWN(수량×단가) · 계층 집계 · TCOSTn = 직접비).
"""
import json
import subprocess
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"))
import server # noqa: E402
from dbf_rw import DbfTable # noqa: E402
from stc_save import _leaf, apply_save, boq_spans # noqa: E402
HELPER = Path(__file__).with_name("helper_stmate_stage_save.cjs")
STCS = sorted((ROOT / "resources/knowledge/original/실무문서").rglob("*.STC"))
IDS = [p.stem[:16] for p in STCS]
def _d(v):
return Decimal(str(v or "0").strip() or "0")
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_slot_2_3_4_edits_persist(stc):
load = server.api_load(server.LoadReq(source_path=str(stc)))
bt_name = load["boq_table_name"]
_spans, excl = boq_spans(DbfTable(server.read_project_files(str(stc))[bt_name]))
leaf = next(
r
for r in load["boq_rows"]
if _leaf(r["BDCODE1"].strip())
and _d(r["TAMT1"]) > 0
and not r["UNIT1"].strip().endswith("%")
and r["_recno"] not in excl
)
rec = leaf["_recno"]
q = {n: float(_d(leaf["QTY1"])) + n for n in (2, 3, 4)}
mls = [float(_d(leaf["MAMT1"])) + 7, float(_d(leaf["LAMT1"])) + 11, float(_d(leaf["SAMT1"]))]
ops = [{"recno": rec, "stage": n, "qty": q[n]} for n in (2, 3, 4)]
ops.append({"recno": rec, "stage": 3, "mls": mls})
raw = json.dumps({"load": load, "ops": ops}, default=str, ensure_ascii=False)
p = json.loads(subprocess.check_output(["node", str(HELPER)], input=raw.encode("utf-8")))
files = server.read_project_files(str(stc))
out, log = apply_save(files, p)
bt = DbfTable(out[bt_name])
for n in (2, 3, 4):
assert _d(bt.get(rec, f"QTY{n}")) == Decimal(str(q[n]))
assert [_d(bt.get(rec, f"{c}AMT3")) for c in "MLS"] == [Decimal(str(v)) for v in mls]
for c, v in zip("MLS", mls): # 행 금액 = ROUNDDOWN(수량 × 단가)
want = (Decimal(str(q[3])) * Decimal(str(v))).quantize(Decimal(1), rounding=ROUND_DOWN)
assert _d(bt.get(rec, f"{c}_AMT3")) == want
# 다른 행 원래 칸 그대로 — 고친 행 · 소계(머리·계) 행만 바뀜
spans, _ = boq_spans(bt)
heads = {i for i, *_ in spans}
assert {x["recno"] for x in log if x["table"] == bt_name} <= {rec} | heads
assert not any(x["field"].endswith("1") for x in log if x["table"] == bt_name)
# PRJLST 차수 직접비 = 그 차수 말단 합
prj = DbfTable(out["PRJLST.DBF"])
pi = next(i for i in range(len(prj)) if prj.get(i, "PCODE") == bt_name[3:5])
for n in (3, 4):
if f"TCOST{n}" not in prj.fields:
continue
tot = sum(
_d(bt.get(i, f"T_AMT{n}"))
for i in range(len(bt))
if _leaf(bt.get(i, "BDCODE1")) and i not in excl
)
assert _d(prj.get(pi, f"TCOST{n}")) == tot
+6 -1
View File
@@ -108,7 +108,12 @@ def test_qty_round_and_hal_rate():
_d, r = run("봉화2024")
assert r["qty"]["first"] == [1, 12.34, 12.34, 12.34]
assert r["qty"]["second"] == [1, 12]
assert r["qty"]["hal"] == [1, "3", 3]
# 할증율 = 기준단가에 곱할 백분율(TwBoqRate · 0% = 100%) — 103% → 계산 원천 가산 3%
assert r["qty"]["hal"] == [1, "103", 3]
assert r["qty"]["zero"] == [1, 0, 0, 0]
assert r["qty"]["w"] == [0, 1, 1, "110"] # 보호된 견적단가 수정안함(CBoxNotUC)
hb = r["halBase"]
assert hb["row"] == hb["want"] and hb["calc"] == hb["want"] # 저장 단가 = 화면 계산 단가
@pytest.mark.parametrize("name", list(PROJECTS))
@@ -0,0 +1,90 @@
"""STMATE 재현 앱 보호표시(UC) 일괄 손질 제외 · 단가 성분 검사(TAMT = 재+노+경 · 코드종류별 비목).
보호 = wM_Boxa 단가 격자 「보호」 열(√/공란 = True/False) → 기초·견적단가(M·L·S·W) 행 COSTN UC.
일괄 손질(가격지 채택 · 기준단가 수정 · 일괄할증 · 일괄변경 · 후보 채택)은 보호 행을 건너뜀(22 §3.1 안내문).
B·D 행 UC 는 산근 편집기 「결과값에 낙찰율적용」(Ck_UC) 자리 — 실무 7건 UC=T ⇔ NAGCD>0.
성분 검사 = 10 §3 검증 조건식 — 실무 7건 COSTN·내역 어긋남 0 · 일부러 어긋낸 칸만 잡힘.
"""
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 server # noqa: E402
from stc_save import apply_save # noqa: E402
HERE = Path(__file__).parent
STCS = sorted((ROOT / "resources/knowledge/original/실무문서").rglob("*.STC"))
IDS = [p.stem[:16] for p in STCS]
_CACHE: dict = {}
def _f(v):
return float(v or 0)
def run(stc):
if stc not in _CACHE:
d = server.api_load(server.LoadReq(source_path=str(stc)))
# 보호 대상 = 바뀔 수 있는 기초단가 몇 건(후보 있는 재료 · 노무 · 경비) — 자료에서 고름
mls = [r for r in d["costn_rows"] if r["CODE"][:1] in "MLS" and _f(r["TAMT1"]) > 0]
cand = [r for r in mls if any(_f(r.get(f"AMT_{i}1")) > 0 for i in range(1, 6))]
prot = [r["CODE"].strip() for r in (cand[:3] + [r for r in mls if r not in cand][:2])]
d["prot"] = prot
out = subprocess.check_output(
["node", str(HERE / "helper_stmate_ui_protect.cjs")],
input=json.dumps(d, ensure_ascii=False, default=str),
text=True,
encoding="utf-8",
)
_CACHE[stc] = (d, json.loads(out))
return _CACHE[stc]
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_batch_edits_skip_protected_rows(stc):
d, r = run(stc)
prot = set(d["prot"])
assert prot
moved = 0
for name, o in r["ops"].items():
assert o["untouched"], name # 보호 행 단가 그대로
assert o["marked"] == sorted(set(o["plain"]) - prot), name # 나머지는 보호 없을 때와 같음
moved += len(set(o["plain"]) & prot)
assert moved > 0 # 보호가 없었으면 바뀌었을 행이 실제로 있었음
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_unprotect_auto_hal_and_bd_uc_is_bid_rate_flag(stc):
d, r = run(stc)
a = r["autoHal"]
assert a["uc"] == "F" and a["got"] is not None and a["got"] == a["want"]
assert r["bdProtected"] == 0 # 산근·일위 UC=T 는 보호로 보지 않음
for row in d["costn_rows"]: # 근거 — UC=T ⇔ 낙찰율 열(NAGCD) > 0
if row["CODE"][:1] in "BD":
assert (str(row.get("UC") or "").strip() == "T") == (_f(row.get("NAGCD")) > 0)
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_protect_mark_saves_only_uc(stc):
d, r = run(stc)
files = server.read_project_files(str(stc))
_out, log = apply_save(files, r["payload"])
got = {(x["table"], x["field"]) for x in log}
assert got == {("COSTN.DBF", "UC")}
assert len(log) == len(d["prot"])
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_component_check_clean_on_real_and_catches_broken(stc):
_d, r = run(stc)
assert r["check"] == []
assert r["checkBoq"] == [], r["checkBoq"][:3]
b = r["broken"]
hits = {(x["code"], x["slot"], x["rule"]) for x in b["res"]}
assert hits == {(b["m"], 1, "own"), (b["m"], 1, "sum"), (b["b"], 1, "sum")}
+250
View File
@@ -0,0 +1,250 @@
"""STMATE 재현 앱 화면 3차 — 직종·노임 적용코드(wM_Set_JLab) · 공종번호 자동입력(wITNum 하위 전체공종 반영).
JLab: 적용코드 = Start8x.Ini CONLD(6자리 코드 이어 붙임 · REA 14 TWM_SET_SYS/TWM_SET_JLAB) · 적용단가 = 일 노임 × 환산식
· 추가·삭제 왕복 = 원래 글 · 저장 = Start8x.Ini 그 키만.
wITNum: cBx_Bl_Lev 「하위 전체공종 반영」 = 묶음 안 공종(계표시)·단가코드를 한 차례로 · 직전공종 번호 + 첫 글자 + 번호.
"""
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 server # noqa: E402
from smt_parser import parse_ini_bytes # noqa: E402
from stc_save import apply_save # noqa: E402
HELPER = Path(__file__).with_name("helper_stmate_ui_r3.cjs")
STCS = sorted((ROOT / "resources/knowledge/original/실무문서").rglob("*.STC"))
IDS = [p.stem[:16] for p in STCS]
def node(payload):
raw = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
return json.loads(subprocess.check_output(["node", str(HELPER)], input=raw))
def _f(v):
return float(v or 0)
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_jlab_codes_hourly_and_save(stc):
load = server.api_load(server.LoadReq(source_path=str(stc)))
ini = load["start_ini"]
r = node({"mode": "jlab", "load": load})
conld = ini.get("CONLD", "")
assert [t["code"] for t in r["j"]["target"]] == [
conld[i : i + 6] for i in range(0, len(conld), 6)
]
assert r["j"]["formula"] == ini.get("RXXNM", "").strip()
costn = {x["CODE"].strip(): x for x in load["costn_rows"]}
for t in r["j"]["target"]: # 적용단가 = 일 노임 × 환산식(왼쪽부터) 버림
assert t["price"] == _f(costn.get(t["code"], {}).get("LAMT1"))
assert t["hourly"] <= t["price"]
assert all(s["code"][0] == "L" and s["code"] not in conld for s in r["j"]["source"])
assert r["back"] == conld and r["dup"] == conld
if not r["add"]:
pytest.skip("고를 노무비 코드 없음")
assert r["added"] == conld + r["add"]
files = server.read_project_files(str(stc))
out, log = apply_save(files, {"source_path": str(stc), "start_ini": {"CONLD": r["added"]}})
names = [n for n in ("START8X.INI", "STINI.MEM") if n in files]
assert names and {(x["table"], x["field"]) for x in log} == {(n, "CONLD") for n in names}
for n in names:
assert parse_ini_bytes(out[n])["CONLD"] == r["added"]
# 공종번호 계획 = 사용자가 wITNum 에서 고를 선택지(뿌리 레벨 · 끝 글자 · 대상 · 마디 한도)와 선택 범위(그 머리 묶음)
# under = [레벨, n번째] — 그 레벨 n번째 머리 묶음만 선택 범위로
def _tree(level, last, target="group", **k):
return {"tree": True, "level": level, "last": last, "target": target, **k}
ITNUM_PLANS = {
"13-2024년 산불진화임도 실시": {"BOQ11.DBF": [_tree(4, ".")]},
"2024년 임도시설사업2차(산불": {
"BOQ11.DBF": [
_tree(3, "", "both", maxDepth=3, under=[4, 1]),
_tree(3, "", "both", maxDepth=3, under=[4, 2]),
_tree(3, ".", "both", maxDepth=3, under=[4, 3]),
_tree(3, ".", "both", maxDepth=3, under=[4, 4]),
]
},
"2024년 간선임도사업(기번3)": {"BOQ11.DBF": [_tree(3, ".")]},
"2025년 계류보전사업(기번1 ": {"BOQ11.DBF": [_tree(3, ".")]},
"22-2025년 산불진화임도사업(": {"BOQ11.DBF": [_tree(3, ".")]},
"2025년 산불진화임도 신설사업": {"BOQ11.DBF": [_tree(3, ".")], "BOQ12.DBF": [_tree(3, ".")]},
"변경 1차분-2025년 산불진화임": {"BOQ11.DBF": [_tree(3, ".")]},
}
STRUCT_E = ("E", "C")
def _ranges(rows, plan):
"""under → range(그 머리 행 ~ 짝 닫는 행)."""
out = []
for o in plan:
o = dict(o)
if "under" in o:
lv, nth = o.pop("under")
heads = [i for i, r in enumerate(rows) if (r.get("BDCODE1") or "").strip() == f"T{lv}"]
i = heads[nth - 1]
end = next(
j for j, r in enumerate(rows) if j > i and r.get("SORTCODE") == rows[i]["ENDCODE"]
)
o["range"] = [i, end]
out.append(o)
return out
def _groups(res, rows):
"""번호 붙은 행을 바로 위 머리(부모)별 차례로 묶음."""
stack, groups, parent_of = [], {}, {}
ends = {}
for i, r in enumerate(rows):
bd = (r.get("BDCODE1") or "").strip()
while stack and i > ends[stack[-1]]:
stack.pop()
if bd[:1] in STRUCT_E or not bd or bd == ":":
continue
par = stack[-1] if stack else -1
if res[i]["want"].strip():
groups.setdefault(par, []).append(i)
parent_of[i] = par
if bd[:1] == "T" and len(bd) == 2:
end = next(
(j for j, q in enumerate(rows) if j > i and q.get("SORTCODE") == r["ENDCODE"]), i
)
ends[i] = end
stack.append(i)
return groups, parent_of
def _clean_prefix(res, idx, parent_want):
"""묶음 앞에서부터 원본 규칙 번호인 행 — 숫자 없음·겹침·빈 번호·순서 바뀜·직전공종 번호 다름에서 멈춤."""
import re as _re
ok = []
for k, i in enumerate(idx, 1):
w = res[i]["want"].strip()
m = _re.match(r"^(.*?)(\d+)\.?$", w)
if not m or int(m[2]) != k:
break
head = m[1].strip().rstrip(".")
if head != parent_want.strip().rstrip("."):
break
ok.append(i)
return ok
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_itnum_tree_reproduces_every_rule_number(stc):
load = server.api_load(server.LoadReq(source_path=str(stc)))
plans = next(v for k, v in ITNUM_PLANS.items() if stc.stem.startswith(k))
tables = {t: _ranges(load["boq_tables"][t], p) for t, p in plans.items()}
out = node({"mode": "itnum", "load": load, "plans": tables})
total = checked = 0
for t, res in out.items():
rows = load["boq_tables"][t]
groups, _par = _groups(res, rows)
good = set()
# 뿌리 쪽부터 — 부모 번호가 원본 규칙 번호로 맞았거나 규칙 꼴이 아닌 머리(□ · [1] 등 = 손 입력)일 때 하위 묶음 대조
for par in sorted(groups):
pw = res[par]["want"] if par != -1 else ""
if _re_digit(pw) and par not in good:
continue
for i in _clean_prefix(res, groups[par], pw if _re_digit(pw) else ""):
assert res[i]["got"] == res[i]["want"], (t, i, res[i])
good.add(i)
total += sum(1 for r in res if r["want"].strip() and r["kind"] in ("T", "LEAF"))
checked += len(good)
# 원본 번호 대부분이 규칙 번호 — 나머지는 손으로 고친 번호(□ · 겹침 · 지운 공종 빈자리 뒤)
assert checked >= 0.75 * total, (checked, total)
def _re_digit(s):
"""규칙 꼴 번호(…숫자 · …숫자.) — 숫자 없는 □ · 「[1]」 같은 손 입력 머리는 아님."""
import re as _re
return bool(_re.match(r"^\s*(.*?)(\d+)\.?$", str(s)))
RATE_AXES = [
"I_KONG_TYPE",
"I_KONG_KIKAN",
"I_ANJUN_TYPE",
"I_KOBO_TYPE",
"I_HWAN",
"I_EQIP",
"I_TBUGUM",
"I_ILBAN",
]
def test_rate_axis_combos_recalculate():
"""제잡비 화면 요율 축 콤보(wM_KanJub) — 바꾸면 제비율표(RATE_R) 다시 조회 → 총공사비가 다시 셈해짐(실무 7건 중 하나 이상)."""
moved = {k: 0 for k in RATE_AXES}
for stc in STCS:
load = server.api_load(server.LoadReq(source_path=str(stc)))
if not load["rate_tables"].get("RATE_R.DBF"):
continue
out = node({"mode": "rateAxes", "load": load, "axes": RATE_AXES})
for k, deltas in out.items():
assert all(isinstance(d, (int, float)) for d in deltas), k
moved[k] += sum(1 for d in deltas if d != 0)
assert all(v > 0 for v in moved.values()), moved
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_rate_table_edit_saves_only_changed_cell(stc):
"""제비율표 수정(wM_KAN_RATE) → /api/ui/save rate_tables — 고친 칸만 RATE_R.DBF 에 · 안 고치면 바이트 그대로."""
from api_ui_code import apply_struct
from smt_parser import parse_dbf_bytes
load = server.api_load(server.LoadReq(source_path=str(stc)))
rows = load["rate_tables"].get("RATE_R.DBF")
if not rows:
pytest.skip("RATE_R 없음")
files = server.read_project_files(str(stc))
same, log0 = apply_struct(files, {"rate_tables": {"RATE_R.DBF": rows}})
assert log0 == [] and same["RATE_R.DBF"] is files["RATE_R.DBF"]
edit = [dict(r) for r in rows]
r, k = 20, "RATE_01"
edit[r][k] = str(_f(edit[r][k]) + 1.25)
out, log = apply_struct(files, {"rate_tables": {"RATE_R.DBF": edit}})
assert [(x["table"], x["recno"], x["field"]) for x in log] == [("RATE_R.DBF", r, k)]
back = parse_dbf_bytes(out["RATE_R.DBF"])[1]
assert _f(back[r][k]) == _f(rows[r][k]) + 1.25
assert all(back[i] == rows[i] for i in range(len(rows)) if i != r)
def test_editor_function_list_and_highlight_follow_dfm():
"""산출근거 편집기 함수 단추(cBoxFunc)·문법 강조 낱말(Syn1.KeyWords) = 원본 TWM_EDIT_SAN DFM."""
dfm = (
ROOT / "resources/knowledge/original/경쟁사 프로그램/STmate/REA 분석/"
"30_수량문자열_수신편집기_작업자료_20261026/ST_EdSan/resource-TWM_EDIT_SAN.dfm.json"
)
nodes = {n.get("name"): n for n in json.loads(dfm.read_text(encoding="utf-8"))["nodes"]}
funcs = [x.strip() for x in nodes["cBoxFunc"]["properties"]["Items.Strings"][1:]]
keys = nodes["Syn1"]["properties"]["KeyWords.Strings"]
js = (
"globalThis.window=globalThis;"
"const L=require('../../tools/stmate-test/scr_edit_lang.js');"
"const X=require('../../tools/stmate-test/scr_edit_expr.js');"
"const ks=L.SYN_KEYWORDS.split(' ');"
"process.stdout.write(JSON.stringify({f:X.FUNC_LIST,k:ks,"
"h:ks.map(k=>L.highlight(k.toLowerCase()+'(1)')),v:L.highlight('Qo = 1')}))"
)
out = json.loads(
subprocess.check_output(
["node", "-e", js], cwd=str(HELPER.parent), text=True, encoding="utf-8"
)
)
assert out["f"] == funcs
assert out["k"] == keys
assert all('class="syn-key"' in h for h in out["h"])
assert 'class="syn-var"' in out["v"] and "syn-key" not in out["v"]
+27 -26
View File
@@ -20,6 +20,7 @@
- `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_ui_protect.py` · `test_stmate_ui_round3.py` · `test_stmate_stage_slots.py` · `test_stmate_stage_register.py` · `test_stmate_report_link.py` — 보호표시 · 공종번호·직종·제비율표 · 차수 칸 저장 · 계약 변환등록 = 실제 울진 계약 STC · 목차·인쇄 옵션
- `test_stmate_file_*.py` — 실무 STC 7건 무손실 왕복(손대지 않은 저장 = 원본 바이트) · 고친 칸만 바뀜 · 새 공사·복사등록·해당분 압축
- `test_stmate_stage_*.py` — 설계변경(영덕·소광 변경 BOQ·엑셀 대조) · 계약 변환(울진 BOQ12 계약내역·총공사비 1원) · 기성·실행(앞뒤 맞춤만)
- `test_stmate_ilwi_eval.py` — 일위대가·중기 구성 재계산(원설계 5건 + 영덕 변경) 1원 · 연쇄 재계산(기초단가 → 원가계산서) 원설계 5건 전 단가·총공사비 그대로
@@ -28,15 +29,15 @@
## 진행률
- 합계: 전체 246 · 구현 173 · 부분 68 · 없음 5 → 진행률 84.1%((173 + 68×0.5) / 246) · 구현만 세면 70.3%
- 합계: 전체 246 · 구현 190 · 부분 51 · 없음 5 → 진행률 87.6%((190 + 51×0.5) / 246) · 구현만 세면 77.2%
### G1 화면·메뉴 (구현 37 · 부분 16 · 없음 1) — 83.3%
### G1 화면·메뉴 (구현 47 · 부분 6 · 없음 1) — 92.6%
- [x] F1-01 메인 메뉴·툴바·하단 금액 집계줄 — STmate.Cpm 메인 · 현재: 구현(원본 메뉴 구조·툴바 차례·단축키 12·체크 항목·최근 폴더(test_stmate_ui))
- [x] F1-02 공사목록표 목록·선택·불러오기 — wPrjList · 현재: 구현(공사목록표 · 다른 내역서 열기(저장 직접비 1원 일치))
- [ ] F1-03 프로젝트 신규 등록·삭제 — wM_Add_Prj · ST_U_DelPrj · 현재: 부분(stc_project 새 공사(빈 상태) · 삭제 화면 없음)
- [x] F1-03 프로젝트 신규 등록·삭제 — wM_Add_Prj · ST_U_DelPrj · 현재: 구현(내역서 삭제 = 고른 공사 행·내역 표만 · 마지막 공사 거부(test_delete_project_drops_row_and_table_only))
- [x] F1-04 프로젝트 등록정보 수정 — wPrjList · 현재: 구현(등록정보 수정)
- [ ] F1-05 복사등록(차수·단계 변환) — wPrjList · ST_PList · 현재: 부분(stc_project 복사등록 차수 인계(K_ORD·직공비·원가 설정) 실물 대조 · 계약 변환 실물 대조 없음)
- [x] F1-05 복사등록(차수·단계 변환) — wPrjList · ST_PList · 현재: 구현(REA K_ORD 전이표 7갈래 · 당초/변경 칸 옮김(test_stage_register_k_ord_matrix))
- [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))
@@ -48,8 +49,8 @@
- [x] F1-14 호표 코드 → 대가표·단가표 이동 — wBoq · 현재: 구현(코드 → 단가목록표·대가표 이동)
- [x] F1-15 공종 목록(코드·명칭·분류 정렬, 대공종 필터) — wKongJong · 현재: 구현(공종목록 코드 넣기 · 대공종 옵션)
- [x] F1-16 주공종 집계표(레벨별 계표시 확인) — wMBoq · 현재: 구현(레벨 거르기·계표시·레벨 올리기/내리기·주공종 집계)
- [ ] F1-17 공종번호 자동입력(머리·끝 글자·번호형식·자리수) — wITNum · 현재: 부분(공종번호 자동입력 · 원본 번호 빈 자리 앞까지 재현(봉화·영월))
- [ ] F1-18 내역 할증율 적용(전체/비목별) — wBoqRate · 현재: 부분(내역 할증율 계산 반영(calcBoq) · 근거 등급 C)
- [x] F1-17 공종번호 자동입력(머리·끝 글자·번호형식·자리수) — wITNum · 현재: 구현(하위 전체공종 반영 · 실무 7건 규칙 번호 255/304 일치 · 나머지는 손 입력(test_itnum_tree_reproduces_every_rule_number))
- [ ] F1-18 내역 할증율 적용(전체/비목별) — wBoqRate · 현재: 부분(원본 TwBoqRate 판독(0%=100% · 기준단가×율 · 보호 견적단가 제외) · 실물 표본 0)
- [x] F1-19 비목별 일괄 보정(절사/반올림 × 소수 0~4 · 범위) — wM_Rate · 현재: 구현(비목별 단가 끝전 · 줄 고정 단가(server fixedUnit)로 다시 불러도 유지)
- [x] F1-20 일위대가표 풀기 — wExpnd · 현재: 구현(일위대가 풀기 = 구성표 × 내역 수량)
- [x] F1-21 풀어넣기 비목별 끝전·가산비목 지정 — wM_Expnd · wM_Expnd10 · 현재: 구현(풀어넣기 비목별 끝전·할증·손료 비율 줄 · 손료 줄 금액 7건 대조)
@@ -57,16 +58,16 @@
- [x] F1-23 내역보관함 (1)(2)(3) — wBoq · 현재: 구현(보관함 (1)(2)(3))
- [x] F1-24 단가목록표 코드종류 필터(10종) — wM_JabPum cBoxCode · 현재: 구현(코드 종류 10종 · 그림코드 = I 코드(원본 화면 탭))
- [x] F1-25 단가 찾기(명칭·규격 / 산근내용) — wM_JabPum cBox_IFORM · 현재: 구현(단가 찾기·사용처 찾기)
- [ ] F1-26 기초단가 직접 수정(재·노·경) — wM_JabPum · 현재: 부분(기초단가 직접 수정·기준단가 수정·후보 채택 · 보호표시 칸 확인 안 됨)
- [x] F1-26 기초단가 직접 수정(재·노·경) — wM_JabPum · 현재: 구현(보호(UC=T · M·L·S·W 행) 제외 · 보호 열(test_stmate_ui_protect) · 실물 표본 0)
- [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 · 현재: 부분(기초단가 직접 수정·기준단가 수정·후보 채택 · 보호표시 칸 확인 안 됨)
- [x] F1-29 기준단가 비율 일괄수정(보호표시·0원 제외) — wM_Edt_Hal · 현재: 구현(비율 일괄수정 보호·0원 제외 · 보호해제 자동할증(test_stmate_ui_protect))
- [x] F1-30 할증 금액·율 두 갈래 편집 — ST_Jabpum Edt_Hal_Amt/Rate · 현재: 구현(할증 율·금액(기준단가 기준 · 두 번 해도 같은 값))
- [ ] F1-31 가격지 후보 비교(1~5단가·최소단가) — wM_Boxa RG_MComp · 현재: 부분(기초단가 직접 수정·기준단가 수정·후보 채택 · 보호표시 칸 확인 안 됨)
- [x] F1-31 가격지 후보 비교(1~5단가·최소단가) — wM_Boxa RG_MComp · 현재: 구현(가격지 후보 비교·채택 보호 행 제외(test_stmate_ui_protect))
- [x] F1-32 자재·공종 분류 필터, 불러간 목록 — wM_Boxa rg_FLT · wM_Box_H · 현재: 구현(속성·명칭·불러간 목록 필터)
- [x] F1-33 대가표 목록·선택(B/D/X) — wM_iLWi_San · 현재: 구현(view_dan_ilwi 236~)
- [x] F1-34 일위대가 구성행 표 편집·노임계수·저장비목 선택 — wM_Edit_iLWi · 현재: 구현(구성행 편집 · 저장 = BDQTY 바뀐 호표만 암호로 다시 씀(stc_save_extra))
- [ ] F1-35 단가산출근거 편집 + 계산(F9) — wM_Edit_San · 현재: 부분(글 편집·즉시 계산, 함수·문법검사 없음)
- [x] F1-35 단가산출근거 편집 + 계산(F9) — wM_Edit_San · 현재: 구현(산근 편집 + F9 · 함수·문법 검사(test_stmate_smt_lang))
- [ ] 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건 실적단가 자료 없음)
@@ -77,17 +78,17 @@
- [x] F1-43 단가 일괄변경·물가변동 — ST_U_ChgAmt · 현재: 구현(ui_code_amt 일괄변경·물가변동 · 당초⇔변경 단가 옮기기)
- [x] F1-44 초기값 입력 및 수정 — wM_Set_Sys · 현재: 구현(초기값 화면 전체 항목 · 저장 = Start8x.Ini 바뀐 키)
- [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)
- [x] F1-46 직종·노임 설정 — wM_Set_JLab · 현재: 구현(직종·노임 적용코드 창 · 저장 시 Start8x.Ini 해당 키만(test_jlab_codes_hourly_and_save))
- [ ] F1-47 제잡비 계산 화면(4형식 탭) — wM_KanJub · 현재: 부분(요율 축 8개 바꾸면 재조회·재계산(test_rate_axis_combos_recalculate) · 계약형식·낙찰방식·건물종류 계산 근거 없음)
- [x] F1-48 제비율표 수정·취소·저장·출력 — wM_KAN_RATE · 현재: 구현(제비율표 수정·취소·저장·출력(test_rate_table_edit_saves_only_changed_cell))
- [x] F1-49 요율표 불러오기·선택 — wM_KAN_RATE · 현재: 구현(요율표 선택·기준 요율표 열기·콤보 바꾸면 자동 재조회)
- [x] F1-50 잡비 금액 불러오기(타 프로젝트 금액을 밑수로) — wM_GetAMT · 현재: 구현(잡비 금액 불러오기 → 제잡비 직접입력(C_ 칸))
- [x] F1-51 인쇄 메뉴 옵션(글꼴·코드필드·수량위치·양식·방향) — PM_Prt · 현재: 구현(인쇄 옵션 바꾸면 결과물 바뀜(test_stmate_report_link))
- [x] F1-52 보고서 인쇄순서·인쇄 공종 집계수준 선택 — PM_Prn_Ord · PM_Sel_St · 현재: 구현(인쇄순서로 원본 3건 시트 차례 재현)
- [x] F1-53 인쇄영역 선택(당초/변경/분할·레벨·변경 1~8차) — wM_Sel_Prn · 현재: 구현(인쇄영역 당초/변경/분할·레벨·차수 반영)
- [ ] F1-54 특수문자·그림코드·메모 입력 — mMinMsg_ · wInDlgImg · wInDlgMemo · 현재: 부분(특수문자·메모 · 그림 넣기 없음)
- [ ] F1-54 특수문자·그림코드·메모 입력 — mMinMsg_ · wInDlgImg · wInDlgMemo · 현재: 부분(특수문자·메모·그림코드 넣기 · 그림 저장 자리 근거 없음)
### G2 계산 엔진 (구현 51 · 부분 9 · 없음 1) — 91.0%
### G2 계산 엔진 (구현 53 · 부분 7 · 없음 1) — 92.6%
- [x] F2-01 노임 일→시간 환산식 좌→우 순차 — 18번 §2.1 · Start8x RXXNM · 현재: 구현(calc.js 84)
- [ ] F2-02 노임 환산 갈래 H0~H5(터널·준설선) — wM_Set_Sys cmBx_Sel_RX · 30번 §4 · 현재: 부분(노임 환산 갈래 연결 · 실무 표본 기본 갈래만)
@@ -103,8 +104,8 @@
- [x] F2-12 일위대가 B코드 구성 조립·성분 절사 — 17번 §2·§5 · 현재: 구현(BDQTY 구성 · 행 소수1 버림 · CHBNM 총액우선 = 행 합계액 기준 · 7건 대조)
- [x] F2-13 호표 참조 그래프·위상순서·순환 오류 — 19번 §6.5 · 09번 §3 · 현재: 구현(X·D·B 참조 순서 · 순환 경로 알림(실무 7건 순환 0))
- [x] F2-14 공구손료·잡재료 % 가산 — 07번 §4 · 현재: 구현(PR 줄 = PRPT 표시 행 밑수 PRIN → PROUT · % 단위 자원 = 누계 · C* 뒤 % = 전체 비율)
- [ ] F2-15 수량 할증과 금액 가산 분리 — 07번 §4 · 현재: 부분(calc.js 215 일위 할증만, 박힌 기본 상태 전용)
- [ ] F2-16 TAMT=M+L+S·코드종류별 성분 검사 — 10번 §3 · 현재: 부분(합산만, L/M/S 종류별 검사 없음)
- [x] F2-15 수량 할증과 금액 가산 분리 — 07번 §4 · 현재: 구현(금액 가산(% 줄)은 별도 행 계산 · X_QTY·X_HAL = 손료계수 칸(REA 정정) · 실무 7건 1원)
- [x] F2-16 TAMT=M+L+S·코드종류별 성분 검사 — 10번 §3 · 현재: 구현(성분 검사 · 실무 7건 어긋남 0 · 일부러 어긋낸 칸만 잡힘(test_component_check_clean_on_real_and_catches_broken))
- [x] F2-17 내역 행 ROUNDDOWN(수량×단가) — 16번 · 06번 §3 · 현재: 구현(calc.js 249)
- [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)
@@ -151,7 +152,7 @@
- [x] F2-60 연쇄 재계산(L/M/S→X→D→B→내역→원가) — 08번 §1 · 현재: 구현(기초단가부터 원설계 5건 전 단가·총공사비 1원 · 변경설계 일부 미대조)
- [ ] F2-61 표준시장단가 적용(ACTTYP·ACTOPT 노무비 끝전 보정) — 22번 §3.3 · 현재: 없음
### G3 수량산출·식언어 (구현 23 · 부분 5 · 없음 1) — 87.9%
### G3 수량산출·식언어 (구현 25 · 부분 3 · 없음 1) — 91.4%
- [x] F3-01 COSTN.SMT 메모 블록 읽기(IFORM 위치) — 21번 §1 · 23번 · 현재: 구현(smt_parser 150)
- [x] F3-02 헤더 변수 정의(;; 줄)·변수 대입 — 23번 · 현재: 구현(smt_engine 112)
@@ -168,9 +169,9 @@
- [ ] F3-13 강제금액(직접 지정 금액) — 28번 §4 · 현재: 없음(옛 '재료비: 숫자' 짐작 처리 걷음 · 원본 문법 근거 대기)
- [x] F3-14 수량기호 Q·A·T·N — wM_Edit_San · 현재: 구현(Q·A·T·N 줄 · 결과적용 비목 4택·0원 비목(test_code_insert_lines_zero_skip_and_result_strip))
- [x] F3-15 식 문법 검사(줄 단위 오류 메시지) — 28번 §4 · 현재: 구현(줄 단위 문법 오류 · 실무 산근 오류 0(test_section_markers_exclusion_and_grammar_errors))
- [ ] F3-16 문법 강조·문법설명·함수/연산 입력 단추 — 26번 §3 · 현재: 부분(문법 강조·설명·함수 단추 화면)
- [x] F3-16 문법 강조·문법설명·함수/연산 입력 단추 — 26번 §3 · 현재: 구현(함수 단추·강조 낱말 = 원본 화면 정의(test_editor_function_list_and_highlight_follow_dfm))
- [x] F3-17 결과적용 비목 4택·0원 비목 입력안함 — 26번 §3 · 현재: 구현(Q·A·T·N 줄 · 결과적용 비목 4택·0원 비목(test_code_insert_lines_zero_skip_and_result_strip))
- [ ] F3-18 결과값 낙찰율 적용·결과금액조정·계약단가 — 26번 §3 · 현재: 부분(낙찰률 단가 = 원단가×율 버림 · 7건 127개 대조 · 화면 입력 없음)
- [x] F3-18 결과값 낙찰율 적용·결과금액조정·계약단가 — 26번 §3 · 현재: 구현(산근 편집기 낙찰율·결과값 적용·계약단가 · 울진 34개 코드 1·2칸 일치(test_editor_bid_rate_input_matches_contract_codes))
- [x] F3-19 Q식(3600qKfE/Cm 등 10꼴) 평가 — 23번 §3 · 현재: 구현(식 그대로 평가 · 변수 대소문자 무시 · 282개 대조)
- [x] F3-20 Cm 사이클식(t1+…·L/V1+L/V2+0.25·선회각 초) — 26번 §1 · 현재: 구현(사칙 대입 · 따옴표 주석 걷음 · 282개 대조)
- [x] F3-21 표준중기공식 등록·수정·삭제 — wM_Edit_EQ · 28번 §1.1 · 현재: 구현(표준중기공식 등록·수정·삭제(test_qcode_read_write_round_trip))
@@ -255,9 +256,9 @@
- [x] F5-35 금액 한글 표기(AMTTOHAN) — 24번 §2.4 · 현재: 구현(금액 한글 표기 · 원본 한글 금액 11건 전부 일치)
- [x] F5-36 SET_REP 데이터 기반 열·폭·제목 — 20번 §3 · 현재: 구현(SET_REP 서식 제목·시트 이름 적용 · 보고서 시험 포함)
### G6 설계변경·계약·실행·기성 (구현 14 · 부분 22 · 없음 0) — 69.4%
### G6 설계변경·계약·실행·기성 (구현 17 · 부분 19 · 없음 0) — 73.6%
- [ ] F6-01 차수 4벌(원설계+3차변경) 칸 관리 — 08번 §2 · 24번 §7 · 현재: 부분(계산 = 차수별 수량·단가·원가계산서 · 저장은 2·3 칸 덮어씀 view_prj_boq 560)
- [x] F6-01 차수 4벌(원설계+3차변경) 칸 관리 — 08번 §2 · 24번 §7 · 현재: 구현(2·3·4차수 칸 고침이 저장 후 유지(test_slot_2_3_4_edits_persist))
- [ ] F6-02 통합형 BOQ11+BOQ12에서 BOQ12 현재안 — 16번 §4.5 · 현재: 부분(울진 BOQ12 = 계약내역(STMATE 착공내역서) 판명 · contract_calc 로 BOQ11 에서 1원 재현)
- [x] F6-03 분리형 변경 STC 생성·차수 인계 — 16번 §4.6 · 현재: 구현(stc_change 소광 원설계 → 실제 변경1차 차수1 말단 47행·직접비·요율 41키 일치(test_stmate_conv_change))
- [x] F6-04 변경 수량 입력 — 설계변경 · 현재: 구현(change_calc · 영덕·소광 변경 BOQ11 1·2차수 수량·단가·금액 일치)
@@ -274,12 +275,12 @@
- [x] F6-15 설계변경증감대비표(0648)·금액대비표 — CHANGE_AMT · 현재: 구현(증감대비표·금액대비표 · 영덕 엑셀 행 차례·금액 일치 · 인쇄·엑셀)
- [ ] F6-16 변경총괄·굴착·시도 변경내역서(0661~0666) — CHANGE_* · 현재: 부분(변경총괄 = 영덕 엑셀 총괄내역서 일치 · 굴착·시도 양식 없음)
- [x] F6-17 계약 변환 비목별 단가적용율(노·재·경) — wM_Mk_Cont · 현재: 구현(contract_calc 노·재·경 적용율 버림 · 울진 BOQ12 계약내역 1원)
- [ ] F6-18 계약 변환 옵션(기초단가·일위/산근 생성·동일코드·노무비율·비과세) — 27번 §2.2 · 현재: 부분(옵션 6개 중 동일코드 개별생성·0% 공내역 반영)
- [ ] F6-18 계약 변환 옵션(기초단가·일위/산근 생성·동일코드·노무비율·비과세) — 27번 §2.2 · 현재: 부분(옵션 6개 동작 · 켬 갈래 실물 일치 · 끔 갈래·비과세 표본 없음)
- [x] F6-19 적용율 0% → W코드 공내역 — 27번 §2.2 · 현재: 구현(0% 공내역 W코드 · 울진 대조)
- [ ] F6-20 적용제외 공정 선택(레벨 9단계) — 27번 §2.3 · 현재: 부분(적용제외 공정 선택 · 머리 단위 실물 대조 없음)
- [x] F6-21 예상적용율 찾기(목표금액 역산)·미리계산 — 27번 §2.3 · 현재: 구현(목표금액 → 적용율 87.745% 역산 · 미리계산)
- [ ] F6-22 설계 → 계약자료 변환등록(당초/변경·레벨) — wM_Conv_Mode · 현재: 부분(변환등록 단추 = /api/file/copy-register)
- [ ] F6-23 낙찰율 열(NAGCD)·계약단가 저장 — 26번 §4 · 현재: 부분(산근 재계산 시 2칸 원단가 → 1칸 낙찰 단가 · 저장은 MAMT3 87.745% 일괄 덮어씀)
- [x] F6-22 설계 → 계약자료 변환등록(당초/변경·레벨) — wM_Conv_Mode · 현재: 구현(소광 원설계 → 변환등록·저장 = 실제 울진 계약 STC 일치(test_stmate_stage_register))
- [x] F6-23 낙찰율 열(NAGCD)·계약단가 저장 — 26번 §4 · 현재: 구현(새 코드 낙찰율·단가·산근 글·구성표 저장 = 울진 계약 STC 일치(test_stmate_stage_register))
- [x] F6-24 계약 원가계산(계약 제잡비) — 27번 · 현재: 구현(계약 원가계산 · 울진 총공사비 333,315,000원 일치)
- [ ] F6-25 계약·실행내역서(0623·0624) — PM_Nae_Cont · 현재: 부분(계약·실행내역서 화면 · 실물 표본 없음)
- [ ] F6-26 실행예산 작업(실행단가·실행수량) — wBoq 실행예산작업 · 현재: 부분(exec_calc 실행단가·수량 · 표본 없음)
+23 -1
View File
@@ -17,6 +17,7 @@
from __future__ import annotations
import pathlib
import re
from dbf_rw import DbfTable
from fastapi import APIRouter, HTTPException
@@ -133,12 +134,33 @@ def _boq_codes(files: dict, req: dict, log: list) -> None:
files[name] = bt.to_bytes()
def _rates(files: dict, req: dict, log: list) -> None:
"""제비율표 수정(wM_KAN_RATE) — rate_tables{표 이름: 행[_recno · RATE_n 칸]} 중 바뀐 칸만."""
for name, rows in (req.get("rate_tables") or {}).items():
name = str(name).upper()
if not re.fullmatch(r"RATE[_A-Z0-9]*\.DBF", name) or name not in files:
continue
tbl = DbfTable(files[name])
for r in rows or []:
i = int(r.get("_recno", -1)) if str(r.get("_recno", "")).isdigit() else -1
if not 0 <= i < len(tbl):
continue
for k, v in r.items():
if k.startswith("_") or k.endswith("_HEX") or tbl.fields.get(k) is None:
continue
if tbl.fields[k].type in ("N", "F"):
_put(tbl, i, k, v, log, name)
if tbl.dirty:
files[name] = tbl.to_bytes()
def apply_struct(files: dict[str, bytes], req: dict) -> tuple[dict[str, bytes], list]:
"""구조 손질만 — 새 files 와 바뀐 목록(코드 칸·끼움·지움)."""
"""구조 손질만 — 새 files 와 바뀐 목록(코드 칸·끼움·지움 · 제비율표 칸)."""
out = dict(files)
log: list = []
_costn(out, req, log)
_boq_codes(out, req, log)
_rates(out, req, log)
return out, log
+80
View File
@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
"""설계 → 계약자료 변환등록(wM_Conv_Mode · wM_Mk_Cont) 끼워 넣기 API.
근거: 27_계약_실행_기성_단계.md §2(계약 변환 옵션 · 적용제외) · 26 §4(NAGCD 낙찰율 열 · 계약단가) ·
16 §4.5(통합형 BOQ11 설계 + BOQ12 계약) · ui_form_catalog wM_Conv_Mode(당초/변경 수량·단가 · 레벨)
실증(울진 2025): 소광 원설계 STC → 계약 STC = PRJLST 새 행(PCODE 다음 번호 · CCODE = 설계 PCODE · PCLASS 4)
+ BOQ1m = 설계 내역 복사 위 계약 공종(새 코드 · 비목 단가 버림) + COSTN 새 코드 행(산근 글·구성표 그대로)
흐름: 화면(contract_calc.registerRows)이 계약 행을 만들고 서버는
stc_project.copy_register(그대로 복사) → stc_save.apply_save(새 표 행 · 새 COSTN · 산근 · 구성표 · PRJLST) 만 함.
"""
from __future__ import annotations
import pathlib
from bdqty import decode_bdqty
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from stc_project import copy_register, write_output
from stc_read import Source
from stc_save import apply_save
router = APIRouter()
CONTRACT_PCLASS = 4 # 계약 공사(울진 2025 계약 행 · REA K_ORD 표 PCLASS 4)
def _root() -> pathlib.Path:
import server # 실행 중 바뀔 수 있는 작업 폴더(시험은 임시 폴더로 바꿈)
return server.WORK_DBF_ROOT
class ContractRegReq(BaseModel):
source_path: str
src_pcode: str = "11"
pname: str = ""
pnum: str = ""
boq_rows: list[dict] = [] # 계약 공종 행 — {_recno, BDCODE1, 단가·금액 칸}
costn_rows: list[dict] = [] # 새 코드 COSTN 행
costn_memos: dict[str, str] = {} # 새 산근 코드 → 산출근거 글
bdqty_rows: list[dict] = [] # 새 대가 코드 구성행(decode_bdqty 꼴)
costw1: float | None = None # 계약 총공사비(화면 계약 원가계산서)
target_folder_name: str = ""
def contract_register(files: dict, req: ContractRegReq) -> tuple[dict, str, list]:
"""(새 files, 새 PCODE, 바뀐 칸 목록) — 쓰기 없음(시험용)."""
out, pcode, log0 = copy_register(files, req.src_pcode, "copy", req.pname)
prj_upd = {"CCODE": req.src_pcode, "PCLASS": CONTRACT_PCLASS}
if req.pnum:
prj_upd["PNUM"] = req.pnum
if req.costw1 is not None:
prj_upd["COSTW1"] = req.costw1
save = {
"boq_table": f"BOQ{pcode}.DBF",
"boq_updates": [dict(r) for r in req.boq_rows],
"costn_updates": [dict(r) for r in req.costn_rows],
"costn_memo_updates": dict(req.costn_memos),
"prjlst_updates": prj_upd,
}
if req.bdqty_rows and "BDQTY.DBF" in out:
save["bdqty_rows"] = decode_bdqty(out["BDQTY.DBF"]) + [dict(r) for r in req.bdqty_rows]
out2, log = apply_save(out, save)
return out2, pcode, log0 + log
@router.post("/api/ui/contract-register")
def api_contract_register(req: ContractRegReq):
try:
s = Source(req.source_path)
except (FileNotFoundError, ValueError) as e:
raise HTTPException(400, str(e))
try:
files, pcode, changes = contract_register(s.files, req)
except (ValueError, KeyError) as e:
raise HTTPException(400, str(e))
target = req.target_folder_name or f"{s.path.stem}_계약{pcode}"
names = dict(s.names, **{f"BOQ{pcode}.DBF": f"BOQ{pcode}.DBF"})
res = write_output(_root(), target, files, names, s.entries)
return {**res, "new_pcode": pcode, "change_count": len(changes)}
+158
View File
@@ -0,0 +1,158 @@
# -*- coding: utf-8 -*-
"""공사(내역서) 삭제·단계 등록(차수) 끼워 넣기 API — 공사목록표(wPrjList) 「내역서 삭제」(Act_Del_PrjExecute · ST_U_DelPrj Delete_Prj).
근거: REA 09 resource-TWPRJLIST 메뉴 「내역서 삭제」 · old/30_원자료 module_exports(ST_U_DelPrj: Delete_Prj)
· 02번(PRJLST 한 행 = 공사 · PCODE '1n' ↔ BOQ1n.DBF · PRJ_MEMO = 그 공사 원가 설정)
규칙: 고른 PCODE 의 PRJLST 행과 BOQ1n.DBF 를 뺌 · 다른 공사 행·표·메모는 바이트 그대로 · 마지막 한 공사는 지우지 않음
(원본 확인 문구·선택지는 근거 없음 — 화면은 확인 창 한 번)
단계 등록(NewProject → MkBoq · REA 31 Main_K_ORD_registration_transition_matrix · 최종보고서 §4.1):
원본 PCLASS × 등록(6 기성 · 7 설계변경) × 플래그(0 차수별 · 1 당초/변경) → 새 K_ORD(1 또는 K+1) · 상한 없음
새 PCLASS = 등록 번호(4 계약 · 6 기성 · 7 설계변경 갈래와 같은 수 — 추정)
K+1 = 다음 차수 칸 열기(stc_project change) · 1 = 원본 마지막 차수 칸을 1·2 칸(당초·변경)으로 옮겨 새로 시작
"""
from __future__ import annotations
import pathlib
from dbf_rw import DbfTable
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from stc_project import SLOT_COL, copy_register, safe_name, write_output
from stc_read import Source
from stc_save import SaveLog, recompute_boq
router = APIRouter()
def _root() -> pathlib.Path:
import server # 실행 중 바뀔 수 있는 작업 폴더(시험은 임시 폴더로 바꿈)
return server.WORK_DBF_ROOT
def delete_project(files: dict, pcode: str) -> tuple[dict, list]:
"""(새 files, 바뀐 목록) — PRJLST 행 · BOQ{pcode}.DBF 뺌."""
pcode = str(pcode).strip()
if "PRJLST.DBF" not in files:
raise ValueError("PRJLST.DBF 없음")
prj = DbfTable(files["PRJLST.DBF"])
idx = [i for i in range(len(prj)) if prj.get(i, "PCODE") == pcode]
if not idx:
raise ValueError(f"PRJLST 에 PCODE {pcode} 공사 없음")
if len(prj) - len(idx) < 1:
raise ValueError("마지막 공사는 지울 수 없음")
log = [
{"table": "PRJLST.DBF", "recno": i, "field": "_delete", "old": pcode, "new": ""}
for i in idx
]
prj.recs = [r for i, r in enumerate(prj.recs) if i not in set(idx)]
prj.dirty = True
out = dict(files)
out["PRJLST.DBF"] = prj.to_bytes()
tbl = f"BOQ{pcode}.DBF"
if out.pop(tbl, None) is not None:
log.append({"table": tbl, "recno": -1, "field": "_drop", "old": tbl, "new": ""})
return out, log
class DelPrjReq(BaseModel):
source_path: str
pcode: str
target_folder_name: str = ""
@router.post("/api/ui/project-delete")
def api_project_delete(req: DelPrjReq):
try:
s = Source(req.source_path)
files, log = delete_project(s.files, req.pcode)
except (FileNotFoundError, ValueError) as e:
raise HTTPException(400, str(e))
target = req.target_folder_name or s.path.stem
names = {k: v for k, v in s.names.items() if k in files}
res = write_output(_root(), safe_name(target), files, names, s.entries)
tbl = f"BOQ{str(req.pcode).strip()}.DBF"
left = pathlib.Path(res["saved_dir"]) / s.names.get(tbl, tbl) # 같은 작업본 폴더에 남은 옛 표
if tbl not in files and left.exists():
left.unlink()
return {**res, "deleted": req.pcode, "changes": log}
# 원본 PCLASS × 등록 × 플래그 → 새 K_ORD("1" | "K+1") — 표에 없는 원본(설계 0 등)은 다음 차수(K+1)
K_ORD_RULE = {
(4, 6, None): "1",
(6, 6, None): "K+1",
(7, 6, None): "1",
(4, 7, None): "1",
(6, 7, None): "1",
(7, 7, 0): "K+1",
(7, 7, 1): "1",
}
def new_k_ord(pclass: int, op: int, flag: int, k: int) -> int:
rule = K_ORD_RULE.get((pclass, op, flag)) or K_ORD_RULE.get((pclass, op, None)) or "K+1"
return k + 1 if rule == "K+1" else 1
def _move_slot(tbl: DbfTable, src: str, dst: tuple[str, ...]) -> None:
pairs = []
for c in tbl.order:
m = SLOT_COL.match(c)
if m and m.group(2) == src:
pairs += [(c, f"{m.group(1)}{d}") for d in dst if f"{m.group(1)}{d}" in tbl.fields]
for i in range(len(tbl)):
for a, b in pairs:
tbl.set_raw(i, b, tbl.raw(i, a))
def stage_register(files: dict, src_pcode: str, op: int, flag: int, pname: str = ""):
"""(새 files, 새 PCODE, 새 K_ORD) — 단계 등록."""
prj = DbfTable(files["PRJLST.DBF"])
si = next(i for i in range(len(prj)) if prj.get(i, "PCODE") == str(src_pcode))
num = lambda f: int(float(prj.get(si, f) or 0)) if f in prj.fields else 0 # noqa: E731
k, pclass = num("K_ORD"), num("PCLASS")
nk = new_k_ord(pclass, int(op), int(flag), k)
out, pcode, _log = copy_register(
files, str(src_pcode), "change" if nk == k + 1 else "copy", pname
)
prj2 = DbfTable(out["PRJLST.DBF"])
ni = next(i for i in range(len(prj2)) if prj2.get(i, "PCODE") == pcode)
prj2.set(ni, "K_ORD", nk)
if "PCLASS" in prj2.fields:
prj2.set(ni, "PCLASS", int(op))
if nk == 1 and k + 1 != 1: # 원본 마지막 차수(k+1) 칸 → 새 공사 당초·변경(1·2) 칸
name = f"BOQ{pcode}.DBF"
bt = DbfTable(out[name])
_move_slot(bt, str(k + 1), ("1", "2"))
direct = recompute_boq(bt, name, {"1", "2"}, SaveLog())
for s, vals in direct.items():
for c, v in zip(("MCOST", "LCOST", "SCOST", "TCOST"), vals):
if f"{c}{s}" in prj2.fields:
prj2.set(ni, f"{c}{s}", v)
out[name] = bt.to_bytes()
out["PRJLST.DBF"] = prj2.to_bytes()
return out, pcode, nk
class StageRegReq(BaseModel):
source_path: str
src_pcode: str = "11"
op: int = 7 # 6 기성 · 7 설계변경
flag: int = 0 # 0 차수별 · 1 당초/변경
pname: str = ""
target_folder_name: str = ""
@router.post("/api/ui/stage-register")
def api_stage_register(req: StageRegReq):
try:
s = Source(req.source_path)
files, pcode, nk = stage_register(s.files, req.src_pcode, req.op, req.flag, req.pname)
except (FileNotFoundError, ValueError, StopIteration) as e:
raise HTTPException(400, str(e) or "공사 없음")
target = req.target_folder_name or f"{s.path.stem}_등록{pcode}"
names = dict(s.names, **{f"BOQ{pcode}.DBF": f"BOQ{pcode}.DBF"})
res = write_output(_root(), safe_name(target), files, names, s.entries)
return {**res, "new_pcode": pcode, "k_ord": nk}
+9 -1
View File
@@ -175,7 +175,12 @@
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/ui/save",
Object.assign(p, UC.structPayload(ctx.dbfData), {
rate_tables: ctx.dbfData.rate_dirty ? ctx.dbfData.rate_tables : {},
}),
);
return postJson("/api/save", p);
}
@@ -183,6 +188,9 @@
function decorateSavePayload(p) {
const d = ctx.dbfData;
if (!d) return p;
// 산근 편집기 계약단가 표시(BCONT) — 고친 코드만 COSTN 칸 덧붙임
const extra = ctx.uiState.costnExtra || {};
(p.costn_updates || []).forEach((u) => Object.assign(u, extra[String(u.CODE).trim()] || {}));
return Object.assign(p, { start_ini: d.start_ini || {}, prjlst_rows: d.prjlst_all || [] });
}
+1 -1
View File
@@ -373,7 +373,7 @@ const StmateCalc = (() => {
? Object.assign({}, lookupUnit(row.refId), { M: su[0], L: su[1], S: su[2] })
: lookupUnit(row.refId);
// 행 고정 단가(fixedUnit) · 내역 할증율(wBoqRate RATE/RM·RL·RS = 비목별 단가 가산, 원 단위 절사)
// — 수량 할증(BDQTY X_HAL)과 다른 금액 가산(07번 §4 · 34번 wBoqRate) · 저장 차수 단가는 이미 반영된 값이라 건너뜀
// — 금액 가산(BDQTY X_QTY·X_HAL 은 손료계수 칸 · REA 정정)(07번 §4 · 34번 wBoqRate) · 저장 차수 단가는 이미 반영된 값이라 건너뜀
if (!su && row.fixedUnit)
["M", "L", "S"].forEach((c) => row.fixedUnit[c] != null && (u[c] = num(row.fixedUnit[c])));
const hr = row.halRate;
+75 -6
View File
@@ -37,6 +37,10 @@ const StmateContract = (() => {
excludeGov: true,
zeroToW: true,
sameCodeSeparate: false,
baseRate: true, // 기초단가에 적용율 — □ = 기초단가(M·L·S) 코드·단가 그대로
makeIlwi: true, // 일위대가/산출근거 생성 — □ = 대가·산근·중기(B·D·X)는 계약단가 W코드로
taxFree: true, // 비과세자재대 — □ = taxFreeCodes(COSTN NOTA) 금액 그대로
taxFreeCodes: [],
};
// 머리글자별 기존 최대 코드 번호 — 새 계약 코드는 그 다음부터
@@ -83,10 +87,16 @@ const StmateContract = (() => {
if (nd.kind !== "leaf") return out;
const d = nd.st[stage] || {};
const excluded = ex.has(i);
const kind = String(nd.code || "")[0] || "";
// 그대로 둘 공종 — 적용제외 · 기초단가 적용율 끔 · 비과세자재대 그대로
const keep =
excluded ||
(o.baseRate === false && /[MLS]/.test(kind)) ||
(o.taxFree === false && (o.taxFreeCodes || []).includes(nd.code));
const unit = {};
for (const [c, , rk] of COMPS) {
const u = n0(d[c + "Unit"]);
unit[c] = excluded ? u : roundDown((u * n0(o[rk])) / 100, n0(o.digits));
unit[c] = keep ? u : roundDown((u * n0(o[rk])) / 100, n0(o.digits));
}
const qty = n0(d.qty);
const amt = {};
@@ -94,16 +104,19 @@ const StmateContract = (() => {
const tUnit = unit.m + unit.l + unit.s;
let newCode = nd.code;
const designT = n0(d.tUnit);
const zeroed = !excluded && o.zeroToW && designT !== 0 && tUnit === 0;
if (!excluded && counter && nd.code) {
const key = zeroed ? "W" + nd.code.slice(1) : nd.code;
const zeroed = !keep && o.zeroToW && designT !== 0 && tUnit === 0;
// 일위대가/산출근거 생성 끔 — 대가·산근·중기는 낙찰율 적용 계약단가(W코드)로
const asW = zeroed || (!keep && o.makeIlwi === false && /[BDX]/.test(kind));
if (!keep && counter && nd.code) {
const key = asW ? "W" + nd.code.slice(1) : nd.code;
if (!o.sameCodeSeparate && codeMap[key]) newCode = codeMap[key];
else {
newCode = nextCode(zeroed ? "W" + nd.code.slice(1) : nd.code);
newCode = nextCode(key);
codeMap[key] = newCode;
}
} else if (zeroed) newCode = "W" + nd.code.slice(1);
} else if (asW) newCode = "W" + nd.code.slice(1);
out.excluded = excluded;
out.kept = keep;
out.zeroed = zeroed;
out.newCode = newCode;
out.design = d;
@@ -252,8 +265,64 @@ const StmateContract = (() => {
return out;
}
/**
* 설계 → 계약자료 변환등록 저장 요청(wM_Conv_Mode · 26 §4 · 울진 2025 실증)
* 내역: 계약 공종 행 = 새 코드 · 비목 단가·금액(1칸) · 변경 수량 적용이면 QTY1 = 그 차수 수량
* 새 COSTN 행: 설계 코드의 명칭·규격·단위·분류 칸을 옮김
* 대가·산근(B·D·X) = 2칸 원단가(설계) · 1칸 계약단가 · NAGCD = 적용율 · UC(결과값에 낙찰율적용)·BCONT(계약단가) = T
* · 산출근거 글(COSTN.SMT)·구성표(BDQTY) 그대로 옮김
* 기초단가·계약단가 W = 1칸 = 2칸 = 계약단가(낙찰율 열 없음)
*/
const COPY_COLS = ["NAME", "SIZE", "UNIT", "BQCODE", "EQCODE", "JDCODE", "NAME2", "SIZE2"];
const COPY_MORE = ["SBMOK", "S_ADJ", "ADJ_M", "ADJ_L", "ADJ_S", "EDT_DATE", "WQCODE"];
function registerRows(conv, costnRows, memos = {}, bdqtyRows = []) {
const st = conv.stage;
const by = new Map((costnRows || []).map((r) => [String(r.CODE || "").trim(), r]));
const o = conv.opts;
const same = n0(o.rateL) === n0(o.rateM) && n0(o.rateL) === n0(o.rateS);
const boq = [],
costn = [],
memo = {},
bdqty = [],
made = new Set();
for (const nd of conv.nodes) {
if (nd.kind !== "leaf" || nd.recno == null || nd.kept) continue;
const s = nd.st[st];
const u = { _recno: nd.recno, BDCODE1: nd.newCode };
if (!nd.pct) {
Object.assign(u, { MAMT1: s.mUnit, LAMT1: s.lUnit, SAMT1: s.sUnit, TAMT1: s.tUnit });
Object.assign(u, { M_AMT1: s.mAmt, L_AMT1: s.lAmt, S_AMT1: s.sAmt, T_AMT1: s.tAmt });
if (st !== 1) u.QTY1 = s.qty;
}
boq.push(u);
if (made.has(nd.newCode) || by.has(nd.newCode) || nd.newCode === nd.code) continue;
made.add(nd.newCode);
const src = by.get(nd.code) || {};
const row = { CODE: nd.newCode };
COPY_COLS.forEach((k) => src[k] != null && (row[k] = src[k]));
const comp = /^[BDX]/.test(nd.newCode);
const d = nd.design || {};
const one = { M: s.mUnit, L: s.lUnit, S: s.sUnit, T: s.tUnit };
const two = comp ? { M: n0(d.mUnit), L: n0(d.lUnit), S: n0(d.sUnit), T: n0(d.tUnit) } : one;
for (const c of ["T", "M", "L", "S"])
Object.assign(row, { [`${c}AMT1`]: one[c], [`${c}AMT2`]: two[c] });
if (comp) {
COPY_MORE.forEach((k) => src[k] != null && (row[k] = src[k]));
Object.assign(row, { NAGCD: same ? n0(o.rateL) : 0, UC: "T", BCONT: "T" });
const m = memos[nd.code];
if (m && String(m.text || "").trim()) memo[nd.newCode] = m.text;
(bdqtyRows || [])
.filter((q) => String(q.BDCODE || "").trim() === nd.code)
.forEach((q) => bdqty.push(Object.assign({}, q, { BDCODE: nd.newCode })));
}
costn.push(row);
}
return { boq, costn, memos: memo, bdqty };
}
return {
CONTRACT_CARRY,
registerRows,
DEFAULT_OPTS,
codeBaseFromCostn,
convert,
+1
View File
@@ -67,6 +67,7 @@
<script src="ui_dan.js"></script>
<script src="ui_code.js"></script>
<script src="ui_code_amt.js"></script>
<script src="ui_code_check.js"></script>
<script src="ui_merge.js"></script>
<script src="plugins_manifest.js"></script>
<script src="app.js"></script>
+18 -3
View File
@@ -92,7 +92,11 @@ const StmateSmtLang = (() => {
.join("\r\n");
}
// 문법 강조 — 주석문 · Text(따옴표) · 숫자 · 변수·단가코드 · 계표시
// 문법 강조 낱말 — 원본 TWM_EDIT_SAN Syn1.KeyWords(함수·상수) · 대소문자 가리지 않음
const SYN_KEYWORDS =
"ABS COS CUT EXP FACT IIF INT LA LN LOG MA MOD PI POWER ROUND ROUNDDOWN SIN SQRT STMATE TAN";
const KEYS = new Set(SYN_KEYWORDS.split(" "));
// 문법 강조 — 주석문 · Text(따옴표) · 숫자 · 함수 낱말 · 변수·단가코드 · 계표시
function highlight(line) {
const s = String(line);
const out = [];
@@ -127,7 +131,8 @@ const StmateSmtLang = (() => {
continue;
}
if ((m = rest.match(/^[A-Za-z_][A-Za-z_0-9]*/))) {
out.push(`<span class="syn-var">${esc(m[0])}</span>`);
const cls = KEYS.has(m[0].toUpperCase()) ? "syn-key" : "syn-var";
out.push(`<span class="${cls}">${esc(m[0])}</span>`);
i += m[0].length;
continue;
}
@@ -155,7 +160,17 @@ const StmateSmtLang = (() => {
"함수 — ∏ INT SIN COS TAN SQRT √ ROUND ROUNDDOWN ROUNDUP CUT LOG LN ABS EXP MOD POWER FACT IIF(조건, 참, 거짓)",
];
return { MARK_NAMES, TRANS, marker, codeLines, stripResults, highlight, GRAMMAR_HELP, esc };
return {
MARK_NAMES,
TRANS,
marker,
codeLines,
stripResults,
highlight,
GRAMMAR_HELP,
SYN_KEYWORDS,
esc,
};
})();
if (typeof window !== "undefined") window.STMATE_SMT_LANG = StmateSmtLang;
+46 -2
View File
@@ -157,6 +157,15 @@
${sel("san-func", [["", "각종 지원함수"]].concat(X().FUNC_LIST.map((f) => [f, f])), "")}
${sel("san-mark", marks, "")}
${sel("san-qty", qopts, "")}
${sel(
"san-img",
[["", "그림코드(Ctrl+I)"]].concat(
((ctx.dbfData && ctx.dbfData.costn_rows) || [])
.filter((r) => /^I\d{5}$/.test(String(r.CODE).trim()))
.map((r) => [String(r.CODE).trim(), `${String(r.CODE).trim()} ${r.NAME || ""}`]),
),
"",
)}
${sel("san-eq", [["", eq.length ? "중기공식 불러오기" : "등록된 중기공식 없음"]].concat(eq.map((e, i) => [String(i), `${e.name} (${e.ins_x ? "공식+코드" : "공식"})`])), "")}
</div>
<div class="win-subbar" style="flex-wrap:wrap;gap:4px;">
@@ -182,6 +191,9 @@
<label><input type="checkbox" id="san-skip0" ${u.sanSkip0 ? "checked" : ""}> 단가가 0원인 비목은 입력안함</label>
<button class="win-btn" id="san-ins">코드 삽입</button>
<span style="margin-left:auto;">결과적용 비목 ${sel("san-sbmok", SB, String(row.SBMOK || "").trim())}</span>
<span title="Ed_NAGCD — 낙찰율(%) · 0 = 없음">낙찰율 <input id="san-nag" type="number" step="0.001" style="width:70px" value="${num(row.NAGCD) || ""}">%</span>
<label title="소수점을 정리한 결과값에 낙찰율을 적용하여 금액을 산출"><input type="checkbox" id="san-uc" ${String(row.UC || "").trim() === "T" ? "checked" : ""}> 결과값에 낙찰율적용</label>
<label title="인쇄시 낙찰율이 표시되지 않고 『계약단가』로 인쇄"><input type="checkbox" id="san-cont" ${String(row.BCONT || "").trim() === "T" ? "checked" : ""}> 계약단가</label>
<label><input type="checkbox" id="san-adj" ${adjOn ? "checked" : ""}> 결과금액조정</label>
${adjOn ? ["M", "L", "S"].map((k) => `${k}:x <input class="san-adjv" data-k="${k}" type="number" step="0.00001" style="width:64px" value="${num(row[`ADJ_${k}`]) || 1}">`).join(" ") : ""}
</div>
@@ -218,6 +230,22 @@
</div>`;
}
/**
* 낙찰율(Ed_NAGCD) · 결과값에 낙찰율적용(Ck_UC → COSTN UC) — 26 §3·§4 · REA 30 TWM_EDIT_SAN
* 처음 걸 때 지금 1칸 단가를 2칸(원단가) 자리로 옮기고, 지우면 2칸 원단가를 1칸으로 되돌림
* 다시 셈(계산완료)은 smt_engine — 2칸 = 산근 결과 · 1칸 = 비목별 원단가 × 낙찰율 버림(실무 7건 UC=T ⇔ NAGCD>0)
*/
function setBidRate(row, rate, uc = true) {
const r = num(rate),
had = num(row.NAGCD) > 0;
const cols = ["T", "M", "L", "S"];
if (r > 0 && !had) cols.forEach((k) => (row[`${k}AMT2`] = String(row[`${k}AMT1`] || "0")));
if (!(r > 0) && had) cols.forEach((k) => (row[`${k}AMT1`] = String(row[`${k}AMT2`] || "0")));
row.NAGCD = String(r > 0 ? r : 0);
row.UC = r > 0 && uc ? "T" : "F";
return row;
}
// 순환 — 이 산근이 부르는 코드가 다시 이 산근에 닿는지(REA 30 mode1 순환참조 검사)
function cycleErrors(ctx, code) {
const C = COMP();
@@ -273,6 +301,7 @@
const redo = () => (keep(), app.renderWorkspace());
area.onkeydown = (e) => {
if (e.key === "F9") (e.preventDefault(), $("san-calc").click());
if (e.ctrlKey && (e.key === "i" || e.key === "I")) (e.preventDefault(), $("san-img").focus());
};
const pick = (id, fn) =>
$(id) &&
@@ -285,6 +314,8 @@
pick("san-func", (v) => insertAt(area, v));
pick("san-mark", (v) => insertLines(area, [v]));
pick("san-qty", (v) => insertAt(area, v));
// 그림 넣기 — 「IMG(I00001)」 한 줄(문법설명 (5) · 그림코드는 한 줄에 하나)
pick("san-img", (v) => insertLines(area, [`IMG(${v})`]));
pick("san-eq", (v) => {
const it = (u.eqItems || [])[Number(v)];
if (!it) return;
@@ -335,6 +366,18 @@
);
const row = ENG().costnLookup(ctx).byCode[code] || {};
$("san-sbmok").onchange = (e) => ((row.SBMOK = e.target.value), redo());
const bid = () => (
keep(),
setBidRate(row, $("san-nag").value, $("san-uc").checked),
app.cascadeAndRender()
);
$("san-nag").onchange = bid;
$("san-uc").onchange = bid;
// 계약단가 표시(BCONT) — 인쇄용 · 저장 묶음에 덧붙임(app.js decorateSavePayload)
$("san-cont").onchange = (e) => {
row.BCONT = e.target.checked ? "T" : "F";
(u.costnExtra = u.costnExtra || {})[code] = { BCONT: row.BCONT };
};
$("san-adj").onchange = (e) => {
row.S_ADJ = e.target.checked ? "T" : "F";
for (const k of "MLS") if (!num(row[`ADJ_${k}`])) row[`ADJ_${k}`] = "1";
@@ -374,5 +417,6 @@
}
P.screens.wM_Edit_iLWi = { render, bind };
global.STMATE_EDIT_SAN = { render, bind, cycleErrors, insertLines };
})(window);
global.STMATE_EDIT_SAN = { render, bind, cycleErrors, insertLines, setBidRate };
if (typeof module !== "undefined" && module.exports) module.exports = global.STMATE_EDIT_SAN;
})(typeof window !== "undefined" ? window : globalThis);
+34
View File
@@ -277,6 +277,37 @@
return out;
}
const RATE_AXES = [
"I_KONG_TYPE",
"I_KONG_KIKAN",
"I_ANJUN_TYPE",
"I_KOBO_TYPE",
"I_HWAN",
"I_EQIP",
"I_TBUGUM",
"I_ILBAN",
];
// 제비율표 다시 조회 — 마지막에 적용한 표(없으면 공사 RATE_R) · 수동 확정이면 그대로(scr_kanjub_rate)
function reRate(ctx, api, cfg) {
const R = global.StmateRate,
u = ctx.uiState;
const rows =
u.kjRateRows ||
(ctx.dbfData && ctx.dbfData.rate_tables && ctx.dbfData.rate_tables["RATE_R.DBF"]);
if (!R || !rows || !rows.length || u.kjAutoRate === false) return null;
const n = stageCfg(ctx).n;
const calc = () => (
api.recompute(),
(ctx.computed.stages && ctx.computed.stages[n] && ctx.computed.stages[n].kanjub) ||
ctx.computed.kanjubComputed
);
const res = R.applyWithLoopCheck(cfg, R.matrixFromRows(rows), calc, {
withTbugum: true,
});
u.kjMsg = res.loop ? res.msg : res.manual ? "요율 수동 확정 상태 — 요율 그대로" : "";
return res;
}
function bind(ctx, api) {
const re = () => api.renderWorkspace();
const { cfg, st } = stageCfg(ctx);
@@ -291,6 +322,8 @@
switchScheme(cfg, schemeOf(k2));
}
setKey(cfg, key, val);
// 요율 축(공사종류·기간·안전공종·고용등급·환경·건설기계·퇴직공제·일반관리비) — 제비율표 다시 조회 후 다시 셈
if (RATE_AXES.includes(key)) reRate(ctx, api, cfg);
re();
});
api.bindEach(".kjx-fix", "onchange", (el) => {
@@ -348,6 +381,7 @@
let rows = ctx.dbfData && ctx.dbfData.rate_tables && ctx.dbfData.rate_tables["RATE_R.DBF"];
if (p) rows = (await api.postJson("/api/load-jbr", { jbr_path: p })).rows;
if (!R || !rows) return alert("제비율표(RATE_R) 없음");
u.kjRateRows = rows; // 요율 축 콤보를 바꿀 때 다시 씀
const m = R.matrixFromRows(rows),
n = stageCfg(ctx).n;
const calc = () => (
+41 -3
View File
@@ -145,7 +145,12 @@ const StmateRate = (() => {
// 순환 점검 — 규모 밑수를 계산 결과에서 다시 잡을 때 묶음이 왕복하면 멈추고 알림(06 §2 계약 ①~③)
// 수동 확정(cfg.rateConfirm.manual)이 있으면 다시 고르지 않음 · 왕복이면 오간 조회 결과(choices)를 돌려줌
function applyWithLoopCheck(cfg, m, calcFn, { scaleOf = (kj) => kj.dT, maxIter = 6 } = {}) {
function applyWithLoopCheck(
cfg,
m,
calcFn,
{ scaleOf = (kj) => kj.dT, maxIter = 6, withTbugum = false } = {},
) {
let kj = calcFn(cfg);
if (cfg.rateConfirm && cfg.rateConfirm.manual) return { cfg, kj, loop: false, manual: true };
const seen = [],
@@ -162,7 +167,7 @@ const StmateRate = (() => {
if (seen[seen.length - 1] === sig) return { cfg, kj, loop: false };
seen.push(sig);
found[sig] = res;
applyToCfg(cfg, res);
applyToCfg(cfg, res, { withTbugum });
kj = calcFn(cfg);
}
return {
@@ -349,7 +354,10 @@ if (typeof module !== "undefined" && module.exports) module.exports = StmateRate
return `<div class="win-form"><div class="win-form-header"><span>원가계산 제비율표 [TWM_KAN_RATE · ${esc(src)} ${m.length}행×${m[0] ? m[0].length : 0}열]</span></div>
<div class="win-subbar"><label>표</label><select id="kr-src" class="win-select">${srcOpts}</select>
<select id="kr-jbr" class="win-select" style="width:200px">${jbr.map((f) => `<option value="${esc(f.path)}">${esc(f.name)}</option>`).join("")}</select>
<button class="win-btn" id="kr-load">기준 JB_R 열기</button><button class="win-btn" id="kr-back">제잡비 계산으로</button></div>
<button class="win-btn" id="kr-load">기준 JB_R 열기</button><button class="win-btn" id="kr-back">제잡비 계산으로</button>
<button class="win-btn" id="kr-undo" ${u.krOrig && u.krOrig[src] ? "" : "disabled"}>수정 취소</button>
<button class="win-btn" id="kr-print">출력</button>
<span class="stm-note">${ctx.dbfData && ctx.dbfData.rate_dirty ? "고친 칸은 [계산완료·저장] 때 공사 STC 의 이 표에 씀" : ""}</span></div>
${lookupPanel(ctx, m)}<div class="win-subbar">${tabs}</div><div style="overflow:auto;max-height:520px">${body}</div></div>`;
}
@@ -371,6 +379,14 @@ if (typeof module !== "undefined" && module.exports) module.exports = StmateRate
const r = n0(el.getAttribute("data-r")),
c = n0(el.getAttribute("data-c"));
if (!m[r]) return;
// 처음 고칠 때 원래 표를 남김(수정 취소)
const rows0 = ctx.dbfData && ctx.dbfData.rate_tables && ctx.dbfData.rate_tables[src];
u.krOrig = u.krOrig || {};
if (!u.krOrig[src])
u.krOrig[src] = {
m: JSON.parse(JSON.stringify(m)),
rows: rows0 && JSON.parse(JSON.stringify(rows0)),
};
m[r][c - 1] = n0(el.value);
// 공사 안 표면 원본 행(RATE_xx 칸)에도 적음
const rows = ctx.dbfData && ctx.dbfData.rate_tables && ctx.dbfData.rate_tables[src];
@@ -379,9 +395,31 @@ if (typeof module !== "undefined" && module.exports) module.exports = StmateRate
.filter((k) => /^RATE_?\d+$/.test(k))
.sort((a, b) => n0(a.replace(/\D/g, "")) - n0(b.replace(/\D/g, "")));
if (keys[c - 1]) rows[r][keys[c - 1]] = String(n0(el.value));
ctx.dbfData.rate_dirty = true; // 저장 길 = /api/ui/save rate_tables(api_ui_code._rates)
}
re();
});
api.onClick("kr-undo", () => {
const o = u.krOrig && u.krOrig[src];
if (!o) return;
u.krMats[src] = o.m;
if (o.rows && ctx.dbfData && ctx.dbfData.rate_tables) ctx.dbfData.rate_tables[src] = o.rows;
delete u.krOrig[src];
if (ctx.dbfData) ctx.dbfData.rate_dirty = Object.keys(u.krOrig).length > 0;
re();
});
api.onClick("kr-print", () => {
const w = global.open("", "_blank");
if (!w) return;
const t = m
.map((row, i) => `<tr><th>${i}</th>${row.map((v) => `<td>${v}</td>`).join("")}</tr>`)
.join("");
w.document.write(
`<html><head><meta charset="utf-8"><title>원가계산 제비율표 ${esc(src)}</title><style>td,th{border:1px solid #999;padding:2px 4px;font:11px monospace;text-align:right}table{border-collapse:collapse}</style></head><body><h3>원가계산 제비율표 — ${esc(src)}</h3><table>${t}</table></body></html>`,
);
w.document.close();
w.print();
});
api.onClick("kr-apply", () => {
const S = global.STMATE_KANJUB_SCREEN,
{ n, cfg } = S.stageCfg(ctx);
+24 -9
View File
@@ -11,11 +11,10 @@
const S = (global.STMATE_STAGE = global.STMATE_STAGE || { tabs: [] });
const n0 = (v) => Number(v) || 0;
const OPTIONS = [
["basicApply", "기초단가(재,노,경,일식)에 단가적용율 적용하기"],
["baseRate", "기초단가(재,노,경,일식)에 단가적용율 적용하기"],
["makeIlwi", "적용율 적용된 일위대가/산출근거 생성하기"],
["sameCodeSeparate", "계약내역내 동일코드의 계약단가 개별생성"],
["laborRatio", "노무비율 적용"],
["taxFreeApply", "비과세자재대(원가계산)에 단가적용율 적용하기"],
["taxFree", "비과세자재대(원가계산)에 단가적용율 적용하기"],
["zeroToW", '단가적용율이 0% 이면 계약단가(W코드)를 "0"化 (공내역 생성)'],
];
const LEVELS = [
@@ -53,6 +52,13 @@
excludeGov: true,
zeroToW: true,
sameCodeSeparate: false,
baseRate: true,
makeIlwi: true,
taxFree: true,
// 비과세자재대 = COSTN NOTA 표시 코드(실무 표본 0건)
taxFreeCodes: ((ctx.dbfData && ctx.dbfData.costn_rows) || [])
.filter((r) => String(r.NOTA || "").trim() === "T")
.map((r) => String(r.CODE).trim()),
level: 0,
stage: 1,
target: "",
@@ -204,6 +210,7 @@
)
.join("")}</select>
<span>단가 적용율 【노】</span>${inp("ct-rl", o.rateL)}<span>%【재】</span>${inp("ct-rm", o.rateM)}<span>%【경】</span>${inp("ct-rs", o.rateS)}<span>%</span>
<button class="win-btn" id="ct-labor" title="재료비, 경비의 단가적용율을 노무비 단가적용율과 동일하게 설정">노무비율 적용</button>
<span>단가 절사 자리</span>${inp("ct-dg", o.digits, 40)}
<label><input type="checkbox" id="ct-gov" ${o.excludeGov ? "checked" : ""}/> 관급(C 구역) 제외</label>
<button class="win-btn primary" id="ct-reg">계약자료 변환등록</button>
@@ -315,20 +322,28 @@
o.rateL = o.rateM = o.rateS = o.found.rate;
re();
});
api.onClick("ct-labor", () => ((o.rateM = o.rateS = o.rateL), re()));
// 설계 → 계약자료 변환등록(wM_Conv_Mode) — 고른 차수(당초/변경) 수량·단가 · 새 PCODE 공사 · 새 코드 COSTN(NAGCD)
api.onClick("ct-reg", async () => {
if (!ctx.currentSourcePath) return alert("먼저 공사를 불러옴");
const c = compute(ctx);
const ex = c.conv.nodes.filter((nd) => nd.kind === "leaf" && nd.excluded && nd.recno != null);
const d = ctx.dbfData;
const reg = K.registerRows(c.conv, d.costn_rows, d.costn_memos || {}, d.bdqty_rows || []);
try {
const r = await api.postJson("/api/file/copy-register", {
const r = await api.postJson("/api/ui/contract-register", {
source_path: ctx.currentSourcePath,
src_pcode: String((ctx.state.projectMeta && ctx.state.projectMeta.pcode) || "11"),
mode: "contract",
pname: `${S.pname(ctx)} (계약)`,
rates: { M: o.rateM, L: o.rateL, S: o.rateS },
exclude_recnos: ex.map((nd) => nd.recno),
pnum: `${o.rateL}% 적용`,
boq_rows: reg.boq,
costn_rows: reg.costn,
costn_memos: reg.memos,
bdqty_rows: reg.bdqty,
costw1: c.kj.grandTotal,
});
alert(`계약자료 변환등록 — ${JSON.stringify(r).slice(0, 300)}`);
alert(
`계약자료 변환등록 — 새 공사 PCODE ${r.new_pcode} · 새 코드 ${reg.costn.length}건 · ${r.saved_stc || ""}`,
);
} catch (e) {
alert("변환등록 실패: " + e.message);
}
+26 -7
View File
@@ -302,8 +302,17 @@
],
"none",
)}</select></div>
<div class="stm-row"><label>번호 초기화(cBx_Bl_Lev)</label><select id="it-reset" class="win-select"><option value="">바로 윗 레벨 머리마다</option>${[2, 3, 4, 5, 6, 7, 8, 9].map((n) => `<option value="${n}">${n} 레벨(${esc(B.dispName(ini, n))}) 이상 머리마다</option>`).join("")}<option value="0">초기화 안 함(이어서)</option></select></div>
<p class="stm-note">예) 4 레벨 · 끝 「.」 → 1. 2. … / 2 레벨 · 첫 「.」 · 끝 공란 · 직전공종 번호 → 1.1 1.2 …</p>
<div class="stm-row"><label>번호 초기화(cBx_Bl_Lev)</label><select id="it-reset" class="win-select"><option value="tree">하위 전체공종 반영(1 · 1.1 · 1.1.1)</option><option value="" selected>바로 윗 레벨 머리마다</option>${[2, 3, 4, 5, 6, 7, 8, 9].map((n) => `<option value="${n}">${n} 레벨(${esc(B.dispName(ini, n))}) 이상 머리마다</option>`).join("")}<option value="0">초기화 안 함(이어서)</option></select></div>
<div class="stm-row"><label>번호 마디 한도</label><select id="it-depth" class="win-select">${opt(
[
[0, "제한 없음"],
[2, "2 마디(1.1)"],
[3, "3 마디(1.1.1)"],
[4, "4 마디"],
],
0,
)}</select><span class="stm-note">하위 전체공종 반영일 때만</span></div>
<p class="stm-note">예) 4 레벨 · 끝 「.」 → 1. 2. … / 2 레벨 · 첫 「.」 · 끝 공란 · 직전공종 번호 → 1.1 1.2 … / 하위 전체공종 반영 = 고른 레벨 머리 아래 공종·단가코드를 묶음마다 1.1 · 1.1.1</p>
<div class="stm-row"><button class="win-btn primary" id="it-ok">공종번호 입력</button></div></div>`,
);
$("it-ok").onclick = () => {
@@ -322,7 +331,9 @@
});
Object.assign(o, {
prefix: val("it-prefix"),
resetLevel: rs === "" ? undefined : Number(rs),
resetLevel: rs === "" || rs === "tree" ? undefined : Number(rs),
tree: rs === "tree",
maxDepth: Number(val("it-depth")) || 0,
});
const ix = x.selIdx(ctx);
if (val("it-range") === "sel") {
@@ -348,8 +359,9 @@
],
"all",
)}</select></div>
<div class="stm-row"><label>전체 할증율(%)</label><input id="br-all" type="number" class="win-input" value="0" /></div>
<div class="stm-row"><label>재료비 · 노무비 · 경비(%)</label><input id="br-m" type="number" class="win-input" value="0" style="width:70px;" /><input id="br-l" type="number" class="win-input" value="0" style="width:70px;" /><input id="br-s" type="number" class="win-input" value="0" style="width:70px;" /></div>
<div class="stm-row"><label>전체 할증율(%)</label><input id="br-all" type="number" step="0.001" class="win-input" value="0" /><span class="stm-note">※ 재료비,노무비,경비에 동일한 할증율이 적용됩니다. ※</span></div>
<div class="stm-row"><label>재료비 · 노무비 · 경비(%)</label><input id="br-m" type="number" step="0.001" class="win-input" value="0" style="width:70px;" /><input id="br-l" type="number" step="0.001" class="win-input" value="0" style="width:70px;" /><input id="br-s" type="number" step="0.001" class="win-input" value="0" style="width:70px;" /></div>
<div class="stm-row"><label><input type="checkbox" id="br-notuc" checked /> 보호된 견적단가 수정안함</label></div>
<div class="stm-row"><label>범위</label><select id="br-range" class="win-select">${opt(
[
["sel", "선택된 항목만"],
@@ -357,7 +369,7 @@
],
"sel",
)}</select></div>
<p class="stm-warn">내역 행 RATE·RM·RL·RS 칸과 비고에 기록 — 금액 반영은 계산 엔진(calc.js calcBoq)의 할증 처리 대기</p>
<p class="stm-note">※ 할증율이 0%인경우 100%로 적용됨 · 단가 = 기준단가(할증전 단가) × 할증율 · 내역 행 RATE·RM·RL·RS 칸에 기록</p>
<div class="stm-row"><button class="win-btn primary" id="br-ok">적용</button></div></div>`,
);
$("br-ok").onclick = () => {
@@ -368,9 +380,16 @@
recs,
{ all: val("br-all"), M: val("br-m"), L: val("br-l"), S: val("br-s") },
val("br-mode"),
{
notUC: $("br-notuc").checked,
base: (c) => {
const r = (ctx.dbfData.costn_rows || []).find((x) => String(x.CODE).trim() === c);
return r && { M: B.num(r.MAMT1), L: B.num(r.LAMT1), S: B.num(r.SAMT1) };
},
},
);
api.closeModal();
ctx.uiState.boqMsg = `할증율 ${res.changed}행 기록`;
ctx.uiState.boqMsg = `할증율 ${res.changed}행 기록${res.kept ? ` · 보호 견적 ${res.kept}행 그대로` : ""}`;
x.after(ctx, api, { ok: true }, true);
};
}
+48 -6
View File
@@ -97,6 +97,7 @@
})
.join("")}
<td class="center">${ad ? esc(slotName(ad)) : base && U.isUserEdited(r) ? "직접" : ""}</td>
<td class="center">${/^[MLSW]/.test(c) ? `<input type="checkbox" class="dan-prot" data-code="${c}" ${U.isProtected(r) ? "checked" : ""} title="보호(UC) — 일괄 손질에서 빠짐" />` : ""}</td>
<td class="center">${esc(r.BQCODE || "")}</td><td class="center">${esc(r.EQCODE || "")}</td>
<td class="center">${/^[BDX]/.test(c) ? `<button class="win-btn dan-ilwi" data-code="${c}">대가표</button>` : ""}</td>
</tr>`;
@@ -124,7 +125,7 @@
${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>
${slots.list.map((x) => `<th title="${esc(x.name)}">${esc(x.name)}</th>`).join("")}<th>채택</th><th title="보호표시(√) — 일괄 손질 제외">보호</th><th>분류번호</th><th>시설코드</th><th></th>
</tr></thead><tbody>${body}</tbody></table></div></div></div>
</div>`;
}
@@ -137,10 +138,11 @@
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)")}
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)")}${b("dan-check", "성분 검사(TAMT=재+노+경)")}
${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>`;
<label><input type="checkbox" id="dan-called" ${s.called ? "checked" : ""} /> 불러간 목록만</label>
<label title="Chk_Auto_Hal — 보호를 풀면 마지막 일괄할증 설정을 그 행에 다시 걺"><input type="checkbox" id="dan-autohal" ${s.autoHal === false ? "" : "checked"} /> 보호해제시 적용단가 자동할증</label></div>`;
}
function boxa(ctx, api) {
@@ -166,7 +168,8 @@
keepUser: scope !== "one" && $("bx-keep").checked,
});
ch.forEach((c) => syncRes(ctx, c.code, c.to, `slot${c.slot}`));
ctx.uiState.danMsg = `${ch.length}건 채택 바꿈`;
const kept = rows.filter(U.isProtected).length;
ctx.uiState.danMsg = `${ch.length}건 채택 바꿈${kept ? ` · 보호 ${kept}건 그대로` : ""}`;
api.closeModal();
api.cascadeAndRender();
};
@@ -180,7 +183,7 @@
<div class="stm-row"><label>소수</label><select id="eh-dot" class="win-select">${[0, 1, 2].map((d) => `<option value="${d}">${d} 자리 미만</option>`).join("")}<option value="-1">1원 단위 미만(10원)</option></select><select id="eh-up" class="win-select"><option value="trunc">절사</option><option value="round">반올림</option></select></div>
<div class="stm-row"><label>대상</label><select id="eh-kind" class="win-select"><option value="M">재료비</option><option value="L">노무비</option><option value="S">경비</option><option value="MLS">기초단가 전체</option>${s.code ? `<option value="one">선택 코드 ${esc(s.code)}</option>` : ""}</select></div>
<p class="stm-note">※ 단【 적용단가 】가 "0" 원인것은 수정안됨 · 기준단가 = 채택 물가지 단가(채택 없으면 지금 적용단가)</p>
<p class="stm-warn">보호표시 칸은 COSTN 자료에서 확인되지 않아 걸러내지 못함</p>
<p class="stm-note">※ 보호표시(√)된 단가도 수정안됨</p>
<div class="stm-row"><button class="win-btn primary" id="eh-ok">수정</button></div></div>`,
);
$("eh-ok").onclick = () => {
@@ -193,12 +196,37 @@
mode: $("eh-up").value,
});
ch.forEach((c) => syncRes(ctx, c.code, c.to, "manual"));
ctx.uiState.danMsg = `기준단가 수정 ${ch.length}건`;
const kept = ctx.dbfData.costn_rows.filter(U.isProtected).length;
ctx.uiState.danMsg = `기준단가 수정 ${ch.length}건${kept ? ` · 보호 ${kept}건 제외` : ""}`;
api.closeModal();
api.cascadeAndRender();
};
}
// 성분 검사(10 §3) — COSTN · 지금 내역 표 · 어긋난 칸 목록
function checkAll(ctx, api) {
const CK = global.STMATE_UI_CODE_CHECK;
if (!CK) return;
const bad = CK.checkComponents(ctx.dbfData.costn_rows).concat(
CK.checkBoq(ctx.dbfData.boq_rows),
);
const { fmt } = api;
api.openModal(
`성분 검사 — 어긋남 ${bad.length}건`,
`<div class="stm-form"><p class="stm-note">TAMT = 재료비 + 노무비 + 경비 · L/M/S 코드는 자기 비목만 · 차수 칸 1~4 · 내역은 단가·금액 칸</p>
${
bad.length
? `<div class="grid-scroll" style="max-height:320px;"><table class="dbgrid"><thead><tr><th>자리</th><th>코드</th><th>차수</th><th>내용</th><th>계</th><th>재</th><th>노</th><th>경</th></tr></thead><tbody>${bad
.map(
(x) =>
`<tr><td>${esc(x.where)}</td><td>${esc(x.code)}</td><td class="center">${x.slot}</td><td>${esc(x.msg)}</td>${["T", "M", "L", "S"].map((k) => `<td class="num">${fmt(x.v[k])}</td>`).join("")}</tr>`,
)
.join("")}</tbody></table></div>`
: "<p>어긋난 칸 없음</p>"
}</div>`,
);
}
function findUse(ctx, api) {
const s = st(ctx),
f = U.findUse(s.code, ctx.dbfData);
@@ -237,9 +265,23 @@
on("dan-chgamt", () => T2.openChgAmt(ctx, api));
on("dan-xr", () => T2.openXRate(ctx, api, s.code));
}
on("dan-check", () => checkAll(ctx, api));
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());
if ($("dan-autohal")) $("dan-autohal").onchange = (e) => (s.autoHal = e.target.checked);
document.querySelectorAll("#dan-form .dan-prot").forEach(
(el) =>
(el.onchange = () => {
const c = el.getAttribute("data-code");
const r = ctx.dbfData.costn_rows.find((x) => U.codeOf(x) === c);
const hal = s.autoHal !== false && ctx.uiState.danHal;
const res = U.setProtect(r, el.checked, { autoHal: hal || null });
if (res && res.hal) syncRes(ctx, c, res.hal.to, "manual");
ctx.uiState.danMsg = `${c} 보호${el.checked ? "" : " 해제"}${res && res.hal ? ` · 자동할증 ${res.hal.to}` : ""}`;
api.cascadeAndRender();
}),
);
const grid = $("dan-grid");
if (s.scroll) grid.scrollTop = s.scroll;
grid.onscroll = () => (s.scroll = grid.scrollTop);
+8
View File
@@ -126,6 +126,7 @@
rate: 100,
digits: 4,
skipZero: false,
force: true, // 차수 칸 옮김은 보호표시와 무관하게 전부
});
if (to === 1)
syncState(
@@ -171,6 +172,13 @@
);
$("hl-ok").onclick = () => {
const k = val("hl-k");
// 마지막 할증 설정 — 보호해제시 자동할증(Chk_Auto_Hal)이 다시 씀
ctx.uiState.danHal = {
how: val("hl-how"),
value: num(val("hl-v")),
digits: Number(val("hl-dot")),
mode: val("hl-up"),
};
const ch = UA.halAll(ctx.dbfData.costn_rows, {
kinds: k === "one" ? c[0] : k,
codes: k === "one" ? [c] : null,
+46 -3
View File
@@ -163,6 +163,30 @@
api.renderWorkspace();
};
// 단계 등록(기성 6 · 설계변경 7 / 차수별 0 · 당초/변경 1) — 원본 PCLASS·K_ORD 로 새 K_ORD(api_ui_project.py) → 새 공사 열고 단계 탭
const stageReg = (op, flag, act) => async (ctx, api) => {
const r = selRow(ctx);
if (!r || !ctx.currentSourcePath) return stage(act)(ctx, api);
const what = op === 6 ? "기성내역" : flag ? "설계변경(당초/변경)" : "차수별 설계변경";
if (!confirm(`[${String(r.PCODE).trim()}] ${r.PNAME} 을(를) ${what} 으로 등록할까요?`))
return stage(act)(ctx, api);
try {
const res = await api.postJson("/api/ui/stage-register", {
source_path: ctx.currentSourcePath,
src_pcode: String(r.PCODE).trim(),
op,
flag,
pname: `${r.PNAME} (${what})`,
});
await api.loadProjectFromServer(res.saved_stc, "wPrjList");
ctx.uiState.prjSel = res.new_pcode;
ctx.uiState.boqMsg = `${what} 등록 — PCODE ${res.new_pcode} · K_ORD ${res.k_ord}`;
stage(act)(ctx, api);
} catch (err) {
alert(`${what} 등록 실패: ${err.message}`);
}
};
// 다른 내역서 열기 — 고른 공사 행의 BOQ{PCODE}.DBF 로 바꿔 불러옴(/api/ui/load-boq · api_ui_code.py)
async function openBoq(ctx, api, pcode) {
const d = ctx.dbfData;
@@ -232,9 +256,9 @@
Act_Prn: (ctx, api) => go(ctx, api, "PM_Prt"),
ActMkCont00: stage("Act_Stg_Contract"),
Act_MkCont41: stage("Act_Stg_Exec"),
ActMkCont10: stage("Act_Stg_Prog"),
ActMkCont21: stage("Act_Stg_Change"),
ActMkCont20: stage("Act_Stg_Change"),
ActMkCont10: stageReg(6, 0, "Act_Stg_Prog"),
ActMkCont21: stageReg(7, 1, "Act_Stg_Change"),
ActMkCont20: stageReg(7, 0, "Act_Stg_Change"),
Act_St8_XML: (ctx, api) => go(ctx, api, "PM_Prt"),
Act_St8_Jodal1: (ctx, api) => go(ctx, api, "PM_Prt"),
Act_Find: (ctx, api) => {
@@ -254,6 +278,25 @@
Act_Indx_PCODE: sortBy("PCODE"),
Act_Indx_PNAME: sortBy("PNAME"),
Act_Indx_PNUM: sortBy("PNUM"),
// 내역서 삭제(Act_Del_PrjExecute) — 고른 공사 행·BOQ1n 을 뺀 작업본 STC 를 쓰고 그것을 다시 엶(api_ui_project.py)
Act_Del_Prj: async (ctx, api) => {
const r = selRow(ctx);
if (!r || !ctx.currentSourcePath) return;
const pc = String(r.PCODE).trim();
if (prjRows(ctx).length < 2) return alert("마지막 공사는 지울 수 없음");
if (!confirm(`[${pc}] ${r.PNAME} 내역서를 삭제할까요? (작업본 STC 에 반영 · 원본은 그대로)`))
return;
try {
const res = await api.postJson("/api/ui/project-delete", {
source_path: ctx.currentSourcePath,
pcode: pc,
});
ctx.uiState.prjSel = "";
await api.loadProjectFromServer(res.saved_stc, "wPrjList");
} catch (err) {
alert("내역서 삭제 실패: " + err.message);
}
},
Act_List_UP: moveSel(-1),
Act_List_Down: moveSel(1),
};
+38
View File
@@ -78,6 +78,7 @@
<div class="stm-row"><label>기타 노임산출계수</label><select class="win-select" id="sys-rx-sel">${S.RX_SETS.map((s) => `<option value="${s.id}" ${s.id === rx.id ? "selected" : ""}>${esc(s.cap)}</option>`).join("")}</select></div>
<div class="stm-row"><label>${esc(rx.btn)}</label>${field({ key: rx.rx, type: "text" }, d)}</div>
<div class="stm-row"><label> └ 적용코드</label>${field({ key: rx.con, type: "codes" }, d)}</div>
<div class="stm-row"><label>시간당 노임산출계수 적용코드</label><button class="win-btn sys-jlab" data-k="CONLD">C 기본(일반구간)</button><button class="win-btn sys-jlab" data-k="${rx.con}">${esc(rx.cap.split(";")[1] || rx.con)}</button></div>
</fieldset>
<fieldset class="stm-group"><legend>소 수 점 적 용 기 준</legend>${S.FIELDS.digits.map((f) => row(f, d)).join("")}</fieldset>
<fieldset class="stm-group"><legend>계 산 방 식</legend>${S.FIELDS.calc.map((f) => row(f, d)).join("")}
@@ -118,8 +119,45 @@
ctx.uiState.boqMsg = `초기값 ${changed.length}칸 반영(${changed.join(", ")})`;
}
// 직종·노임 설정(wM_Set_JLab) — 노무비 코드 목록에서 고른 코드를 적용코드 목록으로(추가·삭제) · 적용단가 미리보기
function openJLab(ctx, api, conKey, orig) {
const d = draft(ctx),
$ = (id) => document.getElementById(id);
const ev = global.STMATE_SMT_EVAL;
const hourly = ev && ev.hourlyWage;
const ini = Object.assign({}, ctx.dbfData.start_ini, d);
const j = S.jlab(ctx.dbfData.costn_rows, ini, conKey, hourly);
const fmt = api.fmt;
const tr = (r, cls) =>
`<tr><td class="center"><input type="checkbox" class="${cls}" value="${esc(r.code)}" /></td><td>${esc(r.code)}</td><td>${esc(r.name)}</td><td>${esc(r.size)}</td><td class="center">${esc(r.unit)}</td>${cls === "jl-del" ? `<td class="num">${r.hourly == null ? "" : fmt(r.hourly)}</td>` : ""}<td class="num">${fmt(r.price)}</td></tr>`;
api.openModal(
`시간당 노임산출계수 적용코드 [wM_Set_JLab] — ${conKey} · 환산식 ${j.formula || "(없음)"}`,
`<div class="stm-form"><p class="stm-note">노무비 코드를 목록에서 선택하십시요 — 선택코드 추가 · 선택코드삭제 · 적용 / 취소</p>
<div style="display:flex;gap:8px;">
<div style="flex:1;"><strong>노무비 목록(Grd_BDLst)</strong><div class="grid-scroll" style="max-height:300px;"><table class="dbgrid"><thead><tr><th></th><th>코드번호</th><th>명칭</th><th>규격</th><th>단위</th><th>단가</th></tr></thead><tbody>${j.source.map((r) => tr(r, "jl-add")).join("")}</tbody></table></div></div>
<div style="flex:1;"><strong>적용될 코드목록(Grd_CONLTD)</strong><div class="grid-scroll" style="max-height:300px;"><table class="dbgrid"><thead><tr><th></th><th>코드번호</th><th>명칭</th><th>규격</th><th>단위</th><th>적용단가</th><th>단가</th></tr></thead><tbody>${j.target.map((r) => tr(r, "jl-del")).join("")}</tbody></table></div></div>
</div>
<div class="stm-row"><button class="win-btn" id="jl-add">선택코드 추가 ▶</button><button class="win-btn" id="jl-del">◀ 선택코드삭제</button><button class="win-btn primary" id="jl-ok">적 용</button><button class="win-btn" id="jl-cancel">취 소</button></div></div>`,
);
let cur = String(d[conKey] != null ? d[conKey] : ini[conKey] || "");
const back = orig == null ? cur : orig;
const picked = (cls) => [...document.querySelectorAll(`.${cls}:checked`)].map((x) => x.value);
const apply = (next) => (
(d[conKey] = next),
api.closeModal(),
openJLab(ctx, api, conKey, back)
);
$("jl-add").onclick = () => apply((cur = S.jlabEdit(cur, picked("jl-add"), [])));
$("jl-del").onclick = () => apply((cur = S.jlabEdit(cur, [], picked("jl-del"))));
$("jl-ok").onclick = () => (api.closeModal(), api.renderWorkspace());
$("jl-cancel").onclick = () => ((d[conKey] = back), api.closeModal(), api.renderWorkspace());
}
function bind(ctx, api) {
if (!ctx.dbfData) return;
document
.querySelectorAll("#sys-form .sys-jlab")
.forEach((b) => (b.onclick = () => openJLab(ctx, api, b.getAttribute("data-k"))));
const d = draft(ctx),
u = ctx.uiState,
$ = (id) => document.getElementById(id);
+89 -5
View File
@@ -43,6 +43,7 @@
* range: [시작 행, 끝 행] 선택 범위(없으면 전체) }
*/
function autoItnum(rows, opts) {
if (opts && opts.tree) return treeItnum(rows, opts);
const o = Object.assign(
{ level: 2, target: "group", first: "", last: ".", type: "num", pad: 1, margin: 0 },
{ prefix: "none" },
@@ -89,6 +90,66 @@
return { changed };
}
/**
* 하위 전체공종 반영(cBx_Bl_Lev 첫 선택) — 대상 레벨 머리(뿌리)부터 그 안의 공종(계표시)·단가코드를 묶음마다 한 차례로
* 뿌리 = 여백 + 첫 글자 + 번호 + 끝 글자 · 번호는 바로 위 머리마다 처음부터
* 하위 = 직전공종 번호(끝 「.」 뗌) + 「.」 + 번호(cBx_itNum_B 직전공종의 공종번호) · maxDepth = 번호 마디 수 한도
* o.range = [시작 행, 끝 행] 선택 범위 — 범위 밖 행은 그대로(번호 차례는 범위 안에서만 셈)
*/
function treeItnum(rows, opts) {
const o = Object.assign(
{ level: 3, target: "both", first: "", last: "", type: "num", pad: 1, margin: 0 },
opts || {},
);
const lo = o.range ? o.range[0] : 0,
hi = o.range ? o.range[1] : rows.length - 1;
const links = B.linkPairs(rows);
const idx = new Map(rows.map((r, i) => [r, i]));
const open = [];
const cnt = new Map(); // 부모 머리(또는 뿌리 위 머리) → 지금 번호
const depth = new Map(); // 번호 붙은 머리 → 마디 수
const wantGroup = o.target !== "leaf",
wantLeaf = o.target !== "group";
const lv = Number(o.level);
let changed = 0;
for (let i = 0; i < rows.length; i++) {
const r = rows[i],
k = rowKind(r);
while (open.length && idx.get(links.get(open[open.length - 1])) < i) open.pop();
const isT = k.kind === "T";
if (i >= lo && i <= hi && (isT || k.kind === "LEAF")) {
const root = [...open].reverse().find((h) => rowKind(h).n === lv);
const par = open.length ? open[open.length - 1] : null;
const isRoot = isT && k.n === lv;
const inTree = !isRoot && root && depth.has(par);
const want = isT ? wantGroup : wantLeaf;
if ((isRoot || inTree) && (want || isT)) {
const key = isRoot ? par || "top" : par;
const n = (cnt.get(key) || 0) + 1;
const d = isRoot ? 1 : depth.get(par) + 1;
if (!o.maxDepth || d <= Number(o.maxDepth)) {
cnt.set(key, n);
const seq = seqText(n, o.type, o.pad);
const text = isRoot
? " ".repeat(Number(o.margin) || 0) + (o.first || "") + seq + (o.last || "")
: String(par.ITNUM || "")
.trim()
.replace(/\.$/, "") +
"." +
seq;
if (want) {
r.ITNUM = text;
changed += 1;
}
if (isT) depth.set(r, d);
}
}
}
if (isT && links.get(r)) open.push(r);
}
return { changed };
}
// 반올림·절사 — 소수 d 자리
function roundTo(v, d, mode) {
const f = Math.pow(10, Number(d) || 0);
@@ -235,13 +296,24 @@
return { changed };
}
// 할증율 적용(wBoqRate) — 행 칸 RATE(전체) 또는 RM·RL·RS(비목별) · 계산 원천 항목에도 halRate 로 둠
function applyHalRate(model, recnos, rates, mode) {
/**
* 할증율 적용(wBoqRate · ST_Boq.CPM TwBoqRate DFM) — 행 칸 RATE(전체) 또는 RM·RL·RS(비목별, N7.3)
* 할증율 = 기준단가(할증전 단가)에 곱할 백분율 · 「할증율이 0%인경우 100%로 적용됨」 → 0 = 그대로
* CBoxNotUC 「보호된 견적단가 수정안함」(기본 켬) — 견적단가(W) 행 중 보호(UC=T) 행은 건너뜀
* 계산 원천 halRate = 할증율 − 100(calc.js 가산식 × (1 + p/100) = × 할증율/100)
* o.base(코드) = 기준단가 {M,L,S}(COSTN 1칸) — 주면 행 1칸 단가도 비목별 원 미만 버림으로 씀(저장 반영)
*/
function applyHalRate(model, recnos, rates, mode, o) {
const opt = Object.assign({ notUC: true }, o || {});
const set = recnos ? new Set(recnos.map(num)) : null;
const byRec = new Map(model.items.map((it) => [num(it._recno), it]));
let changed = 0;
const add = (v) => (num(v) > 0 ? num(v) - 100 : 0);
let changed = 0,
kept = 0;
model.rows.forEach((r) => {
if (!isLeaf(r) || (set && !set.has(num(r._recno)))) return;
if (opt.notUC && codeOf(r)[0] === "W" && String(r.UC || "").trim() === "T")
return void kept++;
const all = num(rates.all),
m = mode === "all" ? all : num(rates.M),
l = mode === "all" ? all : num(rates.L),
@@ -253,15 +325,27 @@
RS: String(s),
});
const it = byRec.get(num(r._recno));
if (it) it.halRate = { M: m, L: l, S: s };
if (it) it.halRate = { M: add(m), L: add(l), S: add(s) };
const b = opt.base && opt.base(codeOf(r));
if (b) {
const u = { M: m, L: l, S: s };
["M", "L", "S"].forEach(
(c) =>
(r[`${c}AMT1`] = String(
Math.trunc((num(b[c]) * (u[c] > 0 ? u[c] : 100)) / 100 + 1e-9),
)),
);
r.TAMT1 = String(num(r.MAMT1) + num(r.LAMT1) + num(r.SAMT1));
}
changed += 1;
});
return { changed };
return { changed, kept };
}
global.STMATE_UI_BOQ_TOOLS = {
seqText,
autoItnum,
treeItnum,
roundTo,
expandIlwi,
roundQty,
+1 -1
View File
@@ -459,7 +459,7 @@
// 구성표(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);
const needsUiSave = (d) => Boolean(d && (d.ui_dirty || d.rate_dirty));
global.STMATE_UI_CODE = {
CODE_RE,
+5
View File
@@ -17,6 +17,8 @@
};
const codeOf = (r) => txt(r && r.CODE);
const OWN = { M: "MAMT", L: "LAMT", S: "SAMT" };
// 보호표시(wM_Boxa 「보호」 열 = 기초·견적단가 UC) — 일괄 손질에서 건너뜀(ui_dan.isProtected 와 같은 판정)
const isProtected = (r) => /^[MLSW]/.test(codeOf(r)) && txt(r.UC) === "T";
// 반올림·절사 — 소수 d 자리(음수 = 10^-d 원 단위)
function roundTo(v, d, mode) {
@@ -51,6 +53,7 @@
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;
if (isProtected(r) && !opt.force) return;
const src = slotVals(r, opt.from);
const T0 = src.M + src.L + src.S;
if (opt.skipZero && !(T0 > 0)) return;
@@ -81,6 +84,7 @@
(rows || []).forEach((r) => {
const c = codeOf(r);
if (!c || !opt.kinds.includes(c[0]) || (only && !only.has(c))) return;
if (isProtected(r)) return;
const cur = slotVals(r, 1);
const curT = cur.M + cur.L + cur.S;
const base = num(r.AMT_61) || curT;
@@ -310,6 +314,7 @@
}
global.STMATE_UI_CODE_AMT = {
isProtected,
roundTo,
chgAmt,
halAll,
+62
View File
@@ -0,0 +1,62 @@
/**
* STMATE w 8.0 — 단가 성분 검사 (ui_code_check.js) · 화면·DOM 없음(node 시험 가능)
* 근거: 지식DB STmate/old/20_분석 10 §3(검증 조건식 `TAMT1 <> MAMT1 + LAMT1 + SAMT1` 세 모듈 · TAMT = 총계)
* · 코드종류별 성분 — L → LAMT = TAMT · M → MAMT = TAMT · S → SAMT = TAMT · 복합/W → TAMT = M + L + S
* 차수 칸 1~4 마다 · 내역(BOQ) 단가코드 행은 단가 칸(TAMTn)과 금액 칸(T_AMTn) 둘 다
*/
(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 OWN = { M: "M", L: "L", S: "S" };
const STRUCT = /^(T\d|E\d|C\d|:)$/;
const near = (a, b) => Math.abs(a - b) < 1e-6;
// 한 칸 무리 검사 — pre(단가 "" · 금액 "_")
function checkSlot(r, code, s, pre, where) {
const out = [];
const f = (k) => `${k}${pre}AMT${s}`;
if (!(f("T") in r)) return out;
const v = { M: num(r[f("M")]), L: num(r[f("L")]), S: num(r[f("S")]), T: num(r[f("T")]) };
if (!near(v.T, v.M + v.L + v.S))
out.push({ where, code, slot: s, rule: "sum", msg: `${f("T")} ≠ 재+노+경`, v });
const own = OWN[code[0]];
if (own && ["M", "L", "S"].some((k) => k !== own && v[k] !== 0))
out.push({ where, code, slot: s, rule: "own", msg: `${own} 코드에 다른 비목 금액`, v });
return out;
}
/** COSTN 행 — 차수 칸 1~4 · [{ where, code, slot, rule: sum|own, msg, v }] */
function checkComponents(rows) {
const out = [];
for (const r of rows || []) {
const c = txt(r.CODE).toUpperCase();
if (!c) continue;
for (const s of [1, 2, 3, 4]) out.push(...checkSlot(r, c, s, "", "COSTN"));
}
return out;
}
/** 내역 단가코드 행 — 단가(TAMTn) · 금액(T_AMTn) · % 줄은 금액 칸만 */
function checkBoq(rows) {
const out = [];
for (const r of rows || []) {
const c = txt(r.BDCODE1).toUpperCase();
if (!c || STRUCT.test(c)) continue;
const pct = txt(r.UNIT1).endsWith("%");
for (const s of [1, 2, 3, 4]) {
if (!pct) out.push(...checkSlot(r, c, s, "", `BOQ#${r._recno}`));
out.push(...checkSlot(r, c, s, "_", `BOQ#${r._recno}`));
}
}
return out;
}
const api = { checkComponents, checkBoq };
global.STMATE_UI_CODE_CHECK = api;
if (typeof module !== "undefined" && module.exports) module.exports = api;
})(typeof window !== "undefined" ? window : globalThis);
+19 -1
View File
@@ -4,6 +4,10 @@
* · 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) — 실자료 표본에는 없음
* 보호표시 = wM_Boxa 단가 격자 「보호」 열(√/공란 = True/False · 논리 칸) → 기초·견적단가(M·L·S·W) 행의 COSTN UC 로 읽음
* · 일괄 손질(가격지 채택·기준단가 수정·일괄할증·일괄변경)은 보호 행을 건너뜀(22 §3.1 안내문)
* · 보호해제시 적용단가 자동할증(Chk_Auto_Hal) = 풀 때 그 행에만 일괄할증을 다시 걺
* · B·D 행의 UC 는 산근 편집기 「결과값에 낙찰율적용」(Ck_UC) 자리 — 실무 7건 UC=T ⇔ NAGCD>0 이라 보호로 보지 않음
*/
(function (global) {
"use strict";
@@ -32,6 +36,17 @@
];
const OWN = { M: "MAMT", L: "LAMT", S: "SAMT" };
const isBase = (code) => /^[MLS]/.test(code);
const isProtected = (r) => /^[MLSW]/.test(codeOf(r)) && String(r.UC || "").trim() === "T";
// 보호 켜기·끄기 — 끌 때 autoHal(일괄할증 설정)이 있으면 그 행만 다시 할증(STMATE_UI_CODE_AMT.halAll)
function setProtect(r, on, o) {
if (!/^[MLSW]/.test(codeOf(r))) return null;
r.UC = on ? "T" : "F";
const UA = global.STMATE_UI_CODE_AMT;
if (on || !(o && o.autoHal) || !UA) return { code: codeOf(r), on: Boolean(on), hal: null };
const kind = codeOf(r)[0];
const hal = UA.halAll([r], Object.assign({}, o.autoHal, { kinds: kind, codes: [codeOf(r)] }));
return { code: codeOf(r), on: false, hal: hal[0] || null };
}
// 가격지 이름 — 프로젝트 초기값(JMOK1~5 · 줄임 JMK10~50 · 표시 차례 JEMNM)
function slotNames(ini) {
@@ -109,7 +124,7 @@
function adopt(r, how, o) {
const opt = Object.assign({ skipZero: true }, o || {});
const c = codeOf(r);
if (!isBase(c) || how === "none") return null;
if (!isBase(c) || how === "none" || isProtected(r)) return null;
const cand = candidates(r);
let slot = Number(how),
v = cand[slot - 1];
@@ -168,6 +183,7 @@
rows.forEach((r) => {
const c = codeOf(r);
if (!c || !isBase(c) || !opt.kinds.includes(c[0]) || (only && !only.has(c))) return;
if (isProtected(r)) return;
const cur = appliedPrice(r);
if (!(cur > 0)) return;
const s = adoptedSlot(r),
@@ -216,6 +232,8 @@
num,
codeOf,
isBase,
isProtected,
setProtect,
slotNames,
candidates,
pages,
+24
View File
@@ -197,3 +197,27 @@ table.dbgrid tr.boq-new td.rowhead {
font-size: 14px;
color: var(--text-sub);
}
/* 산출근거 문법 강조(scr_edit_lang highlight · 원본 TWM_EDIT_SAN Syn1) — 주석 · Text · 단가코드 · 계표시 · 숫자 · 함수 */
.syn-cmt {
color: #15803d;
font-style: italic;
}
.syn-txt {
color: #9a3412;
}
.syn-code {
color: #1d4ed8;
font-weight: 700;
}
.syn-mark {
color: #7e22ce;
font-weight: 700;
}
.syn-num {
color: #0f766e;
}
.syn-key {
color: #1e3a8a;
font-weight: 700;
}
+48
View File
@@ -222,7 +222,55 @@
return a.join("");
}
/**
* 시간당 노임산출계수 적용코드(wM_Set_JLab · Grd_BDLst → Grd_CONLTD) — 노무비(L) 코드 목록 고르기
* conKey = CONLD|CONTD|CON1D~4D · 짝 환산식 = RXXNM|RXTNM|RX1NM~4NM(빈 칸이면 RXXNM)
* 적용단가 = 일 노임(LAMT1) × 환산식(왼쪽부터) · JCDNM 자리 미만 버림(smt_eval.hourlyWage)
* 돌려줌 { formula, target: [{code,name,size,unit,price,hourly}], source: [고를 수 있는 L 코드 행] }
*/
const rxOf = (conKey) =>
conKey === "CONLD" ? "RXXNM" : (RX_SETS.find((x) => x.con === conKey) || {}).rx || "RXXNM";
function jlab(costnRows, ini, conKey, hourly) {
const i = ini || {};
const formula = String(i[rxOf(conKey)] || i.RXXNM || "").trim();
const digits = Number(String(i.JCDNM || "0").trim()) || 0;
const by = new Map((costnRows || []).map((r) => [String(r.CODE || "").trim(), r]));
const list = splitCodes(i[conKey]);
const rowOf = (c) => {
const r = by.get(c) || {};
const price = Number(r.LAMT1) || 0;
const h = hourly ? hourly(price, formula, digits) : null;
return {
code: c,
name: r.NAME || "",
size: r.SIZE || "",
unit: r.UNIT || "",
price,
hourly: h,
};
};
const inList = new Set(list);
const source = (costnRows || [])
.map((r) => String(r.CODE || "").trim())
.filter((c) => /^L\d{5}$/.test(c) && !inList.has(c))
.map(rowOf);
return { formula, target: list.map(rowOf), source };
}
// 선택코드 추가·삭제 — 차례 유지 · 겹침 없음 · 결과 = 이어 붙인 글(Start8x.Ini 값)
function jlabEdit(cur, add, del) {
const gone = new Set((del || []).map((c) => String(c).trim().toUpperCase()));
const out = splitCodes(cur).filter((c) => !gone.has(c));
for (const c of add || []) {
const k = String(c).trim().toUpperCase();
if (/^[A-Z]\d{5}$/.test(k) && !out.includes(k)) out.push(k);
}
return joinCodes(out);
}
global.STMATE_UI_SYS = {
jlab,
jlabEdit,
rxOf,
FIELDS,
RX_SETS,
XML_PRESET,