Compare commits
4
Commits
284ea58419
...
61bb15fbde
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61bb15fbde | ||
|
|
5779943540 | ||
|
|
74446990f8 | ||
|
|
8cd7baea5e |
@@ -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.추정 ?? [])
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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["출처"] = "원파일"
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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["비목"]}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 산근 시트 없음")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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["산근"])
|
||||
|
||||
|
||||
# ── 화면 소스 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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["다름"])
|
||||
Reference in New Issue
Block a user