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>
This commit is contained in:
2026-11-09 07:34:28 +09:00
co-authored by Claude Opus 5.5
parent 5531e5f39f
commit 8cd7baea5e
7 changed files with 1369 additions and 85 deletions
+426
View File
@@ -0,0 +1,426 @@
"""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` → 수식 문서 셈 = 엑셀 블록 금액(`ODD_MONEY` 고정)
빠른 견본 기본 · 전수 = 환경 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 = {
(
"북부청임도",
33,
), # 노무비 줄 「36,248 ÷ 0.096」 오타 — 절 소계 덧셈이 그 줄을 뺌(글 덧셈만 앎)
(
"북부청임도",
3,
), # 아래 넷 = `M02_Doc_NativeEst.triple` 자리 규칙 dict 묶음 TypeError(812-3 몫)
("북부청임도", 54),
("북부청임도", 55),
("북부청사방", 24),
}
# ── 합성(경쟁사 내보내기 꼴) ─────────────────────────────────────────────
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
try:
emitted = fn.emit(saved, no)
_, _, got = nd.doc_and_money(emitted)
except TypeError:
out["다름"].add((tag, no))
continue
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["다름"])