6 Commits
Author SHA1 Message Date
eomsangdonandClaude Opus 5.5 61bb15fbde refactor(812-1): 옛 엑셀 엔진 걷기 2 · 실무 11 금액 100 % · NativeEst 자리 dict 묶음 고침 · 옛 시험 정리
- M02_Doc_NativeEst.triple: 세 줄 수량 식이 같은지 = repr 로 맞댐(자리 규칙 dict 가 set 에 안 들어가 TypeError — 엑셀 줄은 보인 값이 반올림 자리를 타 원파일에 없던 꼴 · 812-3 끝나 브레인이 맡김)
- 엑셀 읽기기: 절 소계 덧셈이 뺀 오타 줄 = 비목 없음(블록 금액도 그래야 맞을 때만) · 세 줄 계 금액 칸 비목 하나 = 옮김 · 소계 61 = 금액 칸 비목 하나 · 「.1」 수 · 총괄표 당초 칸 표지 · STmate 엑셀 줄 금액 · 계 꼬리(검산 줄)
- 화면: 가져온 식 규격 칸 꼬리표 = 원파일 · 엑셀 갈림(line_stack_sourced)
- 시험: 793_1 지움(옛 EST · STmate 엑셀 글 추정 엔진 · 812_1 골든이 대신) · 793_2_san_emit EST 시험 지움(STmate 남김) · 797_1a 옛 표지 ① ② · 줄 단위 합격 · 표본 밖 수 · 버림1 감시 지움(옛 엔진) · 810_1 (c) 엑셀 엔진 대조 지움 · 793_2 실무 블록 · 793_3b 끝전 = 새 길 · 812_1 금액 엇갈림 없음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:38:12 +09:00
eomsangdon 5779943540 Merge branch 'main_laptop_1' of C:/Program_coding/임도설계 및 견적자동화 프로그램 개발 into sub_laptop_3 2026-11-09 07:37:28 +09:00
eomsangdonandClaude Opus 5.5 74446990f8 refactor(812-1): 옛 엑셀 엔진 걷기 1 — San_Est 글 추정 사슬 · San_Eval EST 셈 · 엑셀 검산 · San_Emit.est · San_Book.est_blocks · load
- 남김: San_Est.tokens · num · NUMRE(NativeEst 가 씀) · San_Eval.run_stmate · stmate_block(STC · STmate) · San_Emit.stmate
- 시험 고침: 802_4b1 · 797_1 = 세 줄 → 묶음 한 줄(원파일 길 꼴) · 797_2 = 블록 번은 엑셀 둘 꼴에서

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 07:37:28 +09:00
eomsangdonandClaude Opus 5.5 8cd7baea5e feat(812-1): 엑셀 읽기기 새로 — xlsx · xls → 810-1 구조 줄 꼴 → 원파일 길 한 벌 · 골든 시험 · 올림 길 교체
- M02_San_Xlsx(공통 · 글자 칸 배치 · 금액 칸 · 자원 표) · M02_San_Xlsx_Est(EST 글 → 표지: 변수 = 가장 늦은 대입 값 하나 · 칸 나눔 꼴은 닫는 표지 2 칸 · 합계 줄 · 세 줄 머리 · 노임 · 자재 단가 짝 · 소계 61 · 율 58 · 62 · 구간 글자) · M02_San_Xlsx_Stmate(숨은 G 열 원문 = stc_line · 끝전 파일 투표)
- M02_Doc_FromNative: 엑셀 둘 꼴(번 · 시트 행 · 원본 엑셀 참 · 원파일 거짓) · M02_Doc_Import: 엑셀 길도 소계 비목 칸 비움 · 추정 칸(출처 = 엑셀 꼬리표)
- M02_Sangeun_FromHopyo: 산근 블록 = 새 읽기기(옛 San_Est · Emit.est 부름 걷음 · 옛 저장은 한 번 지어 둠)
- test_812_1: 실제 짝(거창 · STC 다섯) 같음 · 합성 배치 = 거창 실제 · 합성 EST 1,849 · STC 371 · 금액 실무 11(엇갈림 고정 목록)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 07:34:28 +09:00
eomsangdon 284ea58419 Merge branch 'sub_laptop_1' of C:/Aislo_wt/sub_laptop_1 into main_laptop_1 2026-11-09 07:22:28 +09:00
eomsangdonandClaude Fable 5.1 82e598b11b knowledge(요구713): 제안 승인 · PLAN 812 엑셀 읽기기 새로 · 연결 = 로직 · 좌측 차림
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-09 06:42:14 +09:00
24 changed files with 1470 additions and 1160 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ export function buildSourced(
if (guess) f.root.dataset.guess = mark;
return guess
? cell(f.root, lt("Guess"), "warning", lt("Guess_Hint"))
: cell(f.root, lt("Src_Native"), "info", lt("Src_Hint"));
: cell(f.root, lt(head.원파일 ? "Src_Native" : "Src_Excel"), "info", lt("Src_Hint")); // 812-1 엑셀 읽기기 = 「엑셀」
};
if (head.규격) out.push(ro(lt("Spec"), head.규격, "", false));
for (const g of head.추정 ?? [])
+36 -10
View File
@@ -50,19 +50,30 @@ def read(name: str, data: bytes) -> dict[str, Any]:
def pack(got: dict, name: str) -> dict[str, Any]:
"""파서 답 → 둘 꼴(셈 · 변환에 쓰는 칸만 · JSON)."""
out: dict[str, Any] = {"꼴": got["꼴"], "파일": name, "표지": got.get("표지") or []}
head = ("코드", "이름", "규격", "단위", "저장")
out |= {"엑셀": True} if got.get("엑셀") else {} # 812-1 엑셀 읽기기(`M02_San_Xlsx`) — 같은 꼴
head = ("코드", "번", "이름", "규격", "단위", "저장")
if got.get(
"엑셀"
): # 엑셀 읽기기 — 줄 행 = 시트 행(원문 카드 · 옛 엑셀 길과 같은 자리) · 블록 표지
head += ("표지", "몸", "꼬리")
got = {**got, "산근": [_rows(b) for b in got["산근"]]}
if got["꼴"] == "EST":
out["산근"] = [
{**{k: b.get(k) for k in head}, "줄": [_est_line(ln) for ln in b["줄"]]}
{**{k: b.get(k) for k in head if k in b}, "줄": [_est_line(ln) for ln in b["줄"]]}
for b in got["산근"]
]
out["일위"] = [{**{k: b.get(k) for k in head}, "줄": b["줄"]} for b in got["일위"]]
out["일위"] = [
{**{k: b.get(k) for k in head if k in b}, "줄": b["줄"]} for b in got["일위"]
]
return out
out["산근"] = [
{**{k: b.get(k) for k in head}, "줄": [{"행": ln["행"], "글": ln["글"]} for ln in b["줄"]]}
{
**{k: b.get(k) for k in head if k in b},
"줄": [{"행": ln["행"], "글": ln["글"]} for ln in b["줄"]],
}
for b in got["산근"]
]
out["일위"] = [{k: b.get(k) for k in head} for b in got["일위"]]
out["일위"] = [{k: b.get(k) for k in head if k in b} for b in got["일위"]]
res = got.get("자원") or {}
out["단가"] = {
code: {**p, **{k: (res.get(code) or {}).get(k) for k in ("이름", "규격", "단위")}}
@@ -72,6 +83,10 @@ def pack(got: dict, name: str) -> dict[str, Any]:
return out
def _rows(b: dict) -> dict:
return {**b, "줄": [{**ln, "행": ln.get("엑셀행") or ln["행"]} for ln in b["줄"]]}
def _est_line(ln: dict) -> dict:
one = {k: ln.get(k) for k in _EST_KEYS if ln.get(k) not in (None, [], False, "")}
if one.get("자원"):
@@ -81,9 +96,9 @@ def _est_line(ln: dict) -> dict:
# ── 블록 ──────────────────────────────────────────────────────────────
def numbers(saved: dict) -> dict[int, dict]:
"""블록 번 → 산근 블록(엑셀 길 번과 같음)."""
"""블록 번 → 산근 블록(엑셀 길 번과 같음 · 엑셀 읽기기 EST = 시트 「N.」 번)."""
if saved["꼴"] == "EST":
return {i: b for i, b in enumerate(saved["산근"], 1)}
return {int(b.get("번") or i): b for i, b in enumerate(saved["산근"], 1)}
return {int(str(b["코드"])[1:]): b for b in saved["산근"] if str(b["코드"])[1:].isdigit()}
@@ -91,21 +106,32 @@ def emit(saved: dict, no: int, keys: bool = True) -> dict[str, Any]:
"""원파일 산근 블록 하나 → `{로직, 원본}`(원본 `원파일` True · 코드 · 확정 수) · 없는 번 = KeyError."""
blk = numbers(saved)[int(no)]
if saved["꼴"] == "EST":
names = {b["코드"]: i for i, b in enumerate(saved["산근"], 1)}
names = {b["코드"]: n for n, b in numbers(saved).items()}
out = ne.block(blk, int(no), keys, saved["파일"], names)
out["원본"] |= {"원코드": blk["코드"]} | ({"규격": blk["규격"]} if blk.get("규격") else {})
return out
return _excel(saved, out, blk)
from M02_MasterTemplete import M02_San_Stmate as stm
nodes = [{"행": ln["행"], "글": ln["글"], **stm.parse_line(ln["글"])} for ln in blk["줄"]]
saved_amt = blk.get("저장") or {}
head = {"C": saved_amt.get("T"), "D": saved_amt.get("L"), "E": saved_amt.get("M")}
head["F"] = saved_amt.get("S")
block = {"코드": blk["코드"], "이름": blk.get("이름"), "몸": [], "꼬리": [], "머리": head}
block = {"코드": blk["코드"], "이름": blk.get("이름"), "머리": head}
block |= {"몸": blk.get("몸") or [], "꼬리": blk.get("꼬리") or []} # 엑셀 = 줄 금액 · 계 꼬리
out = em.stmate(block, nodes, saved["단가"], saved["끝전"], keys, saved["파일"])
marks = sum(1 for one in out["원본"]["글줄"] if one.get("표지"))
sure = len(out["로직"]["호표"]) + len(out["로직"].get("덧줄") or []) + len(out["로직"]["중간"])
out["원본"] |= {"원파일": True, "갈래": "STmate", "확정": {"확정": sure, "표지": marks}}
return _excel(saved, out, blk)
def _excel(saved: dict, out: dict, blk: dict) -> dict[str, Any]:
"""엑셀 읽기기 꼴(812-1) — 원본 `원파일` 거짓 · `엑셀` 참(화면 꼬리표 「엑셀」 · 원코드 = 단산 번)."""
if saved.get("엑셀"):
out["원본"] |= {"원파일": False, "엑셀": True}
out["원본"] |= {"시트": "단가산출"} if saved["꼴"] == "EST" else {}
out["원본"].pop("원코드", None)
out["원본"]["표지"] += [m for m in blk.get("표지") or [] if m.startswith("참고 · ")]
return out
+12 -6
View File
@@ -304,11 +304,16 @@ def derive(sk: dict, ctx: tuple | None = None, whole: Any = None) -> dict:
doc, notes = dc.cv.convert(_Row(master, key, row), row, given)
_attach(doc, row, ids, src)
_numbers(doc, keep)
if (meta.get("원본") or {}).get("원파일"): # 원파일(810-2) — 소계 비목 칸 비움 · 값 변수 출처
_native(doc)
native = bool((meta.get("원본") or {}).get("원파일"))
if native or (meta.get("원본") or {}).get(
"엑셀"
): # 810-2 · 812-1 — 소계 비목 칸 비움 · 값 변수 출처
_native(doc, native)
head = {k: (meta.get("원본") or {}).get(k) for k in HEAD if (meta.get("원본") or {}).get(k)}
marks = list((meta.get("원본") or {}).get("표지") or [])
if (meta.get("원본") or {}).get("원파일"): # 810-3 — 품셈 조건 글(값 없음) = 화면 「추정」 칸
if native or (meta.get("원본") or {}).get(
"엑셀"
): # 810-3 — 품셈 조건 글(값 없음) = 「추정」 칸
head["추정"] = [
{"행": x.get("행"), "글": " ".join(str(x.get("글") or "").split())}
for x in meta["원본"].get("글줄") or []
@@ -322,13 +327,14 @@ def derive(sk: dict, ctx: tuple | None = None, whole: Any = None) -> dict:
return {**doc, "키": str(sk.get("키") or key), "이름": str(sk.get("이름") or doc.get("이름"))}
def _native(doc: dict) -> None:
"""원파일 문서 — 소계 덧줄(합 밖) 비목 칸 비움(금액만) · 수 하나 변수(옵션 칸) 출처 「원파일」."""
def _native(doc: dict, native: bool = True) -> None:
"""원파일 · 엑셀 읽기기 문서 — 소계 덧줄(합 밖) 비목 칸 비움(금액만) · 수 하나 변수(옵션 칸) 출처
「원파일」(엑셀 = 출처 없음 → 화면 꼬리표 「엑셀」)."""
for line in doc.get("금액") or []:
if line.get("출처") == SUB and line.get("합밖"):
line.pop("비목", None)
for var in doc.get("변수") or []:
if (var.get("연결") or {}).get("갈래") == "숫자" and XL not in var["연결"]:
if native and (var.get("연결") or {}).get("갈래") == "숫자" and XL not in var["연결"]:
var["출처"] = "원파일"
+1 -1
View File
@@ -302,7 +302,7 @@ class Block:
"""기계 · 산근 · 견적 세 줄 → 로직 값묶음 한 줄(수량 식 · 자리 같을 때) · 아니면 줄마다."""
got = [self.money(ln) for ln in rows]
parts = [ln["비목"] for ln in rows]
same = all(got) and len({g for g in got}) == 1 and sorted(parts) == ["L", "M", "S"]
same = all(got) and len({repr(g) for g in got}) == 1 and sorted(parts) == ["L", "M", "S"]
if not same:
for ln, g in zip(rows, got):
self.machine_line(ln, g)
-23
View File
@@ -12,11 +12,8 @@ from __future__ import annotations
import io
import re
import warnings
from pathlib import Path
from typing import Any
from M02_MasterTemplete import M02_Hopyo_ReadEst as read_est
STM_SHEETS = (
"단가산출근거",
"단가산출근거목록표",
@@ -233,23 +230,3 @@ def est_prices(book: dict[str, list]) -> tuple[list[dict[str, Any]], dict[int, d
one = {"종류": "산근", "이름": f"단산{n}", "규격": ""}
out.append({**one, "비목": k, "값": san[n][k]})
return out, san
def est_blocks(book: dict[str, list]) -> list[dict[str, Any]]:
"""→ [{번, 이름, 단위, 글 [줄], 글행 [행]}]."""
blocks = read_est.read(book.get(read_est.SHEET))
keep = ("번", "이름", "단위", "글", "글행")
return [{k: b[k] for k in keep} for b in blocks.values()]
def load(path: str | Path) -> dict[str, Any]:
"""파일 → {꼴, 블록, 단가(STmate dict | EST 목록), 총괄(EST)}."""
path = Path(path)
book = sheets(path.name, path.read_bytes())
kind = kind_of(book)
if kind == "STmate":
return {"꼴": kind, "블록": stmate_blocks(book), "단가": stmate_prices(book)}
if kind == "EST":
prices, san = est_prices(book)
return {"꼴": kind, "블록": est_blocks(book), "단가": prices, "총괄": san}
raise ValueError("STmate · EST 산근 시트 없음")
+1 -89
View File
@@ -1,6 +1,6 @@
"""경쟁사 산근 → 우리 엔진 로직 줄 꼴 내보내기(PLAN 793-2a · 요구서_652 · eval 없음).
793-1 줄 나무(`M02_San_Stmate` · `M02_San_Est`) → `{로직, 원본}` — 꼴 = 793-2 가져온 식 SK
793-1 줄 나무(`M02_San_Stmate`) → `{로직, 원본}`(EST = 원파일 · 엑셀 둘 다 `M02_Doc_NativeEst` · 812-1) — 꼴 = 793-2 가져온 식 SK
만들기 몸(`M02_Sangeun_Imported.py` 머리) · 로직 = `_틀.md` 7 장 꼴(셈 = `master_formula.run` →
`master_cut.engine` · `요소` 는 SK 서버가 채움).
- 로직 = {이름, 결과단위, 입력 [], 중간 [{이름, 식, 표시?}], 호표,
@@ -33,7 +33,6 @@ COMP = {"L": "노무비", "M": "재료비", "S": "경비"}
_CODE = {v: k for k, v in COMP.items()}
_SUM = "@합{}@" # 누계 % 밑 자리(블록 끝에 `finish` 가 채움)
KIND = {"L": "인력", "M": "재료", "X": "기계", "D": "단가산출", "B": "단가산출"}
KIND_EST = {"노임": "인력", "자재": "재료", "기계": "기계", "견적": "고정", "산근": "단가산출"}
RESERVED = {"노무비", "재료비", "경비", "계", "번지", "단계", "줄", "찾기", "로직", "그리고"}
RESERVED |= {"또는", "만약", "올림", "버림", "반올림", "최소", "최대"}
_KEY = re.compile(r"[A-Z]{2}\d{6}")
@@ -531,90 +530,3 @@ def stmate(
more["계약"] = {"낙찰률": float(meta["NAG"]), "금액": _cols(block["머리"])}
end = "큰나머지" if rule.startswith("합버림") else "비목버림"
return o.done(end, adjust, _cols(kye), "단가산출근거", file, **more)
# ── EST ────────────────────────────────────────────────────────────────
_VAR = re.compile(r"([A-Za-z][A-Za-z0-9_]*)\s*(?:\([^)]*\))?\s*=")
def _est_res(role: dict, value: float, comp: str | None) -> dict:
"""EST 단가 토큰 정체 → 자원 이음 열쇠(옮김 = 성분 비목이 단가 비목과 다를 때)."""
p = role["정체"]
pc = p["비목"] if p["비목"] in COMP else (comp or "S")
res = {"꼴": "EST", "갈래": KIND_EST.get(p["종류"], "고정"), "종류": p["종류"]}
res |= {"이름": p["이름"], "규격": p["규격"], "단위": None}
res |= {"단가": {pc: value}}
res |= {"비목": pc, "옮김": comp if comp and comp != pc else None, "열쇠비목": p["비목"]}
return res | ({"모호": True} if role.get("모호") else {})
def est(block: dict, lines: list[dict], want: dict, keys: bool = False, file: str = "") -> dict:
"""EST 블록(`M02_San_Book.est_blocks` 한 개) + 복원 줄(`M02_San_Est.parse_block`) +
총괄표 성분 want {T, L, M, S} → {로직, 원본} · 성분을 낸 줄만 합(나머지 = 합밖)."""
des = ev.designate(lines, want)
mine = {k: c for c, k in des.items() if isinstance(k, int)}
o = Out(block.get("이름"), block.get("단위"), "EST", block.get("번"))
rows = block.get("글행") or []
if want.get("칸"): # 당초 칸 사용 · 적용 ≠ 당초(797-1a)
o.marks.append(f"{NOTE}{want['칸']}")
for c, k in des.items():
if k is None:
o.marks.append(f"성분 {COMP[c]} {want[c]} 을 낸 줄 없음")
val = ev.run_est(lines)
ref: dict[int, str] = {} # 차례 → 엔진 부름 글(앞줄 · 숨은 합)
for L in lines:
k, form, kind = L["차례"], L["꼴"], L.get("종류")
at, glyph = rows[k] if k < len(rows) else k, L["원글"]
comp = mine.get(k)
if form == "빈":
continue
if form == "변수" and L.get("값") is not None:
m = _VAR.search(L["원글"])
eng = o.names.line(m.group(1) if m else "변수")
o.middle(eng, num(L["값"]), None, at, glyph) # 얼린 값(금액엔 보인 값이 박힘)
continue
if form not in ("돈", "값"):
shown = L.get("보인") if form == "글끝" else L.get("값") if form == "식못읽음" else None
if shown is None:
o.glyph(at, glyph)
else: # 금액 줄 못 읽음 — 보인 값 얼림(글줄 값으로 보존)
o.glyph(at, glyph, value=shown, mark=f"{NOTE}금액 줄 못 읽음 · 보인 값 얼림")
continue
head = {} if comp else {"합밖": True}
try:
place = _place(L.get("규칙"))
if k not in val:
raise EmitError("보인 값이 되는 식 · 자리 못 찾음 — 보인 값으로 얼림")
if kind == "단가줄":
i, role = next((i, r) for i, r in L["역할"].items() if r["갈래"] == "단가")
price = next(n[1] for n in ex.numbers(L["식"]) if n[2] == i)
res = _est_res(role, price, comp)
link, many = _m01(res["갈래"], res) if keys else (None, None)
qty = text(L["식"], {}, {i: "1"})
one = resource(o.names.item(L.get("라벨") or ""), None, qty, res, link)
one |= {**head, **({"자리": place} if place else {})}
o.add_line(one, True, at, glyph, L["값"])
if many:
o.glyph(at, glyph, mark=many)
if role.get("모호"): # 임의 선택 아님 — 후보를 사람이 고름
o.glyph(at, glyph, mark=f"{NOTE}단가 후보 여럿({role['남은']}) · 사람 확인")
elif role.get("갈림") in ("문맥", "기계 세 줄"):
o.glyph(at, glyph, mark=f"{NOTE}추정 — 단가 정체 근거 {role['갈림']}")
ref[k] = f"줄.'{one['이름']}'"
continue
if kind == "값줄":
parts = [ref.get(j) or num(lines[j]["값"]) for j in L["숨은"]["줄"]]
formula = " + ".join(parts)
else:
swap = {i: ref[r["줄"]] for i, r in L["역할"].items() if r.get("줄") in ref}
formula = text(L["식"], {}, swap)
except EmitError as error:
o.glyph(at, glyph, mark=str(error))
formula, place = num(L["값"]), None
name = o.names.line(L.get("라벨") or "줄")
one = {"이름": name, "식": formula, "비목": COMP[comp or L.get("비목") or "S"]}
o.add_line(one | head | ({"자리": place} if place else {}), False, at, glyph, L["값"])
if k in val:
ref[k] = name
check = _money(want) | {"계": want["T"]}
return o.done("비목버림", None, check, "단가산출", file)
+2 -236
View File
@@ -1,42 +1,19 @@
"""EST 산근 보이는 글 → 식 복원(PLAN 793-1 · 요구서_652 · 문법 = `_분석_648-A` 3 장 · eval 없음).
"""EST 식 글 토큰(PLAN 793-1 · 812-1 · eval 없음) — 원파일 산근 변환(`M02_Doc_NativeEst`)이 쓰는 수 · 토큰 읽기만.
엑셀엔 식이 없고 인쇄 글뿐 — 「라벨 : 식 = 결과」 로 나눠 식을 되살림. 셈은 `M02_San_Eval.run_est`.
줄 꼴(`꼴`) — 빈 · 그림(분수 ─) · 글 · 글끝(끝이 결과 아님) · 식못읽음 · 변수 · 값 · 돈.
- 돈(`종류` 단가줄 · 참조줄 · 상수줄) — 라벨 · 비목(라벨 끝 노무비 · 재료비 · 경비) ·
값(보인 결과) · 식(`M02_San_Expr` 나무) ·
역할 {수 토큰 차례: 단가(후보 · 정체 · 모호) | 앞줄(같은 블록 앞 결과 줄 차례) | 상수} ·
규칙(보인 결과가 되는 줄 자리 속성 · 기본 버림1)
- 값(`종류` 값줄 「소 계 : 1,355.9」) — 숨은 = {갈래 복사 | 합, 줄 [차례], 규칙}(앞 결과 하나 또는
바로 앞 돈 · 값 줄 이음 합)
- 변수(`종류` 변수식 · 변수값 · 변수기호 · 변수글) — 값 · 규칙 = 얼림(금액엔 반올림된 값이 박힘)
옛 엑셀 글 추정 사슬(줄 꼴 · 역할 · 값 역색인 · `parse_block`)은 812-1 엑셀 읽기기(`M02_San_Xlsx`)로 바뀌어 걷음.
"""
from __future__ import annotations
import collections
import re
from typing import Any
from M02_MasterTemplete import M02_San_Expr as ex
from M02_MasterTemplete.M02_San_Book import flat
COMPW = {"노무비": "L", "재료비": "M", "경비": "S"}
NUMRE = r"\d{1,3}(?:,\d{3})+(?:\.\d*)?|\d+(?:\.\d*)?|\.\d+"
EPS = ex.EPS
OPS = {"×": "*", "x": "*", "X": "*", "*": "*", "÷": "/", "/": "/", "+": "+", "-": "-"}
OPS |= {"-": "-", "−": "-"}
_UNIT = re.compile(r"\s*(?:[A-Za-z가-힣㎡㎥㎏ℓ㎝㎜°˚’'\"Øø]+(?:\s*/\s*[A-Za-z가-힣㎡㎥㎏ℓ]+)?)")
VARLINE = re.compile(r"^\s*(?:[○●◦·\-]\s*)?[A-Za-z][A-Za-z0-9_]*\s*(?:\([^)]*\))?\s*=")
RES = re.compile(
r"^\s*(?:[A-Z]\s+)?("
+ NUMRE
+ r")\s*\.?\s*(?:원)?\s*(?:[₩\\]?\s*/\s*\S+)?\s*(?:\([^()]*\))?\s*$"
)
_PLAIN = re.compile(r"[\s\d.,×÷+\-()A-Za-z가-힣㎡㎥]*")
_LABEL = re.compile(r"^(?P<l>[^::=]{0,40}?)\s*[::]\s*(?P<r>.*)$")
TAIL = re.compile(r"^\s*(" + NUMRE + r")\s*\.?\s*[::][^=]*$") # 결과 뒤 꼬리 글 「3,094.2 : 채집」
_HEADING = re.compile(r"^\s*(\d+|[가-하])\s*[.)]")
def num(s: str) -> float:
@@ -112,214 +89,3 @@ def tokens(seg: str) -> list[tuple] | None:
if depth != 0 or want or not any(t[0] in ("연산", "%") for t in out):
return None
return out
def calc(node: tuple, sub: dict | None = None) -> float | None:
try:
return ex.value(node, {"V": {}, "sub": sub})
except ex.ExprError:
return None
def label_comp(label: str) -> str | None:
w = flat(label)
return next((v for k, v in COMPW.items() if w.endswith(k)), None)
def parse_line(text: str) -> dict[str, Any]:
"""보이는 글 한 줄 → 꼴 · 라벨 · 결과 · 식 나무(역할 전)."""
t = text.strip()
if not t:
return {"꼴": "빈"}
if "─" in t or _PLAIN.fullmatch(t) and "=" not in t and ":" not in t:
return {"꼴": "그림" if "─" in t else "글"}
label, rhs = "", t
mc = _LABEL.match(t)
if mc and not re.search(r"\d\s*$", mc.group("l")):
label, rhs = mc.group("l"), mc.group("r")
if VARLINE.match(rhs) or VARLINE.match(t):
return {"꼴": "변수", "조각": rhs.split("=")}
if "=" not in rhs:
m = re.match(r"^\s*(" + NUMRE + r")\s*\.?\s*$", rhs)
if label and m:
return {"꼴": "값", "라벨": label, "비목": label_comp(label), "값": num(m.group(1))}
return {"꼴": "글"}
segs = rhs.split("=")
mres = RES.match(segs[-1]) or TAIL.match(segs[-1]) # 꼬리 글은 떼고 읽음(797-1a)
if not mres:
lead = re.match(r"\s*(" + NUMRE + r")", segs[-1])
if (
label and lead and re.search(r"\d", "=".join(segs[:-1]))
): # 금액 줄 못 읽음 — 보인 값 보존
return {"꼴": "글끝", "라벨": label, "보인": num(lead.group(1))}
return {"꼴": "글끝"}
shown = num(mres.group(1))
toks = None
for seg in reversed(segs[:-1]):
toks = tokens(seg)
if toks is None: # 라벨 없는 줄 — 왼쪽 글을 깎아 가며
starts = {m.start() for m in re.finditer(NUMRE, seg)}
starts |= {m.start() for m in re.finditer(r"\(\s*\d", seg)}
for p in sorted(starts):
if p and (seg[p - 1].isdigit() or seg[p - 1] in ".,"):
continue
toks = tokens(seg[p:])
if toks:
label = label or seg[:p]
break
if toks:
break
if not toks:
return {"꼴": "식못읽음", "라벨": label, "값": shown}
return {"꼴": "돈", "라벨": label, "비목": label_comp(label), "값": shown, "식": ex.parse(toks)}
# ── 블록 풀이 ──────────────────────────────────────────────────────────
def _ident(p: dict[str, Any]) -> tuple:
return (p["종류"], p["이름"], p["규격"])
def _money(L: dict, prices_by_value: dict, results: list) -> None:
"""돈 줄 — 수 토큰 역할(단가 · 앞줄 · 상수) · 다시 셈 · 자리 규칙."""
comp = L["비목"]
nums = [(n[2], n[1]) for n in ex.numbers(L["식"])]
roles: dict[int, dict[str, Any]] = {}
price_at = None
for i, v in nums:
if v >= 1 and any(abs(r[1] - v) < EPS for r in results):
continue # 앞 결과와 같은 수 = 참조(단가표 우연 일치보다 먼저)
cands = [p for p in prices_by_value.get(v, []) if v >= 1 or (v == 0 and comp)]
if comp:
cands = [p for p in cands if p["비목"] == comp] or cands
if v == 0 and comp and not cands:
cands = [{"종류": "0", "이름": "-", "규격": "", "비목": comp, "값": 0.0}]
first = i == nums[0][0]
if cands and (v >= 100 or (first and v > 0)) or (v == 0 and cands):
price_at = i
roles[i] = {"갈래": "단가", "후보": cands}
break
for i, v in nums:
if i in roles:
continue
hit = [r for r in reversed(results) if abs(r[1] - v) < EPS]
roles[i] = {"갈래": "앞줄", "줄": hit[0][0]} if hit and v >= 1 else {"갈래": "상수"}
L["역할"] = roles
L["셈"] = calc(L["식"])
L["규칙"] = ex.fit(L["셈"], L["값"])
if price_at is not None:
L["종류"] = "단가줄"
pc = roles[price_at]["후보"]
L["비목"] = L["비목"] or (pc[0]["비목"] if len({p["비목"] for p in pc}) == 1 else None)
L["후보수"] = len({_ident(p) for p in pc})
elif any(r["갈래"] == "앞줄" for r in roles.values()):
L["종류"] = "참조줄"
else:
L["종류"] = "상수줄"
def _value(L: dict, lines: list, results: list) -> None:
"""값 줄 — 앞 결과 하나(자리 처리) 또는 바로 앞 돈 · 값 줄 이음 합."""
v, how = L["값"], None
for r in reversed(results):
rule = ex.fit(r[1], v)
if rule:
how = {"갈래": "복사", "줄": [r[0]], "규칙": rule}
break
if not how and flat(L["라벨"]) in ("계", "합계"):
got: dict[str, dict] = {}
for x in reversed(lines):
c = x.get("비목")
if c and x.get("꼴") in ("돈", "값") and c not in got:
got[c] = x
if len(got) == 3:
break
if got:
rule = ex.fit(sum(x["값"] for x in got.values()), v)
if rule:
how = {"갈래": "합", "줄": sorted(x["차례"] for x in got.values()), "규칙": rule}
if not how:
acc, used = 0.0, []
for x in reversed([x for x in lines if x.get("꼴") in ("돈", "값")]):
if len(used) >= 25:
break
acc += x["값"]
used.append(x["차례"])
rule = ex.fit(acc, v)
if rule and len(used) > 1:
how = {"갈래": "합", "줄": used[::-1], "규칙": rule}
break
L["종류"] = "값줄"
L["숨은"] = how
L["규칙"] = how["규칙"] if how else None
def _var(L: dict) -> None:
segs = L.pop("조각")
m = re.match(r"^\s*(" + NUMRE + r")", segs[-1]) if len(segs) > 1 else None
if not m:
L["종류"] = "변수글"
return
shown, toks = num(m.group(1)), None
for seg in reversed(segs[1:-1]):
toks = tokens(seg)
if toks:
break
L["값"] = shown
L["규칙"] = ex.fit(calc(ex.parse(toks)), shown) if toks else None
L["종류"] = "변수식" if toks else ("변수값" if len(segs) == 2 else "변수기호")
def parse_block(text: list[str], prices: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""블록 글 줄 → 복원 줄 목록(역할 · 숨은 합 · 자리 규칙 · 단가 정체)."""
by_value = collections.defaultdict(list)
for p in prices:
by_value[p["값"]].append(p)
lines: list[dict[str, Any]] = []
results: list[tuple] = [] # (차례, 값, 비목)
for k, raw in enumerate(text):
L = {"차례": k, "원글": raw, **parse_line(raw)}
if L["꼴"] == "돈":
_money(L, by_value, results)
results.append((k, L["값"], L["비목"]))
elif L["꼴"] == "값":
_value(L, lines, results)
results.append((k, L["값"], L["비목"]))
elif L["꼴"] == "변수":
_var(L)
lines.append(L)
identify(lines)
return lines
def identify(lines: list[dict[str, Any]]) -> None:
"""단가 토큰 정체 — 값 하나 · 기계 세 줄 겹침 · 문맥(라벨 · 머리 글 이름) · 남으면 모호 표지."""
head = ""
for L in lines:
if L["꼴"] in ("글", "글끝") or _HEADING.match(L["원글"]):
head = L["원글"]
if L.get("종류") != "단가줄":
continue
role = next(r for r in L["역할"].values() if r["갈래"] == "단가")
cands = role["후보"]
ids = {_ident(p) for p in cands}
how = "값"
if len(ids) > 1 and cands[0]["종류"] in ("기계", "산근"):
k0, inter = L["차례"], set(ids)
for M in lines[max(0, k0 - 3) : k0 + 4]:
if M is L or M.get("종류") != "단가줄":
continue
r2 = next(r for r in M["역할"].values() if r["갈래"] == "단가")
ids2 = {_ident(p) for p in r2["후보"]}
if ids2 & inter and len(ids2 & inter) < len(inter):
inter &= ids2
ids, how = inter, "기계 세 줄"
if len(ids) > 1:
ctx = flat(L["원글"]) + "|" + flat(head)
hit = {x for x in ids if x[1] and (x[1] in ctx or x[1][:3] in ctx)}
ids, how = ({x for x in hit if not x[2] or x[2] in ctx} or hit or ids), "문맥"
role["모호"] = len(ids) != 1
role["갈림"] = how
role["남은"] = len(ids)
pick = sorted(ids)[0]
p = next(p for p in cands if _ident(p) == pick)
role["정체"] = {"종류": p["종류"], "이름": p["이름"], "규격": p["규격"], "비목": p["비목"]}
+2 -116
View File
@@ -7,9 +7,8 @@ STmate 셈 규칙(`_분석_649-G2` 1 장) — 줄 금액 = 단가 × (계수 + 1
T% = 바로 앞 계표시 값 × 수량 · 계표시 = 아래 단 누계 · `(-=)` = 찍되 위 합에서 뺌 ·
`->` = 번지엔 쌓되 합에서 뺌 · 블록 끝전 두 갈래(비목 버림 / 계 버림 + 큰 나머지) ·
결과금액 조정(SBM = 계를 한 비목으로) · 계약단가 = 비목 × 낙찰률 원 버림.
EST 셈 규칙(같은 문서 2 장) — 줄 = 복원 식(단가 · 앞줄 결과 · 상수) → 줄 자리 속성 ·
값 줄 = 숨은 복사 · 합 → 자리 속성 · 블록 성분 = 그 값을 낸 마지막 줄.
단가 바뀜 전파 — STmate = 단가 dict 를 바꿔 다시 셈 · EST = `run_est(lines, {정체 열쇠: 새 값})`.
단가 바뀜 전파 — 단가 dict 를 바꿔 다시 셈. 옛 EST 엑셀 셈 · 엑셀 검산(`run_est` · `est_check` ·
`stmate_check`)은 812-1 엑셀 읽기기로 걷음(EST = `M02_Doc_NativeEst`).
"""
from __future__ import annotations
@@ -22,7 +21,6 @@ from M02_MasterTemplete import M02_San_Expr as ex
COMPS = ("L", "M", "S")
SPECIAL = ("L%", "M%", "S%", "T%")
TOL_LINE = 0.05
def _zero() -> dict[str, float]:
@@ -179,115 +177,3 @@ def stmate_block(block: dict, nodes: list[dict], prices: dict, rule: str) -> dic
fin = {c: math.floor(round(kye[c] * nag / 100, 6)) for c in COMPS}
fin["T"] = fin["L"] + fin["M"] + fin["S"]
return {"줄": lines, "합": tot, "계": kye, "금액": fin, "낙찰": nag}
def stmate_check(block: dict, result: dict) -> dict[str, Any]:
"""줄마다 · 블록 검산 — 금액 줄(참조 · 율 · 특수) C~F 대조 · 계표시 줄 · 블록 머리 · 꼬리 계."""
bad, n_amt, n_tot, ok_tot = [], 0, 0, 0
for r, ln in zip(result["줄"], block["몸"], strict=True):
want = {"L": ln["D"] or 0, "M": ln["E"] or 0, "S": ln["F"] or 0}
if r["종류"] in ("참조", "율", "특수"):
n_amt += 1
if not r["값"] or any(abs(r["값"][c] - want[c]) >= TOL_LINE for c in COMPS):
bad.append({"행": ln["행"], "엑셀": want, "셈": r["값"], "오류": r["오류"]})
elif r["종류"] == "계" and ln["C"] is not None:
n_tot += 1
ok_tot += all(abs(r["값"][c] - want[c]) < 0.15 for c in COMPS)
head = block["머리"]
want = {"T": head["C"] or 0, "L": head["D"] or 0, "M": head["E"] or 0, "S": head["F"] or 0}
kye = next((t for t in block["꼬리"] if t["A"] == "계"), None)
want_k = {"T": kye["C"], "L": kye["D"], "M": kye["E"], "S": kye["F"]} if kye else None
same = lambda x, y: all(abs((x[k] or 0) - (y[k] or 0)) < 0.5 for k in "TLMS") # noqa: E731
ok = same(result["금액"], want) and (want_k is None or same(result["계"], want_k))
return {
"맞음": ok,
"엑셀": want,
"엑셀계": want_k,
"금액줄": n_amt,
"틀린줄": bad,
"계줄": n_tot,
"계줄맞음": ok_tot,
}
# ── EST ────────────────────────────────────────────────────────────────
def key_of(ident: dict[str, Any]) -> tuple:
"""EST 단가 정체 → 전파 열쇠 (종류, 이름, 규격, 비목)."""
return (ident["종류"], ident["이름"], ident["규격"], ident["비목"])
def run_est(lines: list[dict], newp: dict[tuple, float] | None = None) -> dict[int, float | None]:
"""복원 줄 → {차례: 다시 셈 값}(줄 자리 속성 그대로) · newp = {정체 열쇠: 새 단가}."""
newp = newp or {}
val: dict[int, float | None] = {}
for L in lines:
k = L["차례"]
if L.get("꼴") == "돈" and L.get("규칙"):
sub = {}
for i, r in L["역할"].items():
if r["갈래"] == "단가" and key_of(r["정체"]) in newp:
sub[i] = newp[key_of(r["정체"])]
elif r["갈래"] == "앞줄" and val.get(r["줄"]) is not None:
sub[i] = val[r["줄"]]
v = _est_calc(L["식"], sub)
val[k] = ex.RULE[L["규칙"]](v) if v is not None else None
elif L.get("종류") == "값줄" and L.get("숨은"):
how = L["숨은"]
s = sum(
(val.get(j) if val.get(j) is not None else lines[j]["값"]) or 0 for j in how["줄"]
)
val[k] = ex.RULE[how["규칙"]](s)
return val
def _est_calc(node: tuple, sub: dict) -> float | None:
try:
return ex.value(node, {"V": {}, "sub": sub})
except ex.ExprError:
return None
def designate(lines: list[dict], want: dict[str, float]) -> dict[str, Any]:
"""블록 성분(총괄표) → 그 값을 낸 줄(마지막 · 라벨 비목 우선) · 0 = 「영」 · 없음 = None."""
out: dict[str, Any] = {}
kinds = ("단가줄", "참조줄", "상수줄", "값줄")
for c in COMPS:
w = want[c]
if not w:
out[c] = "영"
continue
cand = [L for L in lines if L.get("종류") in kinds and abs(L["값"] - w) < ex.EPS]
pick = [L for L in cand if L.get("비목") == c] or cand
out[c] = pick[-1]["차례"] if pick else None
return out
def est_block(lines: list[dict], des: dict[str, Any], newp: dict | None = None) -> dict[str, Any]:
"""성분 줄 → 블록 금액(성분 원 버림 · 계 = 합) · 줄 값."""
val = run_est(lines, newp)
raw = {
c: (0.0 if k == "영" else (val.get(k) if k is not None else None)) for c, k in des.items()
}
if any(v is None for v in raw.values()):
return {"줄": val, "성분": raw, "금액": None}
fin = {c: math.floor(round(raw[c], 6)) for c in COMPS}
fin["T"] = fin["L"] + fin["M"] + fin["S"]
return {"줄": val, "성분": raw, "금액": fin}
def est_check(lines: list[dict], want: dict[str, float]) -> dict[str, Any]:
"""블록 검산 — 다시 셈 성분 = 총괄표 · 줄마다 보인 값 대조(돈 · 값 줄)."""
des = designate(lines, want)
got = est_block(lines, des)
val = got["줄"]
n, bad = 0, []
for L in lines:
if L.get("꼴") == "돈" or L.get("종류") == "값줄":
n += 1
v = val.get(L["차례"])
if v is None or abs(v - L["값"]) >= ex.EPS:
bad.append({"차례": L["차례"], "글": L["원글"].strip(), "보인": L["값"], "셈": v})
fin = got["금액"]
ok = fin is not None and all(abs(fin[c] - want[c]) < 0.5 for c in COMPS)
ok = ok and abs(want["L"] + want["M"] + want["S"] - want["T"]) < 0.5
return {"맞음": ok, "성분줄": des, "금액": fin, "줄수": n, "틀린줄": bad}
+175
View File
@@ -0,0 +1,175 @@
"""경쟁사 산근 엑셀(.xlsx · .xls) 읽기기 — 810-1 원파일 파서와 같은 「구조 줄」 꼴(PLAN 812-1 · 요구 711-2 ·
712 · 713 · 읽기만 · 서버 단독).
`read(name, data)` = `M02_San_Estx.read` · `M02_San_Stc.read` 와 같은 칸 이름 · 같은 뜻
(칸 뜻 `resources/master_data/_원파일파서_틀.md`) → 그 뒤는 원파일 길(`M02_Doc_FromNative.pack` →
`emit` → `M02_Doc_NativeEst` · `M02_San_Emit.stmate` → 수식 문서) 그대로.
EST = `M02_San_Xlsx_Est`(단가산출 시트 글 · 금액 칸 · 같은 책 단가표) ·
STmate = `M02_San_Xlsx_Stmate`(숨은 G 열 원문 = 원파일 원문 · 단가 = 목록표 · 끝전 = 파일 투표).
엑셀이 못 주는 칸(자원 코드 · LinkIndex · 자리 Diget)은 비움 + 표지 「엑셀 — …」 · 글에서 읽어 정한 것은
`연결뜻` 에 적고 줄 `추정` 참 + 블록 표지 「엑셀 추정」.
여기 = 공통(시트 → 글 줄 · 금액 칸 · 자원 표).
"""
from __future__ import annotations
import re
import unicodedata
from typing import Any
from M02_MasterTemplete import M02_San_Book as book
GUESS = "엑셀 추정"
NOTE = "엑셀 — "
class XlsxError(ValueError):
"""산근 시트가 없는 엑셀."""
def flat(v: Any) -> str:
return re.sub(r"\s+", "", str(v or ""))
def number(v: Any) -> float | None:
"""칸 값 → 수(「3,442」 · 「8.」 · 수 칸) · 아니면 None."""
if isinstance(v, bool) or v is None:
return None
if isinstance(v, (int, float)):
return float(v)
s = str(v).strip().replace(",", "")
if not re.fullmatch(r"-?\d+(?:\.\d*)?|-?\.\d+", s):
return None
return float(s.rstrip(".") or 0)
def _cell(v: Any) -> str:
if isinstance(v, float) and v == int(v):
return str(int(v))
return "" if v is None else str(v)
def _width(ch: str) -> int:
return 2 if unicodedata.east_asian_width(ch) in "WFA" else 1
def layout(cells: list[tuple[int, str]], base: int) -> str:
"""글자 칸 나눔 줄(거창 꼴 · 열 = 화면 자리) → 한 줄 글(열 차례 · 모자란 자리 = 빈칸 · 겹친 자리(수 끝 점)
= 빈칸 하나)."""
out, end = "", 0
for col, text in cells:
want = col - base
if want > end:
out += " " * (want - end)
elif out and want < end and not out.endswith(" "):
out += " "
out += text
end = want + sum(_width(c) for c in text)
return out
# ── 시트 머리 ──────────────────────────────────────────────────────────
MONEY = ("합계", "노무비", "재료비", "경비")
def money_cols(rows: list[list[Any]]) -> list[int] | None:
"""머리 줄의 맨 오른쪽 「합계 · 노무비 · 재료비 · 경비」 네 칸 열 → [T L M S]."""
for r in rows[:6]:
names = [flat(v) for v in r]
hits = [j for j in range(len(names) - 3) if tuple(names[j : j + 4]) == MONEY]
if hits:
return list(range(hits[-1], hits[-1] + 4))
return None
def money_of(row: list[Any], cols: list[int]) -> dict[str, float] | None:
got = {c: number(row[j]) if j < len(row) else None for c, j in zip("TLMS", cols)}
got = {c: v for c, v in got.items() if v is not None}
return got or None
def head_col(rows: list[list[Any]], *words: str) -> int | None:
for r in rows[:6]:
for j, v in enumerate(r):
if flat(v) in words:
return j
return None
# ── 자원 표(EST) ───────────────────────────────────────────────────────
def _table(rows: list[list[Any]], price: tuple[str, ...]) -> list[dict[str, Any]]:
"""단가 시트 → [{번, 이름, 규격, 단위, 줄 (원 줄)}](품명 · 규격 · 단위 · 값 열 = 머리 글)."""
c_no, c_name = head_col(rows, "No"), head_col(rows, "품명")
c_size, c_unit = head_col(rows, "규격"), head_col(rows, "단위")
if c_name is None:
return []
out = []
for r in rows:
no = str(r[c_no]).strip() if c_no is not None and c_no < len(r) else ""
if not no.isdigit() or c_name >= len(r) or not str(r[c_name] or "").strip():
continue
out.append(
{
"번": int(no),
"이름": str(r[c_name]).strip(),
"규격": str(r[c_size] or "").strip() if c_size is not None else "",
"단위": str(r[c_unit] or "").strip() if c_unit is not None else "",
"줄": r,
}
)
return out
def resources(sheets: dict[str, list]) -> dict[str, list[dict[str, Any]]]:
"""같은 책 단가표 → {종류: [{번 · 이름 · 규격 · 단위 · 표 · 값 {T L M S}}]}(노임 · 자재 = 적용단가 ·
기계 · 견적 · 산근 = 적용 칸(합이 안 맞으면 당초 · `M02_San_Book.applied`))."""
out: dict[str, list[dict[str, Any]]] = {}
for kind, sheet, comp in (("노임", "노임단가", "L"), ("자재", "자재단가", "M")):
rows = sheets.get(sheet) or []
c = head_col(rows, "적용단가")
got = []
for one in _table(rows, ("적용단가",)):
v = number(one["줄"][c]) if c is not None and c < len(one["줄"]) else None
if v is not None:
got.append({**_drop(one), "표": sheet, "값": {"T": v, comp: v}})
out[kind] = got
for kind, sheet in (
("기계", "기계경비총괄표"),
("견적", "견적단가"),
("산근", "단가산출총괄표"),
):
got = []
for one in _table(sheets.get(sheet) or [], ()):
r = list(one["줄"]) + [None] * 30
vals, note = book.applied_how(r)
vals = {k: float(v or 0) for k, v in vals.items()}
got.append({**_drop(one), "표": sheet, "값": vals} | ({"칸": note} if note else {}))
out[kind] = got
return out
def _drop(one: dict) -> dict:
return {k: v for k, v in one.items() if k != "줄"}
def kind_of(sheets: dict[str, list]) -> str | None:
return book.kind_of(sheets)
def read(name: str, data: bytes) -> dict[str, Any]:
"""엑셀 → {꼴, 산근, 일위, 자원, 내역, 표지(, 단가 · 옵션 STmate)} — 산근 시트 없음 = XlsxError."""
sheets = book.sheets(name, data)
return read_sheets(sheets)
def read_sheets(sheets: dict[str, list]) -> dict[str, Any]:
kind = kind_of(sheets)
if kind == "EST":
from M02_MasterTemplete import M02_San_Xlsx_Est as xe
return xe.read(sheets) | {"엑셀": True}
if kind == "STmate":
from M02_MasterTemplete import M02_San_Xlsx_Stmate as xs
return xs.read(sheets) | {"엑셀": True}
raise XlsxError("STmate · EST 산근 시트 없음")
+663
View File
@@ -0,0 +1,663 @@
"""EST 산근 엑셀 → 810-1 구조 줄(PLAN 812-1 · `M02_San_Estx.cost_line` 과 같은 칸 · 읽기만 · 서버 단독).
엑셀 「단가산출」 시트 = 원파일 LineData 를 보인 글(제어문자 표지 없음) + 줄 금액 칸(원파일 줄 TC NC GC JC ·
0 = 빈칸 · 소계 61 = 비목 칸만) → 표지를 글 · 금액 칸 · 같은 책 단가표(노임 · 자재 · 기계 · 견적 · 단가산출
총괄표)로 다시 정함(`연결뜻` · 표지 「엑셀 추정」 · 자원 코드 · 자리 = 비움):
변수 줄 「이름 = 식 = 값」 → 값 = 결과(`\\x12`) · 「이름 = 값」 → 대입(결과 없음) · 금액 줄 첫 식 조각의 수가
앞 대입 값과 같으면 변수(`\\x11` · 상수 아님) ·
합계 머리(「합 계」) 아래 노무비 · 재료비 · 경비 · 계 줄 = 합계(`\\x1a` · 비목 L M S T) ·
「* 단산 N 참조 *」 · 「* 견적 N 참조 *」 · 기계 이름(+규격) = 세 줄 머리(104 · 101 · 105) → 아래
「노무비 · 재료비 · 경비 : 단가 …」(단가 = 그 자원 비목 값) = 세 줄 · 「소 계」 = 계 줄 ·
노임 · 자재 이름 + 그 단가 수 = 자원 줄(5 · 6 · 비목 = 금액 칸) ·
「…소계 : 수」(금액 칸 = 비목만) = 소계 61(줄참조) · 「수 × n% = 값」 + 금액 칸 = 율 58(줄참조) ·
「(A 수 + 수) × n%」 = 율 62(구간 합) · 「= A수」 = 구간 표지(`\\x06A\\x06`) · 그 밖 = 글 줄.
"""
from __future__ import annotations
import re
from typing import Any
from M02_MasterTemplete import M02_San_Xlsx as sx
SHEET = "단가산출"
LINK = {"자재": 5, "노임": 6, "견적": 101, "산근": 104, "기계": 105, "소계": 61, "율": 58}
MEAN = {5: "자재", 6: "노임", 101: "견적", 104: "산근", 105: "기계", 61: "소계", 58: "율"}
MEAN[62] = "율"
LABEL = {"노무비": "L", "재료비": "M", "경비": "S", "계": "T"}
NUM = r"\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?|\.\d+" # 「.1」 = 엑셀 꼴 0.1
_NUM = re.compile(r"(?<![A-Za-z0-9_.,])(-?(?:" + NUM + r"))\.?(?![\d,])")
_HEAD = re.compile(r"^(\d+)\.(.*?)\[([^\[\]]*)\]\s*$")
_HEADING = re.compile(r"^\s*(?:\(\d+\)|(?:\d+|[가-하])\s*[.)])\s*")
_REF = re.compile(r"\*\s*(단산|견적)\s*(\d+)\s*참조\s*\*")
_ASSIGN = re.compile(r"(?<![A-Za-z0-9_])([A-Za-z][A-Za-z0-9]*)\s*(?:\([^)=]*\))?\s*=(?!=)")
_GROUP62 = re.compile(r"^\s*\(?\s*([A-Z])\s*(" + NUM + r")((?:\s*\+\s*(?:" + NUM + r"))*)")
_UNITS = {"hr", "m", "km", "kg", "ton", "sec", "min", "mm", "cm", "ea", "t"}
_COLON = re.compile(r"[::]")
def _v(s: str) -> float:
return float(s.replace(",", "").rstrip("."))
def _close(a: float, b: float) -> bool:
return abs(a - b) < 1e-6 * max(1.0, abs(b))
def _span(text: str, name: str, start: int = 0) -> tuple[int, int] | None:
"""빈칸을 건너 같은 글자 차례 자리(이름 · 규격) · 없음 None."""
chars = [re.escape(c) for c in sx.flat(name)]
if not chars:
return None
m = re.compile(r"\s*".join(chars)).search(text, start)
return (m.start(), m.end()) if m else None
# ── 시트 → 블록 ───────────────────────────────────────────────────────
def blocks(rows: list[list[Any]]) -> list[dict[str, Any]]:
"""단가산출 시트 → [{번, 머리, 행, 저장, 줄 [{행, 엑셀행, 보임, 돈}]}](머리 = B 열 「N.이름[단위]」)."""
cols = sx.money_cols(rows) or [2, 3, 4, 5]
split = cols[0] > 6 # 거창 꼴 — 글자 칸 나눔(열 = 화면 자리 · B 열 = 머리만)
out: list[dict[str, Any]] = []
for i, r in enumerate(rows):
head = r[1] if len(r) > 1 else None
m = _HEAD.match(head) if isinstance(head, str) and not head[:1].isspace() else None
if m and i > 2:
out.append({"번": int(m.group(1)), "머리": head, "행": i + 1, "줄": []})
out[-1]["저장"] = sx.money_of(r, cols)
continue
if not out:
continue
if split:
cells = [(j, sx._cell(v)) for j, v in enumerate(r[2 : cols[0]], 2)]
text = sx.layout([(j, v) for j, v in cells if v.strip()], 2)
else:
text = sx._cell(head)
one = {"행": len(out[-1]["줄"]) + 1, "엑셀행": i + 1, "보임": text, "칸": split}
out[-1]["줄"].append(one | {"돈": sx.money_of(r, cols)})
for b in out: # 끝 빈 줄(시트 꼬리)
while b["줄"] and not b["줄"][-1]["보임"].strip() and not b["줄"][-1]["돈"]:
b["줄"].pop()
return out
# ── 줄 ────────────────────────────────────────────────────────────────
def _label(text: str) -> str | None:
"""「노무비 :」 · 「경 비 :」 · 「계 :」 → 비목 글자 · 아니면 None."""
m = _COLON.search(text)
if not m:
return None
return LABEL.get(sx.flat(_HEADING.sub("", text[: m.start()])))
def _nums(text: str, lo: int = 0, hi: int | None = None) -> list[tuple[int, int, float]]:
hi = len(text) if hi is None else hi
return [(m.start(1), m.end(1), _v(m.group(1))) for m in _NUM.finditer(text, lo, hi)]
def _result(text: str) -> tuple[int, int, float, int | None] | None:
"""마지막 「=」 뒤 첫 수(구간 글자 「A」 붙음 허용) → (시작, 끝, 값, 글자 자리)."""
eq = text.rfind("=")
if eq < 0:
return None
m = re.match(r"\s*(?:([A-Z])\s*)?(-?(?:" + NUM + r"))\.?(?![\d,])", text[eq + 1 :])
if not m:
return None
at = eq + 1
return at + m.start(2), at + m.end(2), _v(m.group(2)), at + m.start(1) if m.group(1) else None
class Lines:
"""블록 한 벌 줄 → 구조 줄."""
def __init__(self, res: dict[str, list[dict]], tags: list[str]) -> None:
self.res, self.tags = res, tags
self.vars: dict[str, float] = {} # 이름 → 마지막 대입 값
self.ctx: dict | None = None # 세 줄 머리 자원 {종류, 자원}
self.triple = False # 바로 앞이 세 줄
self.heads: list[dict] = [] # 세 줄 머리 줄들
self.lines: list[dict[str, Any]] = []
# ── 꼴 ─────────────────────────────────────────────────────────
def blank(self, src: dict) -> dict[str, Any]:
return {
"행": src["행"],
"엑셀행": src["엑셀행"],
"글": src["보임"],
"보임": src["보임"],
"품명": None,
"규격": None,
"단위": None,
"단": 0,
"색인": 0,
"연결": 0,
"연결뜻": None,
"비목": None,
"자원": None,
"연결단가": [],
"변수": [],
"줄참조": [],
"결과": [],
"합계": False,
"자리": {"자리수": None, "방법": None},
"표지": None,
"기계": None,
"산근": None,
"저장": src.get("돈"),
"추정": False,
"_marks": [],
"_칸": src.get("칸"),
}
def resource(self, kind: str, one: dict) -> dict[str, Any]:
code = (
f"단산{one['번']}" if kind == "산근" else f"{one['표']}#{one['번']}"
) # 원 코드 없음 = 표 줄 번
out = {"코드": code, "종류": kind, "표": one["표"]}
return out | {k: one[k] for k in ("이름", "규격", "단위")} | {"저장": dict(one["값"])}
def link(self, ln: dict, kind: str, one: dict | None) -> None:
ln["연결"] = LINK[kind] if kind in LINK else 62
ln["연결뜻"] = MEAN.get(ln["연결"])
ln["추정"] = True
if one is not None:
ln["자원"] = self.resource(kind, one)
if kind == "산근":
ln["산근"] = ln["자원"]["코드"]
@staticmethod
def money_part(ln: dict) -> str | None:
"""금액 칸 비목 — 노 · 재 · 경 중 값 하나 · 셋 다 = T · 없음 = None."""
got = [c for c in "LMS" if (ln["저장"] or {}).get(c)]
return got[0] if len(got) == 1 else ("T" if got else None)
@staticmethod
def closed(text: str, end: int) -> bool:
"""칸 나눔 꼴 — 표지 붙은 수 뒤는 닫는 표지 2 칸(빈칸 · 수 끝 점 2 이상 · 줄 끝)."""
gap = len(text[end:]) - len(text[end:].lstrip(" ."))
return gap >= 2 or not text[end:].strip()
def wrap(self, ln: dict, s: int, e: int, ch: str) -> None:
ln["_marks"].append((s, e, ch))
# ── 자원 찾기 ─────────────────────────────────────────────────
def names(self, ln: dict, one: dict, start: int = 0) -> int | None:
"""이름 · 규격 · 단위 자리 표지(`\\x0e` · `\\x13` · `\\x15`) · 끝 자리."""
text = ln["보임"]
sp = _span(text, one["이름"], start)
if not sp:
return None
self.wrap(ln, *sp, "\x0e")
ln["품명"] = text[sp[0] : sp[1]]
end = sp[1]
if one.get("규격"):
m = re.match(r"\s*", text[end:])
at = end + m.end()
size = _span(text, one["규격"], at)
if size and size[0] - at <= 1:
lo = size[0] - (1 if text[size[0] - 1 : size[0]] in "([" and size[0] > at else 0)
hi = size[1] + (1 if text[size[1] : size[1] + 1] in ")]" and lo < size[0] else 0)
self.wrap(ln, lo, hi, "\x13")
ln["규격"] = text[lo:hi]
end = hi
unit = re.match(r"\s*(\[[^\[\]]*\])", text[end:])
if unit:
self.wrap(ln, end + unit.start(1), end + unit.end(1), "\x15")
ln["단위"] = unit.group(1)
end += unit.end(1)
return end
def find_item(self, ln: dict) -> tuple[str, dict, tuple[int, int, float]] | None:
"""노임 · 자재 — 글 속 수가 그 단가이고 이름이 그 수 앞에 있음(가장 긴 이름 · 규격 맞음 먼저)."""
text, body = ln["보임"], sx.flat(ln["보임"])
best = None
for hit in _nums(text):
for kind, one in self.res["_값"].get(round(hit[2], 4), ()):
name = sx.flat(one["이름"])
if not name or name not in body:
continue
sp = _span(text, one["이름"])
if not sp or sp[1] > hit[0]:
continue
key = (not one["규격"] or sx.flat(one["규격"]) in body, len(name))
if best is None or key > best[0]:
best = (key, kind, one, hit)
return best[1:] if best else None
def find_head(self, ln: dict) -> tuple[str, dict] | None:
"""세 줄 머리 — 「* 단산 N 참조 *」 · 「* 견적 N 참조 *」 · 기계 이름(+규격)."""
text = ln["보임"]
m = _REF.search(text)
if m:
kind = "산근" if m.group(1) == "단산" else "견적"
one = self.res["_번"].get((kind, int(m.group(2))))
if kind == "견적" and (not one or sx.flat(one["이름"]) not in sx.flat(text)):
body = sx.flat(text[: m.start()]) # 견적 번 ≠ 표 차례 — 이름으로(가장 긴 것)
hits = [x for x in self.res.get("견적") or [] if sx.flat(x["이름"]) in body]
one = (
max(hits, key=lambda x: len(sx.flat(x["이름"]) + sx.flat(x["규격"])))
if hits
else None
)
return (kind, one) if one else None
if "=" in text or self.res["_기계틀"] is None or text.lstrip()[:1] in ("※", "*"):
return None
body, best = sx.flat(text), None
for at in self.res["_기계틀"].finditer(body):
name = at.group(1)
for one in self.res["_기계"][name]:
size = sx.flat(one["규격"])
tail = body[at.start() + len(name) : at.start() + len(name) + len(size) + 2]
if size and size not in tail:
continue
key = len(name) + len(size)
if best is None or key > best[0]:
best = (key, one)
return ("기계", best[1]) if best else None
# ── 줄 갈래 ───────────────────────────────────────────────────
def run(self, src: dict) -> dict[str, Any]:
ln = self.blank(src)
text = ln["보임"]
if not text.strip() and not ln["저장"]:
ln["연결"] = None
return self.done(ln)
heading = bool(_HEADING.match(text))
part = _label(text)
if heading:
ln["단"] = 1
title = sx.flat(_HEADING.sub("", text))
if re.search(r"[합소총]계|^계", title):
self.heads = [] # 합계 절 — 세 줄 머리 닫음
self.triple = False
if self.triple and "소계" in sx.flat(text.split(":")[0]) and self.ctx:
return self.done(self.triple_total(ln))
self.triple = False
if part in ("L", "M", "S") and not heading and self.triple_line(ln, part):
return self.done(ln)
got = self.find_item(ln)
total = ln["저장"] if part == "T" else not ln["저장"] # 합계 = 계 줄만 금액 칸
if part and not heading and got is None and total:
return self.done(self.total_line(ln, part))
if got is not None:
return self.done(self.item(ln, *got))
if (head := self.find_head(ln)) is not None:
kind, one = head
self.link(ln, kind, one)
end = self.names(ln, one)
loose = end is None or (one["규격"] and ln["규격"] is None) # 규격이 이름 곁에 없음
loose = loose or text[end or 0 :].lstrip()[:1] in (":", ":")
if ln["단"] == 0 and kind == "기계" and loose: # 설명 글 속 장비 이름
ln |= {"연결": 0, "연결뜻": None, "자원": None, "추정": False, "품명": None}
ln |= {"규격": None, "단위": None, "_marks": []}
self.plain(ln)
return self.done(ln)
self.heads.append({"종류": kind, "자원": one, "줄": ln, "씀": False})
self.ctx = self.heads[-1]
return self.done(ln)
if self.subtotal(ln) or self.rate62(ln) or self.rate(ln):
return self.done(ln)
self.plain(ln)
return self.done(ln)
def sections(self, saved: dict) -> None:
"""절 소계(가 · 나 … 머리 아래 합계 줄 「노무비 : a + b = X」)가 뺀 줄 하나 = 합에 안 든 줄(비목 없음) —
절(앞 숫자 머리부터) 안 그 비목 줄 결과 합 − X 가 꼭 한 줄 값이고 나머지 줄 값은 모두 덧셈 글에 있을
때만 · 블록 금액도 그 줄을 빼야 맞을 때만(엑셀 글 덧셈 · 원파일 SCheck 0)."""
lines, start = self.lines, 0
for i, ln in enumerate(lines):
text = ln["보임"]
if ln["단"] == 1 and re.match(r"^\s*\d", text):
start = i
if not (ln["합계"] and ln["비목"] in ("L", "M", "S") and ln["결과"] and "+" in text):
continue
head = next((k for k in range(i - 1, start - 1, -1) if lines[k]["단"] == 1), None)
if head is None or not re.match(r"^\s*[가-하]", lines[head]["보임"]):
continue
part, want = ln["비목"], ln["결과"][-1]
kids = [
x for x in lines[start:head] if x["비목"] == part and x["결과"] and not x["합계"]
]
kids += [
x for x in lines[start:head] if x["비목"] == part and x["줄참조"] and not x["결과"]
]
value = {id(x): (x["결과"] or x["줄참조"])[-1] for x in kids}
extra = sum(value.values()) - want
odd = [x for x in kids if abs(value[id(x)] - extra) < 0.5]
colon = _COLON.search(text)
adds = [v for _, _, v in _nums(text, colon.end() if colon else 0, text.rfind("="))]
rest = [value[id(x)] for x in kids if odd and x is not odd[0]]
listed = all(any(_close(v, a) for a in adds) for v in rest)
if len(odd) != 1 or abs(extra) < 0.5 or not listed:
continue
every = sum(
(x["결과"] or x["줄참조"])[-1]
for x in lines
if x["비목"] == part and not x["합계"] and (x["결과"] or x["줄참조"])
)
block = float(saved.get(part) or 0) # 블록 금액이 그 줄을 빼야 맞을 때만
if abs(every - block) >= 1 and abs(every - extra - block) < 1:
odd[0]["비목"] = None
def unused(self) -> None:
"""머리 아닌 줄의 기계 이름 — 아래 세 줄이 안 쓰면 글 줄(설명 속 장비 이름)."""
for h in self.heads:
ln = h["줄"]
if h["종류"] == "기계" and not h["씀"] and ln["단"] == 0:
ln |= {"연결": 0, "연결뜻": None, "자원": None, "추정": False, "품명": None}
ln |= {"규격": None, "단위": None, "_marks": []}
def done(self, ln: dict) -> dict[str, Any]:
self.lines.append(ln)
return ln
def total_line(self, ln: dict, part: str) -> dict:
"""합계 줄 — 「노무비 : 식 = 값」 · 「재료비 : 값」 · 「계 : 값」(계 = 결과)."""
text = ln["보임"]
ln |= {"합계": True, "비목": part, "단": -1}
lead = len(text) - len(text.lstrip())
res = _result(text)
if res:
self.wrap(ln, lead, text.rfind("=") + 1, "\x1a")
self.result(ln, res)
elif part == "T":
colon = _COLON.search(text).end()
self.wrap(ln, lead, colon, "\x1a")
nums = _nums(text, colon)
if nums:
self.wrap(ln, nums[0][0], nums[0][1], "\x12")
ln["결과"] = [nums[0][2]]
else:
self.wrap(ln, lead, len(text.rstrip()), "\x1a")
return ln
def result(self, ln: dict, res: tuple) -> None:
s, e, v, letter = res
if letter is not None:
self.wrap(ln, letter, letter + 1, "\x06")
self.wrap(ln, s, e, "\x12")
ln["결과"].append(v)
def triple_line(self, ln: dict, part: str) -> bool:
"""세 줄 — 단가 = 머리 자원(기계 · 산근 · 견적)의 그 비목 값."""
text = ln["보임"]
colon = _COLON.search(text).end()
nums = _nums(text, colon)
if not nums or not self.heads:
return False
price = nums[0]
tail = text[price[1] :]
if ("=" not in tail and tail.strip()) or tail.lstrip()[:1] == "+":
return False
fit = [
h
for h in reversed(self.heads)
if _close(price[2], float(h["자원"]["값"].get(part) or 0))
]
if not fit:
return False
self.ctx = fit[0]
self.ctx["씀"] = True
one = self.ctx["자원"]
self.link(ln, self.ctx["종류"], one)
ln["비목"] = part
if self.ctx["종류"] == "기계":
ln["기계"] = True
self.money(ln, price)
self.triple = True
return True
def triple_total(self, ln: dict) -> dict:
one = self.ctx["자원"]
self.link(ln, self.ctx["종류"], one)
ln["비목"] = "T"
text = ln["보임"]
res = _result(text)
if res:
self.result(ln, res)
else:
m = re.search(r"[::]\s*(?:([A-Z])\s*)?(" + NUM + r")", text)
if m:
self.result(
ln, (m.start(2), m.end(2), _v(m.group(2)), m.start(1) if m.group(1) else None)
)
money = ln["저장"] or {}
rows = []
for one in reversed(self.lines):
if one["연결"] != ln["연결"] or one["비목"] not in ("L", "M", "S"):
break
rows.append(one)
cols = [c for c in "LMS" if money.get(c)]
total = sum((one["결과"] or [0])[-1] for one in rows)
if len(cols) == 1 and money.get("T") and abs(total - money["T"]) < 1:
for one in rows: # 계 줄 금액 칸 비목 하나 = 값 있는 세 줄 모두 그 비목으로 옮김
if (one["결과"] or [0])[-1]:
one["비목"] = cols[0]
elif money:
for one in rows: # 계 줄 금액 칸이 빈 비목 = 합에 안 든 줄(비목 없음)
if not money.get(one["비목"]) and one["결과"] and one["결과"][-1]:
one["비목"] = None
self.triple = False
return ln
def item(self, ln: dict, kind: str, one: dict, price: tuple) -> dict:
self.link(ln, kind, one)
self.names(ln, one)
part = self.money_part(ln)
ln["비목"] = part if part in ("L", "M", "S") else None
self.money(ln, price)
return ln
def money(self, ln: dict, price: tuple[int, int, float]) -> None:
"""금액 줄 — 단가(`\\x03`) · 첫 식 조각의 앞 대입 값 = 변수(`\\x11`) · 끝 = 결과."""
text = ln["보임"]
s, e, v = price
self.wrap(ln, s, e, "\x03")
ln["연결단가"] = [v]
self.var(ln, e)
res = _result(text)
if res and res[0] > e:
self.result(ln, res)
def var(self, ln: dict, start: int) -> None:
"""첫 식 조각(start ~ 첫 「=」)의 수 중 가장 늦게 대입한 변수 값 하나 = 변수(`\\x11` · 원파일 = 줄마다 하나) ·
칸 나눔 꼴은 닫는 표지 자리(2 칸)가 있는 수만."""
text = ln["보임"]
eq = text.find("=", start)
order = list(self.vars.values())
best = None
for a, b, val in _nums(text, start, eq if eq >= 0 else None):
rank = max((i for i, x in enumerate(order) if _close(val, x)), default=-1)
if ln["_칸"] and not self.closed(text, b):
continue
if rank >= 0 and (best is None or rank > best[0]):
best = (rank, a, b, val)
if best:
self.wrap(ln, best[1], best[2], "")
ln["변수"].append(best[3])
def subtotal(self, ln: dict) -> bool:
"""「…소계 : 수」 — 금액 칸 계 없음 · 비목 하나(61) · 금액 칸 없음 = 비목 없음."""
text = ln["보임"]
m = _COLON.search(text)
if not m or "소계" not in sx.flat(text[: m.start()]):
return False
money = ln["저장"] or {}
parts = [c for c in "LMS" if money.get(c)]
if len(parts) != 1 or (
money.get("T") is not None and not _close(money["T"], money[parts[0]])
):
return False # 소계 61 = 금액 칸 비목 하나(계 칸 없음 · 있으면 같은 값)
got = re.match(r"\s*(?:([A-Z])\s*)?(" + NUM + r")\.?(?![\d,])", text[m.end() :])
if not got:
return False
at = m.end()
if got.group(1):
self.wrap(ln, at + got.start(1), at + got.end(1), "\x06")
self.wrap(ln, at + got.start(2), at + got.end(2), "\x0c")
ln["줄참조"] = [_v(got.group(2))]
ln["저장"] = {**money, "T": money[parts[0]]}
self.link(ln, "소계", None)
ln["비목"] = self.money_part(ln)
ln["표지"] = "소계"
self.var(ln, at + got.end(2))
res = _result(text)
if res and res[0] > at + got.end(2):
self.result(ln, res)
return True
def rate62(self, ln: dict) -> bool:
"""「(A 수 + 수) × n% = 값」 · 「A 수 + 수 = 합 × n% = 값」 — 구간 합 율(62 · 합계 표지)."""
text = ln["보임"]
m = _GROUP62.search(text)
if not m or not re.search(r"[×xX*]", text[m.end() :]) or not ln["저장"]:
return False
self.wrap(ln, m.start(1), m.end(1), "\x07")
self.wrap(ln, m.start(2), m.end(3), "\x1a")
ln |= {"합계": True}
self.link(ln, "율62", None)
ln["비목"] = self.money_part(ln)
ln["표지"] = "율"
for r in re.finditer(r"=\s*(?:([A-Z])\s*)?(" + NUM + r")\.?(?![\d,])", text):
self.result(
ln, (r.start(2), r.end(2), _v(r.group(2)), r.start(1) if r.group(1) else None)
)
return True
def rate(self, ln: dict) -> bool:
"""「수 × n% = 값」 + 금액 칸 — 율(58 · 첫 수 = 줄참조)."""
text = ln["보임"]
res = _result(text)
if not ln["저장"] or not res:
return False
colon = _COLON.search(text)
nums = _nums(text, colon.end() if colon else 0, text.rfind("="))
seen = [v for x in self.lines for v in x["결과"] + x["줄참조"]]
before = any(_close(nums[0][2], v) for v in seen) if nums else False
lead = text[colon.end() if colon else 0 :].lstrip(" (")
before = before and lead[:1].isdigit()
if not nums or ("%" not in text[: text.rfind("=")] and not before):
return False
self.wrap(ln, nums[0][0], nums[0][1], "\x0c")
ln["줄참조"] = [nums[0][2]]
self.link(ln, "율", None)
ln["비목"] = self.money_part(ln)
ln["표지"] = "율"
self.result(ln, res)
return True
def plain(self, ln: dict) -> None:
"""글 · 변수 줄 — 대입마다 「이름 = 식 = 값」 = 결과 · 그 밖 「… = 값」(식 있는 줄) = 결과."""
text = ln["보임"]
ln["연결"] = 0
part = self.money_part(ln)
ln["비목"] = part
hits: list[re.Match] = []
for m in _ASSIGN.finditer(text):
if m.group(1).lower() in _UNITS and re.search(r"\d\.?\s?$", text[: m.start()]):
continue # 「8 hr =」 = 단위
gap = text[hits[-1].end() : m.start()] if hits else ""
if not hits or (re.search(r"\d", gap) and not re.search(r"[×÷+\-*/(=]\s*$", gap)):
hits.append(m)
lead = text[: hits[0].start()].strip(" ○●·-{") if hits else ""
if hits and (not lead or re.search(r"[::]$", lead)):
for n, m in enumerate(hits):
end = hits[n + 1].start() if n + 1 < len(hits) else len(text)
seg = text[m.end() : end]
parts = seg.split("=")
val = re.match(r"\s*(?:[A-Z](?=\d))?(" + NUM + r")", parts[-1])
if not val:
continue
at = m.end() + len(seg) - len(parts[-1])
if len(parts) >= 2:
res = _result(text[:end])
if res and res[0] >= at:
self.result(ln, res)
self.vars.pop(m.group(1), None)
self.vars[m.group(1)] = _v(val.group(1))
return
eq = text.rfind("=")
seg = re.split(r"[=::]", text[:eq])[-1]
if eq > 0 and re.search(r"[×÷+*]|\d\s*/|/\s*\d", seg):
res = _result(text)
if res:
self.result(ln, res)
# ── 책 ────────────────────────────────────────────────────────────────
def _glyph(text: str, marks: list[tuple[int, int, str]]) -> str:
"""보인 글 + 표지 자리 → 원파일 LineData 꼴(닫는 표지 먼저)."""
ins: list[tuple[int, int, str]] = []
for s, e, ch in marks:
ins.append((s, 1, ch))
ins.append((e, 0, ch))
out, last = [], 0
for at, _, ch in sorted(ins, key=lambda x: (x[0], x[1])):
out.append(text[last:at])
out.append(ch)
last = at
out.append(text[last:])
return "".join(out)
def index(res: dict[str, list[dict]]) -> dict[str, Any]:
"""자원 표 → 찾기 색인(단가 → 노임 · 자재 · 이름 → 기계 · (종류, 번) → 산근 · 견적)."""
out: dict[str, Any] = dict(res)
out["_값"] = {}
for kind in ("노임", "자재"):
for one in res.get(kind) or []:
out["_값"].setdefault(round(one["값"]["T"], 4), []).append((kind, one))
out["_기계"] = {}
for one in res.get("기계") or []:
if sx.flat(one["이름"]):
out["_기계"].setdefault(sx.flat(one["이름"]), []).append(one)
names = sorted(out["_기계"], key=len, reverse=True)
out["_기계틀"] = re.compile("(?=(" + "|".join(map(re.escape, names)) + "))") if names else None
out["_번"] = {(k, one["번"]): one for k in ("산근", "견적") for one in res.get(k) or []}
return out
def read(sheets: dict[str, list]) -> dict[str, Any]:
"""EST 엑셀 시트 → {꼴, 산근, 일위, 자원, 내역, 표지}(`M02_San_Estx.read` 꼴 · 일위 = 호표 길)."""
res = sx.resources(sheets)
look = index(res)
total = {one["번"]: one for one in res.get("산근") or []}
out: dict[str, Any] = {"꼴": "EST", "산근": [], "일위": [], "내역": None}
for b in blocks(sheets.get(SHEET) or []):
tags: list[str] = []
one = total.get(b["번"])
m = _HEAD.match(b["머리"])
head = {
"코드": f"단산{b['번']}",
"이름": one["이름"] if one else m.group(2).strip(),
"규격": one["규격"] if one else "",
"단위": one["단위"] if one else m.group(3).strip(),
"저장": b["저장"] or (dict(one["값"]) if one else None),
}
if not one:
tags.append(f"{sx.NOTE}총괄표에 단산{b['번']} 없음")
elif one.get("칸"): # 총괄표 당초 / 적용 칸(797-1a)
tags.append(f"참고 · {one['칸']}")
work = Lines(look, tags)
for src in b["줄"]:
work.run(src)
work.unused()
work.sections(head["저장"] or {})
lines = []
for ln in work.lines:
marks = ln.pop("_marks")
ln.pop("_칸", None)
ln["글"] = _glyph(ln["보임"], marks)
lines.append(ln)
out["산근"].append({**head, "번": b["번"], "엑셀행": b["행"], "줄": lines, "표지": tags})
out["자원"] = {
f"{kind}:{one['번']}": {k: one[k] for k in ("이름", "규격", "단위", "표")}
| {"저장": one["값"]}
for kind, rows in res.items()
for one in rows
}
out["표지"] = [
f"{sx.NOTE}자원 코드 · LinkIndex · 자리(Diget) 없음 — 연결뜻 = 글 · 금액 칸 · 단가표로 정함({sx.GUESS})",
f"{sx.NOTE}자원 코드 = 단가표 이름#No(산근 = 단산 번)",
]
return out
+77
View File
@@ -0,0 +1,77 @@
"""STmate 산근 엑셀 → 810-1 구조 줄(PLAN 812-1 · `M02_San_Stc.read` 와 같은 꼴 · 읽기만 · 서버 단독).
엑셀 「단가산출근거」 숨은 G 열 = 원파일 COSTN.SMT 원문 그대로 → 줄 = `M02_San_Stc.stc_line`(풀이 =
`M02_San_Stmate.parse_line` · 다시 짜지 않음) · 블록 머리 금액(C~F) = 원파일 저장(TAMT1 …) ·
단가 = 목록표(노무비 · 재료비 · 경비 · 중기 · 단가산출근거 · 숨은 메타 = COSTN 칸) ·
끝전 = 파일 투표(`_원파일파서_틀.md` 4 장 SAN_TYP 두 갈래 중 블록 계가 맞는 수가 많은 쪽 · 파일에 하나 ·
같으면 비목버림).
"""
from __future__ import annotations
from typing import Any
from M02_MasterTemplete import M02_San_Book as book
from M02_MasterTemplete import M02_San_Eval as ev
from M02_MasterTemplete import M02_San_Stc as stc
from M02_MasterTemplete import M02_San_Xlsx as sx
RULE = {"비목버림": "1", "합버림_큰나머지": "0"} # 끝전 갈래 → SAN_TYP
def _amt(head: dict) -> dict[str, float]:
return {c: float(head.get(k) or 0) for c, k in zip("TLMS", "CDEF")}
def vote(blocks: list[dict], prices: dict) -> tuple[str, dict[str, int]]:
"""파일 끝전 — 블록마다 두 갈래로 셈해(낙찰 · 조정 뒤) 엑셀 머리 계(C)와 맞는 수 · 많은 쪽(같으면
비목버림)."""
got = dict.fromkeys(RULE, 0)
for blk in blocks:
nodes = [ln["풀이"] for ln in blk["줄"]]
want = (blk.get("저장") or {}).get("T")
for rule in RULE:
try:
total = ev.stmate_block({"코드": blk["코드"]}, nodes, prices, rule)["금액"]["T"]
except Exception: # noqa: BLE001 — 못 셈 = 표 없음
continue
got[rule] += want is not None and abs(float(total) - float(want)) < 0.5
best = max(RULE, key=lambda r: (got[r], r == "비목버림"))
return best, got
def read(sheets: dict[str, list]) -> dict[str, Any]:
"""STmate 엑셀 시트 → {꼴, 산근, 일위, 자원, 내역, 단가, 옵션, 표지}."""
found = book.stmate_prices(sheets)
prices = {c: {k: p.get(k) for k in ("T", "L", "M", "S", "meta")} for c, p in found.items()}
res = {
c: {"종류": stc.KIND.get(c[:1]), **{k: p.get(k) for k in ("이름", "규격", "단위")}}
| {"저장": {k: p.get(k) for k in "TLMS"}}
for c, p in found.items()
}
out: dict[str, Any] = {"꼴": "STmate", "산근": [], "일위": [], "자원": res, "단가": prices}
for blk in book.stmate_blocks(sheets):
tags: list[str] = []
lines = [stc.stc_line(i, ln["G"], res, tags) for i, ln in enumerate(blk["몸"], 1)]
for one, src in zip(lines, blk["몸"]):
one["엑셀행"] = src["행"]
me = found.get(blk["코드"]) or {}
head = {"코드": blk["코드"], "이름": blk.get("이름") or me.get("이름")}
head |= {"규격": me.get("규격"), "단위": me.get("단위"), "저장": _amt(blk["머리"])}
_, tot = ev.run_stmate([ln["풀이"] for ln in lines], prices)
body = [{k: ln.get(k) for k in ("행", "B", "C", "D", "E", "F")} for ln in blk["몸"]]
out["산근"].append(
{**head, "줄": lines, "줄합": tot, "표지": tags, "엑셀행": blk["행"]}
| {
"몸": body,
"꼬리": blk["꼬리"],
} # 엑셀 줄 금액 · 계 꼬리(검산 · `M02_San_Emit.stmate`)
)
rule, got = vote(out["산근"], prices)
out["옵션"] = {"SAN_TYP": RULE[rule]}
out["내역"] = None
out["표지"] = [
f"{sx.NOTE}일위대가 구성 · 내역은 호표 길(산근만 읽음)",
f"{sx.NOTE}끝전 = 파일 투표 {rule}(비목버림 {got['비목버림']} · 합버림 {got['합버림_큰나머지']} 블록)",
]
return out
+36 -70
View File
@@ -1,14 +1,13 @@
"""M02 불러오기 단산 블록 → 가져온 식 단가산출(PLAN 793-3b · 요구 646 · 647 · 652) — 서버 단독.
올림 때 경쟁사 산근 시트(`M02_San_Book.sheets` · STmate 숨은 G 열 포함)를
`실무자료/산근/<HP>.json` 에 둠
(원본 엑셀은 안 둠 · HP 지울 때 같이 지움) → [가져온 식으로 열기] = 그 블록을 793-2a 내보내기
(`M02_San_Emit` · 이음 = 1차 줄 판정) → 793-2 만들기(`M02_Sangeun_Imported.create` · 개인 층 ·
올림 때 경쟁사 산근 시트(`M02_San_Book.sheets` · STmate 숨은 G 열 포함)와 그 엑셀 읽기기 둘 꼴
(812-1 `M02_San_Xlsx` → `M02_Doc_FromNative.pack` · 칸 `엑셀`)을 `실무자료/산근/<HP>.json` 에 둠
(원본 엑셀은 안 둠 · HP 지울 때 같이 지움) → [가져온 식으로 열기] = 그 블록을 원파일과 같은 길
(`M02_Doc_FromNative.emit` · 원본 `엑셀` 참) → 793-2 만들기(`M02_Sangeun_Imported.create` · 개인 층 ·
엑셀 단가 그대로 · 경쟁사 로직 인정 — 근거 기초 제안 없음 · 809-1 · 요구 703) ·
같은 내용 SK 가 있으면 그것(다시씀).
블록 번 = 판정 블록 번(STmate = 코드 「D00102」 숫자 · EST = 단가산출 블록 번).
STmate 블록 끝전(비목버림 | 합버림_큰나머지)은 파일 설정 — 참조 평가기로 둘 다 셈해
엑셀 계와 맞는 것.
STmate 블록 끝전 = 파일 투표(`M02_San_Xlsx_Stmate.vote`).
원파일(.estx · .stc · 810-2 `M02_Doc_FromNative`) = 같은 자리 칸 `원파일`(둘 꼴) — 있으면 원파일 우선
(그 블록 번이 원파일에 있을 때) · 엑셀 올림 때 같은 공사 원파일이 다른 HP 에 있으면 붙임 · 원파일 올림 =
같은 공사 엑셀 HP 에 붙임(없으면 원파일로 새 HP · `upload_native`).
@@ -17,7 +16,6 @@ STmate 블록 끝전(비목버림 | 합버림_큰나머지)은 파일 설정 —
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
@@ -30,7 +28,6 @@ from M02_MasterTemplete import M02_San_Emit as em
DIRNAME = "산근"
RELOAD = "가져온 식 자료 없음 — 엑셀을 다시 올림"
RULES = ("비목버림", "합버림_큰나머지")
MAX_INNER = 6 # 안쪽 산근 깊이 상한
@@ -60,14 +57,33 @@ def keep(folder: str | Path, key: str, name: str, data: bytes) -> str | None:
return f"엑셀 형식 오류 — {type(e).__name__}: {str(e)[:80]}"
if not book.kind_of(sheets):
return None
try:
excel = _excel(name, sheets)
except Exception as e: # noqa: BLE001 — 산근 읽기 실패 = 사유(올리기는 됨)
return f"산근 시트 읽기 오류 — {type(e).__name__}: {str(e)[:80]}"
path = _path(folder, key)
path.parent.mkdir(parents=True, exist_ok=True)
got = {"파일": name, "시트": sheets}
got = {"파일": name, "시트": sheets, "엑셀": excel}
native = next((n for n in _natives(folder) if fn.same(n, sheets)), None)
atomic_write_json(path, got | ({"원파일": native} if native else {}))
return None
def _excel(name: str, sheets: dict) -> dict:
"""산근 시트 → 원파일과 같은 둘 꼴(812-1 엑셀 읽기기 `M02_San_Xlsx` → `M02_Doc_FromNative.pack`)."""
from M02_MasterTemplete import M02_San_Xlsx as sx
return fn.pack(sx.read_sheets(sheets), name)
def _excel_of(path: Path, saved: dict) -> dict:
"""저장 HP 의 엑셀 둘 꼴 — 옛 저장(시트만)은 한 번 지어 그 자리에 둠."""
if not isinstance(saved.get("엑셀"), dict):
saved["엑셀"] = _excel(str(saved.get("파일") or ""), saved["시트"])
atomic_write_json(path, saved)
return saved["엑셀"]
# ── 원파일(810-2) ──────────────────────────────────────────────────────
def _saved(folder: str | Path) -> list[tuple[Path, dict]]:
out = []
@@ -114,32 +130,12 @@ def has(folder: str | Path, key: str) -> bool:
return _path(folder, key).is_file()
def _rule(blk: dict, nodes: list, prices: dict) -> str:
"""STmate 블록 끝전 — 엑셀 계(꼬리 · 없으면 머리)와 맞는 쪽 · 둘 다 아니면 비목버림."""
from M02_MasterTemplete import M02_San_Eval as ev
tails = [t for t in blk["꼬리"] if t["A"].startswith("계") and not t["A"].startswith("계약")]
want = (tails or [blk["머리"]])[0].get("C")
for rule in RULES:
try:
got = ev.stmate_block(blk, nodes, prices, rule)["계"]["T"]
except Exception: # noqa: BLE001 — 못 셈 = 다음 갈래
continue
if want is not None and abs(float(got) - float(want)) < 0.5:
return rule
return RULES[0]
def emit(folder: str | Path, key: str, no: int) -> dict[str, Any]:
"""HP 단산 블록 하나 → `{로직, 원본}`(793-2a 꼴 · 이음 = M01 키)."""
from M02_MasterTemplete import M02_San_Book as book
from M02_MasterTemplete import M02_San_Emit as em
from M02_MasterTemplete import M02_San_Est as est
from M02_MasterTemplete import M02_San_Stmate as stm
"""HP 단산 블록 하나 → `{로직, 원본}`(원파일 우선 · 아니면 엑셀 읽기기 같은 길 · 이음 = M01 키)."""
hopyo_store.read(folder, key) # 없는 HP = 404
path = _path(folder, key)
try:
saved = json.loads(_path(folder, key).read_bytes())
saved = json.loads(path.read_bytes())
except (OSError, ValueError) as e:
raise store.StoreError(409, RELOAD) from e
native = saved.get("원파일")
@@ -147,52 +143,22 @@ def emit(folder: str | Path, key: str, no: int) -> dict[str, Any]:
return fn.emit(native, int(no))
if not saved.get("시트"):
raise store.StoreError(404, f"없는 단산{no}")
sheets, name = saved["시트"], str(saved.get("파일") or "")
if book.kind_of(sheets) == "STmate":
blk = next((b for b in book.stmate_blocks(sheets) if int(b["코드"][1:]) == int(no)), None)
if blk is None:
raise store.StoreError(404, f"없는 단산{no}")
prices = book.stmate_prices(sheets)
nodes = stm.parse_block(blk["몸"])
return em.stmate(blk, nodes, prices, _rule(blk, nodes, prices), True, name)
prices, total = book.est_prices(sheets)
blk = next((b for b in book.est_blocks(sheets) if int(b["번"]) == int(no)), None)
if blk is None or blk["번"] not in total:
excel = _excel_of(path, saved)
if int(no) not in fn.numbers(excel):
raise store.StoreError(404, f"없는 단산{no}")
return em.est(blk, est.parse_block(blk["글"], prices), total[blk["번"]], True, name)
return fn.emit(excel, int(no))
def _inner(
source: Path, key: str, logic: dict, native: bool = False
) -> list[tuple[dict, int | None, str | None]]:
"""안쪽 산근 줄 `[(호표 줄, 같은 엑셀 안쪽 블록 번, 못 잇는 까닭)]` — STmate = 줄 코드가 블록 코드 ·
EST = 줄 이름 「단산N」(N 은 총괄표에 있는 블록). 비목 하나만 부른 줄은 그 비목이 그대로(옮김 없음)일 때만."""
from M02_MasterTemplete import M02_San_Book as book
saved = json.loads(_path(source, key).read_bytes())
"""안쪽 산근 줄 `[(호표 줄, 같은 파일 안쪽 블록 번, 못 잇는 까닭)]` — 원파일 · 엑셀 둘 꼴 같은 길
(`M02_Doc_FromNative.inner` · 줄 자원 코드 = 블록 코드)."""
path = _path(source, key)
saved = json.loads(path.read_bytes())
if native and isinstance(saved.get("원파일"), dict):
return fn.inner(saved["원파일"], logic)
sheets = saved["시트"]
stmate = book.kind_of(sheets) == "STmate"
have = {b["코드"]: int(b["코드"][1:]) for b in book.stmate_blocks(sheets)} if stmate else {}
total = set() if stmate else set(book.est_prices(sheets)[1])
out = []
for line in logic["호표"]:
res = line.get("자원") or {}
if res.get("갈래") != "단가산출":
continue
if stmate:
n = have.get(str(res.get("코드")))
else:
hit = re.fullmatch(r"단산(\d+)", str(res.get("이름")))
n = int(hit.group(1)) if hit and int(hit.group(1)) in total else None
if n is None:
out.append((line, None, "같은 엑셀에 안쪽 블록 없음"))
elif res.get("옮김") or (res.get("꼴") == "EST" and res.get("열쇠비목") not in em.COMP):
out.append((line, n, "비목이 바뀐 부름"))
else:
out.append((line, n, None))
return out
return fn.inner(_excel_of(path, saved), logic)
def _link_inner(source: Path, key: str, no: int, out: dict, trail: tuple) -> None:
@@ -561,3 +561,7 @@ M01은 지금 상당히 잘 개발이 되었어. 일위대가 불러오기의
## 요구 712 (11-09 · 사용자 · 매칭은 로직으로(돈만 보지 말 것) · 권장 순서 뒤 정합성 추정 · 엑셀 엔진 새로 짜는 게 나은지)
결국 전부 단가산출을 제대로 연결할 수 있어야한다인데. 결과적으로 단가산출은 로직과 값으로 되어있음. 경쟁사도 값은 주기적으로 업데이트 됨. 그렇다면 매칭해야하는 자료는 로직임. 단순히 총합계나 계산된 돈만 보면 판단이 어려워지니 조심할 것. 다음으로 권장 순서대로 했을때 정합성이 어느정도 될것같아? 그리고 현 엑셀엔진을 처음부터 코드를 만드는게 더 효율적이고 체계적일 것 같다는 생각이드는데 어떻게 생각함?
## 요구 713 (11-09 · 사용자 · 제안대로 진행(엑셀 읽기기 새로 + 연결 기준 = 로직) · 가져온 단가산출이 좌측에서 읽히게(나열만 X))
다음으로 중요한 점은 경쟁사의 단가산출을 가져왔다 가정하고 현재의 경쟁사 단가산출은 좌측영역에서 제대로 읽기가 힘든 그냥 노무비, 경비, 재료 1,2, 3 형태로 죽 나열만 되어있음. 분석도 중요하지만 보여지는것도 중요함. 너의 제안대로 잘 끝까지 진행해줘
-171
View File
@@ -1,171 +0,0 @@
"""793-1 지킴 — 경쟁사 산근 글 식 파서 · 참조 평가기(`M02_San_*`) · 실무 여덟 파일 금액 그대로.
STmate = 숨은 G 열 원문 식 → 233/233 블록 · 금액 줄 1,315 · 계표시 줄 695 다 맞음.
EST = 보이는 글 → 식 복원 → 222/222 블록 · 돈 · 값 줄 2,901/2,903 · 모호 단가 토큰 9.
단가 바뀜 전파 견본 = `_분석_649-G2` 4 장(보통인부 → 180,000).
엑셀 = `helper_m02_hopyo_golden.FILES`(읽기만) · 없으면 skip(다른 PC).
"""
from __future__ import annotations
from functools import lru_cache
import helper_m02_hopyo_golden as golden
import pytest
from M02_MasterTemplete import M02_San_Book as book
from M02_MasterTemplete import M02_San_Est as est
from M02_MasterTemplete import M02_San_Eval as ev
from M02_MasterTemplete import M02_San_Stmate as stm
# 파일: (블록, 금액 줄, 계표시 줄, 끝전)
STMATE = {
"봉화": (62, 349, 184, "비목버림"),
"동부청영월": (69, 411, 230, "합버림_큰나머지"),
"울진대흥": (47, 261, 128, "비목버림"),
"영덕계류": (15, 86, 41, "비목버림"),
"울진소광": (40, 208, 112, "비목버림"),
}
# 파일: (블록, 돈 · 값 줄, 틀린 줄 상한, 모호 토큰)
EST = {"북부청임도": (94, 1252, 2, 0), "북부청사방": (67, 849, 0, 7), "거창": (61, 802, 0, 2)}
LABOR = ("노임", "보통인부", "", "L")
@lru_cache(maxsize=8)
def _load(tag: str) -> dict:
path = golden.FILES[tag]
if not path.exists():
pytest.skip(f"실무 엑셀 없음 {path.name}")
return book.load(path)
def _stmate_runs(tag: str) -> list[tuple]:
data = _load(tag)
out = []
for blk in data["블록"]:
if not any(ln["G"].strip() for ln in blk["몸"]):
continue
res = ev.stmate_block(blk, stm.parse_block(blk["몸"]), data["단가"], STMATE[tag][3])
out.append((blk, res, ev.stmate_check(blk, res)))
return out
@pytest.mark.parametrize("tag", list(STMATE))
def test_stmate_blocks_equal_excel(tag: str) -> None:
blocks, amount_lines, total_lines, _ = STMATE[tag]
runs = _stmate_runs(tag)
assert len(runs) == blocks
wrong = [b["코드"] for b, _, c in runs if not c["맞음"]]
assert not wrong, wrong
assert sum(c["금액줄"] for _, _, c in runs) == amount_lines
assert [x for _, _, c in runs for x in c["틀린줄"]] == []
assert sum(c["계줄"] for _, _, c in runs) == total_lines
assert sum(c["계줄맞음"] for _, _, c in runs) == total_lines
@pytest.mark.parametrize("tag", list(EST))
def test_est_blocks_equal_excel(tag: str) -> None:
blocks, lines, bad_max, vague = EST[tag]
data = _load(tag)
n, wrong, got_lines, got_bad, got_vague = 0, [], 0, 0, 0
for blk in data["블록"]:
want = data["총괄"].get(blk["번"])
if not want or not (want["T"] or any(want[c] for c in "LMS")):
continue
parsed = est.parse_block(blk["글"], data["단가"])
chk = ev.est_check(parsed, want)
n += 1
if not chk["맞음"]:
wrong.append(blk["번"])
got_lines += chk["줄수"]
got_bad += len(chk["틀린줄"])
got_vague += sum(1 for L in parsed for r in (L.get("역할") or {}).values() if r.get("모호"))
assert (n, wrong) == (blocks, [])
assert got_lines == lines
assert got_bad <= bad_max
assert got_vague == vague
@pytest.mark.parametrize(
"tag, code, labor, before, after",
[
("봉화", "D00102", "L00007", (34119, 11192, 341), (34697, 11192, 346)),
("동부청영월", "D00003", "L00002", (4764, 310, 397), (5097, 309, 397)),
("울진소광", "D01489", "L00016", (182223, 230359, 8152), (185067, 230359, 8209)),
],
)
def test_stmate_price_propagates(tag, code, labor, before, after) -> None:
data = _load(tag)
blk = next(b for b in data["블록"] if b["코드"] == code)
nodes = stm.parse_block(blk["몸"])
rule = STMATE[tag][3]
prices = dict(data["단가"])
prices[labor] = {**prices[labor], "T": 180000, "L": 180000}
for p, want in ((data["단가"], before), (prices, after)):
got = ev.stmate_block(blk, nodes, p, rule)["계"]
assert (got["L"], got["M"], got["S"]) == want
@pytest.mark.parametrize(
"tag, no, after",
[
("북부청사방", 31, (14877, 5491, 0)),
("북부청임도", 2, (22774, 3235, 3830)),
("거창", 1, (3485, 832, 1049)),
("북부청사방", 3, (20147, 391, 0)),
],
)
def test_est_price_propagates(tag, no, after) -> None:
data = _load(tag)
blk = next(b for b in data["블록"] if b["번"] == no)
parsed = est.parse_block(blk["글"], data["단가"])
des = ev.designate(parsed, data["총괄"][no])
got = ev.est_block(parsed, des, {LABOR: 180000.0})["금액"]
assert (got["L"], got["M"], got["S"]) == after
# ── 엑셀 없이 도는 문법 견본(`_분석_648-B` 3-1 봉화 D00102) ──
def test_stmate_grammar_sample() -> None:
prices = {
"M00081": {"T": 11468, "L": 0, "M": 11468, "S": 0, "meta": {}},
"L00007": {"T": 165545, "L": 165545, "M": 0, "S": 0, "meta": {}},
"L00099": {"T": 2, "L": 2, "M": 0, "S": 0, "meta": {"UNT": "L%"}},
}
text = [
"'합판(내수) :'&M00081& * 1.03 * 0.327 :=A01",
";;Q = 2.5 / 3 =",
"'보통인부' &L00007& * [Q] / 4 :=A02",
"&PA01M:M& * 11'%' =",
"&PA02L:S& * 1'%' = // 공구손료",
"(=)",
"&L00099& * 2 =",
"(-=)",
]
nodes = stm.parse_block([{"행": i, "G": g} for i, g in enumerate(text)])
assert [n["종류"] for n in nodes] == ["참조", "대입", "참조", "율", "율", "계", "참조", "계"]
assert nodes[1]["숨김"] and nodes[4]["덧글"] == "공구손료"
assert nodes[3]["율"] == {"곱": False, "범위": "A", "번지": ["A01"], "비목": "M", "옮김": "M"}
lines, tot = ev.run_stmate(nodes, prices)
assert lines[0]["값"]["M"] == 3862.5
assert lines[2]["값"]["L"] == 34350.5 # 165,545 × 0.83(Q 반올림) / 4 → 0.1 버림
assert lines[3]["값"]["M"] == 424.8 # A01 재료 3,862.5 × 11 %
assert lines[4]["값"]["S"] == 343.5 # A02 노무 34,350.5 × 1 % → 경비
assert lines[5]["값"] == pytest.approx({"L": 34350.5, "M": 4287.3, "S": 343.5})
assert lines[6]["종류"] == "특수" and lines[6]["값"]["L"] == 687.0 # 노무 누계 × 2 %
assert lines[7]["값"]["L"] == 687.0 # (-=) 찍되 합에서 뺌
assert tot == pytest.approx({"L": 34350.5, "M": 4287.3, "S": 343.5})
def test_est_grammar_sample() -> None:
prices = [{"종류": "노임", "이름": "보통인부", "규격": "", "비목": "L", "값": 172068.0}]
text = [
"보통인부 : 172,068 × 0.09 인 = 15,486.1",
"공구손료 : 15,486.1 × 3 % = 464.5",
"소 계 : 15,950.6",
"노무비 : 15,950.6 × 1 = 15,950",
]
lines = est.parse_block(text, prices)
assert [L.get("종류") for L in lines] == ["단가줄", "참조줄", "값줄", "참조줄"]
assert lines[3]["규칙"] == "버림0" and lines[2]["숨은"]["갈래"] == "합"
val = ev.run_est(lines, {LABOR: 180000.0})
assert val[0] == 16200.0 and val[1] == 486.0 and val[3] == 16686.0
+3 -72
View File
@@ -1,6 +1,6 @@
"""793-2a 지킴 — 경쟁사 산근 → 우리 엔진 로직 줄 꼴(`M02_San_Emit`) · 엔진 셈 = 엑셀.
여덟 파일 STmate 233 · EST 222 블록을 로직 JSON 으로 내보내 `master_formula.run` →
STmate 233 블록을 로직 JSON 으로(EST 엑셀 = 812-1 엑셀 읽기기 → 원파일 길 · `test_812_1` 이 지킴) 내보내 `master_formula.run` →
`master_cut.engine` 으로 셈 — 노 · 재 · 경 · 계 = 엑셀(STmate 계 꼬리 · EST 총괄표) =
참조 평가기(`M02_San_Eval`) · 줄마다(STmate 금액 줄 · EST 돈 · 값 줄) · 계표시(단계) 같음.
단가 전파 견본 = `_분석_649-G2` 4 장(보통인부 → 180,000) · 계약단가(낙찰률)는 B09 몫 — 엔진 계 ×
@@ -19,7 +19,6 @@ import pytest
from M01_MasterData import M01_MasterData_Store as m01 # noqa: F401 — scripts 길을 둠
from M02_MasterTemplete import M02_San_Book as book
from M02_MasterTemplete import M02_San_Emit as em
from M02_MasterTemplete import M02_San_Est as est
from M02_MasterTemplete import M02_San_Eval as ev
from M02_MasterTemplete import M02_San_Stmate as stm
@@ -34,9 +33,6 @@ STMATE = { # 파일: (블록, 금액 줄, 계표시 줄, 끝전)
"영덕계류": (15, 86, 41, "비목버림"),
"울진소광": (40, 208, 112, "비목버림"),
}
EST = {"북부청임도": (94, 1252), "북부청사방": (67, 849), "거창": (61, 802)} # (블록, 돈 · 값 줄)
# 엑셀 글 식이 보인 값과 안 맞는 줄(793-1 틀린 줄 2) — 보인 값으로 얼림 · 블록 금액은 같음
EST_FROZEN = {"북부청임도": {27: 1, 33: 1}}
K = ("노무비", "재료비", "경비", "계")
LABOR = {"종류": "노임", "이름": "보통인부", "규격": "", "열쇠비목": "L"}
@@ -71,7 +67,8 @@ def _load(tag: str) -> dict:
path = golden.FILES[tag]
if not path.exists():
pytest.skip(f"실무 엑셀 없음 {path.name}")
return book.load(path)
sheets = book.sheets(path.name, path.read_bytes())
return {"블록": book.stmate_blocks(sheets), "단가": book.stmate_prices(sheets)}
def _stmate(tag: str, blk: dict) -> tuple[list, dict]:
@@ -127,30 +124,6 @@ def test_stmate_engine_equals_excel(tag: str) -> None:
assert (lines, steps) == (n_lines, n_steps)
@pytest.mark.parametrize("tag", list(EST))
def test_est_engine_equals_excel(tag: str) -> None:
data = _load(tag)
n, wrong, lines = 0, [], 0
for blk in data["블록"]:
want = data["총괄"].get(blk["번"])
if not want or not (want["T"] or any(want[c] for c in "LMS")):
continue
n += 1
parsed = est.parse_block(blk["글"], data["단가"])
out = em.est(blk, parsed, want)
got = engine(out)
ref = ev.est_block(parsed, ev.designate(parsed, want))["금액"]
same = money(got) == (want["L"], want["M"], want["S"], want["T"])
same &= money(got) == (ref["L"], ref["M"], ref["S"], ref["T"])
bad, a, _ = _lines_equal(out, got)
bad += ms.check(out["로직"], str(blk["번"]))
lines += a
frozen = len(hard(out))
if not same or bad or frozen != EST_FROZEN.get(tag, {}).get(blk["번"], 0):
wrong.append((blk["번"], money(got), bad[:3], hard(out)))
assert (n, wrong, lines) == (EST[tag][0], [], EST[tag][1])
@pytest.mark.parametrize(
"tag, code, labor, before, after",
[
@@ -167,23 +140,6 @@ def test_stmate_price_propagates(tag, code, labor, before, after) -> None:
assert money(engine(out))[:3] == after
@pytest.mark.parametrize(
"tag, no, after",
[
("북부청사방", 31, (14877, 5491, 0)),
("북부청임도", 2, (22774, 3235, 3830)),
("거창", 1, (3485, 832, 1049)),
("북부청사방", 3, (20147, 391, 0)),
],
)
def test_est_price_propagates(tag, no, after) -> None:
data = _load(tag)
blk = next(b for b in data["블록"] if b["번"] == no)
out = em.est(blk, est.parse_block(blk["글"], data["단가"]), data["총괄"][no])
assert em.reprice(out["로직"], LABOR, {"L": 180000.0}) >= 1
assert money(engine(out))[:3] == after
def test_link_keys_m01() -> None:
"""호표 줄 = 793-2 꼴(종류 · 단가 · 이음 = 1차 줄 판정 M01 키) · 덧칸 자원 · 원본."""
tag = "봉화"
@@ -266,28 +222,3 @@ def test_stmate_grammar_sample_engine() -> None:
assert (
float(by["공구_2"]["비목"]["노무비"]) == lines[10]["값"]["L"] == 113.3
) # 34,350.5 × 0.33 %
def test_est_grammar_sample_engine() -> None:
prices = [{"종류": "노임", "이름": "보통인부", "규격": "", "비목": "L", "값": 172068.0}]
text = [
"보통인부 : 172,068 × 0.09 인 = 15,486.1",
"공구손료 : 15,486.1 × 3 % = 464.5",
"소 계 : 15,950.6",
"노무비 : 15,950.6 × 1 = 15,950",
]
lines = est.parse_block(text, prices)
want = {"T": 15950.0, "L": 15950.0, "M": 0.0, "S": 0.0}
out = em.est({"번": 1, "이름": "견본", "단위": "㎡"}, lines, want)
logic = out["로직"]
assert [x["식"] for x in logic["덧줄"]] == [
"((줄.'보통인부' * 3) / 100)",
"줄.'보통인부' + 공구손료",
"소계",
]
assert logic["호표"][0]["종류"] == "인력" and logic["호표"][0]["단가"] == 172068
assert logic["덧줄"][2]["자리"] == {"방법": "버림", "자리": 0}
assert [x.get("합밖") for x in (*logic["호표"], *logic["덧줄"])] == [True, True, True, None]
assert money(engine(out)) == (15950, 0, 0, 15950)
em.reprice(logic, LABOR, {"L": 180000.0})
assert money(engine(out)) == (16686, 0, 0, 16686) # 793-1 run_est 전파와 같음
@@ -337,26 +337,20 @@ def test_원본_보존_사본():
# ── 실무 블록(793-2a 내보내기 → 가져온 식 SK) ─────────────────────────────
REAL = [("봉화", "D00102"), ("동부청영월", "D00003"), ("울진소광", "D01489"), ("북부청임도", 18)]
RULE = {"봉화": "비목버림", "동부청영월": "합버림_큰나머지", "울진소광": "비목버림"}
def _emitted(tag, code):
"""실무 블록 → 812-1 엑셀 읽기기 → 원파일 길(`M02_Doc_FromNative.emit`)."""
import helper_m02_hopyo_golden as golden
from M02_MasterTemplete import M02_San_Book as book
from M02_MasterTemplete import M02_San_Emit as em
from M02_MasterTemplete import M02_San_Est as est
from M02_MasterTemplete import M02_San_Stmate as stm
from M02_MasterTemplete import M02_Doc_FromNative as fn
from M02_MasterTemplete import M02_San_Xlsx as sx
if not golden.FILES[tag].exists():
path = golden.FILES[tag]
if not path.exists():
pytest.skip(f"실무 엑셀 없음 {tag}")
data = book.load(golden.FILES[tag])
if tag in RULE:
blk = next(b for b in data["블록"] if b["코드"] == code)
return em.stmate(blk, stm.parse_block(blk["몸"]), data["단가"], RULE[tag], True, tag)
blk = next(b for b in data["블록"] if b["번"] == code)
want = data["총괄"][blk["번"]]
return em.est(blk, est.parse_block(blk["글"], data["단가"]), want, True, tag)
saved = fn.pack(sx.read(path.name, path.read_bytes()), path.name)
return fn.emit(saved, int(str(code).lstrip("D")))
@pytest.mark.parametrize(("tag", "code"), REAL)
@@ -87,14 +87,16 @@ RULES = [("봉화", "D00102", "비목버림"), ("동부청영월", "D00003", "
@pytest.mark.parametrize(("tag", "code", "rule"), RULES)
def test_STmate_끝전_엑셀_계와_맞는_쪽(tag, code, rule):
from M02_MasterTemplete import M02_San_Book as book
from M02_MasterTemplete import M02_San_Stmate as stm
"""끝전 = 파일 투표(812-1 `M02_San_Xlsx_Stmate.vote` · 블록 계가 맞는 수가 많은 쪽)."""
from M02_MasterTemplete import M02_San_Xlsx as sx
from M02_MasterTemplete import M02_San_Xlsx_Stmate as xs
if not golden.FILES[tag].exists():
pytest.skip(f"실무 엑셀 없음 {tag}")
data = book.load(golden.FILES[tag])
blk = next(b for b in data["블록"] if b["코드"] == code)
assert fh._rule(blk, stm.parse_block(blk["몸"]), data["단가"]) == rule
path = golden.FILES[tag]
got = sx.read(path.name, path.read_bytes())
assert got["옵션"]["SAN_TYP"] == xs.RULE[rule]
assert any(b["코드"] == code for b in got["산근"])
# ── 화면 소스 ─────────────────────────────────────────────────────────
+3 -4
View File
@@ -148,14 +148,13 @@ def test_nested_stmate_follows_inner(world) -> None:
def test_nested_est_by_part_matches_excel(world) -> None:
folder, _ = _hp(world, "북부청임도")
with store.at(folder):
made = fh.make(folder, KEY, 67) # 단산68 을 노무비 · 재료비 · 경비 세 줄로 부름
made = fh.make(folder, KEY, 67) # 단산68 세 줄 = 묶음 한 줄로 부름(812-1 · 원파일 길)
doc = _read(made["이름"])
inner = [x for x in doc[imp.KEY]["로직"]["호표"] if x.get("참조")]
assert [x["참조비목"] for x in inner] == ["노무비", "재료비", "경비"]
assert len({x["참조"]["키"] for x in inner}) == 1
assert len(inner) == 1 and "참조비목" not in inner[0]
assert made["검산"]["같음"] is True and eqm.read(doc)["ok"] is True
# 엑셀 단가 그대로 금액 = 굳힌 값 합과 같음
frozen = sum(float(x["값묶음"][x["참조비목"]]) * 0.03 for x in inner)
frozen = sum(float(v) for v in inner[0]["값묶음"].values())
assert float(made["검산"]["셈"]["계"]) == pytest.approx(
float(made["검산"]["엑셀"]["계"]), abs=1
)
+20 -175
View File
@@ -1,27 +1,23 @@
"""797-1a 지킴 — 엑셀 불러오기 빠진 정보 표지 다섯 · 줄 단위 합격 · 표본 밖 둘 고정(요구 658).
"""797-1a 지킴 — 엑셀 불러오기 빠진 정보 표지(요구 658) · 812-1 뒤 남은 것.
표지 다섯(`M02_San_Emit` · `M02_San_Est` · `M02_San_Book` · `M02_Sangeun_FromHopyo`) —
① 금액 줄 못 읽음(글끝 · 식못읽음 · 결과 뒤 꼬리 글은 떼고 읽음) ② 단가 후보 여럿 · 추정
③ 당초 / 적용 칸 ④ 조정 비율 · 낙찰률 미지원 ⑤ 읽기 불가 사유 — 작은 견본(엑셀 없이).
줄 단위 합격 = 블록 합만이 아니라 표지 없는 줄 중 틀린 줄 0(표지 달린 줄은 틀림에서 뺌) ·
표본 밖(개발에 안 쓴 파일: 2026 수목진료 142 · 울진대흥 공통 46 · 2016 사방 374)을 합격 수로 고정 ·
EST 단가줄 버림1 일치율 감시(낮아지면 실패). 실무문서 엑셀 없으면 skip(읽기만).
③ 당초 / 적용 칸(`M02_San_Book.applied_how` → 엑셀 읽기기 블록 표지 → 원본 표지) ④ 조정 비율 · 낙찰률
미지원(`M02_San_Emit.stmate`) ⑤ 읽기 불가 사유(`M02_Sangeun_FromHopyo.keep`) — 작은 견본(엑셀 없이).
옛 EST 글 추정 표지(① 금액 줄 못 읽음 · ② 단가 후보 여럿) · 줄 단위 합격 · 표본 밖 수 · 버림1 감시는
옛 엔진과 함께 걷음 — 812-1 `test_812_1_xlsx_golden`(실제 짝 · 합성 · 실무 11 금액)이 지킴.
`FILES`(실무 11) = 812-1 시험이 씀.
"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
import helper_m02_hopyo_golden as golden
import pytest
from M02_MasterTemplete import M02_Doc_FromNative as fn
from M02_MasterTemplete import M02_San_Book as book
from M02_MasterTemplete import M02_San_Emit as em
from M02_MasterTemplete import M02_San_Est as est
from M02_MasterTemplete import M02_San_Eval as ev
from M02_MasterTemplete import M02_San_Expr as ex
from M02_MasterTemplete import M02_San_Stmate as stm
from M02_MasterTemplete import M02_San_Xlsx as sx
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh
NOTE = em.NOTE
@@ -37,63 +33,6 @@ def hard(out: dict) -> list[str]:
return [m for m in out["원본"]["표지"] if NOTE not in m]
# ── ① 금액 줄 못 읽음 ──────────────────────────────────────────────────
TAIL = " 보통인부 : 94,338 ×0.0328인 = 3,094.2 : 채집"
PRICE_L = {"종류": "노임", "이름": "보통인부", "규격": "", "비목": "L", "값": 94338.0}
def _est_out(text: list[str], prices: list[dict], want: dict):
rows = list(range(10, 10 + len(text)))
blk = {"이름": "견본", "단위": "m", "번": 1, "글": text, "글행": rows}
lines = est.parse_block(text, prices)
return lines, em.est(blk, lines, want)
def test_tail_text_is_stripped_and_read_as_money() -> None:
one = est.parse_line(TAIL)
assert (one["꼴"], one["값"], one["라벨"]) == ("돈", 3094.2, "보통인부")
want = {"T": 3094, "L": 3094, "M": 0, "S": 0}
lines, out = _est_out([TAIL, " 노무비 : 3,094"], [PRICE_L], want)
assert [x["종류"] for x in lines] == ["단가줄", "값줄"]
assert hard(out) == [] and notes(out) == []
assert out["로직"]["호표"][0]["이름"] == "보통인부"
def test_unreadable_money_line_is_flagged_with_shown_value() -> None:
cut = " 절취량 : (0.5×0.6×1.0) / 2 = 0.15㎥/m"
note = " 야산지 25%, 경사도 중(15~30˚)) 5% => 30% 할증"
bad = " 산출 : 가나다 = 12.5"
assert est.parse_line(cut) == {"꼴": "글끝", "라벨": "절취량", "보인": 0.15}
assert est.parse_line(note) == {"꼴": "글끝"} # 라벨 없는 설명 글 — 표지 안 함
assert est.parse_line(bad)["꼴"] == "식못읽음"
zero = {"T": 0, "L": 0, "M": 0, "S": 0}
_, out = _est_out([cut, note, bad], [], zero)
got = {g["행"]: g for g in out["원본"]["글줄"]}
flag = f"{NOTE}금액 줄 못 읽음 · 보인 값 얼림"
assert (got[10]["표지"], got[10]["값"]) == (flag, 0.15)
assert "표지" not in got[11] and "값" not in got[11]
assert (got[12]["표지"], got[12]["값"]) == (flag, 12.5)
assert len(notes(out)) == 2 and hard(out) == []
# ── ② 단가 후보 여럿 · 추정 ────────────────────────────────────────────
def test_ambiguous_price_is_flagged_not_silently_picked() -> None:
prices = [
{"종류": "자재", "이름": "철근", "규격": "", "비목": "M", "값": 500.0},
{"종류": "자재", "이름": "시멘트", "규격": "", "비목": "M", "값": 500.0},
]
text = [" 철근 : 500 ×2개 = 1,000", " 자재비 : 500 ×3개 = 1,500"]
want = {"T": 1500, "L": 0, "M": 1500, "S": 0}
lines, out = _est_out(text, prices, want)
assert [x["역할"][0]["갈림"] for x in lines] == ["문맥", "문맥"]
assert [x["역할"][0]["모호"] for x in lines] == [False, True]
got = {g["행"]: g["표지"] for g in out["원본"]["글줄"]}
assert got[10] == f"{NOTE}추정 — 단가 정체 근거 문맥"
assert got[11] == f"{NOTE}단가 후보 여럿(2) · 사람 확인"
assert hard(out) == []
assert [it["자원"].get("모호") for it in out["로직"]["호표"]] == [None, True]
# ── ③ 당초 / 적용 칸 ───────────────────────────────────────────────────
def _row(applied: tuple, first: tuple) -> list:
r = [None] * 29
@@ -126,13 +65,18 @@ def test_applied_or_first_column_is_flagged() -> None:
def test_first_column_note_reaches_block_flags() -> None:
head = [[None] * 29 for _ in range(3)]
sheet = [*head, [None, 1, "견본", ""] + [None] * 25]
sheet[3][25:29] = [90, 40, 30, 20]
prices, san = book.est_prices({"단가산출총괄표": sheet})
assert san[1]["칸"] == "당초 칸 사용 — 적용 칸 비어 있음" and san[1]["T"] == 90
assert {(p["종류"], p["비목"], p["값"]) for p in prices} >= {("산근", "T", 90.0)}
_, out = _est_out([" 노무비 : 40"], [], {**san[1]})
total = [*head, [None, "1", "견본", "", "m"] + [None] * 24]
total[2][1:5] = ["No", "품명", "규격", "단위"]
total[3][25:29] = [90, 40, 30, 20]
sheet = [[None, "단가산출"], [], [None, "산출근거", "합계", "노무비", "재료비", "경비"]]
sheet += [
[None, "1.견본[m]", 90, "40", "30", "20"],
[None, " 계 : 90", 90, "40", "30", "20"],
]
got = sx.read_sheets({"단가산출": sheet, "단가산출총괄표": total})
out = fn.emit(fn.pack(got, "견본.xls"), 1)
assert f"{NOTE}당초 칸 사용 — 적용 칸 비어 있음" in out["원본"]["표지"]
assert out["원본"]["엑셀"] is True
# ── ④ 조정 비율 · 낙찰률 미지원 ────────────────────────────────────────
@@ -172,7 +116,7 @@ def test_unreadable_reason_is_returned(tmp_path: Path) -> None:
assert not fh.has(tmp_path, "HP990001") # 못 읽은 파일은 안 둠
# ── 줄 단위 합격 · 표본 밖 고정 · 버림1 감시 ───────────────────────────
# ── 실무 엑셀 11(812-1 시험이 씀) ──────────────────────────────────────
def _oos() -> dict[str, Path]:
pack = DOCS / "2026_실무일위대가 모음"
common = DOCS / "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료"
@@ -185,102 +129,3 @@ def _oos() -> dict[str, Path]:
FILES = {**golden.FILES, **_oos()}
@lru_cache(maxsize=16)
def _load(tag: str) -> dict:
path = FILES[tag]
if not path.exists():
pytest.skip(f"실무 엑셀 없음 {path.name}")
return book.load(path)
def _stmate_tally(tag: str) -> dict:
data = _load(tag)
t = dict.fromkeys(("블록", "맞음", "금액줄", "표지없는틀린줄", "계줄", "계줄맞음"), 0)
for blk in data["블록"]:
if not any(ln["G"].strip() for ln in blk["몸"]):
continue
nodes = stm.parse_block(blk["몸"])
rule = fh._rule(blk, nodes, data["단가"])
chk = ev.stmate_check(blk, ev.stmate_block(blk, nodes, data["단가"], rule))
out = em.stmate(blk, nodes, data["단가"], rule, False, tag)
marked = {g["행"] for g in out["원본"]["글줄"] if g.get("표지")}
t["블록"] += 1
t["맞음"] += chk["맞음"]
t["금액줄"] += chk["금액줄"]
t["계줄"] += chk["계줄"]
t["계줄맞음"] += chk["계줄맞음"]
t["표지없는틀린줄"] += sum(1 for b in chk["틀린줄"] if b["행"] not in marked)
return t
def _est_tally(tag: str) -> dict:
data = _load(tag)
t = dict.fromkeys(("블록", "맞음", "줄수", "표지없는틀린줄", "표지줄", "단가줄", "버림1"), 0)
for blk in data["블록"]:
want = data["총괄"].get(blk["번"])
if not want or not (want["T"] or any(want[c] for c in "LMS")):
continue
parsed = est.parse_block(blk["글"], data["단가"])
chk = ev.est_check(parsed, want)
out = em.est(blk, parsed, want, False, tag)
rows = blk.get("글행") or []
marked = {g["행"] for g in out["원본"]["글줄"] if g.get("표지")}
t["블록"] += 1
t["맞음"] += chk["맞음"]
t["줄수"] += chk["줄수"]
t["표지줄"] += len(marked)
for b in chk["틀린줄"]:
k = b["차례"]
t["표지없는틀린줄"] += (rows[k] if k < len(rows) else k) not in marked
for ln in parsed:
if ln.get("종류") == "단가줄" and ln.get("셈") is not None:
t["단가줄"] += 1
t["버림1"] += abs(ex.RULE["버림1"](ln["셈"]) - ln["값"]) < ex.EPS
return t
# 표본 밖 = 개발에 안 쓴 파일(합격 수 고정) · 골든 = 개발에 쓴 파일
OOS_FIXED = {
"2026수목": {"블록": 142, "맞음": 142, "줄수": 1654},
"대흥공통": {"블록": 46, "맞음": 46, "금액줄": 258, "계줄": 127, "계줄맞음": 127},
"2016사방": {"블록": 374, "맞음": 374, "줄수": 1870},
}
# 단가줄 버림1 일치 수 / 단가줄 수(낮아지면 실패 — 다른 규칙으로 갈리는 줄이 늘었다는 뜻)
FLOOR_1 = {
"북부청임도": (539, 552),
"북부청사방": (363, 364),
"거창": (381, 382),
"2016사방": (718, 718),
"2026수목": (653, 653),
}
@pytest.mark.parametrize("tag", list(FILES))
def test_no_unmarked_wrong_line(tag: str) -> None:
"""줄 단위 — 표지 없는 틀린 줄 0 · 블록 다시 셈 = 엑셀 전부."""
est_form = _load(tag)["꼴"] == "EST"
t = _est_tally(tag) if est_form else _stmate_tally(tag)
assert t["블록"] > 0
assert (t["표지없는틀린줄"], t["맞음"]) == (0, t["블록"]), t
@pytest.mark.parametrize("tag", list(OOS_FIXED))
def test_out_of_sample_counts_fixed(tag: str) -> None:
if tag not in FILES:
pytest.skip("표본 밖 엑셀 없음")
t = _est_tally(tag) if tag != "대흥공통" else _stmate_tally(tag)
assert {k: t[k] for k in OOS_FIXED[tag]} == OOS_FIXED[tag]
@pytest.mark.parametrize("tag", list(FLOOR_1))
def test_unit_price_line_floor1_rate_does_not_drop(tag: str) -> None:
if tag not in FILES:
pytest.skip("실무 엑셀 없음")
t = _est_tally(tag)
hit, total = FLOOR_1[tag]
assert t["단가줄"] >= total and t["버림1"] / t["단가줄"] >= hit / total, (
t["버림1"],
t["단가줄"],
)
@@ -17,9 +17,9 @@ import helper_m02_hopyo_golden as golden
import pytest
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Doc_FromNative as fn
from M02_MasterTemplete import M02_Hopyo_Store as hs
from M02_MasterTemplete import M02_San_Basis_Index as bi
from M02_MasterTemplete import M02_San_Book as book
from M02_MasterTemplete import M02_San_Emit as em
from M02_MasterTemplete import M02_Sangeun_Doc as sd
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh
@@ -271,11 +271,10 @@ def _north(tmp_path):
def test_real_north_block_has_unique_candidate_and_flags(tmp_path) -> None:
sheets = _north(tmp_path)
_, total = book.est_prices(sheets)
nos = [int(b["번"]) for b in book.est_blocks(sheets) if b["번"] in total]
_north(tmp_path)
excel = json.loads(fh._path(tmp_path, "HP-000001").read_bytes())["엑셀"]
found = None
for no in nos:
for no in sorted(fn.numbers(excel)):
out = fh.emit(tmp_path, "HP-000001", no)
if "야면석채집및운반(계곡내)" in out["로직"]["이름"]:
found = out
+4 -2
View File
@@ -166,7 +166,9 @@ def test_stmate_nested_is_sibling_link(world) -> None:
def test_est_by_part_and_frozen_vars(world) -> None:
folder = _hp(world, "북부청임도")
with store.at(folder):
made = fh.make(folder, KEY, 67) # 단산68 을 노무비 · 재료비 · 경비 세 줄로 부름
made = fh.make(
folder, KEY, 67
) # 단산68 세 줄(노 · 재 · 경) = 묶음 한 줄(812-1 · 원파일 길)
doc = _read(made["이름"])
root = dim.derive(doc)
parts = [
@@ -174,7 +176,7 @@ def test_est_by_part_and_frozen_vars(world) -> None:
for x in root["금액"]
if (_var(root, x["이름"]).get("연결") or {}).get("키", "").startswith("SK-")
]
assert parts == [("노무비", "노무비"), ("재료비", "재료비"), ("경비", "경비")]
assert parts == [("묶음", None)]
assert not any(x["식"].strip().replace(".", "").isdigit() for x in root["식"])
excel = dim.run(root, doc)
assert float(excel["계"]) == pytest.approx(float(made["검산"]["엑셀"]["계"]), abs=1)
+1 -90
View File
@@ -2,8 +2,7 @@
(a) 거울 — node `tools/est-test/import-estx.cjs` 결과와 표 이름 · 행 수 · 모든 칸 값 1:1(다름 0)
(b) 블록 줄합(저장 결과만 · 셈 없음) = 제목 표 저장 금액(TC NC GC JC SC) 100 %
(c) 거창 = 지금 엑셀 엔진(`M02_San_Book` · `M02_San_Est` · `M02_San_Eval`)과 대조 — 블록 수 · 금액 ·
줄마다 갈래(단가 · 참조 수) · 다른 곳 = 엑셀 추정이 틀린 곳(고치지 않음 · 수만 지킴)
(c) 엑셀 짝 대조는 812-1 엑셀 읽기기 골든(`test_812_1_xlsx_golden` · 옛 엑셀 엔진 걷음)
원파일 · node 없으면 skip(다른 PC).
"""
@@ -12,7 +11,6 @@ from __future__ import annotations
import base64
import json
import math
import re
import shutil
import subprocess
import sys
@@ -156,90 +154,3 @@ def test_structure_marks() -> None:
assert [ln["비목"] for ln in b["줄"] if ln["합계"]] == ["L", "M", "S", "T"]
sub = [ln for d in [parsed("거창")] for b in d["산근"] for ln in b["줄"] if ln["산근"]]
assert sub and all(ln["자원"]["종류"] == "산근" for ln in sub)
# ── (c) 엑셀 엔진 대조 ─────────────────────────────────────────────────
def _key(s: str) -> str:
num = r"\d[\d,]*\.?\d*|\.\d+"
s = re.sub(num, lambda m: repr(float(m.group().replace(",", "").rstrip(".") or 0)), s)
return re.sub(r"\s", "", s)
def excel_diffs(tag: str = "거창") -> tuple[list[dict], dict]:
"""엑셀 엔진 블록 · 줄 갈래 대 원파일 → (다른 곳 목록, 셈)."""
sys.path.insert(0, str(ROOT / "resources" / "tester"))
import helper_m02_hopyo_golden as golden
from M02_MasterTemplete import M02_San_Book as book
from M02_MasterTemplete import M02_San_Est as est
from M02_MasterTemplete import M02_San_Eval as ev
if not golden.FILES[tag].exists():
pytest.skip("엑셀 없음")
xl = book.load(golden.FILES[tag])
by_no = {b["번"]: b for b in xl["블록"]}
out, cnt = [], {"블록": 0, "금액같음": 0, "글같음": 0, "줄": 0, "갈래같음": 0}
for no, b in enumerate(parsed(tag)["산근"], 1):
x = by_no.get(no)
if x is None:
out.append({"블록": no, "코드": b["코드"], "항목": "엑셀에 블록 없음"})
continue
cnt["블록"] += 1
want = xl["총괄"].get(no)
lines = est.parse_block(x["글"], xl["단가"])
chk = ev.est_check(lines, want) if want else {"금액": None}
saved = {c: b["저장"].get(k) or 0 for c, k in (("L", "L"), ("M", "M"), ("S", "S"))}
if chk["금액"] and all(abs(chk["금액"][c] - saved[c]) < 0.5 for c in "LMS"):
cnt["금액같음"] += 1
else:
out.append(
{
"블록": no,
"코드": b["코드"],
"항목": "금액",
"엑셀": chk["금액"],
"원파일": saved,
}
)
mine = [ln for ln in b["줄"] if ln["보임"].strip()]
if len(mine) == len(x["글"]) and all(
_key(a["보임"]) == _key(c) for a, c in zip(mine, x["글"])
):
cnt["글같음"] += 1
for L, ln in zip(lines, mine):
if not (ln["연결단가"] or ln["변수"] or ln["줄참조"] or L.get("꼴") == "돈"):
continue
cnt["줄"] += 1
roles = [r["갈래"] for r in (L.get("역할") or {}).values()]
hidden = len((L.get("숨은") or {}).get("줄") or []) # 값 줄 = 숨은 복사 · 합
got = (roles.count("단가"), roles.count("앞줄") + hidden)
truth = (len(ln["연결단가"]), len(ln["변수"]) + len(ln["줄참조"]))
money = L.get("꼴") == "돈" or L.get("종류") == "값줄"
auto = ln["합계"] or ln["표지"] == "소계" # 자동 합 · 소계 = 엑셀 숨은 합과 같은 뜻
ok = money and got[0] == truth[0] and (auto or got[1] == truth[1])
cnt["갈래같음"] += ok
if not ok:
out.append(
{
"블록": no,
"코드": b["코드"],
"행": ln["행"],
"항목": "갈래",
"글": ln["보임"].strip(),
"엑셀": f"{L.get('꼴')} 단가 {got[0]} · 앞줄 {got[1]}",
"원파일": f"단가 {truth[0]} · 변수 {len(ln['변수'])} · "
f"줄참조 {len(ln['줄참조'])}",
}
)
return out, cnt
def test_excel_geochang() -> None:
out, cnt = excel_diffs("거창")
assert (cnt["블록"], cnt["금액같음"], cnt["글같음"]) == (61, 61, 61)
assert [o for o in out if o["항목"] != "갈래"] == []
assert (cnt["줄"], cnt["갈래같음"]) == EXCEL_LINES
#: 거창 (원파일 · 엑셀 돈 줄 수, 엑셀 갈래 = 원파일) — 다른 곳 = `_목록_810-1_원파일대조.xlsx`
EXCEL_LINES = (547, 282)
+1 -71
View File
@@ -5,9 +5,7 @@
칸 · 산근 원문 1:1(다름 0) · 줄 풀이 = `M02_San_Stmate.parse_line`
(b) 블록 다시 셈(`M02_San_Eval.stmate_block` · 차수 1 단가 · 낙찰 · 결과 조정 · 끝전 갈래 = 옵션
SAN_TYP 1 비목버림 / 0 합버림_큰나머지) = COSTN 저장 금액(TAMT1 · LAMT1 · MAMT1 · SAMT1) 100 %
(c) 엑셀 짝 다섯(`helper_m02_hopyo_golden`) = 지금 엑셀 엔진 블록 금액 · 숨은 G 열 원문 ·
목록표 단가와
대조 — 다른 곳 = 엑셀 추정이 틀린 곳(수만 지킴) · 엑셀에 안 찍힌 블록 수 기록
(c) 엑셀 짝 대조는 812-1 엑셀 읽기기 골든(`test_812_1_xlsx_golden` · 옛 엑셀 엔진 걷음)
원파일 없으면 skip(다른 PC).
"""
@@ -117,71 +115,3 @@ def test_structure_line() -> None:
assert [ln["자원"]["코드"] for ln in refs] == ["M00080", "M00002", "M00001", "L00001", "L00007"]
assert refs[0]["자원"]["종류"] == "자재" and refs[3]["자원"]["종류"] == "노임"
assert [ln["단"] for ln in b["줄"] if ln["합계"]] == [1, 1, 2]
# ── (c) 엑셀 엔진 대조 ─────────────────────────────────────────────────
PAIRED = ("봉화", "동부청영월", "울진대흥", "영덕계류", "울진소광")
def excel_diffs(tag: str) -> tuple[list[dict], dict]:
"""엑셀 엔진(숨은 G 열 · 목록표 단가) 대 원파일 → (다른 곳, 셈)."""
import helper_m02_hopyo_golden as golden
from M02_MasterTemplete import M02_San_Book as book
if not golden.FILES[tag].exists():
pytest.skip("엑셀 없음")
xl = book.load(golden.FILES[tag])
d = parsed(tag)
rule = RULE[d["옵션"]["SAN_TYP"]]
mine = {b["코드"]: b for b in d["산근"]}
out = []
cnt = {"엑셀블록": len(xl["블록"]), "원파일블록": len(mine), "같이": 0, "금액같음": 0}
cnt |= {"글같음": 0, "단가": 0, "단가같음": 0}
for x in xl["블록"]:
b = mine.get(x["코드"])
if b is None:
out.append({"코드": x["코드"], "항목": "원파일에 블록 없음"})
continue
cnt["같이"] += 1
res = ev.stmate_block(x, stm.parse_block(x["몸"]), xl["단가"], rule)["금액"]
if all(abs(res[c] - b["저장"][c]) < 0.5 for c in "TLMS"):
cnt["금액같음"] += 1
else:
out.append({"코드": x["코드"], "항목": "금액", "엑셀": res, "원파일": b["저장"]})
g = [ln["G"].strip() for ln in x["몸"] if ln["G"].strip()]
if g == [ln["글"].strip() for ln in b["줄"] if ln["글"].strip()]:
cnt["글같음"] += 1
else:
out.append({"코드": x["코드"], "항목": "원문 글"})
for code, p in xl["단가"].items():
q = d["단가"].get(code)
cnt["단가"] += 1
if q and all(abs((p[c] or 0) - q[c]) < 0.5 for c in "TLMS"):
cnt["단가같음"] += 1
else:
out.append({"코드": code, "항목": "단가", "엑셀": p, "원파일": q})
for code in sorted(set(mine) - {x["코드"] for x in xl["블록"]}):
out.append({"코드": code, "항목": "엑셀에 안 찍힌 블록"})
return out, cnt
#: 엑셀 짝 → (엑셀 블록, 같이 있는 블록, 엑셀 단가 줄, 그중 같음, 엑셀에 안 찍힌 원파일 블록)
#: 울진대흥 = 엑셀(신설사업 판)과 원파일(설계원본 판)이 다른 판 — 코드 겹침 14 · 단가 40 빠짐
EXCEL = {
"봉화": (65, 65, 203, 203, 0),
"동부청영월": (69, 69, 201, 201, 0),
"울진대흥": (47, 14, 153, 113, 32),
"영덕계류": (15, 15, 54, 54, 20),
"울진소광": (40, 40, 138, 138, 22),
}
@pytest.mark.parametrize("tag", PAIRED)
def test_excel_engine(tag: str) -> None:
out, cnt = excel_diffs(tag)
n_xl, both, n_price, same_price, extra = EXCEL[tag]
assert (cnt["엑셀블록"], cnt["같이"], cnt["단가"]) == (n_xl, both, n_price)
assert cnt["금액같음"] == cnt["같이"] and cnt["글같음"] == cnt["같이"]
assert cnt["단가같음"] == same_price
assert sum(o["항목"] == "엑셀에 안 찍힌 블록" for o in out) == extra
+410
View File
@@ -0,0 +1,410 @@
"""812-1 골든 — 엑셀 읽기기(`M02_San_Xlsx`) = 810-1 원파일 파서와 같은 구조 줄(읽기만).
① 실제 짝 — 거창 xlsx ↔ 거창 .estx(블록 머리 · 줄마다 연결뜻 · 비목 · 변수 · 줄참조 · 결과 · 합계 · 저장 ·
자원 이름 · 규격 · 단위) · STC 다섯 xlsx ↔ .stc(같은 블록 · 원문 · 줄 칸 · 자원 이름 · 규격 · 끝전 = SAN_TYP)
② 합성 — 원파일 구조 줄 보임 글 + 저장 금액으로 경쟁사 내보내기 꼴 시트를 지어(EST = 거창 꼴 글자 칸 나눔 ·
닫는 표지 2 칸 · 합계 표지 1 칸 · 소계 61 = 비목 칸만 — 거창 실제와 글 배치 같음(수 꼴 빼고) 먼저 확인) →
읽기기 → 원파일 구조 줄과 같음(EST 1,849 · STC 371 블록) · 원파일 자체 엇갈림(같은 글 · 다른 표지)은
`ODD` 로 고정
③ 금액 — 실무 엑셀 11 = 읽기기 → `M02_Doc_FromNative` → 수식 문서 셈 = 엑셀 블록 금액 100 %
빠른 견본 기본 · 전수 = 환경 S8121_ALL=1 · 파일 없으면 skip(다른 PC).
"""
from __future__ import annotations
import math
import os
import re
import sys
import unicodedata
from collections import Counter
from functools import lru_cache
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HERE = Path(__file__).resolve().parent
for p in (ROOT, HERE):
if str(p) not in sys.path:
sys.path.insert(0, str(p))
import helper_m02_hopyo_golden as golden # noqa: E402
import test_797_1a_import_flags as practice # noqa: E402
import test_810_1_estx as estx # noqa: E402
import test_810_1_stc as stc # noqa: E402
from M02_MasterTemplete import M02_Doc_FromNative as fn # noqa: E402
from M02_MasterTemplete import M02_San_Book as book # noqa: E402
from M02_MasterTemplete import M02_San_Xlsx as sx # noqa: E402
ALL = os.environ.get("S8121_ALL") == "1"
F = sx.flat
FIELDS = ("연결뜻", "비목", "변수", "줄참조", "결과", "합계")
#: 원파일 자체 엇갈림 — 같은 보인 글 · 금액 칸인데 원파일 표지가 다른 줄(엑셀이 못 가름 · 고정)
ODD = {
("main", 638, 30), # 산근 세 줄 뒤 「소 계」 연결 없음(같은 꼴 다른 곳 = 104)
("main", 1249, 230), # 같음
("main", 1199, 40), # 「0 × 1% = A0」 금액 0 — 비목 S 가 금액 칸에 없음
("main", 1438, 4), # 「{ 골재장 상차도 }」 글 줄에 비목 M
("main", 1286, 4), # 괄호 속 「L = … = 46.00 m」 결과 표지 없음(같은 꼴 다른 곳 = 결과)
("main", 1391, 6), # 「= 5.714㎡ / 100㎡ =」 중간 값에 결과 표지
*(("main", 1308, r) for r in (8, 16, 26)), # 금액 칸 없는 「소 계 : 수 kg」 = 61(861 = 글)
*(("main", 1441, r) for r in (64, 65, 66, 67)), # 단가가 기계표와 다른 세 줄 · 비목 없음
}
#: 금액 — 셈 ≠ 엑셀 블록(없음 · 생기면 까닭과 함께 고정)
ODD_MONEY: set[tuple] = set()
# ── 합성(경쟁사 내보내기 꼴) ─────────────────────────────────────────────
W, MC = 146, [141, 142, 143, 144]
_OPEN = dict.fromkeys("\x03\x0e\x11\x12\x13\x15\x0c\x06\x07", 0) | {"\x1a": 1}
_CLOSE = dict.fromkeys("\x03\x0e\x11\x12\x13\x15\x0c\x06\x07", 2) | {"\x1a": 1}
def _w(ch: str) -> int:
return 2 if unicodedata.east_asian_width(ch) in "WFA" else 1
def _cls(ch: str) -> str:
if ch.isdigit() or ch in ",.":
return "n"
o = ord(ch)
if 0xAC00 <= o <= 0xD7A3 or 0x3131 <= o <= 0x318E or 0x3300 <= o <= 0x33FF:
return "h"
return "a" if ch.isascii() and ch.isalpha() else "s"
def cells(text: str) -> list[tuple[int, str]]:
"""LineData → 글자 칸(열 = 화면 자리 · 여는 표지 0 · 닫는 표지 2 · 합계 표지 1 칸 · 표지 안 = 한 칸)."""
out: list[list] = []
col, cur, opened, angle = 2, None, set(), False
for i, ch in enumerate(text):
if ord(ch) < 32:
col += _CLOSE.get(ch, 0) if ch in opened else _OPEN.get(ch, 0)
opened ^= {ch}
cur = None
continue
whole = bool(opened & set("\x0e\x13\x15")) or angle
if ch == "<" and ">" in text[i:]:
angle, whole = True, False
if ch == " " and not (whole and cur):
cur, col = None, col + 1
continue
c = _cls(ch)
joined = cur is not None and (
whole
or (c == cur[2] and c != "s")
or (cur[2] == "a" and ch.isdigit())
or (ch == ":" and cur[2] == "h")
)
if joined:
cur[1] += ch
else:
cur = [col, ch, c]
out.append(cur)
angle = angle and ch != ">"
col += _w(ch)
return [(a, b.rstrip()) for a, b, _ in out]
def _fmt(v: float | None) -> str | None:
return None if not v else f"{v:,.6f}".rstrip("0").rstrip(".")
def _money(row: list, saved: dict | None, link: int | None) -> None:
s = saved or {}
if not any(s.get(c) for c in "TLMS"):
return
if link != 61 and s.get("T"):
row[MC[0]] = int(s["T"]) if s["T"] == int(s["T"]) else s["T"]
for c, j in zip("LMS", MC[1:]):
row[j] = _fmt(s.get(c))
def _table(title: str, head: dict, recs: list[dict]) -> list[list]:
rows = [[None] * 30 for _ in range(3)]
rows[0][1] = title
for j, h in head.items():
rows[2][j] = h
for r in recs:
rows.append([r.get(j) for j in range(30)])
return rows
def synth_est(d: dict) -> dict[str, list]:
"""원파일 EST → 엑셀 꼴 시트(단가산출 · 총괄표 · 노임 · 자재 · 기계 · 견적)."""
rows = [[None] * W for _ in range(3)]
rows[0][1], rows[2][1] = "단가산출", "산 출 근 거"
labels = ("합 계", "노무비", "재료비", "경 비")
for j, h in [*zip(MC, labels), *zip((2, 3, 4, 5), labels)]:
rows[2][j] = h
tot = []
for n, b in enumerate(d["산근"], 1):
head = [None] * W
size = f"({b['규격']})" if b.get("규격") else ""
head[1] = f"{n}.{b['이름'] or ''}{size}[{b.get('단위') or ''}]"
_money(head, b["저장"], None)
rows.append(head)
for ln in b["줄"]:
row = [None] * W
for j, v in cells(ln["글"]):
if j < MC[0]:
row[j] = v
_money(row, ln["저장"], ln["연결"])
rows.append(row)
s = b["저장"] or {}
one = {1: str(n), 2: b["이름"], 3: b.get("규격"), 4: b.get("단위"), 5: s.get("T")}
one |= {6: _fmt(s.get("L")), 7: _fmt(s.get("M")), 8: _fmt(s.get("S"))}
tot.append(one | {25: s.get("T"), 26: s.get("L"), 27: s.get("M"), 28: s.get("S")})
head4 = {
1: "No",
2: "품명",
3: "규격",
4: "단위",
5: "합계",
6: "노무비",
7: "재료비",
8: "경비",
}
out = {"단가산출": rows, "단가산출총괄표": _table("단가산출총괄표", head4, tot)}
recs: dict[str, list] = {"노임": [], "자재": [], "기계": [], "견적": []}
for key, v in d["자원"].items():
kind, s = key.split(":")[0], v["저장"] or {}
base = {2: v["이름"], 3: v["규격"], 4: v["단위"]}
if kind == "노임":
recs[kind].append(base | {7: s.get("T")})
elif kind == "자재":
recs[kind].append(base | {19: s.get("T")})
elif kind in ("기계", "견적"):
one = base | {5: s.get("T"), 6: s.get("L"), 7: s.get("M"), 8: s.get("S")}
recs[kind].append(
one | {25: s.get("T"), 26: s.get("L"), 27: s.get("M"), 28: s.get("S")}
)
for kind in recs:
for i, r in enumerate(recs[kind], 1):
r[1] = str(i)
one_price = {1: "No", 2: "품명", 3: "규격", 4: "단위"}
out["노임단가"] = _table("노임단가", one_price | {7: "적용단가"}, recs["노임"])
out["자재단가"] = _table("자재단가", one_price | {19: "적용단가"}, recs["자재"])
out["기계경비총괄표"] = _table("기계경비총괄표", head4, recs["기계"])
out["견적단가"] = _table("견적단가", head4, recs["견적"])
return out
def synth_stmate(n: dict) -> dict[str, list]:
"""원파일 STC → 엑셀 꼴 시트(단가산출근거 숨은 G 열 원문 · 목록표 숨은 메타)."""
rows: list[list] = [[None] * 12 for _ in range(3)]
for b in n["산근"]:
s, at = b["저장"], len(rows) + 3
head = [b["코드"], None, s["T"], s["L"], s["M"], s["S"], None, None, None, None, None, None]
head[6] = f"BDCOD|{b['코드']}\x05POSS|{at}\x05POSE|{at + len(b['줄']) - 1}"
rows += [head, [None, b["이름"]] + [None] * 10]
rows += [[None] * 6 + [ln["글"]] + [None] * 5 for ln in b["줄"]]
out = {"단가산출근거": rows}
lists: dict[str, list] = {k: [] for k in ("노무비", "재료비", "경비", "단가산출근거")}
for code, v in n["자원"].items():
p = n["단가"].get(code) or {}
meta = "\x05".join(
[f"COD|{code}"]
+ [f"{k}|{x}" for k, x in (p.get("meta") or {}).items() if x is not None]
)
r = [None, v["이름"], v["규격"], v["단위"], p.get("T")] + [None] * 7
if code[:1] in "LMS":
r[6] = meta
lists[{"L": "노무비", "M": "재료비", "S": "경비"}[code[:1]]].append(r)
else:
r[5:8], r[9] = [p.get("L"), p.get("M"), p.get("S")], meta
lists["단가산출근거"].append(r)
for k, v in lists.items():
out[f"{k}목록표"] = [[None] * 12] * 3 + v
return out
# ── 맞대기 ─────────────────────────────────────────────────────────────
def _res(r: dict | None, unit: bool = True) -> tuple | None:
if not r:
return None
return (r.get("종류"), F(r.get("이름")), F(r.get("규격")), F(r.get("단위")) if unit else "")
def _vals(v: list) -> list:
return [round(x, 6) for x in v]
def _saved(s: dict | None) -> tuple:
return tuple(round(float((s or {}).get(c) or 0), 6) for c in "TLMS")
def diff_est(tag: str, mine: dict, nat: dict) -> set[tuple]:
"""EST 블록 · 줄 맞대기 → 다른 (파일, 블록, 행) · 블록 머리 다름 = 행 0."""
bad = set()
assert len(mine["산근"]) == len(nat["산근"])
for n, (a, b) in enumerate(zip(mine["산근"], nat["산근"]), 1):
if (F(a["이름"]), F(a["규격"]), a["단위"], _saved(a["저장"])[:4]) != (
F(b["이름"]),
F(b["규격"]),
b["단위"],
_saved(b["저장"])[:4],
):
bad.add((tag, n, 0))
body = [x["보임"].strip() for x in b["줄"] if x["보임"].strip()]
assert len([x for x in a["줄"] if x["보임"].strip()]) == len(body)
for x, y in zip(a["줄"], b["줄"]):
same = all(
_vals(x[f]) == _vals(y[f]) if f in ("변수", "줄참조", "결과") else x[f] == y[f]
for f in FIELDS
)
same = same and _saved(x["저장"]) == _saved(y["저장"])
mine_r, want_r = _res(x["자원"]), _res(y["자원"])
same = same and (mine_r == want_r or (want_r and want_r[0] == "산근" and mine_r))
if not same:
bad.add((tag, n, y["행"]))
return bad
def diff_stc(mine: dict, nat: dict) -> Counter:
"""STmate — 같은 코드 블록 · 원문 · 줄 칸 · 자원 이름 · 규격(단위 = 원파일 COSTN 은 기호 글자) 다른 수."""
got: Counter = Counter()
have = {b["코드"]: b for b in nat["산근"]}
for b in mine["산근"]:
y = have.get(b["코드"])
if y is None:
got["원파일 없는 블록"] += 1
continue
got["블록"] += 1
if [ln["글"].strip() for ln in b["줄"]] != [ln["글"].strip() for ln in y["줄"]]:
got["원문 다름"] += 1
continue
got["저장 다름"] += _saved(b["저장"])[:4] != _saved(y["저장"])[:4]
for x, z in zip(b["줄"], y["줄"]):
got["줄"] += 1
fields = ("연결뜻", "비목", "변수", "줄참조", "합계", "기계", "산근")
got["줄 다름"] += any(x[f] != z[f] for f in fields)
got["자원 다름"] += _res(x["자원"], False) != _res(z["자원"], False)
return got
# ── ① 실제 짝 ─────────────────────────────────────────────────────────
@lru_cache(maxsize=4)
def excel(tag: str) -> dict:
path = practice.FILES.get(tag)
if not path or not path.exists():
pytest.skip(f"실무 엑셀 없음 {tag}")
return sx.read(path.name, path.read_bytes())
def test_real_pair_geochang_equals_estx() -> None:
assert diff_est("거창", excel("거창"), estx.parsed("거창")) == set()
STC_PAIRS = ("봉화", "동부청영월", "울진대흥", "영덕계류", "울진소광")
@pytest.mark.parametrize("tag", STC_PAIRS)
def test_real_pair_stmate_equals_stc(tag: str) -> None:
mine, nat = excel(tag), stc.parsed(tag)
got = diff_stc(mine, nat)
assert got["블록"] and got["줄"]
assert (got["원문 다름"], got["저장 다름"], got["줄 다름"], got["자원 다름"]) == (0, 0, 0, 0), (
got
)
assert mine["옵션"]["SAN_TYP"] == nat["옵션"]["SAN_TYP"] # 끝전 파일 투표 = 원파일 옵션
# ── ② 합성 ────────────────────────────────────────────────────────────
def _num_norm(text: str) -> str:
num = re.compile(r"\d[\d,]*(?:\.\d*)?|\.\d+")
out = num.sub(lambda m: repr(float(m.group(0).replace(",", "").rstrip(".") or 0)), text)
return re.sub(r"\s+", "", out)
def test_synth_layout_equals_real_geochang() -> None:
"""합성 규칙 검증 — 거창 실제 시트 줄 = 원파일 LineData 합성 칸(글 배치 · 수 꼴 「8.」 · 「.1」 빼고)."""
path = golden.FILES["거창"]
if not path.exists():
pytest.skip("거창 엑셀 없음")
rows = book.sheets(path.name, path.read_bytes())["단가산출"]
heads = [
i
for i, r in enumerate(rows)
if isinstance(r[1], str) and re.match(r"^\d+\.", r[1]) and r[141]
]
nat = estx.parsed("거창")["산근"]
bad = total = 0
for k, (h, b) in enumerate(zip(heads, nat)):
end = heads[k + 1] if k + 1 < len(heads) else len(rows)
for ln, r in zip(b["줄"], rows[h + 1 : end]):
real = [(j, sx._cell(v)) for j, v in enumerate(r[2:141], 2) if sx._cell(v).strip()]
mine = sx.layout(real, 2)
total += 1
bad += _num_norm(mine) != _num_norm(sx.layout(cells(ln["글"]), 2))
got = sx.money_of(r, MC) or {}
want = {c: v for c, v in (ln["저장"] or {}).items() if v and c in "TLMS"}
if ln["연결"] == 61:
want.pop("T", None)
bad += {c: round(v, 6) for c, v in got.items()} != {
c: round(v, 6) for c, v in want.items()
}
assert total > 2000 and bad == 0
EST_SAMPLE = ("거창", "통영")
@pytest.mark.parametrize("tag", list(estx.FILES) if ALL else EST_SAMPLE)
def test_synth_est_equals_native(tag: str) -> None:
nat = estx.parsed(tag)
got = diff_est(tag, sx.read_sheets(synth_est(nat)), nat)
assert got == {x for x in ODD if x[0] == tag}, sorted(got)[:10]
@pytest.mark.parametrize("tag", list(stc.FILES) if ALL else ("봉화",))
def test_synth_stmate_equals_native(tag: str) -> None:
nat = stc.parsed(tag)
mine = sx.read_sheets(synth_stmate(nat))
got = diff_stc(mine, nat)
assert got["블록"] == len(nat["산근"]) and got["원파일 없는 블록"] == 0
assert (got["원문 다름"], got["저장 다름"], got["줄 다름"], got["자원 다름"]) == (0, 0, 0, 0), (
got
)
assert mine["옵션"]["SAN_TYP"] == nat["옵션"]["SAN_TYP"]
# ── ③ 금액 ────────────────────────────────────────────────────────────
def sweep(tag: str) -> dict:
"""엑셀 → 읽기기 → 둘 꼴 → 블록마다 수식 문서 셈 = 엑셀 블록 금액(낙찰률 = 계약단가 뒤)."""
import test_810_2_native_doc as nd
path = practice.FILES[tag]
saved = fn.pack(excel(tag), path.name)
out = {"블록": 0, "같음": 0, "다름": set(), "변수줄": 0, "자원줄": 0, "자원추정": 0}
for ln in (x for b in saved["산근"] for x in b["줄"]):
out["변수줄"] += bool(ln.get("변수"))
out["자원줄"] += bool(ln.get("자원"))
for no, blk in fn.numbers(saved).items():
out["블록"] += 1
emitted = fn.emit(saved, no)
_, _, got = nd.doc_and_money(emitted)
mine = {c: got.get(k) for c, k in nd.COST}
nag = (emitted["원본"].get("계약") or {}).get("낙찰률")
if nag:
for c in "LMS":
mine[c] = math.floor(round(float(mine[c]) * nag / 100, 6))
mine["T"] = mine["L"] + mine["M"] + mine["S"]
want = blk.get("저장") or {}
if all(abs(float(mine[c] or 0) - float(want.get(c) or 0)) < 0.5 for c, _ in nd.COST):
out["같음"] += 1
else:
out["다름"].add((tag, no))
assert emitted["원본"]["엑셀"] is True and not emitted["원본"]["원파일"]
return out
MONEY_SAMPLE = ("거창", "봉화")
@pytest.mark.parametrize("tag", list(practice.FILES) if ALL else MONEY_SAMPLE)
def test_money_equals_excel(tag: str) -> None:
got = sweep(tag)
assert got["블록"] and got["다름"] == {x for x in ODD_MONEY if x[0] == tag}, sorted(got["다름"])