Files
Aislo/resources/tester/test_810_2_native_doc.py
T
eomsangdonandClaude Opus 5.5 53210f9a7a feat(810-2): 원파일 구조 줄 → 수식 문서 변환 · 원파일 우선 올림 · 전수 대조
- 새 M02_Doc_NativeEst(EST 산근 블록 → 가져온 로직 · 단가 \x03 · 변수 \x11 · 줄참조 \x0c 표지로 정함 · 변수 이름 그대로 · 분수 그림 공식 = 위아래 줄 칸 · 기계 · 산근 · 견적 세 줄 = 로직 값묶음 한 줄 · 소계 61 = 합 밖 · 율 58 · 62 = 덧줄 · 못 읽은 줄 = 보인 값 얼림 + 참고 표지 · 품셈 조건 글 = 추정 표지)
- 새 M02_Doc_FromNative(원파일 읽기 · 둘 꼴 · 블록 번 = 엑셀 길과 같음 · STmate = 원문 그대로 M02_San_Emit.stmate · 같은 공사 판정 · EST 일위대가 → 실무자료 호표 꼴)
- M02_Sangeun_FromHopyo — 산근 자리 칸 원파일 · 원파일 우선 emit · 안쪽 산근 원파일 코드로 · upload_native(같은 공사 엑셀 HP 에 붙음 · 없으면 새 HP)
- M02_Hopyo_Router 올림 — .estx · .stc 이면 upload_native(한 덩이)
- M02_Doc_Import — 원파일 문서 소계 덧줄 비목 칸 비움 · 수 하나 변수 출처 원파일 · 머리 원파일
- 수식 문서 셈 짝(master_doc · master_doc_calc ↔ common_util_formula_doc · _calc) — 합밖 + 비목 비움 = 금액만(소계 보임) · 그 밖 그대로
- M02_San_Estx 구조 줄 색인(Index) 칸 · 어디서 왔나 카드 「EST · 원파일」
- 전수 대조 — EST 산근 1,849 · STC 371 블록 수식 문서 셈 = 원파일 저장값(STC 계약단가 82 = 낙찰률 버림 뒤) · 표지 없는 틀린 줄 0 · 목록 docs/raw/요구_1104/_목록_810-2_원파일변환.xlsx
- 시험 test_810_2_native_doc(전수 · 꼴 · 문서 셈 거울 · 올림 분기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 20:11:03 +09:00

260 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""810-2 지킴 — 원파일 구조 줄 → 수식 문서(`M02_Doc_NativeEst` · `M02_Doc_FromNative`) · 원파일 우선.
(a) 전수 — EST 여섯 · STC 일곱 산근 블록 모두: 가져온 로직 → 수식 문서 셈(엑셀 단가 그대로) = 원파일
저장 금액(STmate 계약단가 = 낙찰률 버림 뒤) · 검산 줄 틀림은 모두 표지 줄(표지 없는 틀린 줄 0)
(b) 꼴 — 소계 = 합 밖 · 비목 칸 비움(문서 셈 금액만) · 기계 세 줄 = 묶음 한 줄 · 변수 = 이름(상수 아님)
· 분수 그림 공식 = 변수 식 · 수 하나 변수 = 숫자 연결 + 출처 「원파일」 · 품셈 조건 글 = 「추정」 표지
(c) 문서 셈 거울 — 비목 없는 합 밖 줄 파이썬 = TS
(d) 올림 길 — 원파일 = 같은 공사 엑셀 HP 에 붙음 · 가져온 식 = 원파일 · 원파일만 = 새 HP(EST 호표 ·
STmate 호표 없음) · 원파일 HP 호표 → 일위대가(SC) 금액 = 원파일 · 엑셀만 = 엑셀 길 그대로
원파일 · 실무 엑셀 없으면 그 시험만 skip(다른 PC).
"""
from __future__ import annotations
import math
import sys
from decimal import Decimal as D
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
from test_810_1_estx import FILES as EST # noqa: E402
from test_810_1_stc import FILES as STC # noqa: E402
from M02_MasterTemplete import M02_Doc_FromNative as fn # noqa: E402
from M02_MasterTemplete import M02_Doc_Import as dim # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Build as hb # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Excel as hx # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Store as hs # noqa: E402
from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402
from M02_MasterTemplete import M02_Sangeun_Doc as sd # noqa: E402
from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh # noqa: E402
from M02_MasterTemplete import M02_Sangeun_Imported as imp # noqa: E402
from resources.tester.test_m02_labor_ref import world # noqa: E402, F401 — 같은 임시 층
WHO = {"이름": "sub3임시"}
COST = (("L", "노무비"), ("M", "재료비"), ("S", "경비"), ("T", "계"))
def _file(path: Path) -> Path:
if not path.exists():
pytest.skip(f"원파일 없음 {path.name}")
return path
@lru_cache(maxsize=2)
def native(kind: str, tag: str) -> dict:
path = _file((EST if kind == "EST" else STC)[tag])
return fn.read(path.name, path.read_bytes())
@lru_cache(maxsize=1)
def whole():
return imp._whole()
def sk_of(out: dict) -> dict:
"""{로직, 원본} → 저장 없는 가져온 식 SK(만들기 몸과 같은 꼴 · 키 = 시험용)."""
row = out["로직"]
sk = {"키": "SK-999999", "이름": row["이름"], "단위": row["결과단위"].split("/", 1)[1]}
sk |= {"바탕": None, "입력값": {}, "덮개": {}, "뺀줄": [], "더한줄": []}
sk[imp.KEY] = {"로직": row, "원본": out["원본"], "단가기준": "엑셀"}
return sd.clean(sk, None, whole())
def doc_and_money(out: dict) -> tuple[dict, dict, dict]:
sk = sk_of(out)
doc = dim.derive(sk, None, whole())
return sk, doc, dim.run(doc, sk, whole())
# ── (a) 전수 ─────────────────────────────────────────────────────────────
def _sweep(saved: dict) -> dict:
stat = {"블록": 0, "같음": 0, "표지없는틀린줄": 0, "다름": []}
for no, blk in fn.numbers(saved).items():
out = fn.emit(saved, no)
sk, _, got = doc_and_money(out)
mine = {c: got.get(k) for c, k in COST}
nag = (out["원본"].get("계약") or {}).get("낙찰률")
if nag: # 계약단가(B09 몫) — 원파일 저장값은 낙찰률 뒤
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 {}
same = all(
mine[c] is not None and abs(float(mine[c]) - float(want.get(c) or 0)) < 0.5
for c, _ in COST
)
stat["블록"] += 1
stat["같음"] += same
if not same:
stat["다름"].append((no, blk.get("이름")))
marked = {int(m.split(":")[0]) for m in out["원본"]["표지"] if m.split(":")[0].isdigit()}
lines = imp.check(sk, whole())["줄"]
stat["표지없는틀린줄"] += sum(1 for x in lines if not x["같음"] and x["행"] not in marked)
return stat
@pytest.mark.parametrize("tag", list(EST))
def test_est_every_block_equals_saved(tag: str) -> None:
stat = _sweep(native("EST", tag))
assert stat["블록"] and not stat["다름"] and stat["같음"] == stat["블록"], stat["다름"][:5]
assert stat["표지없는틀린줄"] == 0
@pytest.mark.parametrize("tag", list(STC))
def test_stc_every_block_equals_saved(tag: str) -> None:
stat = _sweep(native("STC", tag))
assert stat["블록"] and not stat["다름"], stat["다름"][:5]
assert stat["표지없는틀린줄"] == 0
# ── (b) 꼴 ───────────────────────────────────────────────────────────────
def _block(no: int) -> tuple[dict, dict, dict]:
out = fn.emit(native("EST", "거창"), no)
_, doc, got = doc_and_money(out)
return out, doc, got
def test_subtotal_out_of_sum_no_cost() -> None:
out, doc, got = _block(7) # 지장목 제거 — 인부소계 822.5 · 기구손료 10 %
sub = next(x for x in doc["금액"] if x["이름"] == "인부소계")
assert sub["합밖"] and "비목" not in sub and sub["출처"] == dim.SUB
line = next(x for x in got["줄"] if x["이름"] == "인부소계")
assert line["금액"] == D("822.5") and line["비목"] == {} and line["합밖"]
rate = next(x for x in doc["금액"] if "인부소계" in str(x.get("단가")))
assert rate["비목"] == "경비" and not rate.get("합밖")
assert (got["노무비"], got["재료비"], got["경비"]) == (1437, 58, 210)
def test_machine_one_line_bundle() -> None:
out, doc, got = _block(1) # 측구터파기 토사 — 굴삭기 노 · 재 · 경 세 줄
rows = [x for x in out["로직"]["호표"] if (x.get("자원") or {}).get("갈래") == "기계"]
assert len(rows) == 1 and rows[0]["종류"] == "로직" and rows[0]["수량"] == "(1 / Q)"
assert rows[0]["이음"] and rows[0]["이음"].startswith("로직(GC") # 시간당 기계경비 로직
money = [x for x in doc["금액"] if x["이름"] == rows[0]["이름"]]
assert len(money) == 1 and money[0]["비목"] == "묶음"
line = next(x for x in got["줄"] if x["이름"] == rows[0]["이름"])
assert line["비목"] == {"노무비": D("2523.3"), "재료비": D("832.5"), "경비": D("1049.7")}
def test_variables_by_name_and_fraction_formula() -> None:
out, doc, _ = _block(1)
mids = {m["이름"]: m["식"] for m in out["로직"]["중간"]}
assert mids["Q"] == "반올림(((((((3600 * q) * K) * f) * E)) / (Cm)), 2)" or "Cm" in mids["Q"]
assert {"q", "K", "f", "E", "Cm"} <= set(mids)
by = {v["열쇠"]: v for v in doc["변수"]}
nums = [v for v in by.values() if (v.get("연결") or {}).get("갈래") == "숫자"]
assert nums and all(v.get("출처") == "원파일" for v in nums)
assert not any(x for x in out["원본"]["표지"] if "공식 글 못 읽음" in x)
def test_guess_mark_for_condition_text() -> None:
out, _, _ = _block(7)
assert out["원본"]["확정"]["추정"] >= 1
assert any("추정" in x and "품셈 조건" in x for x in out["원본"]["표지"])
# ── (c) 문서 셈 거울 ─────────────────────────────────────────────────────
def test_bare_out_of_sum_line_mirror() -> None:
import test_802_1_formula_doc as t
doc = t._doc(
[],
[],
[
t._man("갑", "2", "100", 기호="C_갑"),
{"이름": "소계", "단위": "식", "수량": "1", "단가": "C_갑 + 0.07", "합밖": True},
{"이름": "율", "단위": "식", "비목": "경비", "단가": "소계 × 10 ÷ 100"},
],
)
doc["금액"][1]["기호"] = "소계"
want = t.mc.calc(doc, t.Fake())
assert not want["표지"] and want["노무비"] == 200 and want["경비"] == 20
assert want["줄"][1]["금액"] == D("200") and want["줄"][1]["비목"] == {}
got = t._ts({"문서들": {"맨": doc}, "대상": ["맨"], "기록": {}, "글": [], "표": []})
t._same(t.ml._norm(want), got["답"]["맨"], "맨")
bad = dict(doc, 금액=[{**doc["금액"][1], "합밖": False}])
assert any(m["갈래"] == "금액 줄" for m in t.mc.calc(bad, t.Fake())["표지"])
# ── (d) 올림 길 ──────────────────────────────────────────────────────────
def _excel_hp(folder: Path, tag: str) -> str:
src = golden.FILES.get(tag)
if src is None or not src.exists():
pytest.skip(f"실무 엑셀 없음 {tag}")
data = src.read_bytes()
parsed = hx.read_book(src.name, data)
row = hs.save(folder, src.name, parsed, WHO, None, hs.fingerprint(data))
fh.keep(folder, row["키"], src.name, data)
return row["키"]
def test_upload_native_joins_excel_and_wins(world) -> None:
folder = world["personal"]
est = _file(EST["거창"])
with store.at(folder):
hp = _excel_hp(folder, "거창")
before = fh.emit(folder, hp, 15)
assert not before["원본"].get("원파일") # 엑셀만 = 엑셀 길
got = fh.upload_native(folder, est.name, est.read_bytes(), WHO)
assert got["붙음"] == [hp] and got["키"] == hp and got["원파일"]
again = fh.upload_native(folder, est.name, est.read_bytes(), WHO)
assert again["붙음"] == [hp] # 다시 올려도 새 HP 없음
assert len(hs.rows(folder)) == 1
made = fh.make(folder, hp, 15) # 전석 메쌓기 — 안쪽 산근 둘 + 굴삭기
sk = store.read("sangeun", made["이름"])["문서"]
assert sk["가져온"]["원본"]["원파일"] and made["검산"]["같음"]
refs = [x for x in sk["가져온"]["로직"]["호표"] if x.get("참조")]
assert len(refs) == 2 # 산근 속 산근 = 형제 SK 참조(797-1)
doc = dim.of(sk)
assert doc["가져온"]["원파일"] is True
ans = dim.run(doc, sk)
assert (ans["노무비"], ans["재료비"], ans["경비"]) == (74880, 7029, 12306)
# 엑셀 먼저 · 원파일 나중의 반대 — 원파일 HP 가 있으면 엑셀 올림에 붙음
other = world["company"]
with store.at(other):
fh.upload_native(other, est.name, est.read_bytes(), WHO)
hp2 = _excel_hp(other, "거창")
assert fh.emit(other, hp2, 1)["원본"].get("원파일")
def test_upload_native_alone_hopyo_to_sc(world) -> None:
folder = world["personal"]
est, stc = _file(EST["장수"]), _file(STC["봉화"])
with store.at(folder):
got = fh.upload_native(folder, est.name, est.read_bytes(), WHO)
assert got["원파일"] and not got.get("붙음") and got["호표수"] == 3
out = hb.build_all(folder, folder, got["키"], None, True, 1) # 제1호표 = SC + 부르는 SK
assert [x["번"] for x in out["만듦"]] == [1] and out["같음"] == 1 and not out["실패"]
assert out["단가산출"] and all(
store.read("sangeun", k)["문서"]["가져온"]["원본"].get("원파일")
for k in out["단가산출"]
)
st = fh.upload_native(folder, stc.name, stc.read_bytes(), WHO)
assert st["원파일"] and st["호표수"] == 0 # STmate 일위대가 구성 못 읽음 — 산근만
made = fh.make(folder, st["키"], next(iter(fn.numbers(native("STC", "봉화")))))
assert made["검산"]["같음"]
with pytest.raises(store.StoreError):
fh.upload_native(folder, "깨진.estx", b"PK\x03\x04garbage", WHO)
def test_router_branch_by_extension() -> None:
import inspect
from M02_MasterTemplete import M02_Hopyo_Router as router
src = inspect.getsource(router._upload)
assert "from_native.is_native(name)" in src and "upload_native" in src
assert fn.is_native("가.ESTX") and fn.is_native("나.stc") and not fn.is_native("다.xlsx")