Files
eomsangdonandClaude Sonnet 5.5 e23fec8c9c feat(786-1): 별도계상 줄 품목 고르기 없앰 — 후보 고정 줄 하나 · SC 합 밖 고정 줄
- Row.judge: 별도계상 줄은 M01 · 사전 · 장부 후보 안 찾고 고정 줄 하나(품목 「프로젝트에서」 표지) · 갈래 수 그대로
- Make: 별도계상 고정 줄 = 단가 0 · 메모 「별도계상 · 품목은 프로젝트에서」(합 밖)
- Picks.ts: apartPick 표지 셈
- 시험 642_3 · 642_5 · 642_6 Picks 몫을 새 꼴로 고침(옛 단언: 레미콘 자재 목록 별도계상 칸)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 07:17:50 +09:00

450 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""M02 엑셀 불러오기 1차 줄 판정(PLAN 785-3 · 요구 641 · 642 · 설계 642-A 2-3 · 분석 642-C 5장).
엑셀 사전 = 단가표 시트 → 갈래 + M01 키 후보(한 번 · `lexicon`) — JSON 꼴 `{꼴, 줄 [사전 줄]}` ·
사전 줄 = `{갈래, 이름, 규격, 단위, 단가, 시트, 행, 코드?, 후보 [고른 것]}`.
EST = 자재단가(재료 · 이름이 M01 유가전력이면 유가) · 노임단가(인력) · 기계경비총괄표(기계) ·
기계경비적용기준(기본재료비 = 유가 · 환율 = 환율 · 줄 꼴 없음 → 꼬리표만) · 단가산출총괄표 ·
일위대가총괄표 · 머리 줄 = 앞 열 줄 안 「품명」 칸(열은 머리 글로 찾음).
STmate = 목록표(`Stmate._lists`) · 줄 코드가 열쇠(L 인력 · X 기계 · M 재료 · D 단가산출 ·
S · W 고정).
세부행 갈래(위가 먼저) = ① 비고 「단산N · 일위N참조」(이름으로 이은 줄 뺌) ② 품명 · 규격
「별도계상 · 별도계산」 → 재료 ③ 단위 % · 규격 「…비의」 → 율 · hr · 시간 → 기계 · 인 → 인력
④ 사전(코드 › 품명+규격 › 품명) · 갈래 여럿 = 금액 칸 꼴(재료비만 → 재료 · 유가)로 가름 ·
그 밖 = 고정.
후보 찾기 차례 = 장부(`Ledger.recall` · 고른 것 꼴) › M01 표마다(LB · MT · MO · EQ) › M02 층
SK · SC 같은 이름(위 층까지 · 참고).
판정 = 제안 있음(장부 짝 · 후보 하나 · 별도계상 = 고정 줄 하나(`품목` 표지) · 하위 호표 · 율) ·
2차 필요(단가산출 줄 =
`Bind` · `Solve` 몫) · 후보 여럿 · 후보 없음(고정 금액 제안).
고른 것 = `{갈래 기초|단가산출|일위대가|인력|재료|유가|기계|율|고정, 키, 이름, 규격, 단위, 단가,
입력값, 출처, 몫, 비목?, 기준?, 별도계상?, 층?, 주인?}`(설계 2-1 · 만들기 = `Make` 몫).
수량 = `{엑셀, 배, 갈래}` — 우리 수량 = 엑셀 수량 × 몫 × 배(`factor` · 옮김 · 같은 단위 = 1).
서버 단독 · 읽기만(M01 · 층 문서 · 장부).
"""
from __future__ import annotations
import copy
import json
import re
import unicodedata
from decimal import Decimal
from functools import lru_cache
from pathlib import Path
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Hopyo_Elem as elem
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
from M02_MasterTemplete import M02_Hopyo_Match as hm
from M02_MasterTemplete import M02_Hopyo_Material as hmat
from M02_MasterTemplete import M02_Hopyo_ReadEst as est
from M02_MasterTemplete import M02_Hopyo_Stmate as stmate
from M02_MasterTemplete import M02_Labor_Ref as refs
mm, mp = hm.mm, hm.mp
KINDS = ("기초", "단가산출", "일위대가", "인력", "재료", "유가", "기계", "율", "고정")
VERDICTS = ("제안 있음", "2차 필요", "후보 여럿", "후보 없음")
COSTS = ("노무비", "재료비", "경비")
SHEET_KIND = {
"자재단가": "재료",
"노임단가": "인력",
"기계경비총괄표": "기계",
"기계경비적용기준": "유가",
"단가산출총괄표": "단가산출",
"일위대가총괄표": "일위대가",
}
SHEETS = (*SHEET_KIND, *stmate.LISTS)
CODE_KIND = {"L": "인력", "X": "기계", "M": "재료", "D": "단가산출", "B": "일위대가"}
APART = ("별도계상", "별도계산")
MACHINE_UNITS = ("hr", "시간", "h")
CURRENCY = (("달러", "USD"), ("$", "USD"), ("엔", "JPY"), ("유로", "EUR"), ("파운드", "GBP"))
_RATE_BASE = re.compile(r"(노무비|재료비|경비|인건비|자재비|노임|합계)\s*의")
_BASE_COST = {"노": "노무비", "인": "노무비", "품": "노무비", "재": "재료비", "자": "재료비"}
TOP = 5
def flat(value: Any) -> str:
return re.sub(r"\s+", "", unicodedata.normalize("NFKC", str(value or ""))).lower()
def _num(value: Any) -> float | None:
if isinstance(value, bool) or value in (None, ""):
return None
try:
return float(str(value).replace(",", "").strip())
except ValueError:
return None
def _plain(value: Any) -> int | float | None:
if value is None:
return None
f = float(value)
return int(f) if f == int(f) else round(f, 6)
# ── 엑셀 사전 ───────────────────────────────────────────────────────────
def _header(rows: list[list[Any]]) -> tuple[int, dict[str, int]] | None:
for at, row in enumerate(rows[:10]):
heads = [flat(v) for v in row]
if "품명" in heads:
return at, {h: i for i, h in reversed(list(enumerate(heads))) if h}
return None
def _list_rows(rows: list[list[Any]], sheet: str, kind: str) -> list[dict[str, Any]]:
"""단가표 시트 줄 — No(수 · 「제N호표」) · 품명 있는 줄 · 단가 = 적용단가 › 합계 › 단가."""
got = _header(rows)
if got is None:
return []
at, cols = got
name = cols["품명"]
no = cols.get("no", cols.get("호표", name - 1))
price = next((cols[h] for h in ("적용단가", "합계", "단가") if h in cols), None)
cell = lambda r, c: r[c] if c is not None and c < len(r) else None # noqa: E731
out = []
for i, row in enumerate(rows[at + 1 :], at + 2):
label = flat(cell(row, no))
if not flat(cell(row, name)) or not (label.isdigit() or re.fullmatch(r"제\d+호표", label)):
continue
out.append(
{
"갈래": kind,
"이름": str(cell(row, name)).strip(),
"규격": str(cell(row, cols.get("규격")) or "").strip(),
"단위": str(cell(row, cols.get("단위")) or "").strip(),
"단가": _plain(_num(cell(row, price))),
"시트": sheet,
"행": i,
}
)
return out
def _rates(rows: list[list[Any]], sheet: str) -> list[dict[str, Any]]:
"""기계경비적용기준 환율 칸 — 「화폐」 머리 아래 (화폐, 환율) 줄."""
for at, row in enumerate(rows[:10]):
heads = [flat(v) for v in row]
if "화폐" in heads:
c = heads.index("화폐")
return [
{
"갈래": "환율",
"이름": str(r[c]).strip(),
"규격": "",
"단위": "원",
"단가": _plain(_num(r[c + 1])),
"시트": sheet,
"행": i,
} # fmt: skip
for i, r in enumerate(rows[at + 1 :], at + 2)
if c + 1 < len(r) and flat(r[c]) and _num(r[c + 1])
]
return []
def lexicon(sheets: dict[str, list[list[Any]]], keys: bool = True) -> dict[str, Any]:
"""시트 줄 → 엑셀 사전 `{꼴, 줄}` — `keys` = 줄마다 M01 키 후보(한 번)."""
if stmate.is_stmate(sheets):
rows = [
{
"갈래": CODE_KIND.get(code[0], "고정"),
**one,
"단가": None,
"시트": "목록표",
"행": None,
"코드": code,
} # fmt: skip
for code, one in stmate._lists(sheets).items()
]
shape = "STmate"
else:
rows = []
for sheet, kind in SHEET_KIND.items():
if sheets.get(sheet):
rows += _list_rows(sheets[sheet], sheet, kind)
rows += _rates(sheets.get("기계경비적용기준") or [], "기계경비적용기준")
shape = "EST"
for row in rows:
if row["갈래"] == "재료" and elem.oil_rows(row["이름"]):
row["갈래"] = "유가"
if keys:
row["후보"] = m01_picks(row["갈래"], row)
return {"꼴": shape, "줄": rows}
def _index(lex: dict[str, Any] | None) -> dict[str, list[dict[str, Any]]]:
out: dict[str, list[dict[str, Any]]] = {}
for row in (lex or {}).get("줄") or []:
if row.get("코드"):
out.setdefault("코드|" + row["코드"], []).append(row)
out.setdefault(f"{flat(row['이름'])}|{flat(row['규격'])}", []).append(row)
out.setdefault(flat(row["이름"]), []).append(row)
return out
# ── M01 키 후보(표마다) ───────────────────────────────────────────────────
def pick(kind: str, key: Any = None, name: Any = "", spec: Any = "", unit: Any = "", **more: Any):
"""고른 것 한 꼴(설계 2-1)."""
head = {"갈래": kind, "키": key, "이름": str(name or ""), "규격": str(spec or "")}
return {**head, "단위": str(unit or ""), "단가": more.pop("단가", None), "입력값": {},
"출처": more.pop("출처", {}), "몫": None, **more} # fmt: skip
def _rows(table: str) -> dict[str, dict[str, Any]]:
return m01.loaded()[1].numbers.get(table, {})
def m01_picks(kind: str, line: dict[str, Any]) -> list[dict[str, Any]]:
"""갈래 하나 → M01 고른 것 후보(LB · MT · MO · EQ · FX) — 유일 짝이면 하나 · 같은 글 한 번."""
text = tuple(str(line.get(k) or "") for k in ("이름", "규격", "단위"))
return copy.deepcopy(_m01_picks(kind, *text, id(m01.loaded()[1])))
@lru_cache(maxsize=4096)
def _m01_picks(kind: str, name: str, spec: str, unit: str, _whole: int) -> list[dict[str, Any]]:
if kind == "인력":
trade = est.trade(name)
hit = [r for r in _rows("LB").values() if trade and r.get("이름") == trade]
if hit:
return [
pick("인력", hit[0]["키"], hit[0]["이름"], "", "인", 단가=_plain(hit[0].get("값")))
]
got = elem.labor(str(name or ""))
return [pick("인력", x["키"], x["이름"], "", "인", 단가=x["단가"]) for x in got]
if kind == "기계":
text = f"{name} {spec}"
one = elem.machine(text)
rows = [one] if one else []
got = [{"키": r["키"], "이름": r["이름"], "규격": r.get("규격")} for r in rows]
got = got or elem.machines(text)
return [pick("기계", x["키"], x["이름"], x.get("규격"), unit or "hr") for x in got]
if kind == "유가":
return [
pick("유가", x["키"], x["이름"], x["규격"], x["단위"], 단가=x["단가"])
for x in elem.oils(str(name or ""))
]
if kind == "환율":
code = next((c for w, c in CURRENCY if w in str(name)), flat(name).upper())
rows = [r for r in _rows("FX").values() if str(r.get("이름")).startswith(code)]
return [
pick("환율", r["키"], r["이름"], "", r.get("단위"), 단가=_plain(r["값"])) for r in rows
]
if kind == "재료":
clean, apart = hmat.clean({"이름": name, "규격": spec, "단위": unit})
sure, _ = hmat.sure(clean)
got = [r["one"] for r in sure[:1] if not r["one"].get("전국")]
got = got or hmat.candidates(clean, TOP)
more = {"별도계상": True} if apart else {}
return [
pick("재료", x.get("자재"), x["이름"], x.get("규격"), x.get("단위"), **more)
for x in got
if x.get("자재")
]
return []
# ── M02 층 SK · SC(같은 이름 · 참고) ────────────────────────────────────────
def layer_docs(folder: str | Path | None) -> dict[str, list[dict[str, Any]]]:
"""`{정규 이름: [고른 것]}` — 같은 층 → 위 층 · 단가산출 SK · 일위대가 SC."""
out: dict[str, list[dict[str, Any]]] = {}
if folder is None:
return out
for one in ledger.chain(folder):
layer, owner = refs.where(one)
for kind, sub in (("단가산출", "sangeun"), ("일위대가", "precombo")):
for path in sorted((Path(one) / sub).glob("S[CK]-*.json")):
doc = json.loads(path.read_bytes()) if path.is_file() else {}
name = doc.get("이름") or path.stem
got = pick(kind, path.stem, name, "", doc.get("단위"), 층=layer, 주인=owner)
out.setdefault(mm.plain(name), []).append(got)
return out
# ── 줄 판정 ─────────────────────────────────────────────────────────────
def first(line: dict[str, Any]) -> tuple[str | None, str, int | None]:
"""1차 ①~③ — (갈래, 근거, 번) · 못 가름 = (None, "", None)."""
ref = line.get("참조") or {}
if ref and line.get("이음") != "이름":
kind = "단가산출" if ref.get("종류") == "단산" else "일위대가"
return kind, f"비고 {line.get('비고') or line.get('코드') or ''}".strip(), ref.get("번")
text = flat(f"{line.get('이름')}{line.get('규격')}")
if any(w in text for w in APART) or str(line.get("비고") or "").startswith("별산"):
return "재료", "별도계상", None
unit = flat(line.get("단위"))
if unit == "%" or _RATE_BASE.search(str(line.get("규격") or "")):
return "율", "단위 %", None
if unit in MACHINE_UNITS:
return "기계", "단위 hr", None
if unit == "인":
return "인력", "단위 인", None
return None, "", None
def _shape(line: dict[str, Any]) -> tuple[str, ...]:
price = line.get("단가") or {}
return tuple(k for k in COSTS if price.get(k))
def second(line: dict[str, Any], index: dict[str, list[dict[str, Any]]]) -> list[dict[str, Any]]:
"""④ 사전 — 코드 › 품명+규격 › 품명 · 갈래 여럿이면 금액 칸 꼴로 가름 → 사전 줄 목록."""
code = str(line.get("코드") or "")
hits = index.get("코드|" + code) if code else None
hits = hits or index.get(f"{flat(line.get('이름'))}|{flat(line.get('규격'))}")
hits = hits or index.get(flat(line.get("이름"))) or []
if len({h["갈래"] for h in hits}) > 1 and _shape(line) == ("재료비",):
hits = [h for h in hits if h["갈래"] in ("재료", "유가")] or hits
if not hits and code[:1] in CODE_KIND:
hits = [{"갈래": CODE_KIND[code[0]], "이름": line.get("이름"), "규격": line.get("규격")}]
return hits
def _rate_pick(line: dict[str, Any]) -> dict[str, Any]:
"""율 줄 — 기준 = 규격 「…비의」 글(EST 노 · 품 · 인 → 노무비 · 재 · 자 → 재료비 · 경 → 경비 ·
합계 → 비목 셋 중 줄 금액 칸) · 비목 = 줄 단가가 든 칸(없으면 기준)."""
hit = _RATE_BASE.search(f"{line.get('규격')}{line.get('이름')}")
word = hit.group(1) if hit else ""
base = _BASE_COST.get(word[:1], "경비" if word.startswith("경") else None)
cost = next(iter(_shape(line)), None) or base or "재료비"
one = pick("율", None, line.get("이름"), line.get("규격"), "%", 비목=cost, 기준=base or cost)
return one
# 별도계상 줄 표지 `품목` — 품목은 프로젝트에 복사할 때 정함(요구 645-2)
ITEM_LATER = "프로젝트에서"
def _fixed(line: dict[str, Any], apart: bool = False) -> dict[str, Any]:
price = {k: _plain((line.get("단가") or {}).get(k)) or 0 for k in COSTS}
more = {"별도계상": True, "품목": ITEM_LATER} if apart else {}
return pick(
"고정", None, line.get("이름"), line.get("규격"), line.get("단위"), 단가=price, **more
)
def _from_ledger(row: dict[str, Any]) -> list[dict[str, Any]]:
"""장부 짝 → 고른 것 — 새 꼴(`갈래`) 그대로 · 옛 꼴 로직 = 기초 · 자재 = 재료 · 유가."""
out = []
for p in row.get("짝") or []:
if p.get("갈래"):
got = pick(p["갈래"], p.get("키"), p.get("이름"), "", p.get("단위"))
got.update({k: p[k] for k in ("입력값", "몫", "비목", "기준", "별도계상") if k in p})
elif p.get("로직"):
got = {**pick("기초", p["로직"]), "입력값": p.get("입력값") or {}, "몫": p.get("몫")}
else:
code = str(p.get("자재") or "")
got = pick("유가" if code.startswith("MO") else "재료", code)
out.append({**got, "출처": {"장부": row.get("층")}, "배": p.get("배")})
return out
def judge(
line: dict[str, Any],
index: dict[str, list[dict[str, Any]]],
docs: dict[str, list[dict[str, Any]]] | None = None,
book: list | None = None,
doc: str | None = None,
folder: str | Path | None = None,
) -> dict[str, Any]:
"""줄 하나 → 줄 판 `{차례, 행, 갈래 [..], 근거, 번?, 후보, 판정, 수량, 장부?}`."""
kind, why, no = first(line)
hits = [] if kind else second(line, index)
kinds = [kind] if kind else list(dict.fromkeys(h["갈래"] for h in hits)) or ["고정"]
if not kind and hits:
h = hits[0]
why = f"사전 {h.get('시트')} {h.get('행') or h.get('코드') or ''}".strip()
out = {"차례": line.get("차례"), "행": line.get("행"), "갈래": kinds, "근거": why}
if no is not None:
out["번"] = no
qty = {"엑셀": line.get("수량"), "배": 1, "갈래": "같음"}
picks: list[dict[str, Any]] = []
if why == "별도계상": # 모아서 한 번에 셈 — 품목 후보 · 장부 짝 안 찾음(고정 줄 하나 · 786-1)
return {**out, "후보": [_fixed(line, True)], "판정": "제안 있음", "수량": qty}
found = None
if folder is not None:
found = ledger.recall(line, folder, book, doc=doc)
if found and found.get("갈래") == "짝":
picks = _from_ledger(found)
for k in kinds if not picks else ():
if k == "일위대가" and no is not None:
picks.append(pick("일위대가", f"제{no}호표", line.get("이름"), line.get("규격"),
line.get("단위"), 출처={"엑셀": f"제{no}호표"})) # fmt: skip
elif k in ("단가산출", "일위대가"):
pass
elif k == "율":
picks.append(_rate_pick(line))
elif any(h["갈래"] == k and "후보" in h for h in hits): # 사전이 붙인 M01 후보
picks += [p for h in hits if h["갈래"] == k for p in h["후보"]]
else:
picks += m01_picks(k, line)
if not found:
picks += [p for p in (docs or {}).get(mm.plain(line.get("이름")), []) if p["갈래"] in kinds]
picks = list({(p["갈래"], p.get("키"), p.get("층")): p for p in picks}.values())
if found and found.get("갈래") in ("짝", "우리에 없음", "안 씀"):
out["장부"] = {k: found.get(k) for k in ("갈래", "층", "제안", "규격다름")}
if "단가산출" in kinds: # 단산 줄 = 늘 2차 블록 풀이(장부 짝 = 1차 후보 힌트만 · 785-7)
verdict = "2차 필요"
elif (
found and found.get("갈래") in ("짝", "안 씀", "우리에 없음") and not found.get("규격다름")
):
verdict = "제안 있음"
elif kinds[0] in ("일위대가", "율") or len(picks) == 1:
verdict = "제안 있음"
elif picks:
verdict = "후보 여럿"
else:
verdict, picks = "후보 없음", [_fixed(line)]
return {**out, "후보": picks, "판정": verdict, "수량": qty}
def rows(
book: dict[str, Any], folder: str | Path | None = None, doc: str | None = None
) -> dict[int, list[dict[str, Any]]]:
"""Book(+ `사전`) → `{호표 번: [줄 판]}` — 장부 · 층 문서는 한 번 읽어 넘김."""
index = _index(book.get("사전"))
got = ledger.books(folder) if folder is not None else None
docs = layer_docs(folder)
return {
h["번"]: [judge(x, index, docs, got, doc, folder) for x in h.get("줄") or []]
for h in book.get("호표") or []
}
# ── 환산(`M02_Hopyo_Match.factor` 옮김) ─────────────────────────────────────
def factor(have: Any, want: Any, spec: Any = "") -> dict[str, Any]:
"""`{배, 갈래}` — 우리 수량 = 엑셀 수량 × 배 · 배 None = 사용자 입력."""
units = [u.strip() for u in str(want or "").split("·") if u.strip()]
plain = mm.plain_unit(have)
if len(units) > 1:
ok = plain in {mm.plain_unit(u) for u in units}
return {"배": 1 if ok else None, "갈래": "두 단위" if ok else "입력"}
if plain.replace("/", "") == mm.plain_unit(want).replace("/", ""): # 공/㎥ ≡ 공㎥
return {"배": 1, "갈래": "같음"}
if str(want or "").strip() == "자재": # 결과단위 「원/자재 단위」 = 줄 단위 그대로
return {"배": 1, "갈래": "자재 단위"}
a, b = mp.kind_of(have), mp.kind_of(want)
if a is not None and a == b:
scale = mp.UNITS[a]
ratio = Decimal(scale[plain]) / Decimal(scale[mm.plain_unit(want)])
return {"배": hm._json(ratio), "갈래": "단위표"}
found = hm.numbers(spec)
width, thick = hm._meters(found.get("B")), hm._meters(found.get("T"))
by = {("넓이", "부피"): thick, ("길이", "넓이"): width}
if (a, b) == ("길이", "부피") and width is not None and thick is not None:
by[(a, b)] = width * thick
got = by.get((a, b))
if got:
return {"배": hm._json(got), "갈래": "추정"}
return {"배": None, "갈래": "입력"}
def quantity(line: dict[str, Any], want: Any, share: Any = None) -> dict[str, Any]:
"""단가산출 줄 수량 `{엑셀, 몫, 배, 갈래, 우리}` — 우리 = 엑셀 × 몫 × 배(배 None = 입력)."""
got = factor(line.get("단위"), want, line.get("규격"))
qty = line.get("수량")
ours = None
if qty is not None and got["배"] is not None:
ours = _plain(Decimal(str(qty)) * Decimal(str(share or 1)) * Decimal(str(got["배"])))
return {"엑셀": qty, "몫": share, **got, "우리": ours}