- 새 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>
260 lines
13 KiB
Python
260 lines
13 KiB
Python
# 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")
|