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:
@@ -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));
|
||||
});
|
||||
@@ -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)));
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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
|
||||
@@ -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")}
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user