refactor(776-2): EST 단가산출 읽기 어댑터 떼어 냄 · 두 형식 블록 항목 · STmate 블록 단가 · 금액

- 새 M02_Hopyo_ReadEst.py — Basis.read(엑셀 「단가산출」 블록) · trade_names · sources 옮김 + 글 → 항목 풀이(L 직종 · X 장비 · M 자재 · 줄 율에 기대지 않는 것만)
- Basis — 근거 후보만 남김 · 옛 이름(read · SHEET · GLINES · GCHARS · sources · trade_names)은 그대로 다시 냄
- Stmate — 블록 금액(머리 줄 C~F) · 항목(자원 + 단가 I~L · 금액 C~F · 기계 셋 줄 더함 · 행) · 항목 단가 639 중 635
- 항목만으로 지문 인력 · 장비 · 자재 다시 세움 = 지금 지문과 544 쌍 모두 같음(tmp 대조 · 776-3 길)
- S1 · S2 해시 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 08:48:10 +09:00
co-authored by Claude Opus 5.5
parent 9d26e5fcd6
commit 0c2f12c23f
3 changed files with 344 additions and 136 deletions
+10 -119
View File
@@ -1,13 +1,8 @@
"""M02 일위대가 실무자료 단가산출 근거(PLAN 330) — 엑셀 「단가산출」 블록 → 출처 · 직종 ·
우리 돈 로직과 견준 근거 후보.
읽기(`read`) = B열(머리 「산출근거」) 머리 줄 「N.이름[단위]」(앞 빈칸 없음)부터
다음 머리 앞까지 한 블록 ·
출처 = 「…품셈」(오타 「품줌」) 뒤 20 글자 안 절 번호 — 앞 글에 산림 · 임도 = 산림품셈 ·
그 밖 = 건설품셈 ·
직종 = 「이름 : 수」 줄의 빈칸 뺀 이름이 우리 인력(LB) 이름 · 옛이름일 때만(지금 이름으로) ·
글(338) = 머리 뺀 몸 줄 `GLINES` 줄 · 줄마다 `GCHARS` 자 안(맞춤 지문 재료) · 산출근거 열이 빈 줄은
그 오른쪽 ~ 돈 머리(맨 오른쪽 묶음) 앞 칸을 빈칸으로 이어 붙임(거창 꼴 · 돈 머리 없으면 `WIDE` 열).
읽기(`read` · `sources` · `trade_names` · `SHEET` · `GLINES` · `GCHARS`) = EST 어댑터
`M02_Hopyo_ReadEst`(776-2 · 이름은 그대로 다시 냄).
견줌(`candidates`) = 같은 책 조항 같음 1.0 · 윗마디 0.9(엑셀 6-2 ⊃ 우리 6-2-2) ·
앞 마디 같음 0.5(조항이 안 맞으면 후보 아님 · `clause`) · 조항 오타 「13-114」 = 「13-11-4」 ·
건설 공통 · 토목 덤 0.1 · 직종 겹침(보통인부 뺌) 0.5 · 단위 0.15 · 직종도 뜻 낱말도 안 겹치면 버림 ·
@@ -27,129 +22,25 @@ from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match # 서로 부름 — 부를 때만 씀
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
from M02_MasterTemplete.M02_Hopyo_ReadEst import ( # noqa: F401 — 읽기는 EST 어댑터(776-2) · 옛 이름 그대로
GCHARS,
GLINES,
SHEET,
read,
sources,
trade_names,
)
SHEET = "단가산출"
COMMON = "보통인부"
BONUS_PARTS = ("공통", "토목")
_HEAD = re.compile(r"^(\d+)\.\s*(.*?)\s*\[([^\[\]]*)\]\s*$")
_BOOK = re.compile(r"품[셈줌]")
_TRADE = re.compile(r"^[\s*·\-]*([가-힣][가-힣\s]*?)\s*:\s*[\d,]")
_TYPO_CLAUSE = re.compile(r"^(\d+)-(\d)(\d)(\d)$") # 「13-114」 = 13-11-4
GLINES, GCHARS, WIDE = 200, 300, 140
_TYPOS = (("깍기", "깎기"),)
_PLAIN_WORDS = frozenset({"인력", "기계", "장비"}) # 방법 낱말 — 「터널굴착 1발파당 인력」
_MONEY = {"합계": "계", "노무비": "노무비", "재료비": "재료비", "경비": "경비"}
def _flat(value: Any) -> str:
return re.sub(r"\s+", "", str(value or ""))
# ── 엑셀 블록 읽기 ─────────────────────────────────────────────────────
def _cell_text(value: Any) -> str:
if isinstance(value, float) and value == int(value):
value = int(value)
return str(value).strip() if value is not None else ""
@lru_cache(maxsize=2)
def _trade_names(whole_id: int) -> dict[str, str]:
"""{빈칸 뺀 이름 · 옛이름: 지금 이름} — 우리 인력(LB)."""
out: dict[str, str] = {}
for row in m01.loaded()[1].numbers.get("LB", {}).values():
name = str(row.get("이름") or "")
out[_flat(name)] = name
for old in row.get("옛이름") or []:
out.setdefault(_flat(old), name)
return out
def trade_names() -> dict[str, str]:
return _trade_names(id(m01.loaded()[1]))
def sources(text: str) -> list[list[str]]:
"""글 → [[책, 절 번호]] — 「건설공사표준품셈 3-2-1」 · 「산림사업표준품셈(9-14)」."""
out = []
for hit in _BOOK.finditer(text):
num = labor_inputs._NUMBER.search(text, hit.end())
if not num or num.start() - hit.end() > 20:
continue
before = text[max(0, hit.start() - 12) : hit.start()]
book = "산림품셈" if "산림" in before or "임도" in before else "건설품셈"
typo = _TYPO_CLAUSE.match(num.group())
number = "{}-{}{}-{}".format(*typo.groups()) if typo else num.group()
if [book, number] not in out:
out.append([book, number])
return out
def _columns(rows: list[list[Any]]) -> tuple[int, int, dict[str, int]]:
"""(자료 첫 줄, 산출근거 열, {계 · 노무비 · 재료비 · 경비: 열}) — 머리 없으면 (0, 1, {}) ·
돈 머리가 둘 묶음이면 맨 오른쪽(거창 꼴 · 값이 든 쪽)."""
for at, row in enumerate(rows[:10]):
flat = [_flat(v) for v in row]
if "산출근거" in flat:
last = len(flat) - 1
money = {
key: last - flat[::-1].index(head) for head, key in _MONEY.items() if head in flat
}
return at + 1, flat.index("산출근거"), money
return 0, 1, {}
def read(rows: list[list[Any]] | None) -> dict[int, dict[str, Any]]:
"""단가산출 시트 줄 → `{N: {번, 이름, 단위, 행: [시작, 끝], 출처, 직종, 글, 금액}}`
(행 = 엑셀 1부터)."""
if not rows:
return {}
start, col, money = _columns(rows)
end = min(money.values()) if money and min(money.values()) > col + 1 else WIDE
names = trade_names()
blocks: dict[int, dict[str, Any]] = {}
block: dict[str, Any] | None = None
for at, row in enumerate(rows[start:], start + 1):
text = row[col] if col < len(row) else None
head = None
if isinstance(text, str) and text.strip():
head = None if text[:1].isspace() else _HEAD.match(text)
else:
text = " ".join(_cell_text(v) for v in row[col + 1 : end] if _cell_text(v))
if not text:
continue
if head:
block = blocks.setdefault(
int(head.group(1)),
{
"번": int(head.group(1)),
"이름": head.group(2),
"단위": head.group(3).strip(),
"행": [at, at],
"출처": [],
"직종": [],
"글": [],
"금액": {
key: v
for key, c in money.items()
for v in [row[c] if c < len(row) else None]
if isinstance(v, (int, float)) and not isinstance(v, bool)
},
},
)
continue
if block is None:
continue
block["행"][1] = at
if len(block["글"]) < GLINES:
block["글"].append(text.rstrip()[:GCHARS])
block["출처"] += [one for one in sources(text) if one not in block["출처"]]
trade = _TRADE.match(text)
name = names.get(_flat(trade.group(1))) if trade else None
if name and name not in block["직종"]:
block["직종"].append(name)
return blocks
# ── 우리 쪽 조항 · 직종 ────────────────────────────────────────────────
@lru_cache(maxsize=2)
def _ours(whole_id: int) -> dict[str, dict[str, Any]]:
+270
View File
@@ -0,0 +1,270 @@
"""M02 일위대가 실무자료 EST(엑셀꼴) 단가산출 어댑터(PLAN 776-2 · 요구 621-1 ② 정규화) —
엑셀 「단가산출」 시트 → 블록 `{번, 이름, 단위, 행, 출처, 직종, 글, 금액, 항목}`.
형식(EST 글)을 아는 곳은 여기까지.
블록 = B열(머리 「산출근거」) 머리 줄 「N.이름[단위]」(앞 빈칸 없음)부터 다음 머리 앞까지 ·
출처 = 「…품셈」(오타 「품줌」) 뒤 20 글자 안 절 번호 — 앞 글에 산림 · 임도 = 산림품셈 ·
그 밖 = 건설품셈 ·
직종 = 「이름 : 수」 줄의 빈칸 뺀 이름이 우리 인력(LB) 이름 · 옛이름일 때만(지금 이름으로) ·
글 = 머리 뺀 몸 줄 `GLINES` 줄 · 줄마다 `GCHARS` 자 안 · 산출근거 열이 빈 줄은 그 오른쪽 ~ 돈 머리
(맨 오른쪽 묶음) 앞 칸을 빈칸으로 이어 붙임(거창 꼴 · 돈 머리 없으면 `WIDE` 열) ·
금액 = 머리 줄 돈 칸(1 단위).
항목(Book 블록 항목) = 글 줄 풀이 — 줄 율(호표 줄 이름 · 규격)에 기대지 않는 것만 ·
지문 단계가 율을 가름:
L 직종 줄 「직종 : 단가 × 식 = 금액」(못 풀면 금액 ÷ 단가 「역산」 · `직종` = 우리 이름) ·
X 장비 = 장비 절 · 장비 글 줄 뒤 「노무비 · 경비 · 재료비 : 단가 ÷ Q」 — 절 · 장비마다 하나
(노무비(단가 > 0) > 경비 > 재료비 줄의 품) · 이름 = 장비 글 · 단가 = 비목마다 ·
M 자재 = 「이름 단가 × 수량 = 금액」 · 「이름 : 별도계상」(품 없음).
항목 = `{갈래, 이름, 규격, 품, 밑품, 율, 인자, 단가, 절, 절율, 어떻게, 행}`(L 은 + 직종) ·
STmate 어댑터(`M02_Hopyo_Stmate`)도 같은 꼴.
풀이 규칙은 지문(`M02_Hopyo_Finger._text`)과 같음 — 776-3 이 지문을 항목 위로 옮기면
그쪽 사본을 걷음.
"""
from __future__ import annotations
import re
from functools import lru_cache
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
from M02_MasterTemplete.M02_Hopyo_Words import mtypes, parse_rhs
SHEET = "단가산출"
GLINES, GCHARS, WIDE = 200, 300, 140
_HEAD = re.compile(r"^(\d+)\.\s*(.*?)\s*\[([^\[\]]*)\]\s*$")
_BOOK = re.compile(r"품[셈줌]")
_TRADE = re.compile(r"^[\s*·\-]*([가-힣][가-힣\s]*?)\s*:\s*[\d,]")
_TYPO_CLAUSE = re.compile(r"^(\d+)-(\d)(\d)(\d)$") # 「13-114」 = 13-11-4
_MONEY = {"합계": "계", "노무비": "노무비", "재료비": "재료비", "경비": "경비"}
# 항목 풀이 — 지문(`Finger`)과 같은 규칙
COST = ("노무비", "재료비", "경비")
SKIPW = frozenset(
"소계 계 합계 인부소계 자재소계 주자재소계 운반 상차 기계 다짐 적사 살수 집토 암절취 채집 인력 "
"장비 작업조건 1일시공량 작업면적 1시간작업량 기계채집 구조물터파기".split()
)
_COLON = re.compile(
r"^\s*(?:\d+\)|[0-9가-힣]\.)?\s*(?P<name>[^::=]{1,40}?)\s*[::]\s*(?P<rhs>.*)$"
)
_MAT = re.compile(
r"^\s*(?P<name>[^\d\s::=][^::=]*?[A-Za-z가-힣)\]])\s+(?P<rhs>[\d,]+(?:\.\d+)?\s*[×xX*÷/].*=.*)$"
)
_SEC = re.compile(r"^\s*(\d+)\.\s*(.*)$")
_SUB = re.compile(r"^\s*([가-하])\.\s*(.*)$")
_LASTNUM = re.compile(r"=\s*([\d,]+(?:\.\d+)?)\s*$")
def _flat(value: Any) -> str:
return re.sub(r"\s+", "", str(value or ""))
def _cell_text(value: Any) -> str:
if isinstance(value, float) and value == int(value):
value = int(value)
return str(value).strip() if value is not None else ""
# ── 이름 · 출처(두 어댑터 · 뒤 단계 공용) ────────────────────────────────
@lru_cache(maxsize=2)
def _trade_names(whole_id: int) -> dict[str, str]:
"""{빈칸 뺀 이름 · 옛이름: 지금 이름} — 우리 인력(LB)."""
out: dict[str, str] = {}
for row in m01.loaded()[1].numbers.get("LB", {}).values():
name = str(row.get("이름") or "")
out[_flat(name)] = name
for old in row.get("옛이름") or []:
out.setdefault(_flat(old), name)
return out
def trade_names() -> dict[str, str]:
return _trade_names(id(m01.loaded()[1]))
def trade(name: Any) -> str | None:
"""노임 이름 → 우리 인력 이름(빈칸 · 옛이름 · 괄호 글 풀이) · 아니면 None."""
names, flat = trade_names(), _flat(name)
return names.get(flat) or names.get(re.sub(r"\(.*?\)", "", flat))
def sources(text: str) -> list[list[str]]:
"""글 → [[책, 절 번호]] — 「건설공사표준품셈 3-2-1」 · 「산림사업표준품셈(9-14)」."""
out = []
for hit in _BOOK.finditer(text):
num = labor_inputs._NUMBER.search(text, hit.end())
if not num or num.start() - hit.end() > 20:
continue
before = text[max(0, hit.start() - 12) : hit.start()]
book = "산림품셈" if "산림" in before or "임도" in before else "건설품셈"
typo = _TYPO_CLAUSE.match(num.group())
number = "{}-{}{}-{}".format(*typo.groups()) if typo else num.group()
if [book, number] not in out:
out.append([book, number])
return out
def rates_in(text: Any) -> set[float]:
"""글 속 섞음 비율 후보 — (N%) · 8:2 · 9:1."""
t = str(text or "")
out = {round(float(n) / 100, 4) for n in re.findall(r"(\d+(?:\.\d+)?)\s*%", t)}
out = {v for v in out if 0 < v < 1}
for a, b in re.findall(r"(\d+)\s*:\s*(\d+)", t):
a, b = float(a), float(b)
if a and b and a + b <= 20:
out |= {round(a / (a + b), 4), round(b / (a + b), 4)}
return out
# ── 블록 읽기 ─────────────────────────────────────────────────────────
def _columns(rows: list[list[Any]]) -> tuple[int, int, dict[str, int]]:
"""(자료 첫 줄, 산출근거 열, {계 · 노무비 · 재료비 · 경비: 열}) — 머리 없으면 (0, 1, {}) ·
돈 머리가 둘 묶음이면 맨 오른쪽(거창 꼴 · 값이 든 쪽)."""
for at, row in enumerate(rows[:10]):
flat = [_flat(v) for v in row]
if "산출근거" in flat:
last = len(flat) - 1
money = {
key: last - flat[::-1].index(head) for head, key in _MONEY.items() if head in flat
}
return at + 1, flat.index("산출근거"), money
return 0, 1, {}
def read(rows: list[list[Any]] | None) -> dict[int, dict[str, Any]]:
"""단가산출 시트 줄 → `{N: {번, 이름, 단위, 행: [시작, 끝], 출처, 직종, 글, 금액, 항목}}`
(행 = 엑셀 1부터)."""
if not rows:
return {}
start, col, money = _columns(rows)
end = min(money.values()) if money and min(money.values()) > col + 1 else WIDE
names = trade_names()
blocks: dict[int, dict[str, Any]] = {}
block: dict[str, Any] | None = None
for at, row in enumerate(rows[start:], start + 1):
text = row[col] if col < len(row) else None
head = None
if isinstance(text, str) and text.strip():
head = None if text[:1].isspace() else _HEAD.match(text)
else:
text = " ".join(_cell_text(v) for v in row[col + 1 : end] if _cell_text(v))
if not text:
continue
if head:
block = blocks.setdefault(
int(head.group(1)),
{
"번": int(head.group(1)),
"이름": head.group(2),
"단위": head.group(3).strip(),
"행": [at, at],
"출처": [],
"직종": [],
"글": [],
"금액": {
key: v
for key, c in money.items()
for v in [row[c] if c < len(row) else None]
if isinstance(v, (int, float)) and not isinstance(v, bool)
},
"_글행": [],
},
)
continue
if block is None:
continue
block["행"][1] = at
if len(block["글"]) < GLINES:
block["글"].append(text.rstrip()[:GCHARS])
block["_글행"].append(at)
block["출처"] += [one for one in sources(text) if one not in block["출처"]]
trade_ = _TRADE.match(text)
name = names.get(_flat(trade_.group(1))) if trade_ else None
if name and name not in block["직종"]:
block["직종"].append(name)
for block in blocks.values():
block["항목"] = items(block["글"], block.pop("_글행"))
return blocks
# ── 글 → 항목 ─────────────────────────────────────────────────────────
def _ratio(rhs: str) -> float | None:
"""식을 못 풀 때만 — 금액 ÷ 단가(품 되찾기용)."""
m = re.match(r"^\s*([\d,]+(?:\.\d+)?)", rhs)
n = _LASTNUM.search(rhs)
if not m or not n:
return None
p, a = float(m.group(1).replace(",", "")), float(n.group(1).replace(",", ""))
return a / p if p 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}
def items(text: list[str], rows: list[int] | None = None) -> list[dict[str, Any]]:
"""블록 몸 글(머리 뺌) → 항목 목록(L · M 은 글 차례 · X 는 절 · 장비 처음 나온 차례로 뒤에)."""
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_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 h or s:
if h:
sec, sub = int(h.group(1)), ""
sec_rates = rates_in(h.group(2))
else:
sub = s.group(1)
sec_rates = sec_rates | rates_in(s.group(2))
title = (h or s).group(2)
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}
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}
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))
continue
if cm and name in COST:
got = parse_rhs(cm.group("rhs"))
if got and mach:
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)
machines[key] = one
one["단가"][name] = got["단가"]
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))
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))
continue
if not (h or s):
ts = mtypes(row)
if ts and "%" not in row and not cm:
mach, label = True, row.strip()
for one in machines.values():
del one["_순"]
out.append(one)
return out
+64 -17
View File
@@ -13,8 +13,11 @@ PR · 단위 % = % · 그 밖 기타.
단가산출근거 = 「BDCOD|D…」 머리 → 다음 머리 · 「총계」 앞까지 한 블록 · 글 = B 열(머리 이름 줄 ·
소계 · 계 뺌) · 자원 = M 열 「X00000_n」 줄(품 = H 열 · 이름 · 규격 = X 열 「이름 / 규격」 ·
비면 목록표 · 기계 셋 줄(노무비 · 재료비 · 경비)은 하나로 · 절 = 그 앞 번호 절 제목) ·
머리 이름 · 규격 · 단위 = 단가산출근거목록표 · 출처 = 블록 글 `Basis.sources` ·
직종 = 자원 L 이름(우리 인력 · 옛이름 = 지금 이름).
머리 이름 · 규격 · 단위 = 단가산출근거목록표 · 출처 = 블록 글 `ReadEst.sources` ·
직종 = 자원 L 이름(우리 인력 · 옛이름 = 지금 이름) ·
금액 = 머리 줄 C~F(1 단위) · 항목(776-2 · Book 블록 항목 · EST 어댑터 `M02_Hopyo_ReadEst` 와
같은 꼴) = 자원 + `{직종(L), 밑품 = 품, 율 [], 인자 [], 단가 I~L, 금액 C~F, 어떻게 「QTY」, 행}`
(기계 셋 줄 = 더함).
검산 안 함 — `검산 {어긋남: [], 총괄표: 0}`.
"""
@@ -23,8 +26,8 @@ from __future__ import annotations
import re
from typing import Any
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel # 서로 부름 — 부를 때만 씀
from M02_MasterTemplete import M02_Hopyo_ReadEst as hopyo_est
SHEET = "일위대가표"
CALC = "단가산출근거"
@@ -39,6 +42,8 @@ _RES_A = re.compile(r"^([LMXS]\d{5})[LMS]$") # A 열 「L00002L」
_RES_G = re.compile(r"&([LMXS]\d{5})[LMS]&") # G 열 식 「&L00002L&」
_SECTION = re.compile(r"^\s*(?:\d+\s*[.)]|[①-⑩])\s*(.*\S)")
_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
_KIND = {"D": "단가산출", "B": "호표", "L": "노임", "X": "기계", "S": "별도계상", "W": "별도계상"}
@@ -67,6 +72,17 @@ def _code(row: list[Any], cols: Any = CODE_COLS) -> str | None:
return None
def _calc_money(row: list[Any], cols: dict[str, int]) -> dict[str, int | float | None]:
return {k: hopyo_excel._out(v) for k, v in hopyo_excel._money(row, cols, 0).items()}
def _add(into: dict[str, Any], more: dict[str, Any]) -> None:
"""기계 셋 줄(노무비 · 재료비 · 경비) 단가 · 금액을 한 항목에 더함."""
for k, v in more.items():
if v is not None:
into[k] = hopyo_excel._out(hopyo_excel._num(into.get(k) or 0) + hopyo_excel._num(v))
def _lists(sheets: dict[str, list[list[Any]]]) -> dict[str, dict[str, str]]:
"""목록표 → {코드: {이름, 규격, 단위}} — B 이름 · C 규격 · D 단위 · 코드 = 숨은 칸
「COD|…」 · 없으면 코드 꼴 첫 칸(판마다 A 열 · 뒤 열)."""
@@ -86,8 +102,8 @@ def _lists(sheets: dict[str, list[list[Any]]]) -> dict[str, dict[str, str]]:
# ── 단가산출근거 블록 ──────────────────────────────────────────────────
def _blocks(rows: list[list[Any]], table: dict[str, dict[str, str]]) -> dict[str, dict[str, Any]]:
"""{D 코드: {번, 코드, 이름, 규격, 단위, 행, 출처, 직종, 글, 자원}}."""
names = hopyo_basis.trade_names()
"""{D 코드: {번, 코드, 이름, 규격, 단위, 행, 출처, 직종, 글, 자원, 금액, 항목}}."""
names = hopyo_est.trade_names()
out: dict[str, dict[str, Any]] = {}
block: dict[str, Any] | None = None
title = ""
@@ -109,6 +125,8 @@ def _blocks(rows: list[list[Any]], table: dict[str, dict[str, str]]) -> dict[str
"행": [at, at],
"글": [],
"자원": [],
"금액": _calc_money(row, _CALC_AMOUNT),
"항목": [],
},
)
title = ""
@@ -135,30 +153,46 @@ def _blocks(rows: list[list[Any]], table: dict[str, dict[str, str]]) -> dict[str
block["이름"], block["단위"] = name.strip() or text, unit.strip()
continue
if text and _flat(text) not in ("소계", "계"):
if len(block["글"]) < hopyo_basis.GLINES:
block["글"].append(text[: hopyo_basis.GCHARS])
if len(block["글"]) < hopyo_est.GLINES:
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()
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:
continue # 기계 노무비 · 재료비 · 경비 셋 줄 = 하나
_add(block["항목"][-1]["단가"], price) # 기계 노무비 · 재료비 · 경비 셋 줄 = 하나
_add(block["항목"][-1]["금액"], amount)
continue
name, _, spec = _s(_cell(row, NAME_COL)).partition("/")
info = table.get(code) or {}
qty = hopyo_excel._num(_cell(row, QTY_COL))
block["자원"].append(
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(
{
"갈래": 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,
**res,
**({"직종": who} if code[0] == "L" else {}),
"밑품": res["품"],
"율": [],
"인자": [],
"단가": price,
"금액": amount,
"어떻게": "QTY",
"행": at,
}
)
for block in out.values():
block["출처"] = hopyo_basis.sources(" ".join(block["글"]))
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))
return out
@@ -257,7 +291,20 @@ def read(sheets: dict[str, list[list[Any]]]) -> dict[str, Any]:
if not hopyo["근거글"]:
del hopyo["근거글"]
out.append({**hopyo_excel._json(hopyo), "줄": [hopyo_excel._json(x) for x in lines]})
keep = ("번", "코드", "이름", "규격", "단위", "행", "출처", "직종", "글", "자원")
keep = (
"번",
"코드",
"이름",
"규격",
"단위",
"행",
"출처",
"직종",
"글",
"자원",
"금액",
"항목",
)
return {
"꼴": "STmate",
"시트": SHEET,