wip(stmate-test): 2차 묶음 중단 시점 저장 — 내역 끝전 방식·보고서 집계·저장 확장(시험 355 통과)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 19:28:55 +09:00
co-authored by Claude Opus 5.5
parent a60de0c943
commit 4938101164
18 changed files with 1196 additions and 41 deletions
@@ -0,0 +1,12 @@
// STMATE 재현 앱 calc.js 를 상태 JSON(stdin)으로 돌려 차수별 내역 줄 금액(재·노·경)을 냄
const C = require("../../tools/stmate-test/calc.js");
let buf = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (d) => (buf += d));
process.stdin.on("end", () => {
const res = C.calculateAll(JSON.parse(buf));
const out = {};
for (const [n, s] of Object.entries(res.stages))
out[n] = s.boq.rows.map((r) => [r.id, r.code, r.mAmt, r.lAmt, r.sAmt]);
process.stdout.write(JSON.stringify(out));
});
@@ -0,0 +1,162 @@
"""STMATE 재현 앱 저장 덧붙임 — COSTN 새 코드 행 · BDQTY 구성 고침 · QCODE · 제잡비 설정(PRJLST.SMT).
정답은 원본 STC 자체 — 고치지 않으면 원래 바이트 · 고친 것만 바뀜 · 다시 읽으면 고친 값.
쓰기는 임시 폴더(server.WORK_DBF_ROOT 바꿈)에서만.
"""
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 bdqty import decode_bdqty # noqa: E402
from dbf_rw import DbfTable # noqa: E402
from kanjub_cfg import build_kanjub_config, stage_slots # noqa: E402
from qty_qcode import read_qcode # noqa: E402
from smt_parser import parse_dbf_bytes, parse_prjlst_smt # noqa: E402
from stc_archive import files_of, read_entries # noqa: E402
from stc_save import apply_save # noqa: E402
STCS = sorted((ROOT / "resources/knowledge/original/실무문서").rglob("*.STC"))
IDS = [p.stem[:16] for p in STCS]
def _files(p):
return files_of(read_entries(p.read_bytes()))
def _kv(files):
_, prows, _ = parse_dbf_bytes(files["PRJLST.DBF"])
return parse_prjlst_smt(files["PRJLST.SMT"], prows[0]["PRJ_MEMO_HEX"])
@pytest.fixture
def work(tmp_path, monkeypatch):
monkeypatch.setattr(server, "WORK_DBF_ROOT", tmp_path)
return tmp_path
@pytest.mark.parametrize("stc", STCS, ids=IDS)
def test_unchanged_extras_keep_bytes(stc):
f = _files(stc)
kv = _kv(f)
req = {
"kanjub_stages": {n: build_kanjub_config(kv, n) for n in stage_slots(kv)},
"bdqty_rows": decode_bdqty(f.get("BDQTY.DBF", b"")),
"qcode_rows": read_qcode(f)["rows"],
}
out, log = apply_save(f, req)
assert log == [] and all(out[k] is f[k] for k in f)
def test_costn_new_code_row_and_nagcd():
f = _files(STCS[0])
ct = DbfTable(f["COSTN.DBF"])
m_last = max(i for i in range(len(ct)) if ct.get(i, "CODE").startswith("M"))
some = ct.get(0, "CODE")
ups = [
{"CODE": "M09991", "NAME": "새 자재(계약 변환)", "UNIT": "EA", "MAMT1": 1234, "TAMT1": 1234},
{"CODE": some, "NAGCD": 1},
]
_, brows, _ = parse_dbf_bytes(f["BOQ11.DBF"])
boq = [{"_recno": r["_recno"]} for r in brows] + [
{"_new": True, "BDCODE1": "M09991", "NAME1": "새 자재(계약 변환)", "QTY1": 3}
]
out, log = apply_save(f, {"costn_updates": ups, "boq_updates": boq})
nt = DbfTable(out["COSTN.DBF"])
assert len(nt) == len(ct) + 1
assert nt.get(m_last + 1, "CODE") == "M09991" and nt.get(m_last + 1, "NAME") == "새 자재(계약 변환)"
assert float(nt.get(m_last + 1, "MAMT1")) == 1234 and float(nt.get(m_last + 1, "LAMT1")) == 0
assert nt.raw(m_last + 1, "IFORM").strip() == b""
for i in range(len(ct)): # 다른 행 바이트 그대로(NAGCD 고친 칸만)
j = i if i <= m_last else i + 1
if ct.get(i, "CODE") != some:
assert nt.recs[j] == ct.recs[i]
assert float(nt.get(0, "NAGCD")) == 1
bt = DbfTable(out["BOQ11.DBF"])
k = len(bt) - 1
assert bt.get(k, "BDCODE1") == "M09991" and float(bt.get(k, "MAMT1")) == 1234
assert int(float(bt.get(k, "M_AMT1"))) == 3 * 1234
assert any(x["field"] == "_insert" and x["table"] == "COSTN.DBF" for x in log)
@pytest.mark.parametrize("stc", STCS[:3], ids=IDS[:3])
def test_bdqty_edit_add_delete(stc):
f = _files(stc)
rows = decode_bdqty(f["BDQTY.DBF"])
t0 = DbfTable(f["BDQTY.DBF"])
codes = []
for r in rows:
if r["BDCODE"] not in codes:
codes.append(r["BDCODE"])
edit, drop = codes[0], codes[-1]
new = [dict(r) for r in rows if r["BDCODE"] != drop]
i0 = next(i for i, r in enumerate(new) if r["BDCODE"] == edit)
q = float(new[i0]["QTY1"]) + 0.25
new[i0]["QTY1"] = q
extra = dict(next(r for r in rows if r["BDCODE"] != edit), BDCODE=edit, QTY1=2.5)
new.insert(i0 + 1, extra)
out, log = apply_save(f, {"bdqty_rows": new})
got = decode_bdqty(out["BDQTY.DBF"])
assert len(got) == len(new)
assert float(got[i0]["QTY1"]) == q
assert got[i0 + 1]["BDCODE"] == edit and got[i0 + 1]["CODE"] == extra["CODE"]
assert float(got[i0 + 1]["QTY1"]) == 2.5
assert drop not in {r["BDCODE"] for r in got}
# 손대지 않은 호표 행은 원래 레코드 바이트 그대로
nt = DbfTable(out["BDQTY.DBF"])
kept = {bytes(r) for r in t0.recs}
same = [bytes(nt.recs[i]) in kept for i, r in enumerate(got) if r["BDCODE"] not in (edit,)]
assert all(same)
assert {x["table"] for x in log} == {"BDQTY.DBF"}
out2, log2 = apply_save(out, {"bdqty_rows": got}) # 다시 저장 — 그대로
assert log2 == [] and out2["BDQTY.DBF"] is out["BDQTY.DBF"]
def test_qcode_rows_saved_and_loaded(work):
stc = STCS[0]
rows = [
{"CODE": "Q00001", "NAME": "토사 절취량", "BQCODE": "", "QTY0": 12.5, "IFORM": "L*B*H", "XCODE": ""},
{"CODE": "Q00002", "NAME": "암 절취량", "QTY0": 3, "IFORM": "", "XCODE": "A=1"},
]
res = server.api_save(server.SaveDbfReq(source_path=str(stc), target_folder_name="qc", qcode_rows=rows))
assert set(res["changed_files"]) == {"QCODE.DBF", "QCODE.SMT"}
load = server.api_load(server.LoadReq(source_path=res["saved_stc"]))
got = load["qcode_rows"]
assert [r["CODE"] for r in got] == ["Q00001", "Q00002"]
assert float(got[0]["QTY0"]) == 12.5 and got[0]["IFORM"] == "L*B*H" and got[1]["XCODE"] == "A=1"
# 불러온 행 그대로 다시 저장 — 수량코드 파일은 바뀌지 않음
res2 = server.api_save(
server.SaveDbfReq(source_path=res["saved_stc"], target_folder_name="qc2", qcode_rows=got)
)
assert res2["change_count"] == 0
with pytest.raises(Exception):
server.api_save(
server.SaveDbfReq(source_path=str(stc), target_folder_name="qc3", qcode_rows=[{"CODE": "X1"}])
)
@pytest.mark.parametrize("stc", STCS[:3], ids=IDS[:3])
def test_kanjub_stage_change_writes_only_changed_keys(stc):
f = _files(stc)
kv = _kv(f)
st = {n: build_kanjub_config(kv, n) for n in stage_slots(kv)}
key = next(k for k in st[1]["rates"] if f"1_R_{'T_KITA' if k == 'KITA' else k}" in kv)
st[1]["rates"][key] += 0.5
st[1]["addRows"].append(
{"group": 4, "name": "시험 추가금액", "unit": "식", "bigo": "", "amts": [1000, 0, 0, 0, 0]}
)
# 화면 옛 칸(smt_updates)이 원래 값을 보내도 설정 쪽 고침이 이김
sk = f"1_R_{'T_KITA' if key == 'KITA' else key}"
out, log = apply_save(f, {"kanjub_stages": st, "smt_updates": {sk: kv[sk]}})
kv2 = _kv(out)
assert float(kv2[sk]) == pytest.approx(float(kv[sk]) + 0.5)
g4 = int(float(kv.get("ADD_CNT_4", 0) or 0)) + 1
assert kv2["ADD_CNT_4"] == str(g4) and kv2[f"ADD_NAME_4_{g4:02d}"] == "시험 추가금액"
assert kv2[f"ADD_AMT_4_{g4:02d}_1"] == "1000"
changed = {x["field"] for x in log if x["table"] == "PRJLST.SMT"}
assert sk in changed and all(k == sk or k.startswith("ADD_") for k in changed)
assert {k: v for k, v in kv2.items() if k not in changed} == {k: v for k, v in kv.items() if k not in changed}
@@ -0,0 +1,92 @@
"""STMATE 재현 앱 내역서 줄 금액 — 비목우선/총액우선(CHNNM · OPTNM) · 비율 줄(PR).
정답 = 실무 STC 7건 BOQ 저장 줄 금액(M_AMT·L_AMT·S_AMT, 차수별) · 7건 모두 CHNNM=1(비목우선).
비목우선 = 성분별 단가 × 수량 원 미만 절사 · 비율 줄 = 표시(PRPT) 줄 밑수 × 율 ÷ 100 절사.
총액우선 = REA 15 OPTIMIZ(산근·일위대가와 같은 규칙) — 실무 표본 없음 · 손셈 대조.
"""
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"))
sys.path.insert(0, str(Path(__file__).parent))
import server # noqa: E402
from test_stmate_kanjub_stc import DOCS, STCS # noqa: E402
HELPER = Path(__file__).with_name("helper_stmate_kanjub_boq.cjs")
def _run(state):
out = subprocess.check_output(
["node", str(HELPER)],
input=json.dumps(state, ensure_ascii=False),
text=True,
encoding="utf-8",
)
return json.loads(out)
def _with_pr(loaded):
"""비율 줄 칸(PRPT·PRIN·PROUT·차수별 율) — 불러오기가 아직 안 넘기면 BOQ 원행에서 채움."""
st, rows = loaded["sim_state"], loaded["boq_rows"]
for it in st["boqItems"]:
r = rows[int(it["id"].split("_")[1]) - 1]
for k in ("PRPT", "PRIN", "PROUT"):
it.setdefault(k, r.get(k))
it.setdefault("prQtys", [float(r.get(f"QTY{n}") or 0) for n in range(1, 5)])
return st
@pytest.mark.parametrize("name", list(STCS))
def test_boq_rows_match_stc_bimok(name):
loaded = server.api_load(server.LoadReq(source_path=str(DOCS / STCS[name])))
assert str(loaded["start_ini"].get("CHNNM", "1")) == "1"
rows = loaded["boq_rows"]
out = _run(_with_pr(loaded))
bad = []
for n, items in out.items():
for rid, code, m, l, s in items:
r = rows[int(rid.split("_")[1]) - 1]
exp = [float(r.get(f"{c}_AMT{n}") or 0) for c in "MLS"]
if [m, l, s] != exp:
bad.append((n, code, [m, l, s], exp))
assert not bad, f"{name} 줄 금액 불일치 {bad[:5]}"
def _one(units, qty, mode, order="SML"):
state = {
"sysConfig": {"chnMode": mode, "optOrder": order},
"kanjubConfig": {},
"kanjubStages": {1: {}},
"resources": [],
"machines": [],
"sanItems": [],
"ilwiItems": [],
"boqItems": [
{
"id": "BOQ_1",
"code": "B1",
"refId": "B1",
"qty": qty,
"fixedUnit": dict(zip("MLS", units)),
}
],
}
return _run(state)["1"][0][2:]
def test_total_first_optimiz_by_hand():
# 920·5802·278 × 2.12 = 1950.4 · 12300.24 · 589.36 → 총액 14840 · 성분 절사 합 14839 → 소수부 큰 재료비 +1
assert _one([920, 5802, 278], 2.12, "bimok") == [1950, 12300, 589]
assert _one([920, 5802, 278], 2.12, "total") == [1951, 12300, 589]
# 1741·13033·3528 × 1.6 = 2785.6 · 20852.8 · 5644.8 → 남는 2원 = 노무·경비 동률 0.8 → OPTNM 순서(S 먼저)
assert _one([1741, 13033, 3528], 1.6, "total") == [2785, 20853, 5645]
assert _one([1741, 13033, 3528], 1.6, "total", "LMS") == [2785, 20853, 5645]
# 동률 셋 — 남는 1원은 순서 앞 성분
assert _one([1, 1, 1], 0.5, "total", "MLS") == [1, 0, 0]
assert _one([1, 1, 1], 0.5, "total", "LSM") == [0, 1, 0]
+63 -14
View File
@@ -264,6 +264,46 @@ const StmateCalc = (() => {
return { list, map };
}
// 총액우선 끝전 배분(OPTIMIZ) — 성분 절사 · 총액 절사와의 차 1원씩 소수부 큰 성분부터(동률 = 순서 앞)
function optimize(raw, rawT, order) {
const out = { M: roundDown(raw.M, 0), L: roundDown(raw.L, 0), S: roundDown(raw.S, 0) };
const keys = String(order || "SML")
.toUpperCase()
.split("")
.filter((k) => "MLS".includes(k));
const frac = Object.fromEntries(keys.map((k) => [k, roundHalfUp(raw[k] - out[k], 6)]));
let rest = roundDown(rawT, 0) - out.M - out.L - out.S;
while (rest > 0) {
let pick = null;
for (const k of keys) if (frac[k] < 1 && (pick === null || frac[k] > frac[pick])) pick = k;
if (pick === null) break;
((out[pick] += 1), (frac[pick] += 1), (rest -= 1));
}
return out;
}
// 내역 비율 줄(PR · 조달수수료 등) — 밑수 비목(PRIN) 누적 × 율(QTY, %) → 대상 비목(PROUT) 원 미만 절사
// 단가 칸 = 밑수 · 수량 칸 = 율 (원본 BOQ MAMT1 · QTY1 · M_AMT1 저장 꼴) · PRIN 없는 줄은 일반 줄
const PR_COMP = { 1: "M", 2: "L", 3: "S", 4: "T" };
function prRow(row, slot, base) {
if (String(row.code || "").trim() !== "PR") return null;
const bk = PR_COMP[String(row.PRIN ?? "").trim()],
ok = PR_COMP[String(row.PROUT ?? "").trim()];
if (!bk || !ok) return null;
const pct = num((row.prQtys || [])[slot - 1]);
const bv = bk === "T" ? base.M + base.L + base.S : base[bk];
const to = ok === "T" ? "M" : ok;
const a = { M: 0, L: 0, S: 0 },
u = { M: 0, L: 0, S: 0 };
((a[to] = roundDown((bv * pct) / 100, 0)), (u[to] = bv));
const T = a.M + a.L + a.S;
const unitInfo = { M: u.M, L: u.L, S: u.S, T: bv, name: row.name, code: "PR", spec: "", unit: "%" };
const out = { unitInfo, appliedQty: pct, qty: pct, mUnit: u.M, lUnit: u.L, sUnit: u.S, tUnit: bv };
Object.assign(out, { mAmt: a.M, lAmt: a.L, sAmt: a.S, tAmt: T, M: a.M, L: a.L, S: a.S, T });
Object.assign(out, { optimizDiff: 0, optimizTarget: "", aggM: a.M, aggL: a.L, aggS: a.S, aggT: T });
return { M: a.M, L: a.L, S: a.S, T, out };
}
function calcBoq(data, resMap, machMap, sanMap, ilwiMap, useChangedQty = false) {
const slot = typeof useChangedQty === "number" ? useChangedQty : useChangedQty ? 2 : 1;
const sys = data.sysConfig || {},
@@ -292,7 +332,17 @@ const StmateCalc = (() => {
directS = 0,
govRawTotal = 0,
govFeeTotal = 0;
// 내역 비율 줄(PR) 밑수 — 표시(PRPT) 줄 금액 누적 · PR 줄을 지나면 비움(영월 조달수수료 4줄 대조)
let prBase = { M: 0, L: 0, S: 0 };
const rows = (data.boqItems || []).map((row) => {
const pr = prRow(row, slot, prBase);
if (pr) {
prBase = { M: 0, L: 0, S: 0 };
if (row.rowType === "gov_mat") govRawTotal += pr.T;
else if (row.rowType === "gov_fee") govFeeTotal += pr.T;
else ((directM += pr.M), (directL += pr.L), (directS += pr.S));
return Object.assign({}, row, pr.out);
}
const su = slot > 1 && row.units && row.units[slot];
const u = su
? Object.assign({}, lookupUnit(row.refId), { M: su[0], L: su[1], S: su[2] })
@@ -326,25 +376,24 @@ const StmateCalc = (() => {
let optimizDiff = 0,
optimizTarget = "";
if (chnMode === "total") {
const targetT = roundDown(rawT, 0);
optimizDiff = targetT - (M + L + S);
if (optimizDiff !== 0) {
const fracMap = { S: rawS - S, M: rawM - M, L: rawL - L };
let bestKey = "S",
bestFrac = -1;
for (const k of optOrder.split("").filter((c) => ["S", "M", "L"].includes(c))) {
if (fracMap[k] > bestFrac) ((bestFrac = fracMap[k]), (bestKey = k));
}
if (bestKey === "S") S += optimizDiff;
else if (bestKey === "M") M += optimizDiff;
else L += optimizDiff;
T = targetT;
optimizTarget = `${bestKey}(+${optimizDiff})`;
// 총액우선(CHNNM=0) — REA 15 OPTIMIZ: 총액 절사 후 남는 1원을 소수부 큰 성분부터 하나씩
// (동률은 OPTNM 순서 앞쪽) · 산근·일위대가(smt_eval finish)와 같은 규칙
const got = optimize({ M: rawM, L: rawL, S: rawS }, rawT, optOrder);
optimizDiff = got.M + got.L + got.S - (M + L + S);
if (optimizDiff) {
optimizTarget = "MLS"
.split("")
.filter((k) => got[k] !== { M, L, S }[k])
.map((k) => `${k}(+${got[k] - { M, L, S }[k]})`)
.join("");
((M = got.M), (L = got.L), (S = got.S), (T = M + L + S));
}
}
const aggM = roundHalfUp(rawM, 0),
aggL = roundHalfUp(rawL, 0),
aggS = roundHalfUp(rawS, 0);
if (String(row.PRPT ?? "0").trim() && String(row.PRPT ?? "0").trim() !== "0")
((prBase.M += M), (prBase.L += L), (prBase.S += S));
if (row.rowType === "gov_mat") govRawTotal += T;
else if (row.rowType === "gov_fee") govFeeTotal += T;
else ((directM += M), (directL += L), (directS += S));
+47
View File
@@ -0,0 +1,47 @@
/* -*- coding: utf-8 -*- */
/**
* 실적단가 → 일식단가 변환등록(wM_Conv_A2W) 비목율 계산 — 화면 없이 숫자만 (calc_a2w.js)
* 근거: REA 27 conversion_component_rate_rules.json(0x4e37dc 한 줄 · 0x4e40f8 입력 정리 · 0x4e3df0 비목율 일괄적용)
* 율은 백분율 · 노무 L · 재료 M · 경비 E = 100 − (L + M) · 반올림 없음
* 금액 나눔(총액 × 율)은 근거 없음 — 율만 다룸
*/
const StmateA2W = (() => {
const n = (v) => Number(v) || 0;
// 입력칸 정리(0x4e40f8) — L·M 각 100 넘으면 100 · 합이 100 넘으면 고친 칸(sender)을 지키고 다른 칸 = 100 − 그 값
function normalizeEditor(L, M, sender = "L") {
let l = Math.min(n(L), 100),
m = Math.min(n(M), 100);
if (l + m > 100) sender === "M" ? (l = 100 - m) : (m = 100 - l);
return { L: l, M: m, E: 100 - (l + m) };
}
// 한 줄 고침(0x4e37dc) — 합이 100 넘으면 재료 칸(10)을 고친 때만 L = 100 − M · 그 밖은 M = 100 − L
function normalizeRow(L, M, editedCol) {
let l = n(L),
m = n(M);
if (l + m > 100) editedCol === 10 ? (l = 100 - m) : (m = 100 - l);
return { L: l, M: m, E: 100 - (l + m) };
}
/**
* 비목율 일괄적용(0x4e3df0) — rows: [{ sel, rateL 개별노무비율 }] · editor: { L, M, sender }
* opts.individual = 「노무비율ː개별노무비율 · 나머지ː재료비우선」 표시(전역 조건 1 과 함께일 때만)
* 고른 줄: L = 편집 L(개별이 켜졌고 개별 ≠ 0 이면 개별) · M = L + 편집 M > 100 이면 100 − L · E = 나머지
* 안 고른 줄: 세 칸 모두 0(옛 값 지키지 않음)
*/
function bulkApply(rows, editor = {}, opts = {}) {
const ed = normalizeEditor(editor.L, editor.M, editor.sender);
return (rows || []).map((r) => {
if (!r.sel) return Object.assign({}, r, { L: 0, M: 0, E: 0 });
const L = opts.individual && n(r.rateL) !== 0 ? n(r.rateL) : ed.L;
const M = L + ed.M > 100 ? 100 - L : ed.M;
return Object.assign({}, r, { L, M, E: 100 - L - M });
});
}
return { normalizeEditor, normalizeRow, bulkApply };
})();
if (typeof window !== "undefined") window.STMATE_A2W = StmateA2W;
if (typeof module !== "undefined" && module.exports) module.exports = StmateA2W;
+56
View File
@@ -0,0 +1,56 @@
/* -*- coding: utf-8 -*- */
/**
* 노무·장비 수량 계산기(wM_Edit_XRate · 손료계수 수정) 계산 — 화면 없이 숫자만 (calc_xrate.js)
* 근거: REA 30 ST_EdiLWi labor_equipment_quantity_rules.json(0x442f97 · 0x442fec · 0x4434b4 · 0x443634)
* 일일수량 = Q11(기준수량 × 조건계수 × 적용율 ÷ 100)
* 적용수량 = 일일수량 0 이면 0 · 아니면 Qp(조수량 × (노무 1 · 장비 8) ÷ 일일수량)
* 자동 자리 = 기준수량 정수부 자릿수 + 1 (2~6 사이로 묶음) · 일일수량이 아니라 기준수량을 봄
* 조건식 = 빈칸 걷은 뒤 * + - . / 0~9 ^ 만 허용(괄호 없음) · 빈 식 = 1
* Q_d = DP_Common.MROUNDUD(x, d, 1) — 반올림 방향 미확정(REA 15 · 14) · 여기서는 반올림으로 둠(가정)
*/
const StmateXRate = (() => {
const EV = () =>
(typeof window !== "undefined" && window.STMATE_SMT_EVAL) ||
(typeof require !== "undefined" ? require("./smt_eval.js") : null);
// 반올림(가정) — 이진 오차를 십진 자리로 맞춤
function q(v, d) {
const f = 10 ** d,
r = Math.floor(Math.abs(Number(v) || 0) * f + 0.5 + 1e-9) / f;
return v < 0 ? -r : r;
}
// 조건계수 — 허용 글자만 · 괄호 금지 · 계산은 식 계산기(CALC_STR_URC 대응)
function condFactor(text) {
const s = String(text ?? "").replace(/\s+/g, "");
if (!s) return { ok: true, value: 1 };
if (!/^[0-9.+\-*/^]+$/.test(s)) return { ok: false, value: 1, error: "조건식에 쓸 수 없는 글자" };
const v = Number(EV().evalExpr(s, {}));
return Number.isFinite(v) ? { ok: true, value: v } : { ok: false, value: 1, error: "조건식 오류" };
}
// 자동 자리 — 기준수량 정수부 자릿수 + 1, 2~6
function autoPoint(qty1) {
const n = String(Math.trunc(Math.abs(Number(qty1) || 0))).length;
return Math.min(6, Math.max(2, n + 1));
}
/**
* inp: { qty1 기준수량, cond 조건식, hrat 적용율(%), lqty 조수량, labor 참=노무, point 자리, auto 자동자리 }
* 돌려줌: { ok, cond, qday 일일수량, point, cqty 적용수량 }
*/
function compute(inp = {}) {
const c = condFactor(inp.cond);
const qty1 = Number(inp.qty1) || 0;
const qday = q(qty1 * c.value * (Number(inp.hrat) || 0) / 100, 11);
const point = inp.auto ? autoPoint(qty1) : Math.round(Number(inp.point) || 0);
const per = inp.labor ? 1 : 8;
const cqty = qday === 0 ? 0 : q(((Number(inp.lqty) || 0) * per) / qday, point);
return { ok: c.ok, error: c.error, cond: c.value, qday, point, cqty };
}
return { condFactor, autoPoint, compute, round: q };
})();
if (typeof window !== "undefined") window.STMATE_XRATE = StmateXRate;
if (typeof module !== "undefined" && module.exports) module.exports = StmateXRate;
+249
View File
@@ -0,0 +1,249 @@
# -*- coding: utf-8 -*-
"""엑셀 → 내역 불러오기 (wM_From_XLS · CONV_XLS_2_ST) — STMATE 가 내보낸 xlsx 의 「〓 INITIAL 〓」 메타로 내역 행 복원.
근거: XLSX_INITIAL_구조.md · 실무문서/_숨김탭분석.md §E · 24_모듈구조_전수.md §3(CONV_XLS_2_ST · XLS_2_BDREC) ·
16_골든셋_회귀_결과.md §4(SORTCODE↔ENDCODE 짝 · C* 제외 구역) · 34번 wKongJong(소계·계·합계·총계·총합계·총대계)
INITIAL 시트(veryHidden):
[Sheet정보] 목차줄마다 시트 이름 · 열 번호(ITNAME NAME SIZE QTY UNIT SPOS EPOS TAMT TAMOUNT MAMT MAMOUNT
LAMT LAMOUNT SAMT SAMOUNT AAMT AAMOUNT NAMT NAMOUNT INFO) — 1부터 셈
[공사정보] [초기값] [인쇄설정] [PREPARE] — 키 · 값(여러 칸)
내역 시트 INFO 칸(숨은 열) = 0x07 `키|값 0x05 … — COD(코드) · QTYn|n(차수 줄) · EXI · ITT · END(그 묶음이 끝나는 엑셀 줄) ·
IPR · BLA · KWN · EQC · PRI · PRO · UNI · RCU · PSKP(이 줄 뒤 페이지 넘김)
복원 규칙(실무 4건 STC BOQ 와 대조 · resources/tester/test_stmate_conv_xls.py):
- 공종 머리 줄 COD = 닫는 코드(Ek · Ck) → 여는 행 Tk + 닫는 행 Ek/Ck(END 줄 앞에서 안쪽부터 닫음)
- 닫는 행 이름: Ek = 공종번호 칸에 단계 이름 · Ck = 명칭 칸에 「단계 이름(제외금액)」
- 여는 행·닫는 행 금액 = 머리 줄 금액(묶음 합) · SORTCODE = 256 × 차례 · 짝끼리 ENDCODE = 상대 SORTCODE
- PSKP 줄 뒤(그 자리 닫는 행 다음) ':' 페이지 넘김 행 · COD 빈 줄 = 빈 행
- 차수 줄(QTYn|n, n ≥ 2)은 바로 앞 행의 n차 칸
"""
from __future__ import annotations
import io
import re
import warnings
LEVEL_NAME = {1: "소계", 2: "계", 3: "합계", 4: "총계", 5: "총합계", 6: "총대계"}
COLS = (
"ITNAME NAME SIZE QTY UNIT SPOS EPOS TAMT TAMOUNT MAMT MAMOUNT "
"LAMT LAMOUNT SAMT SAMOUNT AAMT AAMOUNT NAMT NAMOUNT INFO"
).split()
# INFO 키 → BOQ 칸
INFO_FIELD = {
"EQC": "EQCODE",
"PRI": "PRIN",
"PRO": "PROUT",
"UNI": "UNIT1",
"RCU": "RCU",
"KWN": "KWNGP",
"BLA": "BL_AMT",
}
INITIAL = "INITIAL"
def load_book(src):
"""경로 또는 바이트 → openpyxl 통합문서(계산된 값)."""
import openpyxl
with warnings.catch_warnings():
warnings.simplefilter("ignore")
if isinstance(src, (bytes, bytearray)):
return openpyxl.load_workbook(io.BytesIO(src), data_only=True)
return openpyxl.load_workbook(src, data_only=True)
def _unx(s: str) -> str:
return re.sub(r"_x([0-9A-Fa-f]{4})_", lambda m: chr(int(m.group(1), 16)), s)
def parse_info(s) -> dict[str, str]:
"""숨은 INFO 글 → {키: 값} (같은 키가 두 번이면 앞의 것)."""
out: dict[str, str] = {}
if not isinstance(s, str):
return out
for k, v in re.findall(r"`(\w+)\|([^\x05\x07`]*)", _unx(s)):
out.setdefault(k, v)
return out
def initial_sheet(wb):
return next((w for w in wb.worksheets if INITIAL in w.title), None)
def read_initial(wb) -> dict:
"""INITIAL 시트 → {sheets: {목차: {sheet, cols}}, sections: {이름: {키: [값…]}}}."""
ws = initial_sheet(wb)
if ws is None:
raise ValueError("〓 INITIAL 〓 시트 없음 — STMATE 가 내보낸 엑셀이 아님")
sheets, sections, sec = {}, {}, ""
for r in ws.iter_rows(values_only=True):
if len(r) < 2 or r[1] is None:
continue
key = str(r[1]).strip()
if key.startswith("[") and key.endswith("]"):
sec = key[1:-1]
continue
if sec == "Sheet정보":
if key == "목차" or not r[2]:
continue
vals = list(r[4:24]) + [None] * 20
cols = {c: int(v) for c, v in zip(COLS, vals) if isinstance(v, (int, float))}
sheets[key] = {"sheet": str(r[2]), "cols": cols}
elif sec:
sections.setdefault(sec, {})[key] = [v for v in r[2:] if v is not None]
return {"sheets": sheets, "sections": sections}
def find_sheet(info: dict, kind: str) -> dict | None:
"""목차 이름 끝말(예 '내역서' · '일위대가표')로 시트 찾기 — '11. 내역서' ↔ '12. 총괄내역서' 가름."""
for k, v in info["sheets"].items():
if re.sub(r"^\d+\.\s*", "", k).strip() == kind:
return v
return None
def _cell(row: tuple, col: int | None):
if not col or col - 1 >= len(row):
return None
return row[col - 1]
def _txt(v) -> str:
return "" if v is None else str(v).rstrip()
def _numv(v) -> float:
try:
return float(v)
except (TypeError, ValueError):
return 0.0
def _amounts(row: tuple, cols: dict, n: int) -> dict:
"""한 줄의 단가·금액 → n차 칸."""
out = {}
for c in "TMLS":
out[f"{c}AMT{n}"] = _numv(_cell(row, cols.get(f"{c}AMT")))
out[f"{c}_AMT{n}"] = _numv(_cell(row, cols.get(f"{c}AMOUNT")))
return out
LABELS = {"공종": "ITNAME", "명칭": "NAME", "규격": "SIZE", "수량": "QTY", "단위": "UNIT"}
BIMOK = {"합계": "T", "노무비": "L", "재료비": "M", "경비": "S"}
def label_cols(ws, cols: dict) -> dict:
"""머리 줄 글(공종·명칭·…·합계/노무비/재료비/경비 + 단가/금액)로 열 번호를 다시 잡음.
변경 내역서는 INITIAL 열 번호가 시트와 어긋남(영덕 변경 실측) — 글이 다 있으면 글 쪽을 씀."""
spos = cols.get("SPOS", 1)
found: dict = {}
for r in ws.iter_rows(min_row=max(1, spos - 3), max_row=max(1, spos - 1), values_only=True):
for j, v in enumerate(r, start=1):
t = re.sub(r"\s+", "", str(v or ""))
if t in LABELS:
found.setdefault(LABELS[t], j)
elif t in BIMOK:
found.setdefault(f"{BIMOK[t]}AMT", j)
found.setdefault(f"{BIMOK[t]}AMOUNT", j + 1)
if all(f"{c}AMT" in found for c in "TLMS") and "NAME" in found:
return {**cols, **found}
return cols
def boq_rows_from_sheet(ws, cols: dict) -> list[dict]:
"""내역 시트 → BOQ 행 목록(DBF 칸 이름 · 숫자는 float · 글은 str)."""
cols = label_cols(ws, cols)
spos, epos = cols.get("SPOS", 1), cols.get("EPOS", ws.max_row)
out: list[dict] = []
stack: list[dict] = [] # 열린 묶음 {code, end, head}
pskp = False
last = None
def close_until(line: int):
while stack and stack[-1]["end"] <= line:
g = stack.pop()
lev = int(g["code"][1:]) if g["code"][1:].isdigit() else 0
name = LEVEL_NAME.get(lev, "")
row = {"BDCODE1": g["code"], "EXIST1": 1 if g["code"][0] == "C" else 0}
row.update({k: v for k, v in g["head"].items() if "AMT" in k})
if g["code"][0] == "C":
row.update(ITNUM="", NAME1=f"{name}(제외금액)")
else:
row.update(ITNUM=name, NAME1="")
row["_pair"] = g["open"]
out[g["open"]]["_pair"] = len(out)
out.append(row)
for line, row in enumerate(
ws.iter_rows(min_row=spos, max_row=epos, values_only=True), start=spos
):
close_until(line)
if pskp:
out.append({"BDCODE1": ":", "NAME1": "페이지 넘김", "EXIST1": 0})
pskp = False
meta = parse_info(_cell(row, cols.get("INFO")))
if "COD" not in meta:
continue
slot = next((int(v) for k, v in meta.items() if re.fullmatch(r"QTY\d", k)), 1)
if slot >= 2 and last is not None: # 차수 줄 — 앞 행의 n차 칸
last[f"QTY{slot}"] = _numv(_cell(row, cols.get("QTY")))
last.update(_amounts(row, cols, slot))
continue
code = meta["COD"].strip()
base = {
"ITNUM": _txt(_cell(row, cols.get("ITNAME"))),
"NAME1": _txt(_cell(row, cols.get("NAME"))),
"SIZE1": _txt(_cell(row, cols.get("SIZE"))),
"UNIT1": _txt(_cell(row, cols.get("UNIT"))).strip(),
"QTY1": _numv(_cell(row, cols.get("QTY"))),
}
base.update(_amounts(row, cols, 1))
for k, f in INFO_FIELD.items():
if k in meta:
base[f] = meta[k]
if "END" in meta and re.fullmatch(r"[EC]\d", code): # 공종 머리
base.update(BDCODE1="T" + code[1:], EXIST1=1, QTY1=0.0)
stack.append({"code": code, "end": int(meta["END"]), "head": base, "open": len(out)})
out.append(base)
elif not code: # 빈 줄(설명 줄)
base.update(BDCODE1="", EXIST1=1)
out.append(base)
else:
base.update(BDCODE1=code, EXIST1=int(_numv(meta.get("EXI"))) if code == "PR" else 0)
out.append(base)
last = out[-1]
pskp = meta.get("PSKP") == "1"
close_until(10**9)
for i, r in enumerate(out):
r["SORTCODE"] = 256.0 * (i + 1)
for r in out:
r["ENDCODE"] = out[r["_pair"]]["SORTCODE"] if "_pair" in r else 0.0
r.pop("_pair", None)
return out
def fill_from_costn(rows: list[dict], costn_rows: list[dict]) -> int:
"""코드 행의 BQCODE · EQCODE · 단위 = 그 코드의 COSTN 값(실무 6표 전 행이 같음) — 채운 행 수.
엑셀 단위 칸은 'M%' 를 '%' 로 보이므로 COSTN 단위가 원래 값."""
cm = {str(r.get("CODE", "")).strip(): r for r in costn_rows or []}
n = 0
for r in rows:
c = cm.get(str(r.get("BDCODE1", "")).strip())
if not c:
continue
for f, g in (("BQCODE", "BQCODE"), ("EQCODE", "EQCODE"), ("UNIT1", "UNIT")):
if g in c:
r[f] = str(c[g]).strip()
n += 1
return n
def boq_from_xlsx(src, kind: str = "내역서") -> dict:
"""xlsx → {rows, sheet, info} — kind = 목차 이름(내역서 · 총괄내역서 …)."""
wb = load_book(src)
info = read_initial(wb)
spec = find_sheet(info, kind)
if not spec or spec["sheet"] not in wb.sheetnames:
raise ValueError(f"INITIAL 목차에 '{kind}' 시트 없음")
rows = boq_rows_from_sheet(wb[spec["sheet"]], spec["cols"])
return {"rows": rows, "sheet": spec["sheet"], "info": info}
+1 -1
View File
@@ -50,7 +50,7 @@ const StmateReportBook = (() => {
cells.O = p.M + p.L + p.S;
const s = num(r.PG_1);
if (s >= 1 && s <= 5) cells.P = str(ini[`JMK${s}0`] || "").trim();
const rm = po.remarkHopyo && !po.codeMode ? lbl : str(r.BQCODE).trim();
const rm = U.remarkOf(po, c, lbl, r);
if (rm) cells.Q = rm;
C.push(sh, cells);
});
+2 -1
View File
@@ -105,7 +105,7 @@ const StmateReportBoq = (() => {
}
// 품목 비고 — 코드 모드(SH_TYP1=0) 코드 · 호표 모드 = 대가·산근·중기는 「산근 n호표」 · 자원은 가격지 위치(BQCODE)
// (SH_TYP3=1 이면 자원도 「자재 n」) · 호표 번호 = 종류별 코드순 자리
// (SH_TYP3=1 이면 자원도 「자재 n」 · 0 이면 자원은 코드) · 호표 번호 = 종류별 코드순 자리
function itemRemark(dbf, idx, b) {
const ini = (dbf && dbf.start_ini) || {};
if (b === "PR") return "";
@@ -116,6 +116,7 @@ const StmateReportBoq = (() => {
const no = idx.ofPrefix(b[0]).findIndex((x) => C.code(x) === b) + 1;
if ("BDX".includes(b[0])) return `${kind}${String(no).padStart(4)}호표`;
if (String(ini.SH_TYP3 ?? "2") === "1") return `${kind}${String(no).padStart(5)}`;
if (String(ini.SH_TYP3 ?? "2") === "0") return b;
return str(r.BQCODE).trim();
}
+246
View File
@@ -0,0 +1,246 @@
/* -*- coding: utf-8 -*- */
/**
* 수량금액 집계 (report_coll.js) — 투입 수량 전개 · 수량금액집계표(0501~0508) · 일식·표준시장 목록표(0104 · 0106)
* 근거: 지식DB STmate/old/20_분석 06 §3 · 18 §2(집계표 금액 = 사사오입) · 20 §3(SET_REP) · old/30_원자료/print_form_map.txt
* 전개 = 내역 품목 수량 → 일위대가(B) 구성 수량 곱 → 산근(D) 식 줄 계수(참조 단가 1 로 셈) · 중기(X)는 시간에서 멈춤
* 산근 안 같은 코드의 비목 줄(노·재·경)은 한 벌 — 비목별 계수 합의 최대 · (-=)·(-==)·「->」 제외 구간은 뺌
* 정답 대조: resources/tester/test_stmate_report_coll.py (원본 STMATE 출력 엑셀)
*/
const StmateReportColl = (() => {
const g = typeof globalThis !== "undefined" ? globalThis : {};
const C = g.STMATE_REPORT_CORE || require("./report_core.js");
const U = g.STMATE_REPORT_UNIT || require("./report_unit.js");
const { num, str, code, half } = C;
const CODE_RE = /^([A-Z])(\d{1,5})$/;
const padCode = (t) => {
const m = String(t).match(CODE_RE);
return m ? m[1] + m[2].padStart(5, "0") : t;
};
/**
* 산근 줄 계수 — 참조 단가를 1 로 두고 같은 식을 셈(줄 끝수 자르기 없음)
* 돌려줌: { 코드: {M,L,S} } 비목별 계수 합 · 제외 구간 줄은 빠짐
*/
function sanFactors(dbf, d, memoText) {
const SE = g.STMATE_SMT_EVAL;
const EN = g.STMATE_ENGINE;
const ctx = { dbfData: dbf, state: {}, uiState: {} };
const { lookup } = EN.costnLookup(ctx);
const one = (k) => {
const r = lookup(k);
if (!r) return null;
// 자원 = 제 비목 칸에만 1(「&L00006L&」 같은 비목 지정 참조도 1) · 그 밖 = 세 비목 모두 1
const own = k[0] === "L" ? "L" : k[0] === "S" ? "S" : "M";
const unit = /^[MLSW]/.test(k) ? { M: 0, L: 0, S: 0, [own]: 1 } : { M: 1, L: 1, S: 1 };
return Object.assign({}, r, unit);
};
const opts = Object.assign(EN.smtOptions(ctx, d), { lineDigits: 14, adj: null, sbmok: "" });
opts.totalFirst = false;
const ev = SE.evaluate(memoText, one, opts);
const amountLines = [];
let pending = [];
for (const ln of ev.lines) {
if (ln.kind === "amount") {
const ok = !ln.excluded;
const item = { ln, ok };
amountLines.push(item);
pending.push(item);
} else if (ln.kind === "section") pending = [];
else if (ln.kind === "exclude") {
// (-=) 지금 구간 · (-==) 이전 전부
const hit = ln.level === 1 ? pending : amountLines;
for (const it of hit) it.ok = false;
pending = [];
}
}
const out = {};
for (const { ln, ok } of amountLines) {
if (!ok) continue;
const m = String(ln.ref).match(/^([A-Z]\d{1,5})([LMS])?$/);
if (!m) continue;
const c = padCode(m[1]);
const r = lookup(c);
if (!r || /%$/.test(String(r.unit || "").trim())) continue;
const f = (out[c] = out[c] || { M: 0, L: 0, S: 0 });
for (const k of "MLS") f[k] += half(ln.amounts[k], 11); // 계수 = 소수 11자리(BDQTY 저장 자리)
}
return out;
}
/**
* 투입 수량 전개 — 차수 내역 품목(비율 줄 빼고) 전부
* 돌려줌: { qty:{코드: 수량}, by:{코드: 품목 묶음 → 수량} } (by = 공종별 집계표 0675~0677 용)
*/
function explode(dbf, items, opts = {}) {
const memos = (dbf && dbf.costn_memos) || {};
const edited = opts.editedSmt || {};
const idx = C.costnIndex(dbf);
const groups = {};
for (const r of (dbf && dbf.bdqty_rows) || [])
if (/^[BX]/.test(String(r.BDCODE || ""))) (groups[r.BDCODE] = groups[r.BDCODE] || []).push(r);
const fcache = {};
const children = (k) => {
if (fcache[k]) return fcache[k];
const out = [];
if (k[0] === "B" && groups[k]) {
for (const r of groups[k]) {
const c = String(r.CODE || "").trim();
const ref = idx.by[c];
if (!ref || /%$/.test(str(ref.UNIT).trim())) continue;
out.push([c, num(r.QTY1)]);
}
} else if (k[0] === "D") {
const text = edited[k] || (memos[k] && memos[k].text) || "";
if (text.trim()) {
const f = sanFactors(dbf, k, text);
for (const [c, v] of Object.entries(f))
out.push([c, /^[MLSW]/.test(c) ? v.M + v.L + v.S : Math.max(v.M, v.L, v.S)]);
}
}
return (fcache[k] = out);
};
const qty = {};
const by = {};
const walk = (k, q, bag, depth) => {
qty[k] = (qty[k] || 0) + q;
if (bag) bag[k] = (bag[k] || 0) + q;
if (depth > 30 || !q) return;
for (const [c, f] of children(k)) walk(c, q * f, bag, depth + 1);
};
for (const it of items) {
if (!it.code || it.pct || !idx.by[it.code]) continue;
const bag = it.group != null ? (by[it.group] = by[it.group] || {}) : null;
walk(it.code, it.qty, bag, 0);
}
return { qty, by };
}
// 차수 내역 품목 — 설계내역서 나무(report_boq boqTree) 품목 줄 · 제외금액 구역(C 로 닫힌 묶음 · 관급 등) 안은 뺌
const inExcluded = (n) => {
for (let p = n.parent; p; p = p.parent) if (p.excluded) return true;
return false;
};
function stageItems(tree) {
return tree.out
.filter((n) => n.kind === "item" && !inExcluded(n))
.map((n) => ({ code: n.code, qty: n.qty, pct: n.pct, node: n }));
}
// 수량 끝수 — 소수 둘째 자리 사사오입(원본 집계표 수량 칸)
const q2 = (v) => half(v, 2);
// 호표 글 — 대가·산근(B·D)은 네 자리 맞춤 · 그 밖은 자리 맞춤(CH_PRN_COD=1)일 때만
const hopyoText = (po, prefix, n) =>
po.padNo || "BD".includes(prefix) ? String(n).padStart(4) : String(n);
/**
* 수량금액집계표 한 장 — 쓰인 코드만(코드순) · 금액 = 수량(끝수 맞춘 값) × 단가 사사오입
* 자원(0501~0504 · 0508) = 단가·금액 한 칸 / 비목(0505 중기 · 0506 대가 · 0507 산근) = 비목 4칸(BMKNM 차례)
* 호표 번호 = 자리 맞춤이면 코드 호표 번호 · 그 밖 = 쓰인 코드 차례
* exs 둘(당초·변경)이면 변경 양식 — 코드마다 당초 줄 + 변경 줄 · changeOnly 면 수량이 바뀐 코드만
*/
function collSheet(u, exs, name, title, prefix, opts = {}) {
const { dbf, idx, ini, order, po, hopyoNo } = u;
const bimok = "XBD".includes(prefix);
const sh = C.newSheet(name, title, C.projectName(dbf), bimok ? "K" : "I");
const head = { A: po.codeMode ? "코드번호" : "호표", B: "명 칭", C: "규 격" };
Object.assign(head, { D: "수 량", E: "단위", _k: "head" });
const cols = ["F", "G", "H", "I"];
if (bimok) {
head.J = "비 고";
order.forEach((k, i) => (head[cols[i]] = C.bimokLabel(ini, k)));
} else Object.assign(head, { F: "단 가", G: "금 액", H: "비 고" });
C.push(sh, head);
const money = (q, p) => {
if (!bimok) {
const unit = C.fix(p.M + p.L + p.S);
return { F: unit, G: half(q * unit) };
}
const v = { M: half(q * p.M), L: half(q * p.L), S: half(q * p.S) };
v.T = v.M + v.L + v.S;
const out = {};
order.forEach((k, i) => (out[cols[i]] = v[k]));
return out;
};
let seq = 0;
for (const r of idx.ofPrefix(prefix)) {
const c = code(r);
if (!exs.some((e) => e.qty[c])) continue;
seq += 1;
const qs = exs.map((e) => q2(e.qty[c] || 0));
if (opts.changeOnly && qs.length > 1 && qs[0] === qs[1]) continue;
const p = idx.price(c, 1); // 1칸 = 낙찰률 적용 단가(내역 단가와 같음)
const lbl = po.codeMode ? c : hopyoText(po, prefix, po.padNo ? hopyoNo[c] : seq);
const cells = { A: lbl, B: str(r.NAME), C: str(r.SIZE), D: qs[0], E: C.unitText(r.UNIT) };
Object.assign(cells, money(qs[0], p), { _k: "item", _code: c });
const rm = U.remarkOf(po, c, lbl, r);
if (rm) cells[bimok ? "J" : "H"] = rm;
C.push(sh, cells);
if (qs.length > 1) C.push(sh, Object.assign({ D: qs[1], _k: "item2", _code: c }, money(qs[1], p)));
}
return sh;
}
/**
* 일식·견적(W · 0104) · 표준시장(P · 0106) 목록표 — 비목 4칸 단가
*/
function wListSheet(u, name, title, prefix) {
const { dbf, idx, ini, order, po, hopyoNo } = u;
const sh = C.newSheet(name, title, C.projectName(dbf), "J");
const cols = ["E", "F", "G", "H"];
const head = { A: po.codeMode ? "코드번호" : "호표", B: "명 칭", C: "규 격" };
Object.assign(head, { D: "단위", I: "비 고", _k: "head" });
order.forEach((k, i) => (head[cols[i]] = C.bimokLabel(ini, k)));
C.push(sh, head);
let seq = 0;
for (const r of idx.ofPrefix(prefix)) {
const c = code(r);
seq += 1;
const p = idx.price(c, 1);
const v = { M: p.M, L: p.L, S: p.S, T: p.M + p.L + p.S };
const lbl = po.codeMode ? c : hopyoText(po, prefix, po.padNo ? hopyoNo[c] : seq);
const cells = { A: lbl, B: str(r.NAME), C: str(r.SIZE), D: C.unitText(r.UNIT) };
Object.assign(cells, { _k: "item", _code: c });
order.forEach((k, i) => (cells[cols[i]] = v[k]));
const rm = U.remarkOf(po, c, lbl, r);
if (rm) cells.I = rm;
C.push(sh, cells);
}
return sh;
}
// 집계표 차례 — [시트 id, 제목, 코드 종류, SET_REP 서식] · W·P 는 코드가 있을 때만
const COLL = [
["재료비수량금액집계표", "재료비 수량,금액 집계표", "M", "0501"],
["노무비수량금액집계표", "노무비 수량,금액 집계표", "L", "0502"],
["경비수량금액집계표", "경 비 수량,금액 집계표", "S", "0503"],
["일식견적수량금액집계표", "일식/견적 수량,금액 집계표", "W", "0504"],
["표준시장수량금액집계표", "표준시장/시장시공 수량,금액 집계표", "P", "0508"],
["중기시간금액집계표", "중기 시간,금액 집계표", "X", "0505"],
["일위대가수량금액집계표", "일위대가 수량,금액 집계표", "B", "0506"],
["단가산출근거수량금액집계표", "단가산출근거 수량,금액 집계표", "D", "0507"],
];
/**
* 집계 묶음 — u = report_unit unitContext · tree = 차수 설계내역 나무
* opts.baseTree(당초 차수 나무)를 주면 변경 양식(당초·변경 두 줄) · opts.changeOnly = 바뀐 코드만
*/
function buildCollSheets(u, tree, opts = {}) {
const ex = explode(u.dbf, stageItems(tree), opts);
const exs = opts.baseTree ? [explode(u.dbf, stageItems(opts.baseTree), opts), ex] : [ex];
const F = (sh, indx) => C.applyForm(sh, u.dbf, indx);
const has = (p) => u.idx.ofPrefix(p).length > 0;
const out = { _explode: ex };
if (has("W")) out.일식견적목록표 = F(wListSheet(u, "일식견적목록표", "일식/견적 목록표", "W"), "0104");
if (has("P"))
out.표준시장목록표 = F(wListSheet(u, "표준시장목록표", "표준시장/시장시공 목록표", "P"), "0106");
for (const [id, title, p, indx] of COLL)
if ("WP".includes(p) ? has(p) : true) out[id] = F(collSheet(u, exs, id, title, p, opts), indx);
return out;
}
return { buildCollSheets, explode, sanFactors, stageItems, padCode };
})();
if (typeof window !== "undefined") window.STMATE_REPORT_COLL = StmateReportColl;
if (typeof module !== "undefined" && module.exports) module.exports = StmateReportColl;
+1 -1
View File
@@ -234,7 +234,7 @@ const StmateReportCore = (() => {
const r = ((dbf && dbf.set_rep_rows) || []).find((x) => String(x.INDX).trim() === indx);
const title = r ? String(r.HEADTITLE || "").trim() : "";
if (!sh || !title) return sh;
const name = title.replace(/\s+/g, "");
const name = title.replace(/[\s,/]+/g, ""); // 시트 이름 = 제목에서 빈칸·쉼표·빗금 뺌
sh.title = title;
sh.indx = indx;
if (sh.rows[0] && sh.rows[0]._k === "title") sh.rows[0].A = title;
+14 -9
View File
@@ -54,7 +54,7 @@ const StmateReportUnit = (() => {
/**
* 출력 설정 — Start8x.Ini
* SH_TYP1 = 0 이면 첫 칸 코드 · 그 밖 호표 번호 / SH_TYP3 = 1 이면 비고에 호표 · 그 밖 가격지 위치(BQCODE)
* SH_TYP1 = 0 이면 첫 칸 코드 · 그 밖 호표 번호 / SH_TYP3 = 0 이면 비고에 코드 · 1 이면 호표 · 그 밖 가격지 위치(BQCODE)
* CH_PRN_COD = 1 이면 번호를 자리 맞춤(중기 「제 n호표」 · 자원 「 n」)
* 줄 비고 종류 이름 = COD_FMT_1~6(노무·자재·경비·대가·중기·산근 · 지식DB 20 §2.4)
*/
@@ -64,11 +64,15 @@ const StmateReportUnit = (() => {
return {
codeMode: String(ini.SH_TYP1 ?? "0") === "0",
remarkHopyo: String(ini.SH_TYP3 ?? "2") === "1",
remarkCode: String(ini.SH_TYP3 ?? "2") === "0",
padNo: String(ini.CH_PRN_COD ?? "0") === "1",
kind,
};
}
const pad = (n, w) => String(n).padStart(w);
// 목록 비고 — 코드(SH_TYP3=0) · 호표 글(SH_TYP3=1 · 호표 모드) · 가격지 위치(BQCODE)
const remarkOf = (po, c, lbl, r) =>
po.remarkCode ? c : po.remarkHopyo && !po.codeMode ? lbl : str(r.BQCODE).trim();
// 호표 비고 글 — 대가·산근 「대가 n호표」 · 그 밖 「노무 n」
const kindNo = (po, p, n) =>
p === "B" || p === "D" ? `${po.kind[p]}${pad(n, 4)}호표` : `${po.kind[p] || ""}${pad(n, 5)}`;
@@ -95,7 +99,7 @@ const StmateReportUnit = (() => {
Object.assign(cells, { _k: "item", _code: c });
const v = { M: tot.M, L: tot.L, S: tot.S, T: tot.M + tot.L + tot.S };
order.forEach((k, j) => (cells[LIST_COLS[j]] = v[k]));
const rm = po.remarkHopyo && !po.codeMode ? lbl : str(r.BQCODE).trim();
const rm = remarkOf(po, c, lbl, r);
if (rm) cells.I = rm;
C.push(sh, cells);
});
@@ -114,7 +118,7 @@ const StmateReportUnit = (() => {
const lbl = po.codeMode ? c : hopyoLabel(po, prefix, i + 1);
const cells = { A: lbl, B: str(r.NAME), C: str(r.SIZE), D: C.unitText(r.UNIT) };
Object.assign(cells, { E: fix(p.M + p.L + p.S), _k: "item", _code: c });
const rm = po.remarkHopyo && !po.codeMode ? lbl : str(r.BQCODE).trim();
const rm = remarkOf(po, c, lbl, r);
if (rm) cells.F = rm;
C.push(sh, cells);
});
@@ -125,7 +129,7 @@ const StmateReportUnit = (() => {
function lineRemark(ctx, ref, c, pct) {
const { po, hopyoNo } = ctx;
const zero = !pct && ref && ref.M + ref.L + ref.S === 0 ? "별산" : "";
if (po.codeMode) return `${zero}${c} `;
if (po.codeMode || po.remarkCode) return `${zero}${c} `;
if (po.remarkHopyo) return `${zero}${kindNo(po, c[0], hopyoNo[c] || "")} `;
return ref && ref.row ? str(ref.row.BQCODE).trim() : "";
}
@@ -162,9 +166,10 @@ const StmateReportUnit = (() => {
const wide = isB || po.padNo;
C.push(sh, { A: wide ? ` 제${pad(no, 5)} 호표` : `제 ${no} 호표`, _k: "hopyo" });
const hd = { A: str(r.NAME), B: str(r.SIZE), D: C.unitText(r.UNIT), _k: "parent", _code: c };
const rm = po.codeMode
? c
: po.remarkHopyo
const rm =
po.codeMode || po.remarkCode
? c
: po.remarkHopyo
? `${po.kind[prefix]}${pad(no, 5)}`
: str(r.BQCODE).trim();
if (rm) hd.M = rm;
@@ -176,7 +181,7 @@ const StmateReportUnit = (() => {
C.push(sh, {
C: 0,
_k: "text",
M: po.remarkHopyo && !po.codeMode ? " " : undefined,
M: (po.remarkHopyo || po.remarkCode) && !po.codeMode ? " " : undefined,
});
continue;
}
@@ -315,7 +320,7 @@ const StmateReportUnit = (() => {
};
}
return { buildUnitSheets, unitContext, compOptions, LIST_COLS, PAIR_COLS };
return { buildUnitSheets, unitContext, compOptions, remarkOf, LIST_COLS, PAIR_COLS };
})();
if (typeof window !== "undefined") window.STMATE_REPORT_UNIT = StmateReportUnit;
+4
View File
@@ -389,8 +389,12 @@ class SaveDbfReq(BaseModel):
smt_updates: dict = {}
costn_memo_updates: dict[str, str] = {}
boq_table: str = ""
boq_deleted: list[int] = []
start_ini: dict = {}
prjlst_rows: list = []
kanjub_stages: dict = {} # 제잡비 차수별 설정 → PRJLST.SMT(바뀐 키만)
bdqty_rows: list[dict] | None = None # 구성표 행 전체(decode_bdqty 꼴) → BDQTY.DBF(바뀐 호표만)
qcode_rows: list[dict] | None = None # 수량코드 행 전체 → QCODE.DBF·SMT(바뀌었을 때만)
@app.get("/api/sources")
+22 -2
View File
@@ -57,7 +57,21 @@
};
}
// 산근 계산 설정 — Start8x.Ini(CHGNM · OPTNM · ZCDNM) · COSTN(SBMOK · S_ADJ)
// 소수 자리 설정 — Start8x.Ini 0·1·2 자리(22 §3.4) · 비거나 이상하면 1(실무 7건 값)
const digitOf = (v, def = 1) => (/^[0-2]$/.test(String(v ?? "").trim()) ? Number(v) : def);
// 외산장비 환율 — 단가의 조사시점(ARENM 1 당초 · 2 변경)의 EXLNM·EXHNM·EXEUR·EXYEN(REA 14 cmBx_ARE)
function fxOf(ini) {
const n = String(ini.ARENM || "1").trim() === "2" ? 2 : 1;
return {
usd: num(ini[`EXLNM${n}`]),
usdHigh: num(ini[`EXHNM${n}`]),
eur: num(ini[`EXEUR${n}`]),
yen: num(ini[`EXYEN${n}`]),
};
}
// 산근 계산 설정 — Start8x.Ini(CHGNM · OPTNM · ZCDNM · ACDNM) · COSTN(SBMOK · S_ADJ)
function smtOptions(ctx, code) {
const ini = (ctx.dbfData && ctx.dbfData.start_ini) || {};
const row = costnLookup(ctx).byCode[code] || {};
@@ -65,6 +79,7 @@
totalFirst: String(ini.CHGNM ?? "1") === "0",
order: ini.OPTNM || "SML",
assignDigits: ini.ZCDNM !== undefined && ini.ZCDNM !== "" ? num(ini.ZCDNM) : 2,
lineDigits: digitOf(ini.ACDNM),
sbmok: String(row.SBMOK || "").trim(),
adj: adjOf(row),
qty: qtyResolver(ctx),
@@ -117,7 +132,7 @@
return m;
}
// 일위대가·중기 구성 계산 설정 — Start8x.Ini(CHBNM · OPTNM · CON*D · RX*NM · EXLNM1) · COSTN RXX
// 일위대가·중기 구성 계산 설정 — Start8x.Ini(CHBNM · OPTNM · CON*D · RX*NM · 환율 · 자리) · COSTN RXX
// 손료 갈래(E0 주간 · E1 야간 · E2 2교대 · E3 3교대, 30 §4) — 계수는 사용자 입력(sysConfig.shiftFactors)
function compositionOptions(ctx, code, row) {
const ini = (ctx.dbfData && ctx.dbfData.start_ini) || {};
@@ -131,6 +146,11 @@
labor: code[0] === "X" ? laborMap(ini, row.RXX, sys.rxFormula) : null,
rx: ini.RXXNM || sys.rxFormula,
usdRate: num(ini.EXLNM1),
fx: fxOf(ini),
// 줄 금액 자리 = 중기 KCD · 일위대가 BPN · 중기 노임 단가 JCD · $장비 단가 ECD(22 §3.4 · REA 14 안내문)
lineDigits: digitOf(code[0] === "X" ? ini.KCDNM : ini.BPNNM),
wageDigits: digitOf(ini.JCDNM, 0),
fxDigits: digitOf(ini.ECDNM, 0),
shiftFactor:
code[0] === "X" && shift !== "E0" ? num((sys.shiftFactors || {})[shift]) || 1 : 1,
};
+35 -12
View File
@@ -382,14 +382,32 @@ const StmateSmtEval = (() => {
// 낙찰률 단가 — 원단가(2칸) × 낙찰률, 성분별 원 미만 버림
const bidRate = (v, pct) => trunc(((Number(v) || 0) * (Number(pct) || 0)) / 100);
// 노임 일→시간 환산 — 식을 왼쪽부터 차례로 곱·나눔 후 원 미만 버림(Start8x.Ini RXXNM)
function hourlyWage(daily, expr) {
// 노임 일→시간 환산 — 식을 왼쪽부터 차례로 곱·나눔 후 JCD 자리 미만 버림(Start8x.Ini RXXNM · JCDNM)
function hourlyWage(daily, expr, digits = 0) {
let acc = Number(daily) || 0;
for (const [, op, n] of String(expr || "")
.replace(/\s+/g, "")
.matchAll(/([*/])?(\d+(?:\.\d+)?)/g))
acc = op === "/" ? acc / Number(n) : acc * Number(n);
return trunc(acc);
return trunc(acc, digits);
}
// 외산장비 환율 — 단위 $ · € · ¥(₩/100¥) · $ 는 10만$ 이상이면 고액 환율(EXHNM) · 단가 천원 단위(÷1000)
// 근거: REA 14 TWM_SET_SYS(mBtn_EXL · ed_EXH · mBtn_EXEU · mBtn_EXJP 안내문) · 자리 = ECD(「X」의 $장비 단가 소수점)
// opts.fx = { usd, usdHigh, eur, yen } · 옛 opts.usdRate 는 usd 로 봄
function foreignUnit(unit, price, opts) {
const fx = opts.fx || { usd: opts.usdRate };
const rate =
unit === "$"
? Number(price) >= 100000 && Number(fx.usdHigh) > 0
? fx.usdHigh
: fx.usd
: unit === "€"
? fx.eur
: unit === "¥" || unit === "¥"
? (Number(fx.yen) || 0) / 100
: null;
return rate === null ? null : Number(rate) || 0;
}
// 나누기수량(DEVD) — 저장값의 역수 · 0 은 0(REA 30 PR_RR · divisor_to_ordinary)
@@ -403,12 +421,14 @@ const StmateSmtEval = (() => {
/**
* 일위대가(B)·중기(X) 구성 조립 — BDQTY 구성행(bdqty.py 복호) 차례대로
* rows: [{ CODE, QTY1, DEVD1, PRPT, PRIN, PROUT }] · lookup(code) → { M, L, S, unit }
* opts: { totalFirst, order, sbmok, adj, labor: Set(시간 환산 직종) 또는 Map(직종 → 환산식), rx, usdRate }
* opts: { totalFirst, order, sbmok, adj, labor: Set(시간 환산 직종) 또는 Map(직종 → 환산식), rx, usdRate · fx }
* 자리(22 §3.4 · REA 14 안내문): lineDigits = 줄 금액(중기 KCD · 일위대가 BPN, 기본 1) · wageDigits = JCD · fxDigits = ECD
* 근거: 지식DB 17(성분 절사 · 중기 손료·운전원·연료·잡품) · 실무 STC 대조
*/
function evaluateComposition(rows, lookup, opts = {}) {
const s = { M: 0, L: 0, S: 0, T: 0 },
base = zero();
base = zero(),
ld = opts.lineDigits ?? 1;
const add = (k, v) => ((s[k === "T" ? "M" : k] += v), (s.T += v));
let closed = false; // C* 줄 뒤의 % 자원 = 앞 금액 전체를 그 비율로(예: 켜쌓기 적용 90%)
for (const row of rows) {
@@ -422,7 +442,7 @@ const StmateSmtEval = (() => {
if (!bk || !ok) continue;
const bv = bk === "T" ? base.M + base.L + base.S : base[bk];
const coef = isDevd(row) || code === "RR" ? q : null;
add(ok, coef === null ? trunc((bv * q) / 100, 1) : trunc(bv * coef, 1));
add(ok, coef === null ? trunc((bv * q) / 100, ld) : trunc(bv * coef, ld));
continue;
}
if (/^C\d$/.test(code)) closed = true;
@@ -432,28 +452,31 @@ const StmateSmtEval = (() => {
const own = code[0] === "L" ? "L" : code[0] === "S" ? "S" : "M";
const pct = unit.match(/^([MLST])%$/);
if (pct && closed) {
for (const k of "MLST") s[k] = trunc((s[k] * q) / 100, 1);
for (const k of "MLST") s[k] = trunc((s[k] * q) / 100, ld);
continue;
}
if (pct) {
const bv = pct[1] === "T" ? s.M + s.L + s.S : s[pct[1]];
add(own, trunc((bv * q) / 100, 1));
add(own, trunc((bv * q) / 100, ld));
continue;
}
let p = { M: r.M, L: r.L, S: r.S };
const lab = opts.labor;
if (lab && lab.has(code)) {
const rx = lab instanceof Map ? lab.get(code) : opts.rx;
p = { M: 0, L: hourlyWage(r.L, rx), S: 0 };
p = { M: 0, L: hourlyWage(r.L, rx, Number(opts.wageDigits) || 0), S: 0 };
}
if (unit === "$") for (const k of "MLS") p[k] = (p[k] * (Number(opts.usdRate) || 0)) / 1000;
const fxRate = foreignUnit(unit, r.M + r.L + r.S, opts);
if (fxRate !== null)
for (const k of "MLS")
p[k] = opts.fxDigits == null ? (p[k] * fxRate) / 1000 : trunc((p[k] * fxRate) / 1000, Number(opts.fxDigits) + 3);
// 손료 갈래(E1~E3) — 손료 행(경비 · 단위 천원)에 사용자 계수
const sf = opts.shiftFactor;
const qq = sf && sf !== 1 && code[0] === "S" && unit === "천원" ? q * sf : q;
s.T += trunc(qq * (p.M + p.L + p.S), 1);
s.T += trunc(qq * (p.M + p.L + p.S), ld);
const marked = String(row.PRPT || "0").trim() !== "0" && String(row.PRPT || "").trim() !== "";
for (const k of "MLS") {
const a = trunc(qq * p[k], 1);
const a = trunc(qq * p[k], ld);
s[k] += a;
if (marked) base[k] += a;
}
+15 -1
View File
@@ -5,6 +5,7 @@
16_골든셋_회귀_결과.md §4(행 금액 = ROUNDDOWN(수량×단가) · 계층 집계 · C* 제외 구역 · TCOSTn = 직접비)
요청 키(SaveDbfReq): costn_updates[{CODE, 칸…}] · costn_memo_updates{CODE: 글} ·
boq_table · boq_updates[{_recno | _new+_after, 칸…}] · prjlst_updates{칸} · smt_updates{키: 값}
· kanjub_stages{차수: 설정} · bdqty_rows[구성행] · qcode_rows[수량코드행](stc_save_extra — 바뀐 것만)
파생 규칙(실무 STC 7건 전 행에서 다시 셈 = 저장값 확인):
- 단가 전파: COSTN n차 단가가 바뀐 코드 → 그 코드 내역 행 중 옛 단가가 COSTN 옛 단가와 같던 행만 새 단가
- 행 금액: 그 행 n차 수량·단가가 바뀌었을 때만 ROUNDDOWN(QTYn × ?AMTn) · T_AMTn = 재+노+경
@@ -20,6 +21,7 @@ from decimal import ROUND_DOWN, Decimal, InvalidOperation
from dbf_memo import SixMemo, ini_update, memo_text_bytes, same_memo
from dbf_rw import DbfTable, find_rows
from smt_parser import parse_ini_bytes
from stc_save_extra import insert_costn_rows, kanjub_smt, save_bdqty, save_qcode
STRUCT_RE = re.compile(r"^(T\d|E\d|C\d|:)$")
SLOTS = ("1", "2", "3", "4", "5")
@@ -268,6 +270,8 @@ def apply_save(files: dict[str, bytes], req: dict, today=None) -> tuple[dict[str
price_cols = [f"{c}AMT{s}" for s in SLOTS for c in "TMLS" if f"{c}AMT{s}" in ct.fields]
for code, i in rows.items():
costn_old[code] = {c: ct.get(i, c) for c in price_cols}
if insert_costn_rows(ct, list(req.get("costn_updates") or []), log):
rows = find_rows(ct, "CODE")
for u in req.get("costn_updates") or []:
code = str(u.get("CODE", "")).strip()
if code not in rows:
@@ -317,10 +321,12 @@ def apply_save(files: dict[str, bytes], req: dict, today=None) -> tuple[dict[str
upd[f"{c}{s}"] = v
_apply_fields(pt, "PRJLST.DBF", pi, upd, log)
smt_upd = req.get("smt_updates") or {}
if smt_upd and "PRJ_MEMO" in pt.fields:
stages = req.get("kanjub_stages") or {}
if (smt_upd or stages) and "PRJ_MEMO" in pt.fields:
memo = SixMemo(files.get("PRJLST.SMT"))
ptr = pt.raw(pi, "PRJ_MEMO")
text = memo.read(ptr).decode("cp949", "replace")
smt_upd = kanjub_smt(text, stages, smt_upd) if stages else smt_upd
new_text, keys = ini_update(text, smt_upd)
if keys:
new_ptr = memo.write(ptr, memo_text_bytes(new_text))
@@ -333,6 +339,14 @@ def apply_save(files: dict[str, bytes], req: dict, today=None) -> tuple[dict[str
if pt.dirty:
out["PRJLST.DBF"] = pt.to_bytes(today)
# 구성표(BDQTY · 암호) · 수량코드(QCODE) — 화면 행이 불러온 행과 다를 때만
if req.get("bdqty_rows") is not None and "BDQTY.DBF" in files:
new_b = save_bdqty(files["BDQTY.DBF"], list(req["bdqty_rows"]), log)
if new_b is not None:
out["BDQTY.DBF"] = new_b
if req.get("qcode_rows") is not None:
out.update(save_qcode(files, list(req["qcode_rows"]), log))
# 초기값(Start8x.Ini · STini.MEM) — 있는 키 중 바뀐 값만 그 줄 자리에서 고침
ini_req = req.get("start_ini") or {}
for name in ("START8X.INI", "STINI.MEM") if ini_req else ():
+171
View File
@@ -0,0 +1,171 @@
# -*- coding: utf-8 -*-
"""저장 덧붙임 — COSTN 새 코드 행 · BDQTY 구성 고침(암호 그대로) · QCODE 수량코드 · 제잡비 설정(PRJLST.SMT).
근거: STC_왕복검증.md §3(알려진 칸만 갱신) · STC_난독화_상태.md(BDQTY 자리별 변환) · 29번 ·
02번(QCODE 15필드) · 25번 §1(저장키) · kanjub_cfg.smt_updates(설정 → 저장키)
규칙:
- 화면이 보낸 값이 불러온 값과 뜻으로 같으면 그 파일은 원래 바이트 그대로
- COSTN 새 코드: 같은 머리글자 마지막 행 뒤에 끼움 · 숫자 칸 0 · 메모 칸 빈 포인터
- BDQTY: 바뀐 호표(BDCODE 묶음)만 다시 늘어놓음 · 원래 행은 원래 바이트에서 바뀐 칸만 암호로 씀
· 새 행 틀 = 같은 구성 코드 행(이름·규격 암호 칸을 그대로 옮김) > 같은 호표 첫 행 > 첫 행
· 복호가 깨진 값(U+FFFD 포함)은 쓰지 않음 — 틀 행의 원래 바이트 유지
- QCODE: 행이 바뀌었을 때만 qty_qcode.build_qcode 로 두 파일을 다시 씀
- 제잡비: 차수별 설정 → smt_updates · 원래 값과 같은 키는 버림 · 없던 키는 빈 값·0 이 아닐 때만
"""
from __future__ import annotations
import re
from bdqty import decode_bdqty
from dbf_crypt import BdqtyCodec, covers
from dbf_memo import _same_ini_value, ini_items
from dbf_rw import DbfTable
from kanjub_cfg import smt_updates
from qty_qcode import build_qcode, read_qcode
CODE_RE = re.compile(r"^[A-Z][0-9A-Z]{5}$")
def _num(v) -> float | None:
try:
return float(str(v).strip().replace(",", ""))
except (TypeError, ValueError):
return None
def same_val(a, b) -> bool:
"""뜻 비교 — 둘 다 숫자로 읽히면 숫자로 · 아니면 앞뒤 공백·줄바꿈 꼴 무시."""
na, nb = _num(a), _num(b)
if na is not None and nb is not None:
return abs(na - nb) < 1e-9
norm = lambda x: str("" if x is None else x).replace("\r\n", "\n").strip() # noqa: E731
return norm(a) == norm(b)
def same_row(old: dict, new: dict) -> bool:
"""old 의 칸 중 new 에 있는 칸이 모두 같은가(new 에 없는 칸은 안 바뀐 것으로 봄)."""
return all(same_val(v, new[k]) for k, v in old.items() if k in new and not k.startswith("_"))
# ── COSTN 새 코드 행 ──
def insert_costn_rows(ct: DbfTable, ups: list[dict], log) -> list[str]:
"""요청 중 COSTN 에 없는 코드를 새 행으로 끼움 — 끼운 코드 목록."""
have = {ct.get(i, "CODE") for i in range(len(ct))}
added = []
for u in ups:
code = str(u.get("CODE", "")).strip().upper()
if code in have or not CODE_RE.match(code):
continue
last = [i for i in range(len(ct)) if ct.get(i, "CODE")[:1] == code[:1]]
pos = (last[-1] + 1) if last else len(ct)
rec = bytearray(b" " * ct.rlen)
for f in ct.fields.values():
if f.type in ("N", "F"):
rec[f.off : f.off + f.len] = ct.encode_value(f.name, 0)
ct.recs.insert(pos, rec)
ct.dirty = True
vals = {k: v for k, v in u.items() if not k.startswith("_")}
vals["CODE"] = code
for k, v in vals.items():
if k in ct.fields and ct.fields[k].type != "M":
ct.set_raw(pos, k, ct.encode_value(k, v))
log.add("COSTN.DBF", pos, "_insert", "", code)
have.add(code)
added.append(code)
return added
# ── BDQTY 구성 ──
def _groups(rows: list[dict]) -> dict[str, list[int]]:
out: dict[str, list[int]] = {}
for i, r in enumerate(rows):
out.setdefault(str(r.get("BDCODE", "")).strip(), []).append(i)
return out
def save_bdqty(b: bytes, new_rows: list[dict], log) -> bytes | None:
"""화면 구성행(decode_bdqty 꼴) → 새 BDQTY 바이트 · 바뀐 것이 없으면 None."""
old = decode_bdqty(b)
if len(old) == len(new_rows) and all(same_row(o, n) for o, n in zip(old, new_rows)):
return None
t = DbfTable(b, codec=BdqtyCodec())
g_old, g_new = _groups(old), _groups(new_rows)
by_comp: dict[str, int] = {}
for i, r in enumerate(old):
by_comp.setdefault(str(r.get("CODE", "")).strip(), i)
recs, plan = [], [] # plan = (새 자리, 비교 기준 옛 행, 새 행)
for code, idx in g_new.items():
olds = g_old.get(code, [])
news = [new_rows[i] for i in idx]
if len(olds) == len(news) and all(same_row(old[o], n) for o, n in zip(olds, news)):
recs += [bytearray(t.recs[o]) for o in olds]
continue
for k, n in enumerate(news):
if k < len(olds):
base = olds[k]
else:
comp = str(n.get("CODE", "")).strip()
base = by_comp.get(comp, olds[0] if olds else 0)
log.add("BDQTY.DBF", len(recs), "_insert", old[base].get("CODE", ""), comp)
plan.append((len(recs), base, n))
recs.append(bytearray(t.recs[base]))
for code in g_old:
if code not in g_new:
log.add("BDQTY.DBF", g_old[code][0], "_delete", code, "")
t.recs, t.dirty = recs, True
for i, base, n in plan:
ref = old[base]
for k, v in n.items():
if k.startswith("_") or k not in t.fields or same_val(ref.get(k, ""), v):
continue
if "�" in str(v): # 복호가 깨진 칸(표 밖 글자)에서 온 값 — 옮겨 온 원래 바이트 유지
continue
if t.fields[k].type in ("N", "F") and _num(v) is None and str(v).strip():
continue # 숫자로 읽히지 않는 복호값(자리 표가 덜 맞는 칸)도 같음
f = t.fields[k]
if not covers(f.off, f.len):
raise ValueError(f"BDQTY {k} 칸은 암호 표 밖이라 쓸 수 없음")
t.set_raw(i, k, t.encode_value(k, v))
log.add("BDQTY.DBF", i, k, str(ref.get(k, "")), str(v))
return t.to_bytes()
# ── QCODE ──
def save_qcode(files: dict[str, bytes], rows: list[dict], log) -> dict[str, bytes]:
"""수량코드 행이 바뀌었을 때만 {QCODE.DBF, QCODE.SMT}."""
cur = read_qcode(files)["rows"]
if len(cur) == len(rows) and all(same_row(o, n) for o, n in zip(cur, rows)):
return {}
bad = [r.get("CODE") for r in rows if not re.fullmatch(r"Q\d{5}", str(r.get("CODE", "")).strip())]
if bad:
raise ValueError(f"수량코드 형식 오류: {bad[:5]}")
clean = [{k: v for k, v in r.items() if not k.startswith("_")} for r in rows]
dbf, smt = build_qcode(clean, files)
log.add("QCODE.DBF", 0, "_rows", str(len(cur)), str(len(rows)))
return {"QCODE.DBF": dbf, "QCODE.SMT": smt}
# ── 제잡비 설정 → PRJLST.SMT 키 ──
def kanjub_smt(text: str, stages: dict, explicit: dict) -> dict:
"""차수별 설정(kanjubStages)과 화면이 직접 준 키를 합친 갱신 사전 — 원래 값과 같은 키는 뺌."""
orig = {k: v for _s, k, v in ini_items(text)}
derived: dict = {}
for slot, cfg in sorted((stages or {}).items(), key=lambda x: int(x[0])):
if not isinstance(cfg, dict):
continue
upd = smt_updates(cfg, int(slot))
if int(slot) != 1:
upd = {k: v for k, v in upd.items() if not k.startswith("ADD_")}
derived.update(upd)
keep = {
k: v
for k, v in derived.items()
if (k in orig and not _same_ini_value(orig[k], v))
or (k not in orig and str(v).strip() not in ("", "0"))
}
out = dict(explicit or {})
for k, v in keep.items():
if k not in out or (k in orig and _same_ini_value(orig[k], out[k])):
out[k] = v
return out
+4
View File
@@ -678,6 +678,10 @@
boq_deleted: d.boq_deleted || ctx.uiState.boqDeleted || [],
prjlst_updates: prjUpd,
smt_updates: smtUpd,
// 서버(stc_save_extra)가 불러온 값과 견줘 바뀐 것만 씀 — 제잡비 설정 · 구성표 · 수량코드
kanjub_stages: ctx.state.kanjubStages || {},
bdqty_rows: d.bdqty_rows || null,
qcode_rows: d.qcode_rows || null,
};
}