Merge remote-tracking branch 'origin/sub_laptop_1' into 안티그래비티

This commit is contained in:
2026-11-13 21:00:30 +09:00
7 changed files with 421 additions and 78 deletions
+12 -6
View File
@@ -59,13 +59,19 @@ def _parts(money: Any) -> dict[str, Decimal]:
def _ratio(line: dict[str, Any], others: list[dict[str, Any]]) -> dict[str, str] | None:
"""% 줄 → `{기준, 비목}` — 밑수 = 다른 줄 엑셀 금액[기준] 합(끝수 1 원 안) · 금액 비목 하나."""
hit = _BASE.search(f"{line.get('규격') or ''} {line.get('이름') or ''}")
"""% 줄 → `{기준, 비목}` — 밑수 = 다른 줄 엑셀 금액[기준] 합(끝수 1 원 안) · 금액 비목 하나 ·
STmate 숨은 칸 율(`율` {밑, 대상} · 828-2)이 있으면 글 「노무비의」 · 금액 칸 짐작 대신 그것."""
under = _d((line.get("단가") or {}).get("계"))
paid = [k for k, v in _parts(line.get("금액")).items() if v]
if not hit or under is None or len(paid) != 1:
return None
base = "노무비" if hit.group(1) == "인건비" else hit.group(1)
if rate := line.get("율"):
base, paid = rate.get("밑"), [rate.get("대상")]
if base not in COSTS or paid[0] not in COSTS or under is None:
return None # 전체(닫기 뒤) 율 · 빈 칸 = 금액만
else:
hit = _BASE.search(f"{line.get('규격') or ''} {line.get('이름') or ''}")
paid = [k for k, v in _parts(line.get("금액")).items() if v]
if not hit or under is None or len(paid) != 1:
return None
base = "노무비" if hit.group(1) == "인건비" else hit.group(1)
total = sum((_parts(x.get("금액"))[base] for x in others), Decimal(0))
return {"기준": base, "비목": paid[0]} if abs(total - under) <= GAP else None
+51 -54
View File
@@ -32,12 +32,13 @@ from typing import Any
from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel # 서로 부름 — 부를 때만 씀
from M02_MasterTemplete import M02_Hopyo_ReadEst as hopyo_est
from M02_MasterTemplete import M02_Hopyo_UnitLine as unit_line
from M02_MasterTemplete import M02_San_Xlsx_Ilwi as ilwi
SHEET = "일위대가표"
CALC = "단가산출근거"
LISTS = ("단가산출근거목록표", "노무비목록표", "중기목록표", "재료비목록표", "경비목록표")
SHEETS = (SHEET, CALC, *LISTS)
_CODE = re.compile(r"^(?:COD\|)?([A-Z]{1,2}\d{5}|PR[A-Z]?\d+|[TC]\d+)(?:_\d+)?(?: →)?$")
_CODE = ilwi.CODE
_META = re.compile(r"(?<![A-Z])COD\|([A-Z]{1,2}\d{5})") # 숨은 칸 「`COD|L00001」
_HEAD_B = re.compile(r"BDCOD\|(B\d{5})")
_HEAD_D = re.compile(r"BDCOD\|(D\d{5})")
@@ -51,6 +52,7 @@ _CALC_PRICE = {"합계": 8, "노무비": 9, "재료비": 10, "경비": 11} # I~
NOTE, CODE_COLS, RES_COL, QTY_COL, NAME_COL = 12, range(12, 26), 12, 7, 23
FORMULA_COL, OUT_COL = 6, 13 # G 식 · N 합 갈 곳(C = 셈 밖 「소계(제외금액)」)
COSTS = ("노무비", "재료비", "경비")
_PART = {"M": "재료비", "L": "노무비", "S": "경비", "T": "전체"} # 숨은 칸 율 비목(828-2)
_KIND = {"L": "L", "X": "X", "M": "M", "S": "E"} # 자원 코드 머리 → 단위 줄 묶음(S 경비 = 값)
_RES_CODE = re.compile(r"^[LMXS]\d{5}$")
_RATE = re.compile(r"^(?:PR|RR)[A-Z]?_") # 율 행 「PR_1」 · 「RRS_(8)_1」
@@ -296,64 +298,59 @@ def _blocks(rows: list[list[Any]], table: dict[str, dict[str, str]]) -> dict[str
# ── 일위대가표 ────────────────────────────────────────────────────────
def _hopyos(rows: list[list[Any]]) -> tuple[list[dict[str, Any]], list[list[dict[str, Any]]]]:
"""호표 · 구성 줄 = 공용 읽기(`M02_San_Xlsx_Ilwi.read` · 828-2) → HP 줄 꼴 · 설명 줄 = 근거글 ·
닫기 · 구분 줄 뺌 · 율 줄 코드 = PR + 율 코드(「PRA01」) · 숨은 칸 율 = `율` {밑, 대상}(비목 이름)."""
hopyos: list[dict[str, Any]] = []
raw: list[list[dict[str, Any]]] = []
want_head = done = False
for at, row in enumerate(rows, 1):
first = _s(_cell(row, 0))
hit = hopyo_excel._HOPYO.fullmatch(_flat(first))
if hit:
code = next((m.group(1) for v in row[1:26] if (m := _HEAD_B.search(_s(v)))), None)
hopyos.append({"번": int(hit.group(1)), "코드": code, "행": [at, at], "근거글": []})
raw.append([])
want_head, done = True, False
continue
if not hopyos or done:
continue
hopyo = hopyos[-1]
if want_head:
hopyo.update(이름=first, 규격=_s(_cell(row, 1)), 단위=_s(_cell(row, 3)))
hopyo["금액"] = {}
want_head = False
continue
if _flat(first).startswith("합계") or _flat(first) == "계":
hopyo["금액"] = hopyo_excel._money(row, _COLS, 1)
done = True # 끝 — 다음 호표까지 줄 안 받음
continue
if not first:
continue
code = _code(row)
hopyo["행"][1] = at
if not code:
if re.search(r"[0-9A-Za-z가-힣]", first):
hopyo["근거글"].append(f"{first} {_s(_cell(row, 1))}".strip())
continue
if code[0] in "TC" and code[1].isdigit():
continue # 계 · 소계 · 계약단가
line = {
"차례": len(raw[-1]),
"이름": first,
"규격": _s(_cell(row, 1)),
"수량": hopyo_excel._num(_cell(row, 2)),
"단위": _s(_cell(row, 3)),
"단가": hopyo_excel._money(row, _COLS, 0),
"금액": hopyo_excel._money(row, _COLS, 1),
"비고": _s(_cell(row, NOTE)),
"코드": code,
"행": at,
}
line["갈래"] = hopyo_excel.kind_of(line) # 갈래 한 규칙(776-2)
line["참조"] = (
{"종류": "단산", "번": int(code[1:]), "코드": code}
if code[0] == "D"
else {"종류": "일위", "번": None, "코드": code}
if code[0] == "B"
else None
)
raw[-1].append(line)
for got in ilwi.read(rows)["호표"]:
hopyo = {k: got[k] for k in ("번", "코드", "행")} | {"근거글": []}
if "머리행" in got:
hopyo |= {k: got[k] for k in ("이름", "규격", "단위")} | {"금액": {}}
if "끝행" in got:
hopyo["금액"] = hopyo_excel._money(rows[got["끝행"] - 1], _COLS, 1)
hopyos.append(hopyo)
raw.append([])
for one in got["줄"]:
if not one["코드"]: # 설명 줄 · 코드 없는 글
if re.search(r"[0-9A-Za-z가-힣]", one["이름"]):
hopyo["근거글"].append(f"{one['이름']} {one['규격']}".strip())
continue
if one["갈래"] in ("닫기", "구분"):
continue # 계 · 소계 · 계약단가
raw[-1].append(_hopyo_line(rows[one["행"] - 1], one, len(raw[-1])))
return hopyos, raw
def _hopyo_line(row: list[Any], one: dict[str, Any], at: int) -> dict[str, Any]:
rate = one.get("율") or {}
code = one["코드"] + rate.get("코드", "") if rate else one["코드"]
line = {
"차례": at,
"이름": one["이름"],
"규격": one["규격"],
"수량": hopyo_excel._num(_cell(row, 2)),
"단위": one["단위"],
"단가": hopyo_excel._money(row, _COLS, 0),
"금액": hopyo_excel._money(row, _COLS, 1),
"비고": one["비고"],
"코드": code,
"행": one["행"],
}
if rate.get(
"밑"
): # 828-2 숨은 칸 율 밑 · 대상 비목(글 「노무비의 %」 짐작보다 먼저 · `M02_Hopyo_Fixed._ratio`)
line["율"] = {k: _PART.get(rate.get(k) or "", "") for k in ("밑", "대상")}
line["갈래"] = hopyo_excel.kind_of(line) # 갈래 한 규칙(776-2)
line["참조"] = (
{"종류": "단산", "번": int(code[1:]), "코드": code}
if code[0] == "D"
else {"종류": "일위", "번": None, "코드": code}
if code[0] == "B"
else None
)
return line
def read(sheets: dict[str, list[list[Any]]]) -> dict[str, Any]:
"""STmate 시트 → HP 꼴 `{꼴, 시트, 호표, 단산, 검산}`(값은 JSON 수)."""
hopyos, raw = _hopyos(sheets.get(SHEET) or [])
+11 -15
View File
@@ -37,7 +37,6 @@ from M02_MasterTemplete import M02_Hopyo_MatSame as ms
LINKED, NO_SPEC, MANY, NONE = "이어짐", "규격 없음 — 고르기", "후보 여럿", "우리 표에 없음"
TOP = 8
_CTX: ContextVar[dict] = ContextVar("재료단서", default={})
_B = re.compile(r"B\d+")
_LEAD = re.compile(r"^[\s\-·\d.)]+")
_RANGE = re.compile(r"[A-Za-z]*\d+(?:\.\d+)?\s*[~∼]\s*[A-Za-z]*\d+(?:\.\d+)?")
@@ -59,21 +58,18 @@ def from_boq(rows: Any) -> list[dict]:
def from_book(rows: Any) -> list[dict]:
"""STmate 엑셀 「일위대가표」 시트 줄 → 자재 줄 단서(숨은 메타 COD M… · 대가 머리 = 이름 · 규격)."""
from M02_MasterTemplete import M02_San_Book as book
"""STmate 엑셀 「일위대가표」(시트 줄 · 또는 `M02_San_Xlsx_Ilwi.read` 답) → 자재 줄 단서(숨은 메타
COD M… · 대가 머리 = 이름 · 규격) — 읽기 = 공용(828-2)."""
from M02_MasterTemplete import M02_San_Xlsx_Ilwi as ilwi
out, head = [], ""
for r in rows or []:
r = list(r) + [None] * 16
if _B.fullmatch(str(r[14] or "").strip()):
head = " ".join(str(x or "").strip() for x in r[:2]).strip()
continue
code = book.meta(r[13]).get("COD", "")
if code.startswith("M") and r[0]:
out.append(
{"곳": "일위대가표", "코드": code, "이름": str(r[0]), "규격": str(r[1] or "")}
)
out[-1] |= {"머리": head} if head else {}
sheet = rows if isinstance(rows, dict) else ilwi.read(rows)
out = []
for hopyo in sheet.get("호표") or []:
head = f"{hopyo.get('이름') or ''} {hopyo.get('규격') or ''}".strip()
for one in hopyo["줄"]:
if "메타" in one and one["코드"].startswith("M") and one["이름"]:
out.append({"곳": "일위대가표", "코드": one["코드"], "이름": one["이름"]})
out[-1] |= {"규격": one["규격"]} | ({"머리": head} if head else {})
return _uniq(out)
+186
View File
@@ -0,0 +1,186 @@
"""STmate 출력 엑셀 「일위대가표」 → 호표 · 구성 줄(PLAN 828-2 · 요구 813 · 814 ㉮ · 읽기만 · 서버 단독).
읽는 곳 하나 — 일위대가 불러오기(`M02_Hopyo_Stmate.read`) · 단가산출 불러오기
(`M02_San_Xlsx_Stmate.read` 「일위」) · 재료 단서(`M02_San_MatFind.from_book`).
호표 = A 「제N호표」 줄(숨은 칸 `BDCOD|B…`) → 다음 줄 이름 · 규격 · 단위 → 구성 줄 → 「합계」 · 「계」.
구성 줄 = 숨은 칸 기준(표시대로 · 글 짐작 안 함 · 원파일 구성표 BDQTY 와 같은 칸) —
N 열 메타 `COD|M00131 ␅ EXI DVD BMK IPR KWN BLA EQC PRI PRO UNI TPR` · O 열 차수 꼬리 `M00131_1`.
율 줄 = 밑줄(IPR 1) 밑 비목(PRI) × 율 → 대상 비목(PRO) · 비목 수 1 M · 2 L · 3 S · 4 T(전체) ·
율 코드 EQC · 닫기(C) 뒤 % 줄(켜쌓기적용 「전체금액의 %」) = `전체`.
별도계상 = 비고 「별산…」(STmate 표기) · 규격 「별도계상」 · 관급 = KWN ≠ 0 · 나누기 = DVD T.
숨은 칸 없는 엑셀(값만 복사) = 코드는 M~Z 열 코드 꼴 첫 것(옛 길) · 시트 `숨은칸` 거짓 ·
단가산출 길 「일위」 = 빈 목록(물러남).
줄 꼴 = 828-1 `M02_San_Ilwi` 일위 구성 줄 꼴 {차례, 코드, 갈래(M L S X B D W · 율 · 닫기 · 구분 · 글),
이름, 규격, 단위, 수량, 단가 {T L M S}, 금액 {T L M S}, 율? {밑, 대상, 곱}, 밑줄?, 관급?, 별도계상?}
+ 엑셀 칸 {행, 차수, 비고, 나누기?, 전체?, 율.코드, 메타}.
"""
from __future__ import annotations
import re
from typing import Any
SHEET = "일위대가표"
CODE = re.compile(r"^(?:COD\|)?([A-Z]{1,2}\d{5}|PR[A-Z]?\d+|[TC]\d+)(?:_\d+)?(?: →)?$")
CODE_COLS = range(12, 26) # M ~ Z 열(옛 길 · 판마다 열 수 다름)
META_COL, CODE_COL, NOTE_COL = 13, 14, 12 # N 메타 · O 코드(차수 꼬리) · M 비고
_HOPYO = re.compile(r"제\s*(\d+)\s*호표")
_HEAD = re.compile(r"BDCOD\|(B\d{5})")
_TAIL = re.compile(r"^[A-Z]{1,2}\d*_(\d+)$")
_X7, _X5 = "_x0007_", "_x0005_"
KIND = "MLSXBDW" # 자원 코드 머리(L 노임 · M 자재 · S 경비 · X 기계 · D 산근 · B 일위 · W 일식)
PART = {"1": "M", "2": "L", "3": "S", "4": "T"} # PRI · PRO 비목 수
_MONEY = {"T": 4, "L": 6, "M": 8, "S": 10} # 단가 열 · 금액 = +1
def _cell(row: list[Any], col: int) -> Any:
return row[col] if col < len(row) else None
def _s(v: Any) -> str:
if isinstance(v, float) and v == int(v):
v = int(v)
return str(v if v is not None else "").strip()
def _num(v: Any) -> float | None:
if isinstance(v, bool) or v in (None, ""):
return None
try:
return float(str(v).replace(",", "").strip())
except ValueError:
return None
def meta(text: Any) -> dict[str, str]:
"""숨은 메타 칸 「`COD|L00003 ␅ EXI|0 …」 → dict(`M02_San_Book.meta` 와 같은 풀이)."""
s = str(text or "").replace(_X7, "").replace(_X5, "\x05")
out = {}
for part in s.split("\x05"):
part = part.strip("`")
if "|" in part:
k, v = part.split("|", 1)
out[k] = v
return out
def scan_code(row: list[Any]) -> str | None:
"""옛 길 — M~Z 열에서 코드 꼴 첫 것(숨은 칸 없는 엑셀 · `M02_Hopyo_Stmate` 와 같음)."""
for col in CODE_COLS:
hit = CODE.match(_s(_cell(row, col)))
if hit:
return hit.group(1)
return None
def kind_of(code: str) -> str:
"""갈래(`M02_San_Ilwi` 꼴) — 자원 머리 글자 M L S X B D W · 율 · 닫기 · 구분 · 글(빈 코드)."""
if code.startswith(("PR", "RR")):
return "율"
if code[:1] in "CT" and code[1:2].isdigit():
return "닫기" if code[0] == "C" else "구분"
return code[:1] if code[:1] and code[0] in KIND else "글"
def _money(row: list[Any], offset: int) -> dict[str, float | None]:
return {k: _num(_cell(row, c + offset)) for k, c in _MONEY.items()}
def _line(row: list[Any], at: int, closed: bool) -> dict[str, Any] | None:
"""구성 줄 하나 — 숨은 메타 있으면 그것 · 없으면 옛 코드 찾기(코드 없으면 None)."""
raw = _cell(row, META_COL)
got = meta(raw) if isinstance(raw, str) and "COD|" in raw else {}
tail = _TAIL.match(_s(_cell(row, CODE_COL)))
if "COD" in got:
code = got["COD"].strip()
else:
code = scan_code(row)
if code is None:
return None
one: dict[str, Any] = {
"행": at,
"코드": code,
"갈래": kind_of(code),
"차수": int(tail.group(1)) if tail else None,
"이름": _s(_cell(row, 0)),
"규격": _s(_cell(row, 1)),
"단위": _s(_cell(row, 3)),
"수량": _num(_cell(row, 2)),
"단가": _money(row, 0),
"금액": _money(row, 1),
"비고": _s(_cell(row, NOTE_COL)),
}
flags = {
"나누기": got.get("DVD") == "T",
"밑줄": got.get("IPR") == "1",
"별도계상": one["비고"].startswith("별산") or "별도계상" in one["규격"],
"관급": got.get("KWN", "0").strip() not in ("0", ""),
}
if code.startswith(("PR", "RR")) and got:
one["율"] = {"밑": PART.get(got.get("PRI", "")), "대상": PART.get(got.get("PRO", ""))}
one["율"] |= {"곱": code.startswith("RR"), "코드": got.get("EQC") or ""}
pct = one["단위"] == "%" or got.get("UNI") == "%"
flags["전체"] = closed and pct and one["갈래"] in ("율", "W")
one |= {k: True for k, v in flags.items() if v}
if got:
one["메타"] = {k: v for k, v in got.items() if k != "COD"}
return one
def read(rows: Any) -> dict[str, Any]:
"""시트 줄 → {숨은칸, 호표 [{번, 코드, 이름, 규격, 단위, 행 [처음,끝], 머리행, 저장 {T L M S}, 줄}]}.
끝 줄(합계 · 계)에서 다음 「제N호표」 까지는 안 받음 · 이름 빈 줄은 건너뜀(옛 길과 같음)."""
hopyos: list[dict[str, Any]] = []
want_head = done = closed = False
hidden = False
for at, row in enumerate(rows or [], 1):
row = list(row)
first = _s(_cell(row, 0))
flat = re.sub(r"\s+", "", first)
if hit := _HOPYO.fullmatch(flat):
code = next((m.group(1) for v in row[1:26] if (m := _HEAD.search(_s(v)))), None)
hopyos.append({"번": int(hit.group(1)), "코드": code, "행": [at, at], "줄": []})
want_head, done, closed = True, False, False
continue
if not hopyos or done:
continue
hopyo = hopyos[-1]
if want_head:
hopyo |= {"이름": first, "규격": _s(_cell(row, 1)), "단위": _s(_cell(row, 3))}
hopyo["머리행"] = at
want_head = False
continue
if flat.startswith("합계") or flat == "계":
hopyo["저장"] = _money(row, 1)
hopyo["끝행"] = at
done = True
continue
if not first:
continue
hopyo["행"][1] = at
one = _line(row, at, closed)
if one is None: # 숨은 칸 없는 엑셀의 코드 없는 글 줄(옛 길 근거글)
if re.search(r"[0-9A-Za-z가-힣]", first):
one = {"행": at, "코드": "", "갈래": "글", "이름": first, "규격": _s(_cell(row, 1))}
hopyo["줄"].append({"차례": len(hopyo["줄"]), **one})
continue
hidden = hidden or "메타" in one
closed = closed or one["갈래"] == "닫기"
hopyo["줄"].append({"차례": len(hopyo["줄"]), **one})
for hopyo in hopyos:
for key in ("이름", "규격", "단위"):
hopyo.setdefault(key, "")
return {"숨은칸": hidden, "호표": hopyos}
def blocks(sheet: dict[str, Any]) -> list[dict[str, Any]]:
"""`read` 답 → 단가산출 불러오기 「일위」 블록(원파일 `M02_San_Stc.read` 「일위」 와 같은 머리 칸 + 줄) ·
숨은 칸 없는 시트 = 빈 목록(구성 줄을 글로 짐작하지 않음)."""
if not sheet.get("숨은칸"):
return []
return [
{k: h.get(k) for k in ("코드", "번", "이름", "규격", "단위", "저장")}
| {"엑셀행": h["행"][0], "줄": h["줄"], "줄합": None, "표지": []}
for h in sheet["호표"]
]
+9 -3
View File
@@ -41,7 +41,8 @@ def vote(blocks: list[dict], prices: dict) -> tuple[str, dict[str, int]]:
def read(sheets: dict[str, list]) -> dict[str, Any]:
"""STmate 엑셀 시트 → {꼴, 산근, 일위, 자원, 내역, 단가, 옵션, 표지}."""
"""STmate 엑셀 시트 → {꼴, 산근, 일위, 자원, 내역, 단가, 옵션, 표지} · 일위 = 일위대가표 숨은 칸
구성 줄(828-2 `M02_San_Xlsx_Ilwi` · 숨은 칸 없으면 빈 목록 + 표지)."""
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 = {
@@ -71,10 +72,15 @@ def read(sheets: dict[str, list]) -> dict[str, Any]:
out["옵션"] = {"SAN_TYP": RULE[rule]}
out["내역"] = None
from M02_MasterTemplete import M02_San_MatFind as mf
from M02_MasterTemplete import M02_San_Xlsx_Ilwi as ilwi
out["단서"] = mf.from_book(sheets.get("일위대가표")) # 827-2 재료 단서
sheet = ilwi.read(sheets.get(ilwi.SHEET)) # 828-2 일위대가표 숨은 칸 → 구성 줄(읽는 곳 하나)
out["일위"] = ilwi.blocks(sheet)
out["단서"] = mf.from_book(sheet) # 827-2 재료 단서
out["표지"] = [
f"{sx.NOTE}일위대가 구성 · 내역은 호표 길(산근만 읽음)",
f"{sx.NOTE}일위대가 구성 = 일위대가표 숨은 칸 · 내역은 안 읽음"
if sheet["숨은칸"]
else f"{sx.NOTE}일위대가표 숨은 칸 없음(값만 복사) — 구성 줄 안 읽음 · 일위대가는 호표 길",
f"{sx.NOTE}끝전 = 파일 투표 {rule}(비목버림 {got['비목버림']} · 합버림 {got['합버림_큰나머지']} 블록)",
]
return out
@@ -125,6 +125,7 @@ def test_clue_head_and_boq_apart():
def test_clue_book_sheet():
meta = "_x0007_`COD|{}_x0005_`EXI|0_x0005_`"
rows = [
[" 제 17 호표", *[None] * 12, "_x0005_`BDCOD|B01217_x0007_`POSS|3_x0007_`POSE|4_x0007_`"],
["파형강관부설", "D=800mm", None, "m", *[None] * 8, "대가 17", None, "B01217"],
[
"파형강관(800mm)",
@@ -0,0 +1,151 @@
"""828-2 STmate 출력 엑셀 「일위대가표」 숨은 칸 → 호표 · 구성 줄(`M02_San_Xlsx_Ilwi` · 요구 813 · 814 ㉮).
5 건 호표 136 · 줄 706 · 율 줄 밑 · 대상 비목 · 닫기 뒤 전체 % · 일식 · 별도계상 · 숨은 칸 없는 파일 물러남 ·
원파일 STC 구성 줄(828-1)과 같은 꼴 · 읽는 곳 하나(호표 길 · 단산 길 · 단서) · 판 일위 줄 재료.
실무 파일이 없으면 건너뜀.
"""
import sys
from functools import lru_cache
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path[:0] = [str(ROOT), str(ROOT / "resources" / "tester"), str(ROOT / "resources" / "master_data" / "scripts")] # fmt: skip
import helper_m02_hopyo_golden as golden # noqa: E402
from M02_MasterTemplete import M02_Doc_FromNative as fn # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Excel as hx # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Fixed as hfix # noqa: E402
from M02_MasterTemplete import M02_San_Book as book # noqa: E402
from M02_MasterTemplete import M02_San_MatFind as mf # noqa: E402
from M02_MasterTemplete import M02_San_Xlsx as sx # noqa: E402
from M02_MasterTemplete import M02_San_Xlsx_Ilwi as il # noqa: E402
TAGS = ("봉화", "동부청영월", "울진대흥", "영덕계류", "울진소광")
HOPYOS = {"봉화": 45, "동부청영월": 23, "울진대흥": 34, "영덕계류": 9, "울진소광": 25}
LINES = {"봉화": 216, "동부청영월": 138, "울진대흥": 184, "영덕계류": 39, "울진소광": 129}
APART = {"봉화": 7, "동부청영월": 18, "울진대흥": 16, "영덕계류": 0, "울진소광": 9}
@lru_cache(maxsize=8)
def _sheets(tag: str) -> dict:
path = golden.FILES[tag]
if not path.exists():
pytest.skip(f"실무 엑셀 없음 {tag}")
return book.sheets(path.name, path.read_bytes())
def _lines(got: dict) -> list[dict]:
return [x for h in got["호표"] for x in h["줄"]]
def _bare(rows: list) -> list:
"""값만 복사한 엑셀 — 숨은 메타(N 열) 지움."""
return [[None if j == il.META_COL else v for j, v in enumerate(r)] for r in rows]
@pytest.mark.parametrize("tag", TAGS)
def test_hopyo_lines_by_meta(tag):
got = il.read(_sheets(tag)[il.SHEET])
lines = _lines(got)
assert got["숨은칸"] and len(got["호표"]) == HOPYOS[tag] and len(lines) == LINES[tag]
assert all("메타" in x for x in lines)
assert all(h["코드"] and h["코드"].startswith("B") and h.get("저장") for h in got["호표"])
assert sum(bool(x.get("별도계상")) for x in lines) == APART[tag]
for x in lines:
if x["갈래"] == "율":
assert (
x["율"]["밑"] in "MLST" and x["율"]["대상"] in "MLST" and x["율"]["코드"] == "A01"
)
if x["코드"] and x["갈래"] not in ("율", "닫기"):
assert x["차수"] == 1
def test_total_count():
got = [il.read(_sheets(t)[il.SHEET]) for t in TAGS]
assert sum(len(g["호표"]) for g in got) == 136 and sum(len(_lines(g)) for g in got) == 706
def test_rate_close_lump():
bong = _lines(il.read(_sheets("봉화")[il.SHEET]))
first = next(x for x in bong if x["갈래"] == "율")
assert (first["이름"], first["율"]["밑"], first["율"]["대상"]) == ("잡재료비", "L", "M")
marked = [x["행"] for x in bong if x.get("밑줄")]
assert 72 in marked and 73 in marked # 포장공 · 보통인부 = 잡재료비 밑수 줄
yd = _lines(il.read(_sheets("영덕계류")[il.SHEET]))
closes = [x for x in yd if x["갈래"] == "닫기"]
whole = [x for x in yd if x.get("전체")]
assert len(closes) == 2 and [(x["코드"], x["수량"]) for x in whole] == [("W00005", 90.0)] * 2
assert sum(x["갈래"] == "W" for x in _lines(il.read(_sheets("울진소광")[il.SHEET]))) == 3
def test_bare_sheet_falls_back():
rows = _bare(_sheets("봉화")[il.SHEET])
got = il.read(rows)
assert not got["숨은칸"] and il.blocks(got) == [] and mf.from_book(rows) == []
out = sx.read_sheets({**_sheets("봉화"), il.SHEET: rows})
assert out["일위"] == [] and any("숨은 칸 없음" in t for t in out["표지"])
# 호표 길 = 옛 코드 찾기(O 열) — 숨은 칸 율 · 호표 코드만 빠지고 줄은 같음
from M02_MasterTemplete import M02_Hopyo_Stmate as st
a = st._hopyos(_sheets("봉화")[il.SHEET])
b = st._hopyos(rows)
strip = [[{k: v for k, v in x.items() if k != "율"} for x in h] for h in a[1]]
heads = [[{k: v for k, v in h.items() if k != "코드"} for h in x[0]] for x in (a, b)]
assert heads[0] == heads[1] and strip == b[1] # 호표 코드(BDCOD)도 숨은 칸이라 빠짐
def test_same_form_as_stc():
import test_810_1_stc as stc
path = stc.FILES["봉화"]
if not path.exists():
pytest.skip("원파일 없음")
xl = sx.read_sheets(_sheets("봉화"))
nat = {b["코드"]: b for b in stc.parsed("봉화")["일위"]}
def key(x):
r = x.get("율") or {}
flags = tuple(bool(x.get(k)) for k in ("밑줄", "관급", "별도계상"))
return (
x["코드"],
x["갈래"],
round(float(x["수량"] or 0), 6),
r.get("밑"),
r.get("대상"),
flags,
)
assert len(xl["일위"]) == 45
for b in xl["일위"]:
assert [key(x) for x in b["줄"]] == [key(x) for x in nat[b["코드"]]["줄"]], b["코드"]
def test_one_reader_three_users():
sh = _sheets("봉화")
clues = mf.from_book(sh[il.SHEET])
assert len(clues) == 41 and clues == mf.from_book(il.read(sh[il.SHEET]))
assert all(c["곳"] == "일위대가표" and c["코드"].startswith("M") for c in clues)
path = golden.FILES["영덕계류"]
hp = hx.read_book(path.name, path.read_bytes())
lines = hp["호표"][6]["줄"] # 제11호표 산악할증 — 숨은 칸 율(노무비 → 노무비 · 0 %)
rate = next(x for x in lines if x["코드"] == "PRA01")
assert rate["율"] == {"밑": "노무비", "대상": "노무비"}
assert hfix._ratio(rate, [x for x in lines if x is not rate]) == {
"기준": "노무비",
"비목": "노무비",
}
def test_census_ilwi_materials():
"""판 일위 자재 줄 = 828-1 `M02_San_Ilwi.materials`(원파일 · 엑셀 같은 길) — 엑셀 구성 줄도 셈."""
from M02_MasterTemplete import M02_San_Ilwi as si
path = golden.FILES["영덕계류"]
saved = fn.pack(sx.read_sheets(_sheets("영덕계류")), path.name)
got = list(si.materials(saved))
assert len(got) == 4 and all(r["재료찾기"]["판정"] for _, _, r in got)
assert {b["코드"] for b, _, _ in got} == {"B01107"}