feat(828-1): 원파일 일위대가 구성 줄 → 일위대가 길 · 중기 구성 → 내 기계 씨앗 · 낙찰률 줄 2 칸 좁힘

- ㉯ STC 일위 = HP 호표(엑셀 일위대가표 길과 같은 꼴 · 「일위대가표」 시트) → 목록 · 만들기 · 고를 재료 판(구성 줄 · 「일위대가 N」)
- ㉰ 구성표 중기 → 기계구성(엑셀 중기사용료와 같은 값 · 달러 손료 = 천원) · 같은 파일 구성 대조 같은구성 → STmate 기계 찾기
- ㉱ 낙찰률 줄 2 칸(원단가) = 구성 셈 · 일위 머리만(엑셀 일위대가표와 같음) · 산근 · 자원 = 1 칸 그대로(엑셀 단가산출근거와 같음 · 812-1 짝 0)
- 별도계상 = 자재 이름 · 규격 「별도계상」 · 「별산」(엑셀 비고 「별산…」 짝 · 828-2 대조)
- MyMachine 엑셀 중기사용료 잡품 줄 = 잡재료 · 잡재료비 이름도(원파일과 같은 값)
- 시험 test_828_1_stc_composition 새로 · test_814_1 낡은 칸 고침(원파일 호표 0 → 45 · 격자 일위대가표 시트) · test_810_1 거울 대조 글 칸 앞 빈칸 걷음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 20:47:55 +09:00
co-authored by Claude Opus 5.5
parent 53932e964f
commit 43e9455688
10 changed files with 422 additions and 26 deletions
+34 -3
View File
@@ -186,8 +186,10 @@ _LIST_COST = ("M", "L", "S", "T")
def hopyo(saved: dict) -> dict[str, Any]:
"""원파일 → 실무자료 읽기 꼴 — 단산(산출근거 머리)만 · 일위대가 안 만듦(요구 743-2 · HeadMainDat 안 씀)."""
out: dict[str, Any] = {"꼴": "원파일", "시트": saved["꼴"], "호표": [], "검산": {"어긋남": []}}
"""원파일 → 실무자료 읽기 꼴 — 단산(산출근거 머리) · 호표 = STmate 구성표 일위 줄(828-1 · 엑셀 일위대가표
길과 같은 꼴) · EST 일위대가 안 만듦(요구 743-2 · HeadMainDat 안 씀)."""
out: dict[str, Any] = {"꼴": "원파일", "시트": saved["꼴"], "호표": _ilwi(saved)}
out["검산"] = {"어긋남": []}
out["단산"] = [
{"번": n, "이름": b.get("이름"), "단위": b.get("단위"), "금액": _cost(b.get("저장"))}
for n, b in numbers(saved).items()
@@ -195,6 +197,23 @@ def hopyo(saved: dict) -> dict[str, Any]:
return out
def _ilwi(saved: dict) -> list[dict]:
"""STmate 일위 구성 줄 → 호표(행 = 격자 「일위대가표」 행 · 줄 재현 · 덮음 = `M02_Hopyo_UnitLine`)."""
if saved.get("꼴") != "STmate" or not saved.get("일위"):
return []
from M02_MasterTemplete import M02_Hopyo_UnitLine as ul
from M02_MasterTemplete import M02_San_Ilwi as il
out, at = il.hopyo(saved["일위"]), 2
for h in out:
h["행"] = [at, at + len(h["줄"])]
for i, line in enumerate(h["줄"], 1):
line["행"] = at + i
at += len(h["줄"]) + 2
ul.check_hopyo(h)
return out
def grid(saved: dict) -> dict[str, Any]:
"""원파일 → 단가산출표 한 시트 격자(`M02_Hopyo_Grid.read` 꼴) — 산출근거 머리(코드 · 이름 · 규격 ·
단위 · 저장 금액) 한 줄씩 · 행 = 1 머리 + 블록 차례."""
@@ -214,7 +233,7 @@ def grid(saved: dict) -> dict[str, Any]:
sheet = {"id": "s1", "이름": LIST, "칸": cells, "틀고정": {"행": 1}}
sheet["열"] = {c: {"폭": w} for c, w in zip(cols, (10, 40, 18, 6, 12, 12, 12, 12))}
book = {"종류": "통합문서", "판": 1, "열": "", "서식": [{}], "시트": [sheet], "활성": "s1"}
return {
out = {
"책": book,
"호표": [],
"단산": rows,
@@ -223,6 +242,18 @@ def grid(saved: dict) -> dict[str, Any]:
"목록": {"호표": [], "단산": [{"번": r["번"], "행": r["행"][0]} for r in rows]},
"줄": [],
}
if hp := _ilwi(saved): # 828-1 STmate 구성표 일위대가 = 「일위대가표」 시트(앞)
from M02_MasterTemplete import M02_San_Ilwi as il
book["시트"].insert(0, il.sheet(hp, "s2"))
book["활성"] = "s2"
out["호표"] = [{"번": h["번"], "이름": h["이름"], "행": h["행"]} for h in hp]
out["보기"][il.SHEET] = "s2"
out["원본"][il.SHEET] = il.SHEET
out["줄"] = [
{"번": h["번"], "차례": ln["차례"], "행": ln["행"]} for h in hp for ln in h["줄"]
]
return out
def _dec(v: Any) -> int | float | None:
@@ -41,6 +41,8 @@ interface MatKind {
단서: string[];
별도계상: boolean;
블록: number[];
/** 일위대가 구성 줄이 있는 호표(828-1 · 코드 숫자) */
호표?: number[];
줄: number;
고름?: MatCand;
}
@@ -133,11 +135,12 @@ export function createMatPick(opt: { scope: HopyoScope; key: string; target: Hop
function kindRow(k: MatKind): HTMLElement {
const what = `${label(k)}${k.단위 ? ` (${k.단위})` : ""}` || "—";
const sub = [k.방법, `${k.블록.length} 블록 · ${k.줄} 줄`, ...k.단서]
const hp = k.호표?.length ? ` · 일위대가 ${k.호표.length}` : "";
const sub = [k.방법, `${k.블록.length} 블록${hp} · ${k.줄} 줄`, ...k.단서]
.filter(Boolean)
.join(" · ");
const badge = createBadge(k.별도계상 ? `별도계상 · ${k.판정}` : k.판정, TONE[k.판정] ?? "warning", { size: 18 }); // prettier-ignore
const row = createListRow({ label: what, sub, trailing: badge, size: 36, title: `단산 ${k.블록.join(" · ")}` }); // prettier-ignore
const row = createListRow({ label: what, sub, trailing: badge, size: 36, title: `단산 ${k.블록.join(" · ")}${hp && ` · 호표 ${k.호표?.join(" · ")}`}` }); // prettier-ignore
const box = el("div", { attrs: { "data-mat-kind": k.종 }, children: [row, controls(k)] });
if (edits.has(k.종)) box.dataset.matEdited = "";
return box;
+3 -1
View File
@@ -415,7 +415,9 @@ class _Stm:
if not one_ and len(sole) == 1: # 비목 꼬리 없어도 값 든 비목 하나면 단가 줄(셈 같음)
one_, to = sole[0], None
res |= {"비목": one_, "옮김": to} if one_ else {}
parts = machine_parts(res) if self.keys else [] # 812-9 기계 찾기 · 조합 = 줄 둘
parts = (
machine_parts(res, pr.get("같은구성")) if self.keys else []
) # 812-9 · 816-2 같은 파일 구성(828-1)
if parts:
res["찾기"], link, many = parts[0], parts[0]["키"], None
res |= {"조합첫": True} if len(parts) > 1 else {}
+61 -7
View File
@@ -5,7 +5,7 @@
일위 구성 줄 꼴(원파일 · 엑셀 같은 꼴 — 읽기기 답 `일위` 블록 `줄`):
{차례, 코드(자원 · PR · RR · C1 · T1 · 빈), 갈래(M L S X B D W · 율 · 닫기 · 구분 · 글), 이름, 규격, 단위,
수량(1 차), 단가 {T L M S}?(셈에 쓴 자원 단가 · 중기 조종원 = 시간 환산), 금액 {T L M S}?(줄 금액),
율? {밑, 대상(M L S T), 곱(RR)}, 밑줄?(율 밑수에 듦), 관급?, 별도계상?(자원 규격 「별도계상」)}
율? {밑, 대상(M L S T), 곱(RR)}, 밑줄?(율 밑수에 듦), 관급?, 별도계상?(자재 이름 · 규격 「별도계상」 · 「별산」)}
블록 = {코드, 이름, 규격, 단위, 저장, 줄, 줄합 {T L M S}(다시 셈 · 끝전 뒤), 표지}
율 줄 이름 = 지은 글(「노무비의 3%」 · 원문 이름 칸은 못 읽음).
HP 호표(`hopyo`) = 엑셀 일위대가표 읽기(`M02_Hopyo_Stmate.read`)와 같은 꼴 — 글 · 구분 · 닫기 줄 뺌.
@@ -123,7 +123,9 @@ def lines(rows: list[dict], res: dict[str, dict], opt: dict) -> tuple[list[dict]
one["밑줄"] = True
if row.get("KWNGP") == "1":
one["관급"] = True
if "별도계상" in one["규격"]:
if kind == "M" and re.search(
r"별도계상|별산", one["이름"] + one["규격"]
): # 엑셀 비고 「별산…」 짝
one["별도계상"] = True
out.append(one)
before = dict(s)
@@ -144,7 +146,9 @@ def lines(rows: list[dict], res: dict[str, dict], opt: dict) -> tuple[list[dict]
continue
if kind not in KIND:
continue
saved = head.get("저장") or {}
saved = (
head.get("원단가") or head.get("저장") or {}
) # 낙찰률 줄 = 2 칸(smt_engine costSlot)
unit = one["단위"].strip()
own = "L" if code[0] == "L" else "S" if code[0] == "S" else "M"
pct = re.fullmatch(r"([MLST])%", unit)
@@ -161,8 +165,8 @@ def lines(rows: list[dict], res: dict[str, dict], opt: dict) -> tuple[list[dict]
p = {k: _f(saved.get(k)) for k in "MLS"}
if code in opt["환산"]:
p = {"M": 0.0, "L": _wage(p["L"], opt["환산식"]), "S": 0.0}
if unit == "$":
p = {k: v * opt["달러"] / 1000 for k, v in p.items()}
if unit == "$": # 달러 → 천원(엑셀 「중기사용료」 값 · 원 미만 버림)
p = {k: _tr(v * opt["달러"] / 1000) for k, v in p.items()}
elif unit in ("€", "¥", "¥"):
p = dict.fromkeys("MLS", 0.0)
one["단가"] = {"T": p["M"] + p["L"] + p["S"], **p}
@@ -187,7 +191,7 @@ def make_of(name: str, spec: str, rows: list[dict]) -> dict[str, Any]:
make: dict[str, Any] = {"이름": name, "규격": spec, "손료": []}
for one in rows:
g, q, unit = one["갈래"], one["수량"], one["단위"].strip().upper()
if g == "S" and unit == "천원" and q and (one.get("단가") or {}).get("S"):
if g == "S" and unit in ("천원", "$") and q and (one.get("단가") or {}).get("S"):
make["손료"].append([q * 10_000, one["단가"]["S"] * 1000, False])
elif g == "L" and q > 0 and not make.get("조종원"):
make["조종원"], make["조종원직종"] = q, one["이름"].strip()
@@ -222,7 +226,7 @@ def compose(
if parent[:1] not in ("B", "X") or not head:
continue
got, tot = lines(group, res, options(parent, ini, sbm.get(parent)))
saved = head.get("저장") or {}
saved = head.get("원단가") or head.get("저장") or {}
ok = not _f(saved.get("T")) or same(tot, saved)
if parent[0] == "B":
blocks[parent] = {"줄": got, "줄합": tot, "표지": [] if ok else [MISMATCH]}
@@ -272,3 +276,53 @@ def hopyo(blocks: list[dict]) -> list[dict[str, Any]]:
head |= {k: str(b.get(k) or "") for k in ("이름", "규격", "단위")}
out.append({**head, "금액": _hp(b.get("저장")), "줄": rows})
return out
SHEET = "일위대가표"
_COLS = "ABCDEFGHI"
_HEAD = ("품명", "규격", "수량", "단위", "재료비", "노무비", "경비", "합계", "코드")
def sheet(hps: list[dict], sid: str) -> dict[str, Any]:
"""HP 호표(행 있음) → 격자 「일위대가표」 시트(머리 줄 = 「제N호표」 · 금액 = 저장 · 줄 금액 = 다시 셈)."""
cells: dict[str, dict] = {f"{c}1": {"값": h} for c, h in zip(_COLS, _HEAD)}
def put(at: int, vals: list) -> None:
for c, v in zip(_COLS, vals):
if v not in (None, ""):
cells[f"{c}{at}"] = {"값": v}
for h in hps:
money = [h["금액"].get(k) for k in ("재료비", "노무비", "경비", "계")]
put(
h["행"][0],
[f"제{h['번']}호표 {h['이름']}", h["규격"], None, h["단위"], *money, h["코드"]],
)
for ln in h["줄"]:
money = [ln["금액"].get(k) for k in ("재료비", "노무비", "경비", "계")]
put(ln["행"], [ln["이름"], ln["규격"], ln["수량"], ln["단위"], *money, ln["코드"]])
widths = (36, 22, 10, 6, 12, 12, 12, 12, 10)
return {
"id": sid,
"이름": SHEET,
"칸": cells,
"틀고정": {"행": 1},
"열": {c: {"폭": w} for c, w in zip(_COLS, widths)},
}
def materials(saved: dict):
"""일위 구성 자재 줄(M) → (블록, 줄, 자원 {이름 · 규격 · 단위 · 코드 · 재료찾기?}) — 재료 찾기 = 산근 줄과 같은
길(827-2 `M02_San_Link.m01` · 파일 단서 · 머리 = 부모 일위 이름 규격) · 유가 · 환율 줄 = 재료찾기 없음."""
from M02_MasterTemplete import M02_San_Link as link
from M02_MasterTemplete import M02_San_MatFind as mf
for b in saved.get("일위") or []:
head = f"{b.get('이름') or ''} {b.get('규격') or ''}"
for ln in b.get("줄") or []:
if ln.get("갈래") != "M":
continue
res = {k: ln.get(k) or "" for k in ("이름", "규격", "단위", "코드")} | {"종류": "자재"}
with mf.clues_on(saved.get("단서"), head):
link.m01("재료", res, "")
yield b, ln, res
+18 -1
View File
@@ -179,7 +179,8 @@ def entry(item: dict, sym: str, excel: Any) -> dict | None:
# ── 판(그 파일 재료 종) ──────────────────────────────────────────────────
def census(folder: str | Path, key: str) -> list[dict]:
"""HP 블록마다 재료 줄 판정 → 종 [{종, 이름, 규격, 단위, 판정, 방법, 키, 후보, 단서, 별도계상, 블록, 줄}]."""
"""HP 블록마다 재료 줄 판정 → 종 [{종, 이름, 규격, 단위, 판정, 방법, 키, 후보, 단서, 별도계상, 블록, 줄,
호표?(828-1 일위 구성 줄 · 코드 숫자)}]."""
from M02_MasterTemplete import M02_Doc_FromNative as fn
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh
@@ -216,6 +217,22 @@ def census(folder: str | Path, key: str) -> list[dict]:
g["줄"] += 1
if no not in g["블록"]:
g["블록"].append(no)
from M02_MasterTemplete import M02_San_Ilwi as il
for b, _, res in il.materials(src) if src else (): # 828-1 일위 구성 줄(호표 = 코드 숫자)
if not (found := res.get("재료찾기")):
continue
k = kind_of(res)
g = groups.setdefault(
k,
{"종": k, **{x: _text(res.get(x)) for x in ("이름", "규격", "단위")},
**{x: found.get(x) for x in ("판정", "방법", "키", "후보", "단서")},
"별도계상": _apart(res), "블록": [], "줄": 0},
) # fmt: skip
g["줄"] += 1
hp = g.setdefault("호표", [])
if (n := int(str(b["코드"])[1:])) not in hp:
hp.append(n)
out_ = sorted(groups.values(), key=lambda g: (KINDS.index(g["판정"]) if g["판정"] in KINDS else 9, -len(g["블록"]), g["이름"])) # fmt: skip
atomic_write_json(path, {**saved, CENSUS: out_})
return out_
+1 -1
View File
@@ -151,7 +151,7 @@ def _stm_sheet(rows: list[list]) -> dict[str, dict]:
cur["손료"].append([_num(v[2]) * 10_000, _num(v[4]) * 1000, False])
elif len(v) > 2 and str(v[2]) == "인" and (_num(v[1]) or 0) > 0 and not cur.get("조종원"):
cur["조종원"], cur["조종원직종"] = _num(v[1]), str(v[0]).strip()
elif first == "잡품" and len(v) > 2 and cur.get("잡재료율") is None:
elif first in ("잡품", "잡재료", "잡재료비") and len(v) > 2 and cur.get("잡재료율") is None:
cur["잡재료율"] = _num(v[2])
elif len(v) > 3 and str(v[3]).upper() in ("L", "ℓ"):
_fuel(cur, v[0], v[2])
+8 -4
View File
@@ -8,7 +8,8 @@
<HII 꼬리표 · 길이 · 블록 번호>) · BOQ11.DBF(내역).
BDQTY.DBF(일위대가 · 중기 구성) = 828-1 `M02_San_Bdqty`(믿을 칸 · 스스로 검사 · 어긋나면 물러남 = 일위 줄 없음 +
표지) → `M02_San_Ilwi`(일위 구성 줄 · 다시 셈 · 중기 구성 `기계구성` · 같은 파일 구성 대조 `같은구성`).
SYSINFO = 난독화 → 표지. 금액 칸 = 차수 1 · 낙찰률 줄(NAGCD > 0) = 2 칸(원단가 · 828-1 ㉱ · 2~4 차 수량은 못 읽음).
SYSINFO = 난독화 → 표지. 금액 칸 = 차수 1(산근 · 자원 = 엑셀 단가산출근거와 같음) · 낙찰률 줄(NAGCD > 0) 2 칸 =
`원단가`(828-1 ㉱ — 구성 셈 · 일위 머리 = 엑셀 일위대가표와 같음 · 2~4 차 수량은 못 읽음).
"""
from __future__ import annotations
@@ -160,7 +161,7 @@ def read(data: bytes) -> dict[str, Any]:
code = r.get("CODE") or ""
if not code:
continue
saved = _amt(r, AMT2 if _f(r.get("NAGCD")) > 0 else AMT)
saved = _amt(r, AMT)
res[code] = {
"종류": KIND.get(code[:1]),
"이름": r.get("NAME"),
@@ -168,6 +169,8 @@ def read(data: bytes) -> dict[str, Any]:
"단위": r.get("UNIT"),
"저장": saved,
}
if _f(r.get("NAGCD")) > 0: # 낙찰률 줄 원단가(828-1 ㉱ — 구성 셈 · 일위 머리)
res[code]["원단가"] = _amt(r, AMT2)
prices[code] = {**saved, "meta": {k: r.get(f) for k, f in META.items()}}
out: dict[str, Any] = {"꼴": "STmate", "산근": [], "일위": [], "자원": res, "단가": prices}
ini = z.read(names["START8X.INI"]).decode("cp949", "replace") if "START8X.INI" in names else ""
@@ -179,7 +182,8 @@ def read(data: bytes) -> dict[str, Any]:
continue
tags: list[str] = []
head = {k: res[code][k] for k in ("이름", "규격", "단위", "저장")}
if text is None and code in comp:
if text is None and code in comp: # 일위 머리 = 원단가(엑셀 일위대가표와 같음)
head["저장"] = res[code].get("원단가") or head["저장"]
out["일위"].append({"코드": code, **head, **comp[code]})
continue
if text is None:
@@ -210,7 +214,7 @@ def read(data: bytes) -> dict[str, Any]:
*([read_ok] if why is None else [UNREAD[0], FALLBACK + why]),
UNREAD[1],
*out.pop("구성표지", []),
"금액 = 차수 1 칸(TAMT1 · LAMT1 · MAMT1 · SAMT1) · 낙찰률 줄 = 2 칸(원단가)",
"금액 = 차수 1 칸(TAMT1 · LAMT1 · MAMT1 · SAMT1) · 일위 머리 · 구성 셈의 낙찰률 줄 = 2 칸(원단가)",
"블록 끝전 갈래 = 옵션 SAN_TYP 후보(뜻 미확정)",
*(f"모르는 코드 머리 {c}" for c in odd),
]
+4 -4
View File
@@ -3,8 +3,8 @@
(a) 거울 — `tools/stmate-test/smt_parser.py`(parse_dbf_bytes · extract_costn_memos)와
COSTN · BOQ11 행 ·
칸 · 산근 원문 1:1(다름 0) · 줄 풀이 = `M02_San_Stmate.parse_line`
(b) 블록 다시 셈(`M02_San_Eval.stmate_block` 계 · 결과 조정 · 끝전 갈래 = 옵션
SAN_TYP 1 비목버림 / 0 합버림_큰나머지) = COSTN 저장 금액(차수 1 칸 · 낙찰률 줄 = 2 칸 · 828-1) 100 %
(b) 블록 다시 셈(`M02_San_Eval.stmate_block` · 차수 1 단가 · 낙찰 · 결과 조정 · 끝전 갈래 = 옵션
SAN_TYP 1 비목버림 / 0 합버림_큰나머지) = COSTN 저장 금액(TAMT1 · LAMT1 · MAMT1 · SAMT1) 100 %
(c) 엑셀 짝 대조는 812-1 엑셀 읽기기 골든(`test_812_1_xlsx_golden` · 옛 엑셀 엔진 걷음)
원파일 없으면 skip(다른 PC).
"""
@@ -100,8 +100,8 @@ def test_saved_amounts(tag: str) -> None:
assert (len(d["산근"]), len(d["일위"]), d["옵션"]["SAN_TYP"]) == (n_cost, n_head, san)
assert all(b["줄"] and not b["표지"] for b in d["일위"]) # 828-1 구성표 읽음
bad = []
for b in d["산근"]: # 저장 = 계약 전 계(낙찰률 줄 = 2 칸 원단가 · 828-1 ㉱)
got = ev.stmate_block(b, [ln["풀이"] for ln in b["줄"]], d["단가"], RULE[san])["계"]
for b in d["산근"]:
got = ev.stmate_block(b, [ln["풀이"] for ln in b["줄"]], d["단가"], RULE[san])["금액"]
if any(abs(got[c] - b["저장"][c]) >= 0.5 for c in "TLMS"):
bad.append((b["코드"], got, b["저장"]))
assert bad == []
@@ -71,11 +71,12 @@ def test_one_file_and_list_sheet(world) -> None:
folder = world["personal"]
with store.at(folder):
got = fh.upload_native(folder, stc.name, stc.read_bytes(), t.WHO)
assert got["원파일"] and got["호표수"] == 0 and "붙음" not in got
assert got["원파일"] and got["호표수"] == 45 and "붙음" not in got # 828-1 구성표 일위대가
grid = hs.grid(folder, got["키"])
blocks = fn.numbers(t.native("STC", "봉화"))
assert grid["보기"] == {"단가산출표": "s1"} and len(grid["단산"]) == len(blocks)
head = grid["책"]["시트"][0]["칸"]
assert grid["보기"] == {"단가산출표": "s1", "일위대가표": "s2"}
assert len(grid["단산"]) == len(blocks) and len(grid["호표"]) == 45
head = next(s for s in grid["책"]["시트"] if s["id"] == "s1")["칸"]
assert [head[f"{c}1"]["값"] for c in "ABCDEFGH"] == list(fn._LIST_HEAD)
first = next(iter(blocks.values()))
assert head["A2"]["값"] == first["코드"] and head["H2"]["값"] == first["저장"]["T"]
@@ -0,0 +1,284 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""828-1 지킴 — STmate 원파일 구성표(BDQTY) 읽기 · 실무 .STC 일곱 · 엑셀 짝 다섯(읽기만 · 임시 storage).
① 거울 — `M02_San_Bdqty.decode` = `tools/stmate-test/bdqty.py` 믿을 칸 1:1(7 건)
② 스스로 검사 ① ~ ③ 실패 흉내(바이트 흩뜨림) → 일위 줄 없음 + 물러남 표지(오류 아님)
③ 일위 구성 줄 = 엑셀 일위대가표(코드 차례 · 수량 · 136 호표 706 줄) · 다시 셈 = 저장 단가(B 216 + X 176)
④ 중기 구성 = 엑셀 「중기사용료」(`M02_San_MyMachine._stm_sheet`) 118/118 · 내 기계 씨앗 꼴
⑤ 낙찰률 줄(NAGCD > 0) — 일위 머리 · 구성 셈 = 2 칸 · 산근 · 자원 = 1 칸(엑셀 짝) · 원설계 그대로
⑥ 일위대가 길 — 올림 → 호표 목록 · 「일위대가표」 시트 · 하나 만들기 금액 = 원본 · 재료 판에 구성 줄
"""
from __future__ import annotations
import io
import re
import struct
import sys
import warnings
import zipfile
from functools import lru_cache
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
for p in (ROOT, ROOT / "tools" / "stmate-test", ROOT / "resources" / "tester"):
if str(p) not in sys.path:
sys.path.insert(0, str(p))
import helper_m02_hopyo_golden as golden # noqa: E402
from M02_MasterTemplete import M02_Doc_FromNative as fn # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Build as hb # noqa: E402
from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402
from M02_MasterTemplete import M02_San_Bdqty as bq # noqa: E402
from M02_MasterTemplete import M02_San_Ilwi as il # noqa: E402
from M02_MasterTemplete import M02_San_MatPick as mp # noqa: E402
from M02_MasterTemplete import M02_San_MyMachine as mm # noqa: E402
from M02_MasterTemplete import M02_San_Stc as S # noqa: E402
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh # noqa: E402
from resources.tester import test_810_1_stc as stc # noqa: E402
from resources.tester.test_m02_labor_ref import world # noqa: E402, F401 — 같은 임시 층
#: 엑셀 짝 → 같은 공사 STC(코드 같음 · 울진대흥 = 코드 새로 매김 → 이름 짝)
PAIR = {"봉화": "봉화", "동부청영월": "동부청영월", "울진대흥": "울진대흥", "영덕계류": "영덕계류", "울진소광": "울진소광"} # fmt: skip
@lru_cache(maxsize=8)
def parsed(tag: str) -> dict:
return S.read(stc.raw(tag))
def _members(data: bytes) -> dict[str, bytes]:
z = zipfile.ZipFile(io.BytesIO(data))
return {i.filename: z.read(i) for i in z.infolist()}
def _bdqty_name(m: dict) -> str:
return next(k for k in m if k.upper() == "BDQTY.DBF")
# ── ① 거울 ───────────────────────────────────────────────────────────────
@pytest.mark.parametrize("tag", list(stc.FILES))
def test_mirror_bdqty(tag: str) -> None:
import bdqty
m = _members(stc.raw(tag))
raw = m[_bdqty_name(m)]
theirs = [{k: r[k] for k in bq.TRUSTED if k in r} for r in bdqty.decode_bdqty(raw)]
assert bq.decode(raw) == theirs and len(theirs) > 300
# ── ② 스스로 검사 · 물러남 ───────────────────────────────────────────────
def _field(raw: bytes, name: str) -> tuple[int, int, int, int]:
"""(헤더 길이, 레코드 길이, 칸 자리, 칸 길이)."""
_, hlen, rlen = struct.unpack_from("<IHH", raw, 4)
o, off = 32, 1
while raw[o] != 0x0D:
if raw[o : o + 11].split(b"\0")[0].decode() == name:
return hlen, rlen, off, raw[o + 16]
off += raw[o + 16]
o += 32
raise KeyError(name)
def _scrambled(name: str) -> bytes:
"""봉화 STC — BDQTY 첫 레코드 그 칸 바이트를 흩뜨린 사본."""
data = stc.raw("봉화")
m = _members(data)
key = _bdqty_name(m)
raw = bytearray(m[key])
hlen, _, off, ln = _field(bytes(raw), name)
for j in range(off, off + ln):
raw[hlen + j] ^= 0x5A
m[key] = bytes(raw)
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
for k, v in m.items():
z.writestr(k, v)
return buf.getvalue()
@pytest.mark.parametrize("name", ["CODE", "BDCODE", "DEVD1"])
def test_check_falls_back(name: str) -> None:
d = S.read(_scrambled(name))
why = [x for x in d["표지"] if x.startswith(S.FALLBACK)]
assert len(why) == 1 and S.UNREAD[0] in d["표지"]
assert all(not b["줄"] and S.UNREAD[0] in b["표지"] for b in d["일위"])
assert not d.get("기계구성") and len(d["산근"]) == 65 # 산근 길 그대로
assert {"CODE": "자식 코드", "BDCODE": "부모 코드", "DEVD1": "나누기"}[name] in why[0]
def test_check_rules() -> None:
ok = [{"BDCODE": "B00001", "CODE": c, "DEVD1": "F"} for c in ("M00001", "PR", "C1", "T2", "")]
assert bq.check(ok, {"M00001"}) is None
assert bq.check([{"BDCODE": "Q00001", "CODE": "", "DEVD1": ""}], set())
assert bq.check([{"BDCODE": "B00001", "CODE": "M9", "DEVD1": ""}], set())
assert bq.check([{"BDCODE": "B00001", "CODE": "", "DEVD1": "T"}], set())
# ── ③ 일위 구성 줄 = 엑셀 · 다시 셈 ─────────────────────────────────────────
def _meta(s: str) -> dict:
return dict(t.split("|", 1) for t in re.split(r"_x000[0-9A-Fa-f]_`", s or "") if "|" in t)
@lru_cache(maxsize=8)
def _xl(tag: str, sheet: str) -> list[list]:
import openpyxl
path = golden.FILES[tag]
if not path.exists():
pytest.skip(f"실무 엑셀 없음 {tag}")
with warnings.catch_warnings():
warnings.simplefilter("ignore")
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
return [list(r) + [None] * 30 for r in wb[sheet].iter_rows(values_only=True)]
def _xl_ilwi(tag: str) -> dict[str, dict]:
"""엑셀 일위대가표 → {호표 코드: {머리 (이름, 규격), 줄 [(코드, 수량, 이름)]}}(숨은 N 열 메타)."""
out: dict[str, dict] = {}
cur = None
for r in _xl(tag, "일위대가표"):
n = r[13]
if isinstance(n, str) and "BDCOD|" in n:
cur = out.setdefault(_meta(n)["BDCOD"], {"머리": None, "줄": []})
continue
if cur is None:
continue
if r[0] == "합 계":
cur = None
elif isinstance(n, str) and "COD|" in n:
cur["줄"].append((_meta(n).get("COD", "").strip(), r[2], str(r[0] or "")))
elif cur["머리"] is None:
cur["머리"] = (str(r[0] or "").strip(), str(r[1] or "").strip())
return out
def _flat(v) -> str:
return re.sub(r"\s+", "", str(v or ""))
def _same_lines(xl: list, mine: list, res: dict) -> bool:
if len(xl) != len(mine):
return False
for (code, qty, name), one in zip(xl, mine, strict=True):
if code != one["코드"] and (
code[:1] != one["코드"][:1] or _flat(name) != _flat(one["이름"])
):
return False
if code not in ("PR", "RR") and qty is not None and abs(float(qty) - one["수량"]) > 1e-6:
return False
return True
def test_lines_equal_excel_ilwi() -> None:
hops = lines = 0
for xt, st in PAIR.items():
d = parsed(st)
mine = {b["코드"]: b for b in d["일위"]}
by_name = {(_flat(b["이름"]), _flat(b["규격"])): b for b in d["일위"]}
for code, x in _xl_ilwi(xt).items():
b = mine.get(code)
if b is None or (_flat(b["이름"]), _flat(b["규격"])) != tuple(map(_flat, x["머리"])):
b = by_name[tuple(map(_flat, x["머리"]))]
assert _same_lines(x["줄"], b["줄"], d["자원"]), (xt, code)
hops += 1
lines += len(x["줄"])
assert (hops, lines) == (136, 706)
def test_recompute_equals_saved() -> None:
n_b = n_x = 0
for tag in stc.FILES:
d = parsed(tag)
assert not any(b["표지"] for b in d["일위"]), tag # 일위 다시 셈 = 저장(B00038 꼴 포함)
assert not [m for m in d["표지"] if il.MISMATCH in m], tag # 중기 다시 셈 = 저장
n_b += len(d["일위"])
n_x += sum(1 for c in d["자원"] if c[:1] == "X")
assert (n_b, n_x) == (216, 176)
b38 = next(b for b in parsed("소광변경1")["일위"] if b["코드"] == "B00038")
assert [ln["코드"] for ln in b38["줄"]][-2:] == ["C1", "PR"] and not b38["표지"]
def test_line_shape() -> None:
b = next(b for b in parsed("봉화")["일위"] if b["코드"] == "B00013")
rate = b["줄"][-1]
assert rate["갈래"] == "율" and rate["율"] == {"밑": "L", "대상": "M", "곱": False}
assert rate["이름"] == "노무비의 3%" and rate["금액"]["M"] == pytest.approx(409.3)
assert all(ln["밑줄"] for ln in b["줄"][:2]) and b["줄합"]["T"] == b["저장"]["T"]
kinds = {ln["갈래"] for t in stc.FILES for x in parsed(t)["일위"] for ln in x["줄"]}
assert kinds <= {"M", "L", "S", "X", "B", "D", "W", "율", "닫기", "구분", "글"}
apart = [ln for x in parsed("동부청영월")["일위"] for ln in x["줄"] if ln.get("별도계상")]
assert len(apart) == 17 # 엑셀 비고 「별산…」 17 줄과 같음(분석 813 2 장)
# ── ④ 중기 구성 ──────────────────────────────────────────────────────────
def test_machines_equal_excel() -> None:
n = m = 0
for xt, st in PAIR.items():
xl = mm._stm_sheet(_xl(xt, "중기사용료"))
mine = parsed(st)["기계구성"]
for a in {
id(m): m for m in xl.values()
}.values(): # 호표 하나 = 구성 하나(코드 · 이름 열쇠 둘)
b = mine.get(mm.key_of(a["이름"], a["규격"]))
assert a == b, (xt, a["이름"])
n += 1
rows = _xl(xt, "중기사용료")
heads = [rows[i + 1] for i, r in enumerate(rows) if re.fullmatch(r"제\d+호표", _flat(r[0]))]
assert all(mm.key_of(h[0], h[1]) in mine for h in heads), xt # 엑셀 읽기기가 못 읽는 머리도
m += len(heads)
assert (n, m) == (100, 118) # 엑셀 읽기기 100(울진대흥 머리 코드 없음 9 만) · 엑셀 호표 118
def test_my_machine_seed_and_peers() -> None:
d = parsed("봉화")
make = d["기계구성"]["X00018"]
assert make["손료"] == [[2799.0, 875000.0, False]] and make["주연료"] == 1.3
assert make["잡재료율"] == 20.0
line = {"자원": {"이름": make["이름"], "규격": make["규격"], "갈래": "기계"}}
body = mm.body(make, 0, line, "봉화.STC")
assert body and body["이름"] == make["이름"]
saved = fn.read("봉화.STC", stc.raw("봉화"))
assert saved["기계구성"]["X00018"] == make # 둘 꼴(올림 저장)에 감
assert any(p.get("같은구성") for p in saved["단가"].values())
# ── ⑤ 낙찰률 줄 2 칸 · 원설계 그대로 ─────────────────────────────────────────
def test_bid_rate_rows_slot_two() -> None:
d = parsed("영덕계류")
assert d["자원"]["D01416"]["저장"]["T"] == 2239 # 산근 · 자원 = 1 칸(엑셀 단가산출근거와 같음)
assert d["자원"]["D01416"]["원단가"]["T"] == 2547 # 2 칸 = 원단가(2239 = 2547 × 88 %)
b = next(b for b in d["일위"] if b["코드"] == "B01092")
assert b["저장"]["T"] == 62804 == b["줄합"]["T"] # 일위 머리 = 2 칸(엑셀 일위대가표 62,804)
for tag in ("봉화", "동부청영월", "울진대흥", "소광22"):
z, names = S._zip(stc.raw(tag))
rows = S.dbf(z.read(names["COSTN.DBF"]))
assert all(S._f(r.get("NAGCD")) == 0 for r in rows)
res = parsed(tag)["자원"]
assert all(res[r["CODE"]]["저장"]["T"] == S._f(r["TAMT1"]) for r in rows if r["CODE"])
assert not any("원단가" in v for v in res.values())
# ── ⑥ 일위대가 길 ─────────────────────────────────────────────────────────
def test_hopyo_route(world) -> None:
folder = world["personal"]
folder.mkdir(parents=True, exist_ok=True)
tag = "동부청영월"
with store.at(folder):
row = fh.upload_native(folder, stc.FILES[tag].name, stc.raw(tag), {"이름": "sub3임시"})
assert row["호표수"] == 23
grid = fn.grid(fn.read(stc.FILES[tag].name, stc.raw(tag)))
assert grid["보기"]["일위대가표"] == "s2" and grid["책"]["시트"][0]["이름"] == "일위대가표"
from M02_MasterTemplete import M02_Hopyo_View as hv
got = hv.listing(folder, row["키"], folder)
assert len(got["호표"]) == 23 and all(h["맞춤"][1] for h in got["호표"])
no = next(h["번"] for h in got["호표"] if h["번"] == 1984) # 측구보호용잡석채움 — 단산 둘
made = hb.build_all(folder, folder, row["키"], None, True, no)
assert [x["번"] for x in made["만듦"]] == [no] and made["같음"] == 1, made
assert len(made["단가산출"]) == 2
kinds = mp.census(folder, row["키"])
assert any(g.get("호표") for g in kinds) # 구성 줄 재료가 판에 옴