feat(778-3): 단위 줄 정규화 — 단가산출 블록 · 일위대가 줄 = 노무 · 기계 · 자재 · 율 · 하위 · 값 줄(D-1 · 요구 622 · 623 · 624)

- 새 M02_Hopyo_UnitLine — 단위 줄 꼴 · 자체 검산(줄 재현 0.5 % · 줄합 = 블록 금액 덮음) · 값 줄 → 하위 가름 · 율 줄 기준꼴 · 기준줄 · 할증 풀이 · 일위대가 줄 투영(묶음 · 재현 · % 기준) · 두 어댑터 끝에 settle
- ReadEst.items — 줄 금액 · 율 · 값 줄 · 합계 · 소계 절 = 밖 · 끝 점 「28,500. ×」 · 이름 속 = · / 자재 · 「(지주)」 · 「\/㎡」 금액 꼬리 · 「직종 : P」 선언 줄 뺌 · 자재 손료율 표지 · 기계 비목마다 매개값 계수 · 절 = 제목
- Stmate — 같은 기계 코드 두 묶음 따로(울진대흥 1129 · 2119 · 울진소광 1448 단가 두 배 흠) · 비목마다 H 다르면 계수(봉화 덤프 (Cm-t1)/Cm · 영월 물탱크 ×0.722) · PR · RR 율 행(기준 표지 PA01M 꼴) · 이름 없는 율 행 = 위 라벨 줄 이름 · 기준글 · G 열 「* N'%'」 자원 = 율 줄 · 자재 손료율 · D 행 = 하위 · N 열 C = 밖
- Store.BOOK 3 — 읽기 꼴 바뀜(옛 판 HP = 다시 올림)
- 여덟 파일 불리는 블록: 줄 재현 811/836 → 836/842 · 덮음 201 → 204/206(남은 둘 = 엑셀 합이 진동로울러 노무 뺌) · 일위대가 줄 1,344/1,350
- 새 시험 test_778_3_hopyo_unit_lines

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 13:20:03 +09:00
co-authored by Claude Opus 5.5
parent 2f93dd8b11
commit 660e3318a9
6 changed files with 691 additions and 77 deletions
+7 -4
View File
@@ -18,8 +18,9 @@ STmate 꼴(「일위대가표」 · 「단가산출근거」 시트) = `M02_Hopy
어긋남은 글 목록(막지 않음).
행(329-1) = 호표 [제N호표 줄, 마지막 구성 줄] · 구성 줄 한 줄(엑셀 1부터) ·
근거(330-1) = 단산N참조 줄에 「단가산출」 블록 `{출처, 직종, 행}`(`M02_Hopyo_Basis.read`) ·
`단산` = 블록마다 `{번, 이름, 단위, 행, 출처, 직종, 글}`(338 · 글 = 머리 뺀 몸 줄) ·
`꼴` = 「엑셀꼴」.
`단산` = 블록마다 `{번, 이름, 단위, 행, 출처, 직종, 글, 금액, 항목}`(338 · 글 = 머리 뺀 몸 줄) ·
`꼴` = 「엑셀꼴」 · 두 형식 끝에 `UnitLine.settle`(단위 줄 하위 · 재현 · 덮음 · 일위대가 줄
투영 · 778-3).
"""
from __future__ import annotations
@@ -31,6 +32,7 @@ from typing import Any
from M02_MasterTemplete import M02_Hopyo_ReadEst as hopyo_basis # EST 단가산출 어댑터(776-2)
from M02_MasterTemplete import M02_Hopyo_Stmate as hopyo_stmate
from M02_MasterTemplete import M02_Hopyo_UnitLine as unit_line
SHEET = "일위대가"
SUMMARY = "일위대가총괄표"
@@ -300,7 +302,7 @@ def read_book(name: str, data: bytes) -> dict[str, Any]:
sheets, xls = _open(name, data, LISTS)
_OPENED = (data, sheets, xls) # 격자(`Grid.read`)가 이어 씀 — 한 번 열기
if hopyo_stmate.is_stmate(sheets):
return hopyo_stmate.read(sheets)
return unit_line.settle(hopyo_stmate.read(sheets))
rows = sheets.get(SHEET)
if rows is None:
raise ExcelError(NO_SHEET)
@@ -365,13 +367,14 @@ def read_book(name: str, data: bytes) -> dict[str, Any]:
]
keep = ("번", "이름", "단위", "행", "출처", "직종", "글", "금액", "항목")
basis = [{k: b[k] for k in keep} for b in blocks.values()]
return {
book = {
"꼴": "엑셀꼴",
"시트": SHEET,
"호표": out,
"단산": basis,
"검산": {"어긋남": wrong, "총괄표": len(totals)},
}
return unit_line.settle(book) # 단위 줄 하위 · 재현 · 덮음 · 일위대가 줄 투영(778-3)
def _json(value: Any) -> Any:
+152 -31
View File
@@ -9,16 +9,15 @@
글 = 머리 뺀 몸 줄 `GLINES` 줄 · 줄마다 `GCHARS` 자 안 · 산출근거 열이 빈 줄은 그 오른쪽 ~ 돈 머리
(맨 오른쪽 묶음) 앞 칸을 빈칸으로 이어 붙임(거창 꼴 · 돈 머리 없으면 `WIDE` 열) ·
금액 = 머리 줄 돈 칸(1 단위).
항목(Book 블록 항목) = 글 줄 풀이 — 줄 율(호표 줄 이름 · 규격)에 기대지 않는 것만 ·
지문 단계가 율을 가름:
항목(Book 블록 항목 · 778-3) = 글 줄 → 단위 줄(꼴 = `M02_Hopyo_UnitLine`) · 줄 금액 = 「= 금액」 ·
끝 점(「28,500. ×」) 뗌 · 율(호표 줄 이름 · 규격)에 기대지 않는 것만(섞음 율은 지문 몫):
L 직종 줄 「직종 : 단가 × 식 = 금액」(못 풀면 금액 ÷ 단가 「역산」 · `직종` = 우리 이름) ·
X 장비 = 장비 절 · 장비 글 줄 뒤 「노무비 · 경비 · 재료비 : 단가 ÷ Q」 — 절 · 장비마다 하나
(노무비(단가 > 0) > 경비 > 재료비 줄의 품) · 이름 = 장비 글 · 단가 = 비목마다 ·
M 자재 = 「이름 단가 × 수량 = 금액」 · 「이름 : 별도계상」(품 없음).
항목 = `{갈래, 이름, 규격, 품, 밑품, 율, 인자, 단가, 절, 절율, 어떻게, 행}`(L 은 + 직종) ·
STmate 어댑터(`M02_Hopyo_Stmate`)도 같은 꼴.
풀이 규칙은 지문(`M02_Hopyo_Finger._text`)과 같음 — 776-3 이 지문을 항목 위로 옮기면
그쪽 사본을 걷음.
(품 = 노무비(단가 > 0) › 경비 › 재료비 줄 · 비목마다 매개값이 다르면 `계수`) ·
M 자재 = 「이름 단가 × 수량 = 금액」(이름 속 = · / 도) · 「이름 : 별도계상」(품 없음) ·
R 율 = 「기준 × N% = 금액」(라벨 = 이 줄 · 앞 줄 · 기준 = 「노무비의」 앞 낱말) ·
E 값 = 셈 글 아닌 「이름 : … = 금액」(하위 가름은 `UnitLine.settle`) ·
절 = 제목(작은 절 = 「윗 제목 · 작은 제목」) · 합계 · 소계 절 안 줄 = `밖`.
"""
from __future__ import annotations
@@ -28,6 +27,7 @@ from functools import lru_cache
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Hopyo_UnitLine as unit_line
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
from M02_MasterTemplete.M02_Hopyo_Words import mtypes, parse_rhs
@@ -53,6 +53,26 @@ _MAT = re.compile(
_SEC = re.compile(r"^\s*(\d+)\.\s*(.*)$")
_SUB = re.compile(r"^\s*([가-하])\.\s*(.*)$")
_LASTNUM = re.compile(r"=\s*([\d,]+(?:\.\d+)?)\s*$")
# 단위 줄(778-3) — 줄 금액 · 끝 점 · 율 · 느슨한 자재 · 셈 글 · 합계 절
NUM = r"[\d,]+(?:\.\d+)?"
_AMOUNT = re.compile( # 끝 「= 금액」 — 원 · ₩ · 「\/㎡」 단위 · 「(지주)」 꼬리 허용
r"=\s*[A-Z]?\s*(" + NUM + r")\s*\.?\s*(?:원|₩)?\s*(?:\\?/\s*\S+)?\s*(?:\([^()]*\))?\s*$"
)
_DOT = re.compile(r"(\d)\.(?=\s|$|%)")
_DECR = re.compile(r"[×xX*]\s*(0?\.\d+)\s*=")
_PCTN = re.compile(r"(\d+(?:\.\d+)?)\s*%")
_BASEW = re.compile(r"([가-힣]+)의\s*[\d.]+\s*%")
_TOTAL = re.compile(r"(합\s*계|소\s*계|총\s*계)")
_NUMBERED = re.compile(r"^\s*(?:\d+|[가-하])\s*[.)]\s*") # 「3.」 · 「가.」 · 「1)」 머리
_TOP_L = re.compile(r"^\s*\d+\s*\.\s*(.*)$")
_SUB_L = re.compile(r"^\s*[가-하]\s*\.\s*(.*)$")
_DECIMAL = re.compile(r"^\s*\d+\.\d")
_LOOSE = re.compile(
r"^\s*(?P<name>[^\d\s::].*?[A-Za-z가-힣)\]])\s+(?P<rhs>" + NUM + r"\s*[×xX*÷/].*=.*)$"
)
_CALC = re.compile(
r"(^|[\s(])(Q\d?|q\w?|K|k|f\d?|E\w?|Cm|L|V\d|T|rt|n|t\d|A|N|W|V|S)\s*[=:]|─|㎥/hr|m3/hr|/hr\b"
)
def _flat(value: Any) -> str:
@@ -187,7 +207,7 @@ def read(rows: list[list[Any]] | None) -> dict[int, dict[str, Any]]:
return blocks
# ── 글 → 항목 ─────────────────────────────────────────────────────────
# ── 글 → 단위 줄 ───────────────────────────────────────────────────────
def _ratio(rhs: str) -> float | None:
"""식을 못 풀 때만 — 금액 ÷ 단가(품 되찾기용)."""
m = re.match(r"^\s*([\d,]+(?:\.\d+)?)", rhs)
@@ -198,22 +218,82 @@ def _ratio(rhs: str) -> float | None:
return a / p if p else None
def _num(text: str) -> float | None:
try:
return float(text.replace(",", ""))
except ValueError:
return None
def amount(row: str) -> float | None:
"""줄 끝 「= 금액」(원 · 끝 점 · 앞 글자 하나 허용)."""
m = _AMOUNT.search(row.replace("W", ""))
return _num(m.group(1)) if m else None
def _item(kind: str, name: str, got: dict[str, Any], how: str, **more: Any) -> dict[str, Any]:
keep = {k: got.get(k) for k in ("품", "밑품", "율", "인자")}
return {"갈래": kind, "이름": name, "규격": "", **keep, "어떻게": how, **more}
keep = {"품": got.get("품"), "밑품": got.get("밑품")}
keep |= {"율": got.get("율") or [], "인자": got.get("인자") or []}
return {"묶음": kind, "이름": name, "규격": "", **keep, "어떻게": how, **more}
def _material(hit: Any, row: str, money: float | None, place: dict) -> dict[str, Any]:
"""자재 줄 「이름 단가 × 수량 [× 손료율] = 금액」 — 손료율 = 식 속 % · 끝 「× 0.xx =」."""
got = parse_rhs(hit.group("rhs"))
more = {"단가": {"재료비": got["단가"]} if got else {}, "금액": money, **place}
one = _item("M", hit.group("name").strip(), got or {}, "식", **more)
pct = [v for v, note in (got or {}).get("율") or [] if note == "%"]
dec = _DECR.search(row)
dec = float(dec.group(1)) if dec and 0.5 <= float(dec.group(1)) < 1 else None
if (
pct or dec
): # ponytail: 끝 「× 0.95 =」 = 손료율(0.5~1) 어림 — 글에 「손료」 표지가 생기면 그것으로
one["손료율"] = pct[-1] if pct else dec
return one
def _rate(row: str, prev: str, cm: Any) -> dict[str, Any]:
"""율 줄 「기준 × N% = 금액」(라벨 = 이 줄 이름 · 없으면 앞 줄) →
{이름, 품 = 율, 기준값, 기준}."""
pm = _PCTN.findall(row) or _PCTN.findall(prev)
rate = float(pm[-1]) / 100 if pm else None
dec = _DECR.search(row)
if dec and not _PCTN.findall(row):
rate = float(dec.group(1))
body = row.split(":", 1)[-1] if cm else row
par = re.search(r"\(([^()]*\d[^()]*)\)\s*[×xX*]", body)
if par and "+" in par.group(1):
base: float | None = sum(_num(x) or 0 for x in re.findall(NUM, par.group(1)))
else:
hit = re.search(r"(" + NUM + r")\s*[×xX*]\s*(?:" + NUM + r")\s*(?:%|=)", body)
hit = hit or re.search(r"(" + NUM + r")", body)
base = _num(hit.group(1)) if hit else None
word = _BASEW.search(row) or _BASEW.search(prev)
label = _NUMBERED.sub("", (cm.group("name") if cm else prev)).strip()[:40]
return {"이름": label, "품": rate, "기준값": base, "기준": word.group(1) if word else ""}
def items(text: list[str], rows: list[int] | None = None) -> list[dict[str, Any]]:
"""블록 몸 글(머리 뺌) → 항목 목록(L · M 은 글 차례 · X 는 절 · 장비 처음 나온 차례로 뒤에)."""
"""블록 몸 글(머리 뺌) → 단위 줄(글 차례 · 기계 = 절 · 장비마다 하나 · 첫 비목 줄 자리 ·
품 = 노무비(단가 > 0) › 경비 › 재료비 줄) · 합계 · 소계 절 안 = `밖` · 값 → 하위 가름은 책
단위(`M02_Hopyo_UnitLine.settle`)."""
rows = rows or [0] * len(text)
names = trade_names()
out: list[dict[str, Any]] = []
machines: dict[tuple, dict[str, Any]] = {}
sec, sub, mach, label = 0, "", False, ""
sec, sub, mach, label, total, prev = 0, "", False, "", False, ""
top = path = "" # 절 제목 · 작은 절이면 「윗 제목 · 작은 제목」
whole_out = False # 합계 · 소계 맨 윗 절 안
sec_rates: set[float] = set()
for raw, at in zip(text, rows, strict=True):
row = str(raw).rstrip()
h, s = _SEC.match(row), _SUB.match(row)
if (lt := _TOP_L.match(row)) and not _DECIMAL.match(row): # 몫 절 · 셈 밖(띄어 쓴 「가 .」)
top = path = lt.group(1).strip()
whole_out = total = bool(_TOTAL.search(top))
elif ls := _SUB_L.match(row):
total = whole_out or bool(_TOTAL.search(ls.group(1)))
path = path if total else f"{top} · {ls.group(1).strip()}"
if h or s:
if h:
sec, sub = int(h.group(1)), ""
@@ -225,46 +305,87 @@ def items(text: list[str], rows: list[int] | None = None) -> list[dict[str, Any]
ts = mtypes(title)
mach = bool(ts)
label = title.strip() if ts else ""
where = f"{sec}.{sub}" if sub else str(sec)
place = {"절": where, "절율": sorted(sec_rates), "행": at}
else:
row = _DOT.sub(r"\1", row) # 「28,500. ×」 끝 점
place = {
"절": path,
"윗절": top,
"절율": sorted(sec_rates),
"행": at,
**({"밖": True} if total else {}),
}
money = amount(row)
cm = _COLON.match(row)
name = re.sub(r"^[0-9가-힣]\.", "", _flat(cm.group("name"))) if cm else ""
who = names.get(name) if cm else None
if who:
rhs = cm.group("rhs")
raw_name, how = cm.group("name").strip(), {"직종": who, **place}
raw_name, how = cm.group("name").strip(), {"직종": who, "금액": money, **place}
if got := parse_rhs(rhs):
out.append(_item("L", raw_name, got, "식", 단가={"노무비": got["단가"]}, **how))
elif r := _ratio(rhs):
got = {"품": r, "밑품": r, "율": [], "인자": []}
out.append(_item("L", raw_name, got, "역산", **how))
got = {"품": r, "밑품": r}
out.append(_item("L", raw_name, got, "역산", 단가={}, **how))
elif money is not None: # 품 없는 「직종 : P」 선언 줄은 뺌(값은 뒤 줄)
out.append(_item("L", raw_name, {}, "식", 단가={}, **how))
prev = row
continue
if cm and name in COST:
got = parse_rhs(cm.group("rhs"))
if got and mach:
if mach and (got or money is not None):
key = (sec, sub, label)
prio = 0 if (name == "노무비" and got["단가"] > 0) else 1 if name == "경비" else 2
one = machines.get(key)
if one is None or prio < one["_순"]:
price = one["단가"] if one else {}
one = _item("X", label, got, "식", 단가=price, _순=prio, **place)
if one is None:
one = _item("X", label, {}, "식", 단가={}, 금액=0.0, _순=9, _q={}, **place)
machines[key] = one
one["단가"][name] = got["단가"]
out.append(one)
one["금액"] = round(one["금액"] + (money or 0), 6)
if got:
prio = (
0 if (name == "노무비" and got["단가"] > 0) else 1 if name == "경비" else 2
)
if prio < one["_순"]:
one.update({k: got[k] for k in ("품", "밑품", "율", "인자")}, _순=prio)
one["단가"][name] = got["단가"]
one["_q"][name] = got["품"]
prev = row
continue
if cm and name not in SKIPW and re.search(r"(별도|별산)", cm.group("rhs")):
got = {"품": None, "밑품": None, "율": [], "인자": []}
out.append(_item("M", cm.group("name").strip(), got, "별도", **place))
more = {"단가": {}, "금액": 0.0, **place}
out.append(_item("M", cm.group("name").strip(), {}, "별도", **more))
prev = row
continue
mm = _MAT.match(row)
if mm and not cm and (got := parse_rhs(mm.group("rhs"))):
price = {"재료비": got["단가"]}
out.append(_item("M", mm.group("name").strip(), got, "식", 단가=price, **place))
if mm and not cm:
out.append(_material(mm, row, money, place))
prev = row
continue
if not (h or s):
ts = mtypes(row)
if ts and "%" not in row and not cm:
mach, label = True, row.strip()
if (
money is None
or total
or (cm and (name in ("소계", "계", "합계") or name.endswith("소계")))
):
prev = row
continue
dec = _DECR.search(row)
loose = None if cm else _LOOSE.match(row)
if loose and (names.get(_flat(loose.group("name"))) or _BASEW.search(loose.group("name"))):
loose = None # 직종 · 「…의 N%」 이름 = 자재 아님
if loose: # 자재(이름 속 = · / · 손료율 % 도)
out.append(_material(loose, row, money, place))
elif "%" in row or (dec and "%" in prev):
got = {"묶음": "R", "규격": "", **_rate(row, prev, cm), "단가": {}, "금액": money}
out.append({**got, "어떻게": "율", **place})
elif cm and not _CALC.search(row):
first = re.search(r"(" + NUM + r")\s*[×xX*]", cm.group("rhs"))
price = {"계": _num(first.group(1))} if first else {}
one = {"묶음": "E", "이름": cm.group("name").strip()[:40], "규격": "", "품": None}
out.append({**one, "단가": price, "금액": money, "어떻게": "값", **place})
prev = row
for one in machines.values():
del one["_순"]
out.append(one)
unit_line.machine_done(one)
return out
+138 -41
View File
@@ -15,9 +15,12 @@ PR · 단위 % = % · 그 밖 기타.
비면 목록표 · 기계 셋 줄(노무비 · 재료비 · 경비)은 하나로 · 절 = 그 앞 번호 절 제목) ·
머리 이름 · 규격 · 단위 = 단가산출근거목록표 · 출처 = 블록 글 `ReadEst.sources` ·
직종 = 자원 L 이름(우리 인력 · 옛이름 = 지금 이름) ·
금액 = 머리 줄 C~F(1 단위) · 항목(776-2 · Book 블록 항목 · EST 어댑터 `M02_Hopyo_ReadEst` 와
같은 꼴) = 자원 + `{직종(L), 밑품 = 품, 율 [], 인자 [], 단가 I~L, 금액 C~F, 어떻게 「QTY」, 행}`
(기계 셋 줄 = 더함).
금액 = 머리 줄 C~F(1 단위) · 항목(778-3 · 단위 줄 꼴 = `M02_Hopyo_UnitLine`) = 행마다
자원 L 노무 · X 기계 · M 자재 · S 경비 = E 값(품 = H · 단가 I~L · 줄 금액 C · 어떻게 「QTY」) ·
기계 셋 줄 = 같은 코드 · 절 · 아직 빈 비목일 때만 하나(같은 코드 두 묶음 = 따로 ·
비목마다 H 가 다르면 `계수`) · G 열 「&자원& * N'%'」 = R 율(기준값 = I) ·
PR · RR 행 = R 율(PR = N % · RR = 곱 · 기준 = G 열 표지 「&PA01M:M&」 앞) ·
D 코드 행 = S 하위 · N 열 「C…」 = `밖`(소계(제외금액)) · 자원 = 자원 코드 줄 옛 칸.
검산 안 함 — `검산 {어긋남: [], 총괄표: 0}`.
"""
@@ -28,6 +31,7 @@ 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
SHEET = "일위대가표"
CALC = "단가산출근거"
@@ -45,6 +49,17 @@ _COLS = {"합계": 4, "노무비": 6, "재료비": 8, "경비": 10}
_CALC_AMOUNT = {"합계": 2, "노무비": 3, "재료비": 4, "경비": 5} # 단가산출근거 C~F 금액
_CALC_PRICE = {"합계": 8, "노무비": 9, "재료비": 10, "경비": 11} # I~L 단가(T · L · M · S)
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 = ("노무비", "재료비", "경비")
_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」
_SUB_D = re.compile(r"^(D\d{5})_\d+$") # 다른 블록 부름
_PCT_REF = re.compile(r"&([A-Z][A-Z0-9_]*?)(?::[A-Z])?&\s*\*\s*[\d.]+\s*'%'\s*=") # 율 꼴 자원 행
_LOSS = re.compile(r"\*\s*([\d.]+)\s*'%'") # 자재 손료율 「* 0.443'%'」
_BASE_WORD = re.compile(r"([가-힣]+)의\s*[\d.]+\s*%") # 율 기준글 「(인건비의 3%)」
_NUMBERED = re.compile(r"^\s*(?:\d+|[가-하])\s*[.)]\s*")
_TAG = re.compile(r"&([A-Z][A-Z0-9_]*?)(?::[A-Z])?&") # 율 기준 표지 「&PA01M:M&」 → PA01M
def is_stmate(names: Any) -> bool:
@@ -100,12 +115,78 @@ def _lists(sheets: dict[str, list[list[Any]]]) -> dict[str, dict[str, str]]:
# ── 단가산출근거 블록 ──────────────────────────────────────────────────
def _part(row: list[Any]) -> str | None:
"""기계 셋 줄 가운데 이 줄 비목(I~L 단가 칸 · 노무비 › 재료비 › 경비)."""
return next((k for k in COSTS if _cell(row, _CALC_PRICE[k]) not in (None, "")), None)
def _same_machine(last: dict[str, Any] | None, code: str, title: str, part: str | None) -> bool:
"""바로 앞 기계 줄과 한 기계(셋 줄)인가 — 같은 코드 · 절 · 이 비목이 아직 빔
(같은 코드 두 묶음 = 따로 · 울진대흥 1129)."""
return bool(
last
and last["묶음"] == "X"
and last["코드"] == code
and last["절"] == title
and part
and part not in last["_q"]
)
def _unit(row: list[Any], code: str, title: str, info: dict[str, str]) -> dict[str, Any]:
"""자원 행 → 단위 줄(L 노무 · X 기계 · M 자재 · S 경비 = E 값 · G 열 「'%'」 = R 율)."""
name, _, spec = _s(_cell(row, NAME_COL)).partition("/")
qty = hopyo_excel._num(_cell(row, QTY_COL))
q = float(qty) if qty is not None else None
one = {
"묶음": _KIND.get(code[0], "E"),
"코드": code,
"이름": name.strip() or info.get("이름", ""),
"규격": spec.strip() if name.strip() else info.get("규격", ""),
"단위": info.get("단위", ""),
"품": q,
"절": title,
}
if rate := _PCT_REF.search(_s(_cell(row, FORMULA_COL))): # 「기구손료 : &L00020& * 2'%'」
base = hopyo_excel._num(_cell(row, _CALC_PRICE["합계"]))
more = {"기준": rate.group(1), "기준값": float(base) if base is not None else None}
return {**one, "묶음": "R", "품": q / 100 if q is not None else None, **more}
if one["묶음"] == "M" and (loss := _LOSS.search(_s(_cell(row, FORMULA_COL)))):
one["손료율"] = round(float(loss.group(1)) / 100, 9) # 「&M00081& * 1.03'㎡'* 0.443'%'」
return one
def _rate_row(row: list[Any], code: str, text: str, label: str) -> dict[str, Any]:
"""PR · RR 율 행 → 단위 줄 R(기준 = G 열 표지 「&PA01M:M&」 앞 · PR = N % · RR = 곱) · 이름이
없는 식 줄(「210,212.3 * 0.09」)은 위 라벨 줄(「기구손료 : (가공노력품의 9%)」)에서 이름 ·
기준글 · 못 찾으면 이름 빈 줄 + `이름없음`."""
qty = hopyo_excel._num(_cell(row, QTY_COL))
base = hopyo_excel._num(_cell(row, _CALC_PRICE["합계"]))
tag = _TAG.search(_s(_cell(row, FORMULA_COL)))
rate = float(qty) / (100 if code.startswith("PR") else 1) if qty is not None else None
name = text.split(":")[0] if ":" in text else ""
if not re.search(r"[가-힣A-Za-z]", name):
name = _NUMBERED.sub("", label.split(":")[0] if ":" in label else label)
word = _BASE_WORD.search(f"{text} {label}")
return {
"묶음": "R",
"코드": code,
"이름": name.strip()[:40],
"규격": "",
"품": rate,
"기준": tag.group(1) if tag else "",
"기준값": float(base) if base is not None else None,
**({"기준글": word.group(1)} if word else {}),
**({} if name.strip() else {"이름없음": True}),
}
def _blocks(rows: list[list[Any]], table: dict[str, dict[str, str]]) -> dict[str, dict[str, Any]]:
"""{D 코드: {번, 코드, 이름, 규격, 단위, 행, 출처, 직종, 글, 자원, 금액, 항목}}."""
names = hopyo_est.trade_names()
out: dict[str, dict[str, Any]] = {}
block: dict[str, Any] | None = None
title = ""
title = label = "" # 절 제목 · 율 행 위 라벨 줄(코드 · 금액 없는 글)
for at, row in enumerate(rows, 1):
head = next(
(hit for v in row[:8] if (hit := _HEAD_D.search(_s(v)))), None
@@ -123,12 +204,11 @@ def _blocks(rows: list[list[Any]], table: dict[str, dict[str, str]]) -> dict[str
"단위": info.get("단위", ""),
"행": [at, at],
"글": [],
"자원": [],
"금액": _calc_money(row, _CALC_AMOUNT),
"항목": [],
},
)
title = ""
title = label = ""
continue
if block is None:
continue
@@ -137,16 +217,18 @@ def _blocks(rows: list[list[Any]], table: dict[str, dict[str, str]]) -> dict[str
block["행"][1] = at
block = None
continue
text = _s(_cell(row, 1))
res = _RES.match(_s(_cell(row, RES_COL)))
text, cell = _s(_cell(row, 1)), _s(_cell(row, RES_COL))
res = _RES.match(cell)
code = res.group(1) + res.group(2) if res else None
if not code:
alt = _RES_A.match(first) or _RES_G.search(_s(_cell(row, 6)))
alt = _RES_A.match(first) or _RES_G.search(_s(_cell(row, FORMULA_COL)))
code = alt.group(1) if alt else None
rate = None if code else _RATE.match(cell)
sub = None if code or rate else _SUB_D.match(cell)
if not text and not code:
continue
block["행"][1] = at
if _s(_cell(row, RES_COL)) == block["코드"]: # 머리 이름 줄 「이름 규격 / 단위」
if cell == block["코드"]: # 머리 이름 줄 「이름 규격 / 단위」
if not block["이름"]:
name, _, unit = text.rpartition("/")
block["이름"], block["단위"] = name.strip() or text, unit.strip()
@@ -156,41 +238,56 @@ def _blocks(rows: list[list[Any]], table: dict[str, dict[str, str]]) -> dict[str
block["글"].append(text[: hopyo_est.GCHARS])
if (sec := _SECTION.match(text)) and not re.match(r"^\s*\d+\.\d", text):
title = sec.group(1).strip()
money = _calc_money(row, _CALC_AMOUNT)
place = {
"단가": _calc_money(row, _CALC_PRICE),
"금액": money.get("계"),
"행": at,
"절": title,
"윗절": title,
**({"밖": True} if _s(_cell(row, OUT_COL)).startswith("C") else {}),
}
if rate:
block["항목"].append({**_rate_row(row, cell, text, label), **place, "어떻게": "율"})
continue
if code or sub or money.get("계") is not None:
label = "" # 라벨은 바로 위 글 줄만
elif text:
label = text.strip()
if sub and sub.group(1) != block["코드"] and money.get("계"):
qty = hopyo_excel._num(_cell(row, QTY_COL))
one = {"묶음": "S", "코드": sub.group(1), "이름": text[:40], "규격": ""}
more = {"품": float(qty) if qty is not None else None, "어떻게": "QTY"}
block["항목"].append(
{**one, **more, **place, "하위": {"종류": "단산", "번": int(sub.group(1)[1:])}}
)
continue
if not code:
continue
price, amount = _calc_money(row, _CALC_PRICE), _calc_money(row, _CALC_AMOUNT)
last = block["자원"][-1] if block["자원"] else None
if code[0] == "X" and last and last["코드"] == code and last["절"] == title:
_add(block["항목"][-1]["단가"], price) # 기계 노무비 · 재료비 · 경비 셋 줄 = 하나
_add(block["항목"][-1]["금액"], amount)
last, part = block["항목"][-1] if block["항목"] else None, _part(row)
if code[0] == "X" and _same_machine(last, code, title, part):
_add(last["단가"], place["단가"]) # 기계 노무비 · 재료비 · 경비 셋 줄 = 하나
last["금액"] = hopyo_excel._out(
hopyo_excel._num(last["금액"] or 0) + hopyo_excel._num(place["금액"] or 0)
)
last["_q"][part] = hopyo_excel._num(_cell(row, QTY_COL))
continue
name, _, spec = _s(_cell(row, NAME_COL)).partition("/")
info = table.get(code) or {}
qty = hopyo_excel._num(_cell(row, QTY_COL))
res = {
"갈래": code[0],
"코드": code,
"이름": name.strip() or info.get("이름", ""),
"규격": spec.strip() if name.strip() else info.get("규격", ""),
"품": float(qty) if qty is not None else None,
"절": title,
}
block["자원"].append(res)
who = hopyo_est.trade(res["이름"]) if code[0] == "L" else None
block["항목"].append(
{
**res,
**({"직종": who} if code[0] == "L" else {}),
"밑품": res["품"],
"율": [],
"인자": [],
"단가": price,
"금액": amount,
"어떻게": "QTY",
"행": at,
}
)
one = _unit(row, code, title, table.get(code) or {})
who = hopyo_est.trade(one["이름"]) if one["묶음"] == "L" else None
one = {**one, **({"직종": who} if one["묶음"] == "L" else {}), "밑품": one["품"]}
one.update(율=[], 인자=[], 어떻게="율" if one["묶음"] == "R" else "QTY", **place)
if one["묶음"] == "X":
one["_q"] = {part: hopyo_excel._num(_cell(row, QTY_COL))} if part else {}
block["항목"].append(one)
for block in out.values():
for one in block["항목"]:
if "_q" in one:
unit_line.machine_done(one)
block["자원"] = [ # 옛 칸(S1 투영) — 자원 코드 줄만
{"갈래": one["코드"][0], **{k: one[k] for k in ("코드", "이름", "규격", "품", "절")}}
for one in block["항목"]
if _RES_CODE.match(one["코드"])
]
block["출처"] = hopyo_est.sources(" ".join(block["글"]))
trades = [names.get(_flat(r["이름"])) for r in block["자원"] if r["갈래"] == "L"]
block["직종"] = list(dict.fromkeys(t for t in trades if t))
+1 -1
View File
@@ -32,7 +32,7 @@ DONE = "불러옴"
NO_GRID = "격자 없음 — 다시 올리면 보임"
RELOAD = "읽기 꼴이 바뀐 옛 불러오기 — 엑셀을 다시 올림"
EXPIRE = timedelta(days=1) # 작업 중 HP 보관 — 올린 뒤 하루
BOOK = 2 # 읽기 꼴(Book) 판 — 읽기 · 정규화 출력 꼴을 바꾸면 올림
BOOK = 3 # 읽기 꼴(Book) 판 — 읽기 · 정규화 출력 꼴을 바꾸면 올림
_KEY = re.compile(r"^HP-(\d{6})$")
# ponytail: 프로세스 안 잠금 — 서버를 여럿 띄우면 파일 잠금으로
_LOCK = threading.Lock()
+219
View File
@@ -0,0 +1,219 @@
"""M02 일위대가 실무자료 단위 줄(PLAN 778-3 · D-1 · 요구 622) — 단가산출 블록 · 일위대가 줄을 같은
「세부항목 계산 단위」 꼴로 · 자체 검산. 형식(EST · STmate)을 모름 — 읽기 어댑터
(`M02_Hopyo_ReadEst.items` · `M02_Hopyo_Stmate._blocks`)가 블록 `항목` 을 단위 줄로 냄.
단위 줄 = `{묶음, 이름, 규격, 단위, 품, 밑품, 율, 인자, 계수, 단가, 금액, 밖, 기준, 기준값,
하위, 할증, 재현, 직종, 절, 절율, 코드, 어떻게, 행}`:
묶음 = L 노무 · X 기계 · M 자재 · R 율 · S 하위 · E 값
품 = 매개값(L 품 · X 시간 · M 수량 · R 율 소수 · S 수량 · E 없음) · 밑품 · 율 · 인자 = 지문 몫
계수 = X 비목마다 매개값이 다를 때 `{비목: 그 비목 매개값 ÷ 품}`(덤프 재료비 (Cm-t1)/Cm)
단가 = {노무비 · 재료비 · 경비(· 계)} · 금액 = 줄 금액(None = 엑셀에 없음)
밖 = 셈 밖(STmate N 열 C · EST 합계 · 소계 절) · 기준 · 기준값 = 율 줄 기준 표지 · 기준 금액 ·
기준글 = 라벨 「…의 N%」 앞 낱말(STmate) · 기준꼴 · 기준줄 = 기준값이 어느 줄 합인가
(`base_rows`) ·
이름없음 = 율 줄 이름 못 찾음 · 손료율 = M 「수량 × 손료율 × 단가」 꼴 표지(율 줄 아님)
하위 = S `{종류 단산|일위, 번}` · 할증 = X 이름 · 규격 「할증120%」 → 1.2
재현 = 단가 × 매개값(× 계수) ≈ 금액(0.5 % · 1 원) · 율 = 기준값 × 율 · 별도 · 값 = 참
블록 `줄합` = 밖 아닌 줄 금액 합 · `덮음` = 줄합 ≈ 블록 금액(금액 없으면 None).
일위대가 줄 → `묶음` · `재현` · % 줄 `기준`(노무비 · 재료비 · 주재료비 · 경비 = 규격 글) ·
호표 `줄합` · `덮음`.
값 줄(E · 첫 수 = 다른 블록 · 호표 1 단위 금액 ±0.51 · 100 넘음) → S 하위.
"""
from __future__ import annotations
import re
from typing import Any
COSTS = ("노무비", "재료비", "경비")
TOL = 0.005
_SURCHARGE = re.compile(r"할증\s*(\d+(?:\.\d+)?)\s*%")
_KIND = {
"노임": "L",
"기계": "X",
"자재": "M",
"별도계상": "M",
"%": "R",
"단가산출": "S",
"호표": "S",
}
def near(a: Any, b: Any, tol: float = TOL) -> bool:
if a is None or b is None:
return False
a, b = float(a), float(b)
return abs(a - b) <= max(tol * max(abs(a), abs(b)), 1.0)
def money(d: Any) -> float | None:
"""금액 · 단가 칸 → 수(계 › 비목 합 · 수면 그대로)."""
if isinstance(d, (int, float)) and not isinstance(d, bool):
return float(d)
if not isinstance(d, dict):
return None
if d.get("계") not in (None, ""):
return float(d["계"])
vals = [float(d[c]) for c in COSTS if d.get(c) not in (None, "")]
return sum(vals) if vals else None
def machine_done(one: dict[str, Any]) -> None:
"""기계 줄 마무리(두 어댑터) — 비목마다 매개값이 다르면 `계수`."""
one.pop("_순", None)
got_q, main = one.pop("_q", {}), one.get("품")
if main:
ratio = {
k: round(float(q) / main, 9)
for k, q in got_q.items()
if q is not None and abs(float(q) / main - 1) > 1e-6
}
if ratio:
one["계수"] = ratio
def _parts(price: dict[str, Any]) -> float | None:
vals = [float(price[c]) for c in COSTS if price.get(c) not in (None, "")]
return sum(vals) if vals else money(price)
def redo(one: dict[str, Any]) -> bool:
"""줄 재현 — 단가 × 매개값(× 계수) ≈ 금액."""
kind, q, got = one["묶음"], one.get("품"), one.get("금액")
if one.get("어떻게") == "별도" or (kind == "E" and q is None):
return True
if kind == "S" and q is None:
return got is not None
if kind == "R":
return bool(q is not None and near((one.get("기준값") or 0) * q, got))
price = one.get("단가") or {}
if q is None or got is None:
return False
if kind == "X":
ratio = one.get("계수") or {}
want = sum(float(price[c]) * q * ratio.get(c, 1) for c in COSTS if price.get(c))
return near(want, got)
unit = _parts(price)
return bool(unit is not None and (near(unit * q, got) or (unit == 0 and got == 0)))
def _subs(units: list[dict[str, Any]], totals: dict[tuple[str, int], float]) -> None:
"""값 줄 첫 수가 다른 블록 · 호표 1 단위 금액이면 하위."""
for one in units:
p = money(one.get("단가")) if one["묶음"] == "E" else None
if not p or p < 100:
continue
hit = next((k for k, v in totals.items() if abs(p - v) <= 0.51), None)
if hit:
got = one.get("금액")
q = round(float(got) / p, 6) if got is not None else None
one.update(묶음="S", 품=q, 하위={"종류": hit[0], "번": hit[1]}, 어떻게="QTY")
def _labor_part(one: dict[str, Any]) -> float:
"""기계 줄 노무비 몫 = 노무 단가 × 시간(× 계수)."""
price, q = (one.get("단가") or {}).get("노무비"), one.get("품")
return float(price) * q * (one.get("계수") or {}).get("노무비", 1) if price and q else 0.0
def base_rows(units: list[dict[str, Any]]) -> None:
"""율 줄 기준값 = 어느 줄 합인가 → `기준꼴` · `기준줄 [행]`(못 찾으면 안 붙임) — 차례:
바로 앞 같은 묶음 줄들(절 소계) › 앞 인력 › 앞 인력 + 기계 노무 › 앞 자재 › 앞 자재 + 율 ›
앞 줄 모두 › 블록 인력 › 블록 자재."""
live = [u for u in units if not u.get("밖") and u.get("금액")]
for i, one in enumerate(units):
if one["묶음"] != "R" or not one.get("기준값"):
continue
prev = [u for u in units[:i] if u in live]
run: list[dict[str, Any]] = []
for u in reversed(prev):
if u["묶음"] == "R" or (run and u["묶음"] != run[0]["묶음"]):
break
run.insert(0, u)
of = lambda us, k: [u for u in us if u["묶음"] in k] # noqa: E731
pools = [
("절 소계", run, None),
("인력", of(prev, "L"), None),
("노무비", of(prev, "LX"), "L+X노무"),
("자재", of(prev, "M"), None),
("자재 + 율", of(prev, "MR"), None),
("앞 줄 모두", of(prev, "LXM"), None),
("인력(블록)", of(live, "L"), None),
("자재(블록)", of(live, "M"), None),
]
for name, pool, how in pools:
total = sum(
_labor_part(u) if how and u["묶음"] == "X" else float(u["금액"]) for u in pool
)
if pool and near(total, one["기준값"]):
one["기준꼴"], one["기준줄"] = name, [u.get("행") for u in pool]
break
def check_block(block: dict[str, Any], totals: dict[tuple[str, int], float]) -> None:
units = block.get("항목") or []
own = ("단산", int(block["번"]))
_subs(units, {k: v for k, v in totals.items() if k != own})
base_rows(units)
for one in units:
if one["묶음"] == "X" and (hit := _SURCHARGE.search(f"{one['이름']} {one['규격']}")):
one["할증"] = round(float(hit.group(1)) / 100, 4)
one["재현"] = redo(one)
total = money(block.get("금액"))
lines = round(sum(float(one.get("금액") or 0) for one in units if not one.get("밖")), 4)
block["줄합"] = lines
block["덮음"] = near(lines, total) if total else None
def check_hopyo(hopyo: dict[str, Any]) -> None:
"""일위대가 줄 → 단위 줄 투영(묶음 · 재현 · % 기준) · 호표 줄합 · 덮음."""
run = dict.fromkeys(COSTS, 0.0)
main = lines = 0.0
for line in hopyo.get("줄") or []:
kind = _KIND.get(str(line.get("갈래")), "E")
q, got = line.get("수량"), money(line.get("금액"))
line["묶음"] = kind
if line.get("갈래") == "별도계상":
line["재현"] = True
elif kind == "R":
spec = re.sub(r"\s+", "", str(line.get("규격") or ""))
base = (
"주재료비"
if "주재료" in spec
else "재료비"
if "재료비" in spec
else "경비"
if spec.startswith("경비")
else "노무비"
)
line["기준"] = base
value = main if base == "주재료비" else run[base]
line["재현"] = bool(q and got is not None and near(value * float(q) / 100, got))
else:
p = money(line.get("단가"))
line["재현"] = bool(q is not None and p is not None and near(float(q) * p, got))
if kind != "R":
for c in COSTS:
run[c] += float((line.get("금액") or {}).get(c) or 0)
if line.get("갈래") == "자재":
main += float((line.get("금액") or {}).get("재료비") or 0)
lines += got or 0
total = money(hopyo.get("금액"))
hopyo["줄합"] = round(lines, 4)
hopyo["덮음"] = near(lines, total) if total else None
def settle(book: dict[str, Any]) -> dict[str, Any]:
"""읽은 책(HP 꼴) → 블록 단위 줄 하위 가름 · 재현 · 덮음 + 일위대가 줄 투영(제자리)."""
totals: dict[tuple[str, int], float] = {}
for block in book.get("단산") or []:
if (v := money(block.get("금액"))) is not None:
totals[("단산", int(block["번"]))] = v
for hopyo in book.get("호표") or []:
if (v := money(hopyo.get("금액"))) is not None:
totals[("일위", int(hopyo["번"]))] = v
for block in book.get("단산") or []:
check_block(block, totals)
for hopyo in book.get("호표") or []:
check_hopyo(hopyo)
return book
@@ -0,0 +1,174 @@
"""778-3(D-1) 단위 줄 — 단가산출 블록 · 일위대가 줄을 같은 계산 단위 꼴로(`M02_Hopyo_UnitLine`).
꼴(EST 글 · STmate 행) · 같은 기계 코드 두 묶음 따로 · 비목마다 매개값 `계수` · 율 행 · 셈 밖 ·
여덟 파일(불리는 블록) 줄 수 · 재현 · 덮음 · 할증 풀이 · 요구 624(이름 없는 율 줄 · 두 행 노무 ·
손료율 표지). 읽기만 · 서버 없음.
"""
from __future__ import annotations
import warnings
from collections import Counter
from functools import lru_cache
import pytest
from M02_MasterTemplete import M02_Hopyo_Excel as hx
from M02_MasterTemplete import M02_Hopyo_ReadEst as est
from M02_MasterTemplete import M02_Hopyo_Sangeun as sg
from M02_MasterTemplete import M02_Hopyo_Stmate as st
from M02_MasterTemplete import M02_Hopyo_UnitLine as ul
from resources.tester.helper_m02_hopyo_golden import FILES
warnings.filterwarnings("ignore", category=UserWarning, module="openpyxl")
KEYS = {"묶음", "이름", "규격", "품", "단가", "금액", "어떻게", "행", "절", "윗절"}
def test_EST_글_단위_줄():
text = [
" 1. 굴착기(무한궤도)(0.7㎥)",
" 노무비 : 59,020 ÷ 26.95 × 0.9 = 1,970.9",
" 재료비 : 19,473 ÷ 26.95 = 722.5",
" 2. 인 부",
" 보통인부 : 172,068 × 0.011인 = 1,892.7 (지주)",
" 합판(내수),대전 (12×1220=2440) 28,500. × 0.1 = 2,850.",
" 각재(외송) 500,000 × 0.038 ㎥ × 38. % = 7,220 ₩ / ㎡",
" 특별인부 : P",
" 3. 공구손료(인력품의 3%)",
" 1,892.7 × 0.03 = 56.7",
" 레미콘 : 별도계상",
" 4. 합 계",
" 보통인부 : 172,068 × 0.011인 = 1,892.7",
]
got = est.items(text, list(range(1, len(text) + 1)))
book = {"단산": [{"번": 1, "금액": {"계": 14712.8}, "항목": got}], "호표": []}
ul.settle(book)
kinds = [(u["묶음"], bool(u.get("밖"))) for u in got]
assert kinds == [
("X", False),
("L", False),
("M", False),
("M", False),
("R", False),
("M", False),
("L", True),
] # fmt: skip — 「특별인부 : P」 선언 줄 뺌
assert all(KEYS <= set(u) for u in got)
mach, labor, mat, loss, rate = got[:5]
assert mach["품"] == pytest.approx(0.9 / 26.95) and mach["계수"] == {
"재료비": pytest.approx(1 / 0.9)
}
assert mach["금액"] == pytest.approx(2693.4) and mach["재현"]
assert labor["직종"] == "보통인부" and labor["금액"] == 1892.7 and labor["재현"]
assert mat["이름"].startswith("합판") and mat["품"] == 0.1 and mat["재현"] # 끝 점 · 이름 속 =
assert loss["손료율"] == 0.38 and loss["금액"] == 7220 and loss["재현"] # 손료율 = 자재 줄 표지
assert rate["품"] == 0.03 and rate["기준값"] == 1892.7 and rate["기준"] == "인력품"
assert (
rate["재현"] and rate["절"] == "공구손료(인력품의 3%)" and rate["이름"].startswith("공구")
)
assert rate["기준꼴"] == "인력" and rate["기준줄"] == [5]
assert book["단산"][0]["줄합"] == pytest.approx(14712.8) and book["단산"][0]["덮음"]
def _row(**cells) -> list:
row = [None] * 24
for col, v in cells.items():
row[int(col[1:])] = v
return row
def test_STmate_같은_코드_두_묶음_따로_계수_율_밖():
machine = "굴삭기(0.7m3) / 0.7㎥,(암석)"
rows = [
_row(c0="제 1호표", c2=100, c6="`BDCOD|D00001"),
_row(c1=" (1) 채집"),
_row(c1=" ① 굴삭기", c2=10, c3=10, c7=0.1, c8=100, c9=100, c12="X00009_1", c23=machine),
_row(c1=" 재료비", c2=5, c4=5, c7=0.05, c8=100, c10=100, c12="X00009_1", c23=machine),
_row(c1=" (2) 상차"),
_row(c1=" 노무비", c2=20, c3=20, c7=0.2, c8=100, c9=100, c12="X00009_1", c23=machine),
_row(
c1=" 기구손료 : 30 * 2 %",
c2=0.6,
c4=0.6,
c6="&PA01M:M& * 2'%' =",
c7=2,
c8=30,
c12="PR_1",
),
_row(c1=" 기구손료 : (가공노력품의 9%)"),
_row(
c1=" 30 * 0.09 = 2.7",
c2=2.7,
c4=2.7,
c6="&RA01:M& * 0.09 =",
c7=0.09,
c8=30,
c12="RR_1",
),
_row(
c1=" 보통인부 : 1 * 0.5",
c2=50,
c3=50,
c7=0.5,
c8=100,
c9=100,
c12="L00002_1",
c13="C1_1",
c23="보통인부 / ",
), # fmt: skip
_row(c0="총계"),
]
blocks = st._blocks(rows, {})
units = blocks["D00001"]["항목"]
xs = [u for u in units if u["묶음"] == "X"]
assert [x["품"] for x in xs] == [0.1, 0.2] # 두 묶음 따로(단가 두 배 아님)
assert xs[0]["단가"]["계"] == 200 and xs[1]["단가"]["계"] == 100
assert xs[0]["계수"] == {"재료비": 0.5} and xs[0]["금액"] == 15
rate, bare = [u for u in units if u["묶음"] == "R"]
assert rate["품"] == 0.02 and rate["기준"] == "PA01M" and rate["기준값"] == 30
assert bare["이름"] == "기구손료" and bare["기준글"] == "가공노력품" and bare["품"] == 0.09
assert next(u for u in units if u["묶음"] == "L").get("밖") is True
assert [r["코드"] for r in blocks["D00001"]["자원"]] == ["X00009", "X00009", "L00002"]
@lru_cache(maxsize=1)
def _books() -> dict:
return {tag: hx.read_book(p.name, p.read_bytes()) for tag, p in FILES.items()}
def test_여덟_파일_불리는_블록_줄_재현_덮음():
kinds, outside, redo, cover = Counter(), Counter(), Counter(), Counter()
lines = Counter()
for book in _books().values():
_, first = sg.calls(book)
called = {int(k) for k in first}
for block in book["단산"]:
if int(block["번"]) not in called:
continue
cover[block["덮음"]] += 1
for u in block["항목"]:
if u.get("밖"):
outside[u["묶음"]] += 1
continue
kinds[u["묶음"]] += 1
redo[u["재현"]] += 1
for hopyo in book["호표"]:
lines.update(bool(x["재현"]) for x in hopyo["줄"])
assert dict(kinds) == {"L": 366, "X": 190, "M": 130, "R": 152, "S": 3, "E": 1}
assert dict(outside) == {"L": 2}
assert redo[True] >= 836 and sum(redo.values()) == 842 # 전 811/836
assert cover[True] == 204 and sum(cover.values()) == 206 # 남은 둘 = 엑셀 자체 흠(합이 줄 뺌)
assert lines[True] == 1344 and sum(lines.values()) == 1350
def test_할증_풀이_같은_코드_따로():
books = _books()
block = next(b for b in books["울진소광"]["단산"] if b["번"] == 1448)
xs = [u for u in block["항목"] if u["묶음"] == "X"]
assert [x["할증"] for x in xs] == [1.2, 1.2, 1.25] and all(x["재현"] for x in xs)
block = next(b for b in books["울진대흥"]["단산"] if b["번"] == 1129)
dig = [u for u in block["항목"] if u.get("코드") == "X00009"]
assert len(dig) == 2 and all(u["단가"]["계"] == 100378 and u["재현"] for u in dig)
block = next(b for b in books["봉화"]["단산"] if b["번"] == 3)
dump = next(u for u in block["항목"] if u["이름"].startswith("덤프"))
assert dump["계수"]["재료비"] == pytest.approx(0.1527, abs=1e-4) and dump["재현"]