feat(일위대가실무): 338-1 맞춤 핵심 서버 1 단계 — 엑셀 지문 · 우리 로직 지문 캐시 · 옵션 거꾸로 · 순위 · 판정 · 조합 · 뒤 셈 + 정답 표
- 새 파일 — Words(식 풀기 · 장비 갈래 · 조건 낱말) · Finger(블록 글 · STmate 자원 → 지문 · 기계 줄 · 직접 줄 호표) · Shares(절 몫 벡터) · Ours(마스터 판 열쇠 · 우리 지문 캐시 tmp/m02_hopyo · 엔진 셈 캐시 · 자재 값 찾기 건너뜀) · Option(옵션 공간 · 거꾸로 찾기 · 견줌) · Fit(손잡이 한 곳 · 순위 · 뒤 셈 · 맞춤셈 캐시) · Combine(조합 · 섞인 줄 몫 먼저) · Verdict(블록 셈 · 맞음 / 다른 점 / 후보) - Match — b_order(이름 · 근거 차례 · 같음 근거 검사 · 옛 판 글 · 조항 오타) · clause(윗마디 0.9 · 나 창 Basis.clause 있으면 그것) · 이름 후보 로직은 접지 않음 - 정답 표 fixtures/m02_hopyo_golden.json(사람 136 · 새 파일 51) · 골든 도우미 - 북부청 92 줄 실측 — 1순위 87 · 다섯 안 91 · 맞음 41 틀림 0 · 다른 점 28 틀림 0 · 조합 맞음 57/66 · 우리 지문 셈 37 초 · 캐시 읽기 0.05 초 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
"""M02 일위대가 맞춤 — 조합 풀이(PLAN 338-1 · 설계 2-7): 블록 몫(절마다 노임 · 장비 벡터)을 우리
|
||||
로직 단위당 벡터 조합으로 풂 · 섞인 줄은 인력 몫 로직이 1순위(몫 먼저).
|
||||
|
||||
맞음 = 차원(직종 · 장비 갈래)마다 작은/큰 가중 평균(보통인부 0.5 · 장비 있음 0.5 + 시간 0.5 ·
|
||||
크기 다르면 × 0.8 · 갈래 일부만 같으면 × 0.8) · 몫 x(0.1 … 0.9) × 0.95 · 제목 몫 수와 같은 x 면
|
||||
+0.1.
|
||||
덧 로직 = 1순위가 못 덮는(< COVER) 절은 절 1위(≥ 0.75) · 아니면 몫마다(≥ 0.6) · 장비만 든 몫 =
|
||||
「기계 시간당 기계경비」(체크 안 함 · 시간 = 엑셀 값).
|
||||
순위 덤 = 앞 차례 0.3 · 인용 조항 같음 0.3 · 같은 책 0.1 · 후보 풀 = 앞 차례 15 + 드문 직종 · 장비
|
||||
색인
|
||||
(40 개 이하) + 절 제목 이름 후보 8. 우리 벡터 = `M02_Hopyo_Ours` 지문 변형 L · X(따로 안 셈).
|
||||
규칙 · 문턱은 336 실험(C5 `c03_vec` · `c05_solve` · C7 `solve`) 그대로 · 문턱은 `M02_Hopyo_Fit`
|
||||
머리.
|
||||
"""
|
||||
|
||||
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_Fit as fit
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
|
||||
from M02_MasterTemplete import M02_Hopyo_Ours as ours
|
||||
|
||||
COMMON = "보통인부"
|
||||
MACHINE = {"굴착기": "GC000267"} # 굴착기(무한궤도) 시간당 기계경비 · 그 밖 장비 = GC000268
|
||||
MACHINE_ANY = "GC000268"
|
||||
MACHINE_SET = {"GC000267", "GC000268", "GC000269", "GC000275"}
|
||||
_SHARE = re.compile(r"(\d{1,3})\s*%")
|
||||
_RATIO = re.compile(r"(\d)\s*:\s*(\d)")
|
||||
_STATE: dict[str, Any] = {"판": None}
|
||||
|
||||
|
||||
# ── 우리 벡터 · 색인 ───────────────────────────────────────────────────
|
||||
def _vec(v: dict[str, Any]) -> dict[str, Any]:
|
||||
eq = [(frozenset(a), frozenset(b), q) for a, b, q in v.get("X") or [] if a and q]
|
||||
return {"노임": {t: q for t, q in (v.get("L") or {}).items() if q}, "장비": eq}
|
||||
|
||||
|
||||
def _load() -> dict[str, Any]:
|
||||
"""{변형: {로직: [(입력, 벡터)]}, 색인: {차원: {로직}}} — 우리 지문 판마다 한 번."""
|
||||
table = ours.table() or []
|
||||
ver = ours.version()
|
||||
if _STATE["판"] != (ver, id(table)):
|
||||
var: dict[str, list] = {}
|
||||
idx: dict[str, set] = {}
|
||||
for o in table:
|
||||
vs = [(x.get("in") or {}, _vec(x)) for x in o.get("변형") or []]
|
||||
var[o["로직"]] = [(g, v) for g, v in vs if v["노임"] or v["장비"]]
|
||||
if var[o["로직"]]:
|
||||
for d in dims(var[o["로직"]][0][1]):
|
||||
idx.setdefault(d, set()).add(o["로직"])
|
||||
_STATE.update(판=(ver, id(table)), 변형=var, 색인=idx)
|
||||
return _STATE
|
||||
|
||||
|
||||
def variants(code: str) -> list[tuple[dict, dict]]:
|
||||
return _load()["변형"].get(code) or []
|
||||
|
||||
|
||||
def _kinds(key: str) -> tuple[frozenset, str]:
|
||||
kind, _, size = key.partition("|")
|
||||
return frozenset(kind.split("+")), size
|
||||
|
||||
|
||||
def dims(E: dict[str, Any]) -> set[str]:
|
||||
out = {t for t in E.get("노임") or {} if t != COMMON}
|
||||
eq = E.get("장비") or {}
|
||||
if isinstance(eq, dict):
|
||||
return out | {"+".join(sorted(_kinds(k)[0])) for k in eq}
|
||||
return out | {"+".join(sorted(a)) for a, _, _ in eq}
|
||||
|
||||
|
||||
def sim(E: dict[str, Any], ov: dict[str, Any], x: float = 1.0) -> float:
|
||||
"""E = 엑셀 몫 {노임, 장비: {「굴착기|0.6」: 시간}} · ov = 우리 벡터 — 0..1."""
|
||||
num = den = 0.0
|
||||
et, ot = E.get("노임") or {}, ov.get("노임") or {}
|
||||
for t in set(et) | set(ot):
|
||||
w = 0.5 if t == COMMON else 1.0
|
||||
a, b = et.get(t, 0.0), ot.get(t, 0.0) * x
|
||||
den += w
|
||||
num += w * min(a, b) / max(a, b) if a > 0 and b > 0 else 0
|
||||
oe = ov.get("장비") or []
|
||||
used = set()
|
||||
for k, a in (E.get("장비") or {}).items():
|
||||
kinds, size = _kinds(k)
|
||||
best, pick = 0.0, None
|
||||
for at, (kinds2, sizes2, b) in enumerate(oe):
|
||||
if not kinds & kinds2:
|
||||
continue
|
||||
f = 1.0 if (not size or not sizes2 or float(size) in sizes2) else 0.8
|
||||
f *= 1.0 if kinds == kinds2 else 0.8 # 굴착기 ↔ 굴착기+집게
|
||||
b *= x
|
||||
s = f * (0.5 + 0.5 * (min(a, b) / max(a, b) if a > 0 and b > 0 else 0))
|
||||
if s > best:
|
||||
best, pick = s, at
|
||||
den += 1.0
|
||||
num += best
|
||||
if pick is not None:
|
||||
used.add(pick)
|
||||
den += sum(1.0 for at in range(len(oe)) if at not in used)
|
||||
return num / den if den else 0.0
|
||||
|
||||
|
||||
def fit_one(E: dict[str, Any], code: str, share: bool) -> tuple[float, dict, float]:
|
||||
"""(맞음, 입력, 몫 x) — 변형마다 x = 1 과 몫 x 중 가장 좋은 것."""
|
||||
top = (0.0, {}, 1.0)
|
||||
for given, ov in variants(code):
|
||||
s = sim(E, ov)
|
||||
if s > top[0]:
|
||||
top = (s, given, 1.0)
|
||||
if share:
|
||||
for x in fit.SHARES:
|
||||
s2 = 0.95 * sim(E, ov, x)
|
||||
if s2 > top[0]:
|
||||
top = (s2, given, x)
|
||||
return top
|
||||
|
||||
|
||||
def cover(E: dict[str, Any], code: str) -> float:
|
||||
"""E 의 차원을 로직 벡터가 얼마나 덮나(로직의 남는 차원은 안 셈)."""
|
||||
top = 0.0
|
||||
for _, ov in variants(code):
|
||||
num = den = 0.0
|
||||
for t, a in (E.get("노임") or {}).items():
|
||||
w = 0.5 if t == COMMON else 1.0
|
||||
b = (ov.get("노임") or {}).get(t, 0.0)
|
||||
den += w
|
||||
num += w * (min(a, b) / max(a, b) if a > 0 and b > 0 else 0)
|
||||
for k in E.get("장비") or {}:
|
||||
den += 1
|
||||
num += any(_kinds(k)[0] & a for a, _, _ in ov.get("장비") or [])
|
||||
top = max(top, num / den if den else 0)
|
||||
return top
|
||||
|
||||
|
||||
# ── 후보 풀 · 앞선 믿음 ────────────────────────────────────────────────
|
||||
def title_cands(title: str, unit: str, got: dict, n: int = 8) -> list[str]:
|
||||
text = re.sub(r"\s+", "", re.sub(r"[\d.·:()\[\]%]+", " ", str(title or "")))
|
||||
if len(text) < 2:
|
||||
return []
|
||||
found = hopyo_match.candidates({"이름": text, "단위": unit}, "로직", got, n)
|
||||
return [c["로직"] for c in found if c.get("로직")]
|
||||
|
||||
|
||||
class Prior:
|
||||
"""앞선 믿음 — 앞 차례 · 줄 근거 조항(윗마디 포함) · 같은 책."""
|
||||
|
||||
def __init__(self, line: dict[str, Any], ranked: list[str]):
|
||||
self.rank = {c: k for k, c in enumerate(ranked)}
|
||||
self.n = max(len(ranked), 1)
|
||||
self.theirs = [list(x) for x in ((line.get("근거") or {}).get("출처") or [])]
|
||||
self.books = {b for b, _ in self.theirs}
|
||||
self.table = hopyo_basis.ours()
|
||||
|
||||
def __call__(self, code: str) -> float:
|
||||
p = 0.3 * (1 - self.rank[code] / self.n) if code in self.rank else 0.0
|
||||
our = self.table.get(code)
|
||||
if our and self.theirs:
|
||||
p += 0.3 if hopyo_match.clause(self.theirs, our)[0] >= 0.9 else 0
|
||||
p += 0.1 if our["책"] in self.books else 0
|
||||
return p
|
||||
|
||||
|
||||
def _sum(parts: list[dict]) -> dict[str, Any]:
|
||||
out: dict[str, dict] = {"노임": {}, "장비": {}}
|
||||
for s in parts:
|
||||
for k in ("노임", "장비"):
|
||||
for d, q in (s.get(k) or {}).items():
|
||||
out[k][d] = out[k].get(d, 0) + q
|
||||
return out
|
||||
|
||||
|
||||
def pool_for(E: dict[str, Any], base: list[str], extra: list[str]) -> list[str]:
|
||||
idx = _load()["색인"]
|
||||
rare: set[str] = set()
|
||||
for d in dims(E):
|
||||
if len(idx.get(d, ())) <= 40:
|
||||
rare |= idx.get(d, set())
|
||||
return list(dict.fromkeys([*base, *extra, *sorted(rare)]))
|
||||
|
||||
|
||||
def _hint(title: Any) -> float | None:
|
||||
m = _SHARE.search(str(title or ""))
|
||||
return int(m.group(1)) / 100 if m and 0 < int(m.group(1)) < 100 else None
|
||||
|
||||
|
||||
def ranked(E: dict, pool: list[str], prior: Prior, share: bool, hint: float | None) -> list[tuple]:
|
||||
"""[(순위 점수, 맞음, 몫 x, 로직, 입력)] — 순위 차례 · 제목 몫과 같은 x 면 +0.1."""
|
||||
out = []
|
||||
for c in pool:
|
||||
f, given, x = fit_one(E, c, share)
|
||||
if f <= 0:
|
||||
continue
|
||||
score = f + prior(c)
|
||||
if hint is not None:
|
||||
score += 0.1 if abs(x - hint) < 1e-9 else -0.1
|
||||
out.append((score, f, x, c, given))
|
||||
out.sort(key=lambda z: -z[0])
|
||||
return out
|
||||
|
||||
|
||||
def is_mixed(parts: list[dict], line: dict[str, Any]) -> bool:
|
||||
"""섞인 줄 — 제목 · 이름 · 규격에 몫 표시 + 노임만 든 몫 + 장비만 든 몫."""
|
||||
text = " ".join(
|
||||
[str(p.get("제목")) for p in parts] + [str(line.get(k)) for k in ("이름", "규격")]
|
||||
)
|
||||
marked = bool(_SHARE.search(text) or _RATIO.search(text))
|
||||
man = any(p.get("노임") and not p.get("장비") for p in parts)
|
||||
mach = any(p.get("장비") and not p.get("노임") for p in parts)
|
||||
return marked and man and mach
|
||||
|
||||
|
||||
def machine_of(E: dict[str, Any]) -> str:
|
||||
kinds = {frozenset(_kinds(k)[0]) for k in E.get("장비") or {}}
|
||||
return MACHINE["굴착기"] if kinds == {frozenset({"굴착기"})} else MACHINE_ANY
|
||||
|
||||
|
||||
# ── 풀이 ───────────────────────────────────────────────────────────────
|
||||
def combine(
|
||||
primary: str | None, parts: list[dict], line: dict, got: dict, base: list[str]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""[{로직, 어디, 맞음, 몫, 단계, 입력}] — 1순위 + 1순위가 못 덮는 절 · 몫의 로직."""
|
||||
one = lambda c, w, f, x, step, g=None: { # noqa: E731
|
||||
"로직": c,
|
||||
"어디": w,
|
||||
"맞음": f,
|
||||
"몫": x,
|
||||
"단계": step,
|
||||
"입력": g or {},
|
||||
}
|
||||
chosen = [one(primary, "1순위", None, 1.0, "1순위")] if primary else []
|
||||
if not parts:
|
||||
return chosen
|
||||
prior, unit = Prior(line, base), str(line.get("단위") or "")
|
||||
mixed = is_mixed(parts, line)
|
||||
groups: dict[str, list] = {}
|
||||
for p in parts:
|
||||
groups.setdefault(str(p.get("절") or p.get("제목") or ""), []).append(p)
|
||||
have = {primary} if primary else set()
|
||||
|
||||
def machine(E: dict, where: str) -> None:
|
||||
if mixed or len(groups) > 1:
|
||||
m = machine_of(E)
|
||||
chosen.append(one(m, where, None, 1.0, "장비 몫"))
|
||||
have.add(m)
|
||||
|
||||
for title, ps in groups.items():
|
||||
E = _sum(ps)
|
||||
if primary and cover(E, primary) >= fit.COVER:
|
||||
continue
|
||||
if E["장비"] and not E["노임"]: # 장비만 든 절 = 기계 시간 로직(닫힌 세계 규칙)
|
||||
machine(E, title)
|
||||
continue
|
||||
share = any(_hint(p.get("제목")) for p in ps) or _hint(title) is not None
|
||||
pool = pool_for(E, base, title_cands(title, unit, got))
|
||||
cand = ranked(E, pool, prior, share, _hint(title))
|
||||
if cand and cand[0][1] >= fit.COMBO[1] and cand[0][3] not in have:
|
||||
chosen.append(
|
||||
one(cand[0][3], title, round(cand[0][1], 3), cand[0][2], "절", cand[0][4])
|
||||
)
|
||||
have.add(cand[0][3])
|
||||
continue
|
||||
for p in ps:
|
||||
if primary and cover(p, primary) >= fit.COVER:
|
||||
continue
|
||||
if p.get("장비") and not p.get("노임"):
|
||||
machine(p, p.get("제목"))
|
||||
continue
|
||||
hint = _hint(p.get("제목"))
|
||||
pool = pool_for(p, base, title_cands(p.get("제목"), unit, got))
|
||||
cand = ranked(p, pool, prior, hint is not None or share, hint)
|
||||
if cand and cand[0][1] >= fit.COMBO[2] and cand[0][3] not in have:
|
||||
f, x, c, g = round(cand[0][1], 3), cand[0][2], cand[0][3], cand[0][4]
|
||||
chosen.append(one(c, p.get("제목"), f, x, "몫", g))
|
||||
have.add(c)
|
||||
return chosen
|
||||
|
||||
|
||||
def solve(
|
||||
top: list[str], parts: list[dict], line: dict, got: dict, base: list[str]
|
||||
) -> tuple[list[str], list[dict[str, Any]]]:
|
||||
"""(새 차례, 조합 대상) — 섞인 줄이고 1순위가 블록 합을 MIXED_CUT 못 덮으면 1순위를 인력 몫
|
||||
로직으로 바꿈(몫 먼저) · 조합이 하나뿐이면 대상 빈 것."""
|
||||
chosen = combine((top or [None])[0], parts, line, got, base)
|
||||
codes = list(dict.fromkeys(c["로직"] for c in chosen if c["로직"]))
|
||||
if top and len(codes) > 1 and parts and is_mixed(parts, line):
|
||||
if cover(_sum(parts), top[0]) < fit.MIXED_CUT:
|
||||
labor = [c for c in chosen if c["로직"] != top[0] and c["단계"] in ("몫", "절")]
|
||||
if labor:
|
||||
chosen = [c for c in chosen if c["로직"] != top[0]]
|
||||
top = [labor[0]["로직"]] + [c for c in top if c != labor[0]["로직"]][:4]
|
||||
seen: set[str] = set()
|
||||
chosen = [
|
||||
c for c in chosen if not (c["로직"] in seen or seen.add(c["로직"]))
|
||||
] # 같은 로직은 처음 것
|
||||
if len(chosen) < 2:
|
||||
return top, []
|
||||
return top, [{**c, "체크": c["로직"] not in MACHINE_SET or c["로직"] == top[0]} for c in chosen]
|
||||
@@ -0,0 +1,309 @@
|
||||
"""M02 일위대가 실무자료 엑셀 지문(PLAN 338-1 · 설계 2-2) — 호표 줄 + 이어진 단가산출 블록 →
|
||||
직종 품 · 장비 시간 · 자재 수량 · 율 · 계수 · 조건 · 조항 · 몫. 금액 안 씀(식 맨 앞 단가는 뗌).
|
||||
|
||||
엑셀꼴 = 블록 `글`(머리 뺀 몸 줄)을 줄마다 풂 — 절 `^\\d+\\.` · 작은 절 `^[가-하]\\.` ·
|
||||
직종 줄 「직종 : 단가 × 식 = 금액」(우리 인력 이름 · 옛이름) · 장비 = 「노무비 · 경비 · 재료비 :
|
||||
단가 ÷ Q」
|
||||
+ 지금 장비(노무비 > 경비 > 재료비) · 자재 = 「이름 단가 × 수량 = 금액」 · 「: 별도계상」 ·
|
||||
못 풀면 금액 ÷ 단가(「역산」 · 품 되찾기만).
|
||||
STmate = 블록 `자원`(L · X · M · S) 품이 곧 품(노무비 · 재료비 · 경비 세 줄 같은 품은 한 번) ·
|
||||
섞음 = 이름 · 규격 몫 둘(작은 쪽 인력 · 큰 쪽 장비).
|
||||
몫(조합용 · 설계 2-7) = 맨 윗 절마다 노임 · 장비 벡터(`M02_Hopyo_Shares`).
|
||||
블록 없는 기계(hr) 줄 = `machine` · 노임 · 장비를 바로 적은 호표 = `direct`.
|
||||
값 · 규칙은 336 실험(C4 `s1_excel_fp` · K5 `stmate`) 그대로.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import Counter
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
|
||||
from M02_MasterTemplete import M02_Hopyo_Shares as shares
|
||||
from M02_MasterTemplete.M02_Hopyo_Shares import trade
|
||||
from M02_MasterTemplete.M02_Hopyo_Words import conds, mtypes, parse_rhs, specnums
|
||||
|
||||
COST = {"노무비", "재료비", "경비"}
|
||||
SKIPW = {
|
||||
"소계",
|
||||
"계",
|
||||
"합계",
|
||||
"인부소계",
|
||||
"자재소계",
|
||||
"주자재소계",
|
||||
"운반",
|
||||
"상차",
|
||||
"기계",
|
||||
"다짐",
|
||||
"적사",
|
||||
"살수",
|
||||
"집토",
|
||||
"암절취",
|
||||
"채집",
|
||||
"인력",
|
||||
"장비",
|
||||
"작업조건",
|
||||
"1일시공량",
|
||||
"작업면적",
|
||||
"1시간작업량",
|
||||
"기계채집",
|
||||
"구조물터파기",
|
||||
}
|
||||
_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*÷/].*=.*)$"
|
||||
)
|
||||
_PCT = re.compile(r"([가-힣]+)의\s*([\d.]+)\s*%")
|
||||
_COEF = re.compile(
|
||||
r"(?<![A-Za-z가-힣])(q|qs|q1|K|k|f|f1|f2|E|Es|Cm|L|V1|V2|T|rt|n|t1|t2|t3|t4)\s*=\s*"
|
||||
r"(\d+(?:\.\d+)?)(?![\d.]*\s*[×x*÷/])"
|
||||
)
|
||||
_HEAD = re.compile(r"^\s*(\d+)\.\s*(.*)$")
|
||||
_SUB = re.compile(r"^\s*([가-하])\.\s*(.*)$")
|
||||
OLD = re.compile(
|
||||
r"((?<![\d,.])20(0\d|1\d|20)(?![\d,.])|(?<!\d)(1\d|20)년|임도품셈|임도표준품셈|사방품셈|사방사업표준품셈)"
|
||||
)
|
||||
_LASTNUM = re.compile(r"=\s*([\d,]+(?:\.\d+)?)\s*$")
|
||||
_HOURS = re.compile(r"(hr|시간)", re.I)
|
||||
TRADE_UNITS = ("인",)
|
||||
|
||||
|
||||
def _flat(s: Any) -> str:
|
||||
return re.sub(r"\s+", "", str(s or ""))
|
||||
|
||||
|
||||
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 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 empty(line: dict[str, Any], block: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
block = block or {}
|
||||
basis = line.get("근거") or {}
|
||||
name, spec = str(line.get("이름") or ""), str(line.get("규격") or "")
|
||||
return {
|
||||
"이름": name,
|
||||
"규격": spec,
|
||||
"단위": str(line.get("단위") or ""),
|
||||
"출처": [list(x) for x in block.get("출처") or basis.get("출처") or []],
|
||||
"인력": {}, # 직종: {품, 밑품, 율, 어떻게}
|
||||
"장비": [], # {갈래, 규격, 시간, 밑시간, 율, 절, 글}
|
||||
"자재": [], # {이름, 품, 밑품}
|
||||
"율": [], # [바탕, %]
|
||||
"계수": {},
|
||||
"조건": {},
|
||||
"이름조건": {k: sorted(v) for k, v in conds(f"{name} {spec}").items()},
|
||||
"옛판": False,
|
||||
"몫": [],
|
||||
}
|
||||
|
||||
|
||||
def _labor(fp: dict, name: str, q: float, base: float, rates: list, how: str) -> None:
|
||||
one = fp["인력"].setdefault(name, {"품": 0.0, "밑품": 0.0, "율": [], "어떻게": []})
|
||||
one["품"] += q
|
||||
one["밑품"] += base
|
||||
one["율"] += rates
|
||||
one["어떻게"].append(how)
|
||||
|
||||
|
||||
def _round(fp: dict[str, Any]) -> dict[str, Any]:
|
||||
for one in fp["인력"].values():
|
||||
one["품"], one["밑품"] = round(one["품"], 6), round(one["밑품"], 6)
|
||||
return fp
|
||||
|
||||
|
||||
def excel(block: dict[str, Any] | None, line: dict[str, Any]) -> dict[str, Any]:
|
||||
"""HP 블록(설계 3-1) + 그 줄(이름 · 규격 · 단위) → 지문 · 블록이 없거나 글 · 자원이 없으면
|
||||
기계(hr) 줄 지문(장비 1 시간) 또는 빈 지문."""
|
||||
if block and block.get("자원"):
|
||||
return _round(_stmate(block, line))
|
||||
if block and block.get("글"):
|
||||
return _round(_text(block, line))
|
||||
return machine(line)
|
||||
|
||||
|
||||
def machine(line: dict[str, Any]) -> dict[str, Any]:
|
||||
"""블록 없는 기계(hr) 줄 → 장비 [{갈래, 규격, 시간 1}]."""
|
||||
fp = empty(line)
|
||||
text = f"{fp['이름']} {fp['규격']}"
|
||||
types = mtypes(text)
|
||||
if types:
|
||||
hours = 1.0 if _HOURS.search(fp["단위"]) else None
|
||||
one = {"갈래": sorted(types), "규격": sorted(specnums(text)), "시간": hours}
|
||||
fp["장비"] = [{**one, "밑시간": None, "율": [], "절": "0", "글": fp["이름"]}]
|
||||
fp["조건"] = fp["이름조건"]
|
||||
return fp
|
||||
|
||||
|
||||
def _text(block: dict[str, Any], line: dict[str, Any]) -> dict[str, Any]:
|
||||
"""엑셀꼴 — 블록 글 줄마다 풂(C4 `north`)."""
|
||||
fp = empty(line, block)
|
||||
head = f"{block.get('번', '')}.{block.get('이름', '')} [{block.get('단위', '')}]"
|
||||
text = [head, *[str(t) for t in block.get("글") or []]]
|
||||
front = [t for t in text[:5]] + [t for t in text if "※" in t or "작업조건" in t]
|
||||
head_text = " ".join([fp["이름"], fp["규격"], *front])
|
||||
fp["조건"] = {k: sorted(v) for k, v in conds(head_text).items()}
|
||||
fp["옛판"] = bool(OLD.search(" ".join(text[:6])))
|
||||
sec, sub, mach, label = 0, "", None, ""
|
||||
got_cost: dict[tuple, tuple] = {}
|
||||
item_rates = rates_in(f"{fp['이름']} {fp['규격']}")
|
||||
sec_rates: set[float] = set()
|
||||
|
||||
def unmix(got: dict | None) -> dict | None:
|
||||
"""율 메모가 없는데 맨 인자가 섞음 비율 후보와 같으면 율로 가름."""
|
||||
if got and not got["율"]:
|
||||
for v in got.get("인자") or []:
|
||||
if round(v, 4) in item_rates | sec_rates and 0 < v < 1:
|
||||
return {**got, "밑품": got["밑품"] / v, "율": [[round(v, 4), "섞음"]]}
|
||||
return got
|
||||
|
||||
for raw in text[1:]:
|
||||
row = raw.rstrip()
|
||||
h, s = _HEAD.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 = (ts, specnums(title)) if ts else None
|
||||
label = title.strip() if ts else ""
|
||||
for k, v in _PCT.findall(row):
|
||||
fp["율"].append([k, float(v)])
|
||||
cm = _COLON.match(row)
|
||||
name = re.sub(r"^[0-9가-힣]\.", "", _flat(cm.group("name"))) if cm else ""
|
||||
who = hopyo_basis.trade_names().get(name) if cm else None
|
||||
if who:
|
||||
rhs = cm.group("rhs")
|
||||
got = unmix(parse_rhs(rhs))
|
||||
if got:
|
||||
_labor(fp, who, got["품"], got["밑품"], got["율"], "식")
|
||||
elif r := _ratio(rhs):
|
||||
_labor(fp, who, r, r, [], "역산")
|
||||
continue
|
||||
if cm and name in COST:
|
||||
got = unmix(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
|
||||
if key not in got_cost or prio < got_cost[key][0]:
|
||||
got_cost[key] = (prio, mach, got)
|
||||
continue
|
||||
if cm and name not in SKIPW and re.search(r"(별도|별산)", cm.group("rhs")):
|
||||
fp["자재"].append({"이름": cm.group("name").strip(), "품": None, "밑품": None})
|
||||
continue
|
||||
mm = _MAT.match(row)
|
||||
if mm and not cm and (got := parse_rhs(mm.group("rhs"))):
|
||||
fp["자재"].append(
|
||||
{"이름": mm.group("name").strip(), "품": got["품"], "밑품": got["밑품"]}
|
||||
)
|
||||
continue
|
||||
if not (h or s):
|
||||
ts = mtypes(row)
|
||||
if ts and "%" not in row and not cm:
|
||||
mach, label = (ts, specnums(row)), row.strip()
|
||||
for k, v in _COEF.findall(row):
|
||||
fp["계수"].setdefault(k, float(v))
|
||||
for (sec_, sub_, lab), (_, (ts, sp), got) in got_cost.items():
|
||||
where = f"{sec_}.{sub_}" if sub_ else str(sec_)
|
||||
one = {"갈래": sorted(ts), "규격": sorted(sp), "시간": got["품"], "밑시간": got["밑품"]}
|
||||
fp["장비"].append({**one, "율": got["율"], "절": where, "글": lab})
|
||||
fp["몫"] = shares.sections(text[1:])
|
||||
return fp
|
||||
|
||||
|
||||
def _stmate(block: dict[str, Any], line: dict[str, Any]) -> dict[str, Any]:
|
||||
"""STmate — 자원 품이 곧 품(K5 `stmate`)."""
|
||||
fp = empty(line, block)
|
||||
text = [str(t) for t in block.get("글") or []][:60]
|
||||
fp["조건"] = {
|
||||
k: sorted(v) for k, v in conds(" ".join([fp["이름"], fp["규격"], *text[:4]])).items()
|
||||
}
|
||||
fp["옛판"] = bool(OLD.search(" ".join(text[:4])))
|
||||
for t in text:
|
||||
for k, v in _COEF.findall(t):
|
||||
fp["계수"].setdefault(k, float(v))
|
||||
for k, v in _PCT.findall(t):
|
||||
fp["율"].append([k, float(v)])
|
||||
rates = rates_in(f"{fp['이름']} {fp['규격']}")
|
||||
lab_rate = min(rates) if len(rates) >= 2 else None
|
||||
mach_rate = max(rates) if len(rates) >= 2 else None
|
||||
seen: Counter = Counter()
|
||||
for x in block.get("자원") or []:
|
||||
kind, q = x.get("갈래"), x.get("품")
|
||||
nm, sp = str(x.get("이름") or ""), str(x.get("규격") or "")
|
||||
if not q:
|
||||
continue
|
||||
seen[(x.get("코드"), round(float(q), 9))] += 1
|
||||
if seen[(x.get("코드"), round(float(q), 9))] > 1: # 노무비 · 재료비 · 경비 세 줄 같은 품
|
||||
continue
|
||||
q = float(q)
|
||||
ts = mtypes(f"{nm} {sp}")
|
||||
if kind == "L":
|
||||
if who := trade(nm):
|
||||
base = q / lab_rate if lab_rate else q
|
||||
_labor(fp, who, q, base, [[lab_rate, "섞음"]] if lab_rate else [], "QTY")
|
||||
elif kind == "X" or (kind == "S" and ts):
|
||||
one = {"갈래": sorted(ts), "규격": sorted(specnums(sp) | specnums(nm)), "시간": q}
|
||||
base = q / mach_rate if mach_rate else q
|
||||
fp["장비"].append({**one, "밑시간": base, "율": [], "절": "0", "글": f"{nm} {sp}"})
|
||||
elif kind in ("M", "S"):
|
||||
fp["자재"].append({"이름": f"{nm} {sp}".strip(), "품": q, "밑품": q})
|
||||
fp["몫"] = shares.sections_stm(block.get("자원") or [], text)
|
||||
return fp
|
||||
|
||||
|
||||
def direct(hopyo: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""노임 · 장비를 바로 적은 호표 → 줄 수량 합 지문(호표 1 단위) · 근거글 출처 — 없으면 None."""
|
||||
lines = hopyo.get("줄") or []
|
||||
fp = empty({k: hopyo.get(k) for k in ("이름", "규격", "단위")})
|
||||
texts = [str(t) for t in hopyo.get("근거글") or []]
|
||||
texts += [f"{x.get('이름')} {x.get('규격')}" for x in lines if x.get("갈래") == "근거글"]
|
||||
fp["출처"] = hopyo_basis.sources(" ".join(texts))
|
||||
hit = False
|
||||
for x in lines:
|
||||
q = x.get("수량")
|
||||
if not isinstance(q, (int, float)) or q <= 0:
|
||||
continue
|
||||
if kind_of(x) == "노임" and (who := trade(x.get("이름"))):
|
||||
_labor(fp, who, float(q), float(q), [], "QTY")
|
||||
hit = True
|
||||
elif kind_of(x) == "기계":
|
||||
text = f"{x.get('이름')} {x.get('규격')}"
|
||||
if ts := mtypes(text):
|
||||
one = {"갈래": sorted(ts), "규격": sorted(specnums(text)), "시간": float(q)}
|
||||
fp["장비"].append({**one, "밑시간": float(q), "율": [], "절": "0", "글": text})
|
||||
hit = True
|
||||
return _round(fp) if hit and fp["인력"] else None
|
||||
|
||||
|
||||
def kind_of(line: dict[str, Any]) -> str:
|
||||
"""줄 갈래 + 「노임」(단위 인 + 우리 직종 · 나 창 읽기 전에도)."""
|
||||
kind = str(line.get("갈래") or "")
|
||||
unit = _flat(line.get("단위"))
|
||||
if kind in ("기타", "자재", "노임") and unit in TRADE_UNITS and trade(line.get("이름")):
|
||||
return "노임"
|
||||
return kind
|
||||
@@ -0,0 +1,411 @@
|
||||
"""M02 일위대가 맞춤 — 순위 · 판정 · 블록 셈 · 뒤 셈 · 맞춤셈 캐시(PLAN 338-1 · 설계 2-4 · 2-6 ·
|
||||
2-10).
|
||||
|
||||
순위 = 로직마다 가장 닮은 변형의 지문 점수(인력 · 장비 · 자재 · 율 · 단위 · 조건 · 조항 · 이름 ·
|
||||
금액 없음)
|
||||
→ 지문 위 POOL ∪ 이름 · 근거 차례(`Match.b_order`) 위 다섯 → 점수 + BETA × 차례 덤 → 위 다섯 ·
|
||||
같음(이름 같음 · 씨앗) 1위 고정 — 지문 「딱 맞음」이 다른 로직이면 지문 · 섞인 줄은 이름 · 근거
|
||||
차례로
|
||||
몫 풀이(`Combine.solve`).
|
||||
판정(1위) = 맞음(옵션 수준 ≤ 1 · 엑셀 몫을 다 덮음 · 숫자 하나뿐 줄 아님) · 다른 점(옵션 수준 2 또는
|
||||
직종 묶음 같음 + 인용 조항 같음) · 후보 — 글은 숫자 사실만(엑셀 · 우리 품).
|
||||
뒤 셈 = 올리기 · 보기가 `ensure` → 일감 줄 하나 · 블록 차례로 셈 · 20 블록마다 캐시 덮음 ·
|
||||
캐시 = `tmp/m02_hopyo/맞춤_<HP16>_<판16>.json`. 서버 단독 · 원본 읽기만.
|
||||
손잡이(아래 상수)는 336 · K5 실험 값 그대로 — 한 파일로 다시 맞추지 않음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
|
||||
from M02_MasterTemplete import M02_Hopyo_Finger as finger
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
|
||||
from M02_MasterTemplete import M02_Hopyo_Ours as ours
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete.M02_Hopyo_Words import close, cond_score, cosine, jacc, nfkc
|
||||
|
||||
# ── 손잡이(한 곳) ──────────────────────────────────────────────────────
|
||||
W = {
|
||||
"인력": 3.0,
|
||||
"장비": 2.0,
|
||||
"자재": 1.0,
|
||||
"율": 0.3,
|
||||
"단위": 0.5,
|
||||
"조건": 0.6,
|
||||
"조항": 1.5,
|
||||
"이름": 0.6,
|
||||
}
|
||||
BETA = 1.0
|
||||
RB = (1, 0.6, 0.45, 0.35, 0.25)
|
||||
POOL = 40
|
||||
TOP_OPT = 5
|
||||
CAP = 400
|
||||
TOL = (0.005, "R", 0.03)
|
||||
SURE = (0.03, 0.3)
|
||||
MIXED_CUT = 0.35
|
||||
COMBO = (0.85, 0.75, 0.6)
|
||||
COVER = 0.6
|
||||
SHARES = (0.1, 0.2, 0.3, 0.5, 0.7, 0.8, 0.9)
|
||||
EVERY = 20 # 블록 몇 개마다 캐시 덮음
|
||||
COUNT = {"개소", "개", "본", "ea", "조", "매", "대", "기", "공"}
|
||||
HOUR = {"hr", "시간", "h"}
|
||||
NO_NUMBER = "숫자 판정 없음"
|
||||
_STATE: dict[str, Any] = {"판": None}
|
||||
|
||||
|
||||
# ── 우리 지문 정리(점수용) ─────────────────────────────────────────────
|
||||
def mat_words(name: Any) -> set[str]:
|
||||
t = nfkc(name).lower().replace(".", "").replace(" ", "")
|
||||
out = set(hopyo_match.words(name)) | ({"pvc"} if "pvc" in t else set())
|
||||
return {w for w in out if len(w) >= 2}
|
||||
|
||||
|
||||
def _prep(o: dict[str, Any], grams: dict[str, set]) -> dict[str, Any]:
|
||||
"""기계경비 로직은 조종원 뺌 · 모두 0 인 변형 뺌 · 섞기 전 변형 덧 · 고를 수 있는 장비."""
|
||||
vs = []
|
||||
for v in o["변형"]:
|
||||
L = {} if o["기계경비"] else {t: q for t, q in v["L"].items() if q > 0}
|
||||
X = [(set(a), set(b), q) for a, b, q in v["X"] if q > 0]
|
||||
M = {k: q for k, q in v["M"].items() if q > 0}
|
||||
if not (L or X or M) and (v["L"] or v["X"] or v["M"]):
|
||||
continue
|
||||
vs.append({"in": v["in"], "L": L, "X": X, "M": M})
|
||||
ri, rm = (v.get("mix") or {}).get("인력비율"), (v.get("mix") or {}).get("기계비율")
|
||||
if (ri and 0 < ri < 100) or (rm and 0 < rm < 100): # 섞기 전 품(인력 · 기계 100%)
|
||||
Lu = {t: q * 100 / ri for t, q in L.items()} if ri and 0 < ri < 100 else L
|
||||
Xu = [(a, b, q * 100 / rm) for a, b, q in X] if rm and 0 < rm < 100 else X
|
||||
vs.append({"in": {**v["in"], "_섞기전": "100%"}, "L": Lu, "X": Xu, "M": M})
|
||||
if not vs: # 엔진이 못 돈 로직 — 직종 이름만(품 모름)
|
||||
X0 = [(set(a), set(b), None) for a, b in o["장비"] if a]
|
||||
vs = [{"in": None, "L": {t: None for t in o["직종"]}, "X": X0, "M": {}}]
|
||||
opt = []
|
||||
if o["기계경비"] and (o["장비고름"] or o["장비"]):
|
||||
ts = set().union(*[set(a) for a, _ in o["장비고름"] + o["장비"]])
|
||||
sp = set().union(*[set(b) for _, b in o["장비고름"]]) if o["장비고름"] else set()
|
||||
opt = [(ts, sp)]
|
||||
mats = set().union(*[mat_words(n) for n in o["자재"]], set())
|
||||
for v in vs:
|
||||
for n in v["M"]:
|
||||
mats |= mat_words(n)
|
||||
return {
|
||||
"로직": o["로직"],
|
||||
"단위": o["단위"],
|
||||
"vs": vs,
|
||||
"opt": opt,
|
||||
"matw": mats,
|
||||
"pctv": {round(p, 3) for _, p in o["덧줄율"]},
|
||||
"nameg": grams.get(o["로직"], set()),
|
||||
"nc": {k: set(v) for k, v in o["이름조건"].items()},
|
||||
"oc": {k: set(v) for k, v in o["고름조건"].items()},
|
||||
}
|
||||
|
||||
|
||||
def prepared() -> list[dict[str, Any]] | None:
|
||||
"""점수용 우리 지문 — 우리 지문 준비 전 None."""
|
||||
table = ours.table()
|
||||
if table is None:
|
||||
return None
|
||||
if _STATE["판"] != id(table):
|
||||
grams = {x["로직"]: x["grams"] for x in hopyo_match.targets()["로직"]}
|
||||
_STATE.update(판=id(table), 표=[_prep(o, grams) for o in table])
|
||||
return _STATE["표"]
|
||||
|
||||
|
||||
# ── 엑셀 지문 정리 · 점수 ──────────────────────────────────────────────
|
||||
def ex_prep(efp: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
**efp,
|
||||
"E": {t: o["품"] for t, o in efp["인력"].items() if o["품"]},
|
||||
"Eb": {t: o["밑품"] for t, o in efp["인력"].items() if o["밑품"]},
|
||||
"matl": [w for w in (mat_words(m["이름"]) for m in efp["자재"]) if w],
|
||||
"pctv": {round(v, 3) for _, v in efp["율"]},
|
||||
"nameg": hopyo_match._grams(hopyo_match.tidy(efp["이름"])),
|
||||
"theirs": [list(x) for x in efp["출처"]],
|
||||
"조건s": {k: set(v) for k, v in efp["조건"].items()},
|
||||
"이름조건s": {k: set(v) for k, v in efp["이름조건"].items()},
|
||||
}
|
||||
|
||||
|
||||
def unit_fit(a: Any, b: Any) -> bool:
|
||||
"""단위 맞음 + 별칭(개소 = 개 = 본 · hr = 시간) — 품 크기 견줌에만."""
|
||||
if hopyo_match.fits(a, b):
|
||||
return True
|
||||
na = "".join(str(a or "").split()).replace("/", "").lower()
|
||||
for nb in ("".join(x.split()).replace("/", "").lower() for x in str(b or "").split("·")):
|
||||
if na == nb or (na in COUNT and nb in COUNT) or (na in HOUR and nb in HOUR):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def lab_sim(E: dict, Eb: dict, ov: dict, unit_ok: bool) -> tuple[float, float | None]:
|
||||
if not ov:
|
||||
return 0.0, None
|
||||
keys = set(E) | set(ov)
|
||||
w = lambda t: 0.5 if t == "보통인부" else 1.0 # noqa: E731
|
||||
st = sum(w(t) for t in set(E) & set(ov)) / sum(w(t) for t in keys)
|
||||
if any(q is None for q in ov.values()): # 품 모름 — 직종 겹침만
|
||||
return 0.6 * st, None
|
||||
shape = max(cosine(E, ov), cosine(Eb, ov))
|
||||
if not unit_ok:
|
||||
return 0.35 * st + 0.25 * shape, None
|
||||
mag = sum(max(close(E.get(t), ov.get(t)), close(Eb.get(t), ov.get(t))) for t in keys) / len(
|
||||
keys
|
||||
)
|
||||
return 0.35 * st + 0.25 * shape + 0.40 * mag, mag
|
||||
|
||||
|
||||
def mach_sim(EM: list, OX: list, opt: list, unit_ok: bool) -> float:
|
||||
if not OX and not opt:
|
||||
return 0.0
|
||||
got = []
|
||||
for m in EM:
|
||||
mt, ms = set(m["갈래"]), set(m["규격"])
|
||||
best = 0.0
|
||||
for ot, osp, oh in OX:
|
||||
j = jacc(mt, ot)
|
||||
if not j:
|
||||
continue
|
||||
sp = (1.0 if ms & osp else 0.0) if ms and osp else 0.5
|
||||
hc = max(close(m.get("시간"), oh), close(m.get("밑시간"), oh)) if unit_ok and oh else 0
|
||||
best = max(best, 0.6 * j + 0.2 * sp + 0.2 * hc)
|
||||
for ot, osp in opt:
|
||||
if mt and mt <= ot:
|
||||
sp = (1.0 if ms & osp else 0.0) if ms and osp else 0.5
|
||||
best = max(best, 0.6 + 0.2 * sp + 0.2 * (1.0 if unit_ok else 0.0))
|
||||
got.append(best)
|
||||
et = set().union(*[set(m["갈래"]) for m in EM])
|
||||
ot_all = set().union(*[t for t, _, _ in OX]) if OX else set()
|
||||
extra = len(ot_all - et) / max(1, len(ot_all | et)) if ot_all else 0.0
|
||||
return sum(got) / len(got) * (1 - 0.5 * extra)
|
||||
|
||||
|
||||
def score_pair(e: dict, o: dict, table: dict | None = None) -> tuple[float, dict, dict | None]:
|
||||
"""(점수, 채널, 고른 변형) — 로직마다 가장 닮은 변형 · `table` = `Basis.ours()`(한 번 읽어
|
||||
넘김)."""
|
||||
unit_ok = unit_fit(e["단위"], o["단위"])
|
||||
ch = {"단위": 1.0 if unit_ok else 0.0}
|
||||
g1, g2 = e["nameg"], o["nameg"]
|
||||
ch["이름"] = 2 * len(g1 & g2) / (len(g1) + len(g2)) if g1 and g2 else 0.0
|
||||
our = (table if table is not None else hopyo_basis.ours()).get(o["로직"])
|
||||
c = hopyo_match.clause(e["theirs"], our)[0] if (our and e["theirs"]) else 0.0
|
||||
ch["조항"] = c * (0.5 if e["옛판"] else 1.0)
|
||||
hit, _ = cond_score(e["조건s"], o["nc"], o["oc"])
|
||||
_, clash = cond_score(e["이름조건s"], o["nc"], {})
|
||||
ch["조건"] = hit - clash
|
||||
if e["matl"]:
|
||||
near = lambda ws: any(a in b or b in a for a in ws for b in o["matw"]) # noqa: E731
|
||||
ch["자재"] = sum(1 for ws in e["matl"] if near(ws)) / len(e["matl"])
|
||||
ch["율"] = 1.0 if (e["pctv"] and o["pctv"] and e["pctv"] & o["pctv"]) else 0.0
|
||||
base = sum(W.get(k, 0) * v for k, v in ch.items())
|
||||
best, bestv, bestch = None, None, {}
|
||||
for v in o["vs"]:
|
||||
s, vch = base, {}
|
||||
if e["E"]:
|
||||
ls, mag = lab_sim(e["E"], e["Eb"], v["L"], unit_ok)
|
||||
vch["인력"], vch["품"] = ls, mag
|
||||
s += W["인력"] * ls
|
||||
elif v["L"]:
|
||||
s -= 0.6
|
||||
if e["장비"]:
|
||||
vch["장비"] = mach_sim(e["장비"], v["X"], o["opt"], unit_ok)
|
||||
s += W["장비"] * vch["장비"]
|
||||
elif v["X"] or o["opt"]:
|
||||
s -= 0.4
|
||||
if best is None or s > best:
|
||||
best, bestv, bestch = s, v, vch
|
||||
return best, {**ch, **bestch}, bestv
|
||||
|
||||
|
||||
def rank(e: dict, n: int = POOL) -> list[tuple]:
|
||||
"""[(점수, 로직, 채널, 변형)] — 위 n."""
|
||||
table = hopyo_basis.ours()
|
||||
out = [(*_swap(score_pair(e, o, table), o["로직"]),) for o in prepared() or []]
|
||||
out.sort(key=lambda x: -x[0])
|
||||
return out[:n]
|
||||
|
||||
|
||||
def _swap(got: tuple, code: str) -> tuple:
|
||||
return got[0], code, got[1], got[2]
|
||||
|
||||
|
||||
def sure(e: dict, pool: list[tuple]) -> bool:
|
||||
"""딱 맞음 — 1위 직종 · 품(3% 안)이 다 맞음(보통인부 하나뿐 줄 아님) 또는 장비 갈래 · 시간 맞음
|
||||
·
|
||||
2위와 점수 차 SURE[1] 넘음."""
|
||||
if not pool:
|
||||
return False
|
||||
s1, _, ch, _ = pool[0]
|
||||
gap = s1 - pool[1][0] if len(pool) > 1 else 9
|
||||
if e["E"]:
|
||||
rich = len(e["E"]) >= 2 or set(e["E"]) != {"보통인부"} or bool(e["장비"])
|
||||
ok = rich and (ch.get("품") or 0) >= 1 - SURE[0] and ch.get("인력", 0) >= 1 - SURE[0]
|
||||
else:
|
||||
ok = bool(e["장비"]) and ch.get("장비", 0) >= 0.95
|
||||
return ok and gap > SURE[1]
|
||||
|
||||
|
||||
def fuse(e: dict, pool: list[tuple], border: list[str]) -> list[str]:
|
||||
"""지문 점수 + BETA × 이름 · 근거 차례 덤 → 위 다섯."""
|
||||
bonus = {k: RB[i] for i, k in enumerate(border[:5])}
|
||||
cand = {k: s for s, k, _, _ in pool}
|
||||
by = {o["로직"]: o for o in prepared() or []}
|
||||
table = hopyo_basis.ours()
|
||||
for k in bonus:
|
||||
if k not in cand and k in by:
|
||||
cand[k] = score_pair(e, by[k], table)[0]
|
||||
return sorted(cand, key=lambda k: -(cand[k] + BETA * bonus.get(k, 0)))[:TOP_OPT]
|
||||
|
||||
|
||||
def single(e: dict) -> bool:
|
||||
"""숫자 하나뿐 줄 — 직종 하나 또는 장비 시간 하나(기계 1 시간 줄 뺌) · 같은 숫자 로직이 많아
|
||||
우연 맞음."""
|
||||
E = [k for k, x in e["인력"].items() if x.get("품")]
|
||||
X = [m for m in e["장비"] if m.get("시간")]
|
||||
return len(E) + len(X) == 1 and not (X and abs(X[0]["시간"] - 1.0) < 1e-9)
|
||||
|
||||
|
||||
def has_numbers(efp: dict) -> bool:
|
||||
return bool(efp["인력"] or any(m.get("시간") for m in efp["장비"]) or efp["자재"])
|
||||
|
||||
|
||||
# ── 블록 셈 ────────────────────────────────────────────────────────────
|
||||
def compute(block: dict | None, line: dict, got: dict | None = None) -> dict[str, Any]:
|
||||
"""블록 + 줄 하나 → 맞춤셈 한 칸(설계 3-2 꼴)."""
|
||||
from M02_MasterTemplete import M02_Hopyo_Verdict as verdict # 판정이 이 파일을 부름
|
||||
|
||||
return verdict.block(block, line, got or hopyo_match.targets())
|
||||
|
||||
|
||||
# ── 뒤 셈 · 캐시 ───────────────────────────────────────────────────────
|
||||
_JOBS: queue.Queue = queue.Queue()
|
||||
_PENDING: set[str] = set()
|
||||
_WORKER: dict[str, Any] = {"스레드": None}
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _hp_path(folder: Path, key: str) -> Path:
|
||||
return hopyo_store._path(folder, key)
|
||||
|
||||
|
||||
def cache_path(folder: Path, key: str) -> Path:
|
||||
hp = hashlib.sha256(_hp_path(folder, key).read_bytes()).hexdigest()[:16]
|
||||
return ours.folder() / f"맞춤_{hp}_{ours.version()}.json"
|
||||
|
||||
|
||||
def units(doc: dict[str, Any]) -> list[tuple[str, dict | None, dict]]:
|
||||
"""셈 단위 [(열쇠, 블록, 줄)] — 블록 + 줄 열쇠 한 번 · 블록 없는 기계 줄 · 직접 줄 호표."""
|
||||
blocks = {int(b["번"]): b for b in doc.get("단산") or [] if b.get("번") is not None}
|
||||
out, seen = [], set()
|
||||
for h in doc.get("호표") or []:
|
||||
for line in h.get("줄") or []:
|
||||
ref = line.get("참조") or {}
|
||||
kind = finger.kind_of(line)
|
||||
if ref.get("종류") == "단산":
|
||||
key = unit_key(line)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
out.append((key, blocks.get(int(ref.get("번") or 0)), line))
|
||||
elif kind == "기계":
|
||||
key = unit_key(line)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
out.append((key, None, line))
|
||||
if (fp := finger.direct(h)) is not None and f"호표:{h['번']}" not in seen:
|
||||
seen.add(f"호표:{h['번']}")
|
||||
out.append(
|
||||
(f"호표:{h['번']}", {"_지문": fp}, {k: h.get(k) for k in ("이름", "규격", "단위")})
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def unit_key(line: dict[str, Any]) -> str:
|
||||
ref = line.get("참조") or {}
|
||||
if ref.get("종류") == "단산":
|
||||
return f"단산:{ref.get('번')}|{hopyo_match.line_key(line)}"
|
||||
return f"줄:{hopyo_match.line_key(line)}"
|
||||
|
||||
|
||||
def _read(path: Path) -> dict[str, Any] | None:
|
||||
try:
|
||||
doc = json.loads(path.read_bytes())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return doc if isinstance(doc, dict) else None
|
||||
|
||||
|
||||
def result(folder: Path, key: str) -> dict[str, Any] | None:
|
||||
"""맞춤셈 캐시(이 HP 파일 · 지금 판) — 없으면 None."""
|
||||
try:
|
||||
return _read(cache_path(folder, key))
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def status(folder: Path, key: str) -> dict[str, Any]:
|
||||
"""{끝, 한, 모두, 까닭?}."""
|
||||
got = result(folder, key)
|
||||
if got and got.get("끝"):
|
||||
return {"끝": True, "한": got["셈"][0], "모두": got["셈"][1]}
|
||||
why = None if ours.ready() else "우리 로직 준비 중"
|
||||
done, total = got["셈"] if got else (0, None)
|
||||
return {"끝": False, "한": done, "모두": total, **({"까닭": why} if why else {})}
|
||||
|
||||
|
||||
def run_job(folder: Path, key: str) -> dict[str, Any]:
|
||||
"""HP 파일 하나를 끝까지 셈(뒤 스레드 · 시험이 바로 부름) → 캐시."""
|
||||
if ours.table() is None:
|
||||
thread = ours.warm(build_missing=True)
|
||||
if thread is not None:
|
||||
thread.join()
|
||||
path = cache_path(folder, key)
|
||||
doc = hopyo_store.read(folder, key)
|
||||
todo = units(doc)
|
||||
got = _read(path) or {}
|
||||
done = got.get("블록") if got.get("판") == ours.version() else None
|
||||
out = {"판": ours.version(), "지문판": ours.FP_VERSION, "셈": [0, len(todo)], "끝": False}
|
||||
out["블록"] = dict(done or {})
|
||||
targets = hopyo_match.targets()
|
||||
for at, (unit, block, line) in enumerate(todo, 1):
|
||||
if unit not in out["블록"]:
|
||||
out["블록"][unit] = compute(block, line, targets)
|
||||
out["셈"] = [at, len(todo)]
|
||||
if at % EVERY == 0:
|
||||
atomic_write_json(path, out)
|
||||
out["끝"] = True
|
||||
atomic_write_json(path, out)
|
||||
return out
|
||||
|
||||
|
||||
def _work() -> None:
|
||||
while True:
|
||||
folder, key = _JOBS.get()
|
||||
try:
|
||||
run_job(folder, key)
|
||||
except Exception: # noqa: BLE001 — 한 파일이 깨져도 일감 줄은 계속(보기에서 「셈 중」 그대로)
|
||||
pass
|
||||
finally:
|
||||
with _LOCK:
|
||||
_PENDING.discard(f"{folder}|{key}")
|
||||
|
||||
|
||||
def ensure(folder: Path, key: str) -> dict[str, Any]:
|
||||
"""캐시 있으면 끝 · 없으면 일감 줄에 넣음(같은 파일은 한 번) → {끝, 한, 모두, 까닭?}."""
|
||||
now = status(folder, key)
|
||||
if now["끝"]:
|
||||
return now
|
||||
with _LOCK:
|
||||
tag = f"{folder}|{key}"
|
||||
if tag not in _PENDING:
|
||||
_PENDING.add(tag)
|
||||
_JOBS.put((Path(folder), key))
|
||||
if _WORKER["스레드"] is None or not _WORKER["스레드"].is_alive():
|
||||
_WORKER["스레드"] = threading.Thread(target=_work, name="m02-hopyo-fit", daemon=True)
|
||||
_WORKER["스레드"].start()
|
||||
return now
|
||||
@@ -319,7 +319,7 @@ def candidates(
|
||||
scored.sort(key=lambda x: -x[0])
|
||||
out = []
|
||||
for score, one in scored:
|
||||
label = (one["이름"], one.get("규격"), one["단위"])
|
||||
label = one.get("로직") or (one["이름"], one.get("규격"), one["단위"]) # 로직은 접지 않음
|
||||
if label in seen:
|
||||
continue
|
||||
seen.add(label)
|
||||
@@ -329,6 +329,86 @@ def candidates(
|
||||
return out
|
||||
|
||||
|
||||
# ── 인용 조항 · 이름 · 근거 차례(338 · 설계 2-4 B 차례) ─────────────────────
|
||||
_OLD_TEXT = re.compile(r"(20(1\d|2[0-4])|1\d년|2[0-4]년|임도품셈|임도표준품셈|사방품셈)")
|
||||
_TYPO_CLAUSE = re.compile(r"^(\d+)-(\d)(\d)(\d)$") # 13-114 → 13-11-4
|
||||
_GENERIC = frozenset(
|
||||
"인력 기계 장비 소계 합계 노무비 재료비 경비 적용 기준 별도계상 별도 인부 보통인부 특별인부 "
|
||||
"설치 해체 운반 작업 시공 사용 포함 공구손료 경장비 손료 기계경비 재료 인건비 품셈 표준품셈 "
|
||||
"건설표준품셈 이하 이상".split()
|
||||
)
|
||||
|
||||
|
||||
def fix_typo(theirs: list) -> list[list[str]]:
|
||||
"""엑셀 인용 조항 오타 — 「13-114」 → 「13-11-4」."""
|
||||
out = []
|
||||
for book, n in theirs or []:
|
||||
m = _TYPO_CLAUSE.match(str(n))
|
||||
out.append([book, f"{m.group(1)}-{m.group(2)}{m.group(3)}-{m.group(4)}" if m else n])
|
||||
return out
|
||||
|
||||
|
||||
def clause(theirs: list, our: dict[str, Any]) -> tuple[float, list[str], str]:
|
||||
"""(점수, 맞은 우리 조항 글, 꼬리표) — 같음 1 · 윗마디 0.9(엑셀 6-2 ↔ 우리 6-2-2) · 앞 마디 0.5
|
||||
·
|
||||
나 창 `Basis.clause` 가 있으면 그것."""
|
||||
if hasattr(hopyo_basis, "clause"):
|
||||
return hopyo_basis.clause(theirs, our)
|
||||
nums = [n for book, n in theirs if book == our["책"]]
|
||||
same = sorted(n for n in nums if n in our["조항"])
|
||||
if same:
|
||||
return 1.0, [f"{our['구분']} {n}" for n in same], "조항 같음"
|
||||
kids = sorted(c for c in our["조항"] for n in nums if c.startswith(n + "-"))
|
||||
if kids:
|
||||
return 0.9, [f"{our['구분']} {n}" for n in kids], "조항 같음"
|
||||
heads = {n.rsplit("-", 1)[0] for n in nums}
|
||||
near_ = sorted(n for n in our["조항"] if n.rsplit("-", 1)[0] in heads)
|
||||
if near_:
|
||||
return 0.5, [f"{our['구분']} {n}" for n in near_], "앞 마디 같음"
|
||||
return 0.0, [], ""
|
||||
|
||||
|
||||
def _strong_basis(line: dict[str, Any], cand: dict[str, Any]) -> bool:
|
||||
if cand["근거"]["직종"]:
|
||||
return True
|
||||
mine = {w for w in hopyo_basis._loose_words(f"{line.get('이름')}") if w not in _GENERIC}
|
||||
theirs = {w for w in hopyo_basis._loose_words(cand["이름"]) if w not in _GENERIC}
|
||||
return any(a in b or b in a for a in mine for b in theirs)
|
||||
|
||||
|
||||
def b_order(line: dict[str, Any], got: dict[str, Any] | None = None, text: Any = ()) -> dict:
|
||||
"""이름 · 근거 차례 `{상태: 같음 | 후보 | 없음, top: [로직 5], 합침: [로직 15]}` — 이름 후보 +
|
||||
근거 후보 합침(접힘 없음) · 「같음」도 근거 검사(인용 조항이 다른 로직을 가리키면 후보) ·
|
||||
옛 판 글이면 같음 그대로 · 씨앗은 이름 같음 자리."""
|
||||
got = got or targets()
|
||||
basis_ = dict(line.get("근거") or {})
|
||||
if basis_.get("출처"):
|
||||
line = {**line, "근거": {**basis_, "출처": fix_typo(basis_["출처"])}}
|
||||
basis = hopyo_basis.candidates(line, got)
|
||||
named = candidates(line, "로직", got, None)
|
||||
merged = [c.get("로직") for c in hopyo_basis.merge(basis, named) if c.get("로직")]
|
||||
found = _exact(line, "로직", got)
|
||||
if len(found) == 1:
|
||||
tgt = found[0]["로직"]
|
||||
theirs = (line.get("근거") or {}).get("출처") or []
|
||||
our = hopyo_basis.ours().get(tgt)
|
||||
if _OLD_TEXT.search(" ".join(str(t) for t in list(text)[:5])) or not theirs:
|
||||
return {"상태": "같음", "top": [tgt], "합침": merged[:15]}
|
||||
if our and clause(theirs, our)[0] >= 0.9:
|
||||
return {"상태": "같음", "top": [tgt], "합침": merged[:15]}
|
||||
strong = [c for c in basis if c["근거"]["까닭"].startswith("조항 같음")]
|
||||
strong = [c for c in strong if _strong_basis(line, c)]
|
||||
if not strong:
|
||||
return {"상태": "같음", "top": [tgt], "합침": merged[:15]}
|
||||
top = list(dict.fromkeys([strong[0]["로직"], tgt] + [c["로직"] for c in strong[1:]]))
|
||||
return {"상태": "후보", "top": top[:5], "합침": merged[:15]}
|
||||
top = [x["로직"] for x in found]
|
||||
rest = [c for c in hopyo_basis.merge(basis, named) if c.get("로직") not in top]
|
||||
first = next((c.get("로직") for c in named if c.get("로직") not in top), None)
|
||||
top += [c["로직"] for c in hopyo_basis.pick(rest, first if basis else None, 5 - len(top))]
|
||||
return {"상태": "후보" if top else "없음", "top": top[:5], "합침": merged[:15]}
|
||||
|
||||
|
||||
# ── 층 기억 ───────────────────────────────────────────────────────────
|
||||
def chain(folder: Path) -> list[Path]:
|
||||
"""기억을 볼 층 폴더 — 같은 층 → 위 층(개인 → 회사 → 마스터)."""
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
"""M02 일위대가 맞춤 — 옵션 거꾸로 찾기(PLAN 338-1 · 설계 2-5): 고른 우리 로직의 입력 칸마다
|
||||
후보(옵션 공간)를 엔진으로 돌려 엑셀 품(직종 품 · 장비 시간 · 자재 수량)과 같은 입력을 찾음.
|
||||
|
||||
공간 = 고르기 모두 · 기계(EQ) = 엑셀 규격 수와 맞는 기종(없으면 모두) · 범위 = 두 끝 + 범위 안 엑셀
|
||||
수 ·
|
||||
표 찾기 칸 = 그 표 줄 값 · 그 밖 수 = 자동값 + 같은 이름 계수 + 단위 붙은 엑셀 수(% 칸 = 0 덧 ·
|
||||
「품」 칸 = 엑셀 품 × 결과단위) · 뺌 = 지역 · 보정작업 · 조종원직종.
|
||||
조합 = 곱 ≤ CAP 모두(안 닿는 칸 · 같은 품 값 묶음 셈) · 넘으면 엑셀 조건 낱말로 고르기 좁힘 ·
|
||||
그래도 넘으면 기준점 둘에서 하나씩 + 고르기 둘씩.
|
||||
수준 = 0(0.5%) · 1(R = 0.5% + 엑셀 값 자릿수 반올림 폭) · 2(3%) · 9(없음) — 「딱 맞음」(핵심 + 엑셀
|
||||
몫을
|
||||
다 덮음)만 셈 · 같은 수준이면 거리 작은 것. 규칙 · 문턱은 336 K5(`k5_lib` · `stage_b`) 그대로.
|
||||
엔진 = `M02_Hopyo_Ours.run`(읽기만).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Hopyo_Fit as fit # 손잡이(CAP · TOL) — 부를 때만 씀
|
||||
from M02_MasterTemplete import M02_Hopyo_Ours as ours
|
||||
from M02_MasterTemplete.M02_Hopyo_Words import nfkc, specnums
|
||||
|
||||
FIXED = ("지역", "보정작업", "조종원직종")
|
||||
_NUM = re.compile(r"(?<![\d,.])(\d+(?:\.\d+)?)(?![\d,])")
|
||||
UNITS = {
|
||||
"%": ("%",),
|
||||
"㎝": ("㎝", "cm"),
|
||||
"cm": ("㎝", "cm"),
|
||||
"㎞": ("㎞", "km"),
|
||||
"km": ("㎞", "km"),
|
||||
"㎜": ("㎜", "mm"),
|
||||
"mm": ("㎜", "mm"),
|
||||
"㎥": ("㎥", "m3"),
|
||||
"㎡": ("㎡", "m2"),
|
||||
"m": ("m",),
|
||||
"회": ("회",),
|
||||
"개월": ("개월",),
|
||||
"층": ("층",),
|
||||
"t": ("t", "ton", "톤"),
|
||||
"ton": ("t", "ton", "톤"),
|
||||
"톤": ("t", "ton", "톤"),
|
||||
"분": ("분",),
|
||||
"초": ("초", "sec"),
|
||||
"kg": ("kg", "㎏"),
|
||||
"㎏": ("kg", "㎏"),
|
||||
}
|
||||
UNITW = {"mm", "cm", "kg", "ton", "sec", "kgf", "mpa"}
|
||||
|
||||
|
||||
# ── 엑셀 쪽 수 ─────────────────────────────────────────────────────────
|
||||
def ex_of(efp: dict[str, Any], text: list[str] | None = None) -> dict[str, Any]:
|
||||
"""엑셀 지문 → 옵션 공간 재료(글 · 글머리 · 계수 · 규격 수 · 조건 · 품 값)."""
|
||||
texts = [efp["이름"], efp.get("규격") or ""] + [str(t) for t in text or []]
|
||||
sp: set[float] = set()
|
||||
for m in efp["장비"]:
|
||||
sp |= set(m.get("규격") or [])
|
||||
sp |= specnums(f"{efp['이름']} {efp.get('규격') or ''}")
|
||||
return {
|
||||
"글": texts,
|
||||
"글머리": texts[:8],
|
||||
"계수": efp.get("계수") or {},
|
||||
"규격수": sp,
|
||||
"조건": efp.get("조건") or {},
|
||||
"품값": {Decimal(str(x["품"])) for x in efp["인력"].values() if x.get("품")},
|
||||
}
|
||||
|
||||
|
||||
def excel_numbers(texts: list[str], spec: dict[str, Any] | None = None) -> set[Decimal]:
|
||||
"""글의 수 — spec 이 있으면 그 칸 단위가 뒤에 붙거나 칸 이름이 앞에 붙은 수만(쉼표 단가 뺌)."""
|
||||
out: set[Decimal] = set()
|
||||
flat = [nfkc(t) for t in texts]
|
||||
if spec is None:
|
||||
pats = [_NUM]
|
||||
else:
|
||||
u = nfkc(spec.get("단위")).strip().lower()
|
||||
vs = UNITS.get(u, (u,) if u and len(u) <= 3 and "/" not in u else ())
|
||||
vs = () if u == "%" else vs # 「노무비의 2%」 같은 율이 감률 칸 후보로 드는 것을 막음
|
||||
num = r"(\d+(?:\.\d+)?)"
|
||||
pats = []
|
||||
if vs:
|
||||
alt = "|".join(re.escape(x) for x in vs)
|
||||
pats.append(
|
||||
re.compile(r"(?<![\d,.])" + num + r"\s*(?:" + alt + r")(?![A-Za-z가-힣])", re.I)
|
||||
)
|
||||
pats.append(
|
||||
re.compile(re.escape(nfkc(spec["이름"])) + r"\s*[:=(]?\s*" + num + r"(?![\d,])")
|
||||
)
|
||||
for t in flat:
|
||||
for pat in pats:
|
||||
for n in pat.findall(t):
|
||||
v = Decimal(n)
|
||||
if 0 < v <= 2000:
|
||||
out.add(v.normalize() if v == v.to_integral() else v)
|
||||
return out
|
||||
|
||||
|
||||
def eq_numbers(code: str) -> set[float]:
|
||||
"""기계 규격 수 — 단위 붙은 수 + 맨 수(「41 [80∼95]」 → 41 · 80 · 95)."""
|
||||
one = ours.whole().numbers.get("EQ", {}).get(str(code)) or {}
|
||||
bare = {float(n) for n in re.findall(r"\d+(?:\.\d+)?", nfkc(one.get("규격")))}
|
||||
return ours.eq_of(code)[1] | bare
|
||||
|
||||
|
||||
def _uses(row: dict) -> tuple[dict, set]:
|
||||
try:
|
||||
return m01.mau._uses(row)
|
||||
except Exception: # noqa: BLE001 — 식을 못 읽는 로직은 찾기 칸 없음
|
||||
return {}, set()
|
||||
|
||||
|
||||
def space(code: str, ex: dict[str, Any] | None = None) -> list[tuple[str, list, str]]:
|
||||
"""[(입력 이름, 후보, 갈래)] — 갈래 = 기계 · 고르기 · 범위 · 표 · 수."""
|
||||
whole = ours.whole()
|
||||
row = whole.logic(code)
|
||||
auto = m01.mau.values(whole, row)
|
||||
finds, _ = _uses(row)
|
||||
ex = ex or {}
|
||||
texts = ex.get("글") or []
|
||||
coef = {k.lower(): Decimal(str(v)) for k, v in (ex.get("계수") or {}).items()}
|
||||
spn = ex.get("규격수") or set()
|
||||
times = Decimal(str(ours.times_of(row)))
|
||||
out = []
|
||||
for s in row.get("입력") or []:
|
||||
name, opts = s["이름"], s.get("고르기") or []
|
||||
if name in FIXED:
|
||||
continue
|
||||
if opts and all(isinstance(v, str) and ours.EQ_CODE.match(v) for v in opts):
|
||||
hits = {v: len(eq_numbers(v) & spn) for v in opts} if spn else {}
|
||||
most = max(hits.values(), default=0)
|
||||
keep = [
|
||||
v for v in opts if most and hits[v] == most
|
||||
] # 엑셀 규격 수가 가장 많이 겹치는 기종
|
||||
out.append((name, keep or list(opts), "기계"))
|
||||
elif opts:
|
||||
out.append((name, list(opts), "고르기"))
|
||||
elif isinstance(s.get("범위"), list) and len(s["범위"]) == 2:
|
||||
lo, hi = (Decimal(str(x)) for x in s["범위"])
|
||||
vals = {lo, hi} | {v for v in excel_numbers(texts, s) if lo <= v <= hi}
|
||||
out.append((name, sorted(vals), "범위"))
|
||||
elif name in finds:
|
||||
rows = set()
|
||||
for ref, cond in finds[name]:
|
||||
rows |= set(m01.mau._fits(whole, ref, cond))
|
||||
out.append((name, sorted(rows) or [auto.get(name)], "표"))
|
||||
else:
|
||||
base = auto.get(name)
|
||||
vals = {base} if base is not None else set()
|
||||
if name.lower() in coef:
|
||||
vals.add(coef[name.lower()])
|
||||
vals |= excel_numbers(texts, s) if texts else set()
|
||||
if str(s.get("단위") or "").strip() == "%":
|
||||
vals.add(Decimal(0))
|
||||
if name == "품":
|
||||
vals |= {v * times for v in ex.get("품값", set())}
|
||||
vals.discard(None)
|
||||
out.append((name, sorted(vals, key=lambda v: (v != base, v)) or [base], "수"))
|
||||
return out
|
||||
|
||||
|
||||
def _product(sp: list) -> list[dict]:
|
||||
names = [n for n, _, _ in sp]
|
||||
return [dict(zip(names, vals)) for vals in itertools.product(*[o for _, o, _ in sp])]
|
||||
|
||||
|
||||
def narrow(sp: list, ex: dict[str, Any] | None) -> list:
|
||||
"""고르기 후보를 엑셀 조건 낱말로 좁힘 — 낱말이 든 후보가 있으면 그것만."""
|
||||
words = {w for ws in ((ex or {}).get("조건") or {}).values() for w in ws}
|
||||
flat = re.sub(r"\s+", "", nfkc(" ".join((ex or {}).get("글머리") or [])))
|
||||
out = []
|
||||
for name, opts, kind in sp:
|
||||
if kind == "고르기" and len(opts) > 1:
|
||||
hit = [
|
||||
o
|
||||
for o in opts
|
||||
if any(w in str(o) for w in words) or (len(str(o)) >= 2 and str(o) in flat)
|
||||
]
|
||||
if hit:
|
||||
out.append((name, hit, kind))
|
||||
continue
|
||||
out.append((name, opts, kind))
|
||||
return out
|
||||
|
||||
|
||||
def modes() -> tuple[tuple[str, float, bool], ...]:
|
||||
"""허용 셋 — 0.5% · R(0.5% + 엑셀 값 자릿수 반올림 폭) · 3%."""
|
||||
return (("0.5%", fit.TOL[0], False), ("R", fit.TOL[0], True), ("3%", fit.TOL[2], False))
|
||||
|
||||
|
||||
def combos(
|
||||
sp: list, ex: dict[str, Any] | None = None, cap: int | None = None
|
||||
) -> tuple[list[dict], str]:
|
||||
"""(조합, 어떻게 — 모두 · 좁힘 · 하나씩)."""
|
||||
cap = cap or fit.CAP
|
||||
if (math.prod(len(o) for _, o, _ in sp) if sp else 1) <= cap:
|
||||
return _product(sp), "모두"
|
||||
sp2 = narrow(sp, ex)
|
||||
if math.prod(len(o) for _, o, _ in sp2) <= cap:
|
||||
return _product(sp2), "좁힘"
|
||||
base = {n: o[0] for n, o, _ in sp2}
|
||||
out = [dict(base)] + [{**base, n: v} for n, o, _ in sp2 for v in o[1:]]
|
||||
small = [(n, o) for n, o, k in sp2 if k != "수"]
|
||||
for (n1, o1), (n2, o2) in itertools.combinations(small, 2):
|
||||
if len(o1) * len(o2) <= 400:
|
||||
out += [{**base, n1: a, n2: b} for a in o1 for b in o2]
|
||||
seen, uniq = set(), []
|
||||
for c in out:
|
||||
k = json.dumps(c, sort_keys=True, default=str, ensure_ascii=False)
|
||||
if k not in seen:
|
||||
seen.add(k)
|
||||
uniq.append(c)
|
||||
return uniq[:cap], "하나씩"
|
||||
|
||||
|
||||
def sig(v: dict | None, nd: int = 4) -> str | None:
|
||||
"""품 꼴 서명 — 직종 · 장비(갈래 · 시간) · 자재 · 딸린 로직(유효 nd 자리)."""
|
||||
if v is None:
|
||||
return None
|
||||
r = lambda x: float(f"{x:.{nd}g}") # noqa: E731
|
||||
return json.dumps(
|
||||
[
|
||||
sorted((k, r(q)) for k, q in v["L"].items() if q),
|
||||
sorted((tuple(a), tuple(b), r(q)) for a, b, q in v["X"] if q),
|
||||
sorted((k, r(q)) for k, q in v["M"].items() if q),
|
||||
sorted((k, r(q)) for k, q in v["S"].items() if q),
|
||||
],
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def plan(code: str, ex: dict[str, Any] | None = None) -> tuple[list[dict], str, list, list, dict]:
|
||||
"""(조합, 어떻게, 움직이는 칸, 안 닿는 칸, 값 묶음)."""
|
||||
sp = space(code, ex)
|
||||
if (math.prod(len(o) for _, o, _ in sp) if sp else 1) <= fit.CAP:
|
||||
cb = _product(sp)
|
||||
sg = [sig(ours.run(code, c)) for c in cb]
|
||||
active, inert, cls = [], [], {}
|
||||
for n, o, k in sp:
|
||||
if len(o) < 2:
|
||||
continue
|
||||
ctx: dict[str, dict] = {}
|
||||
for c, s_ in zip(cb, sg, strict=True):
|
||||
rest = {x: str(y) for x, y in c.items() if x != n}
|
||||
ctx.setdefault(json.dumps(rest, sort_keys=True, ensure_ascii=False), {})[
|
||||
str(c[n])
|
||||
] = s_
|
||||
keys = sorted(ctx)
|
||||
prof = {str(v): tuple(ctx[g].get(str(v)) for g in keys) for v in o}
|
||||
if len(set(prof.values())) == 1:
|
||||
inert.append(n)
|
||||
continue
|
||||
active.append(n)
|
||||
if k != "고르기":
|
||||
ids: dict = {}
|
||||
cls[n] = {v: ids.setdefault(pf, len(ids)) for v, pf in prof.items()}
|
||||
return cb, "모두", active, inert, cls
|
||||
base = {n: o[0] for n, o, _ in sp}
|
||||
alt = {n: o[-1] for n, o, _ in sp}
|
||||
act, inert = [], []
|
||||
for n, o, k in sp:
|
||||
if len(o) == 1:
|
||||
continue
|
||||
s0 = {sig(ours.run(code, {**base, n: v})) for v in o}
|
||||
s1 = {sig(ours.run(code, {**alt, n: v})) for v in o} if len(s0) == 1 else s0
|
||||
if len(s0) == 1 and len(s1) == 1:
|
||||
inert.append(n)
|
||||
continue
|
||||
if k != "고르기": # 같은 표 줄에 드는 수는 한 값으로 — 두 기준점 품이 같으면 같은 값
|
||||
keep, seen = [], set()
|
||||
for v in o:
|
||||
pair = (sig(ours.run(code, {**base, n: v})), sig(ours.run(code, {**alt, n: v})))
|
||||
if pair not in seen:
|
||||
seen.add(pair)
|
||||
keep.append(v)
|
||||
o = keep
|
||||
act.append((n, o, k))
|
||||
cb, how = combos(act, ex)
|
||||
return [{**base, **c} for c in cb], how, [n for n, _, _ in act], inert, {}
|
||||
|
||||
|
||||
def vary(hits: list[dict], act: list[str], cls: dict) -> list[str]:
|
||||
"""맞은 조합들 사이에서 값이 갈리는 칸 — 수 칸은 같은 값 묶음이면 한 값."""
|
||||
return [
|
||||
n for n in act if len({cls.get(n, {}).get(str(g.get(n)), str(g.get(n))) for g in hits}) > 1
|
||||
]
|
||||
|
||||
|
||||
# ── 맞대기 ──────────────────────────────────────────────────────────────
|
||||
def near(a: Any, b: Any, t: float, r: float = 0.0) -> bool:
|
||||
"""a(엑셀) ≈ b(우리) — 비 t 안 · r = 엑셀 값 자릿수 반올림 폭."""
|
||||
return bool(a and b and a > 0 and b > 0 and abs(a - b) <= max(t * max(a, b), r))
|
||||
|
||||
|
||||
def ru(x: float) -> float:
|
||||
"""엑셀 값 반올림 폭 — 소수 둘째~넷째 자리로 적힌 값만 · 값의 4% 안."""
|
||||
s = repr(float(x))
|
||||
if "e" in s or "." not in s:
|
||||
return 0.0
|
||||
d = len(s.split(".")[1].rstrip("0"))
|
||||
return min(0.5 * 10 ** (-d), 0.04 * float(x)) if 2 <= d <= 4 else 0.0
|
||||
|
||||
|
||||
def mat_words(name: Any) -> set[str]:
|
||||
"""자재 이름 낱말 — 한글 두 자 넘는 덩이 · 영문 세 자 넘는 덩이 · 단위 낱말 뺌."""
|
||||
t = nfkc(name).lower().replace(".", "")
|
||||
return {w for w in re.findall(r"[가-힣]{2,}|[a-z]{3,}", t) if w not in UNITW}
|
||||
|
||||
|
||||
def _named(ws: set[str], k: str) -> bool:
|
||||
w = mat_words(k)
|
||||
return bool(ws & w) or any(a in b or b in a for a in ws for b in w)
|
||||
|
||||
|
||||
def compare(
|
||||
e: dict, v: dict | None, t: float, base: bool = False, rnd: bool = False
|
||||
) -> dict | None:
|
||||
"""엑셀 지문 ↔ 우리 셈 → {직종, 장비, 자재, 핵심, 직종만, 덧} · base = 엑셀 밑품으로."""
|
||||
if v is None:
|
||||
return None
|
||||
tol = lambda x: ru(x) if rnd else 0 # noqa: E731
|
||||
E = {k: (x["밑품"] if base else x["품"]) for k, x in e["인력"].items() if x.get("품")}
|
||||
ov = {k: q for k, q in v["L"].items() if q and q > 0}
|
||||
out: dict[str, Any] = {"덧": []}
|
||||
if E:
|
||||
same = set(E) == set(ov)
|
||||
ok = same and all(near(E[k], ov[k], t, tol(E[k])) for k in E)
|
||||
out["직종"] = "같음" if ok else "직종 같음" if same else "다름"
|
||||
else:
|
||||
out["직종"] = "우리만" if ov else "없음"
|
||||
EX = [m for m in e["장비"] if m.get("시간")]
|
||||
OX = [(set(a), q) for a, _, q in v["X"] if q]
|
||||
if EX and OX:
|
||||
ok = []
|
||||
for m in EX:
|
||||
mine = [q for a, q in OX if a & set(m["갈래"])]
|
||||
if not mine:
|
||||
out["덧"].append("엑셀만 장비 " + "·".join(sorted(m["갈래"])))
|
||||
continue
|
||||
h = m["밑시간"] if base and m.get("밑시간") else m["시간"]
|
||||
hit = any(near(h, q, t, tol(h)) for q in mine) or near(h, sum(mine), t, tol(h))
|
||||
ok.append(hit)
|
||||
et = set().union(*[set(m["갈래"]) for m in e["장비"]])
|
||||
out["덧"] += ["우리만 장비 " + "·".join(sorted(a)) for a, _ in OX if not a & et]
|
||||
out["장비"] = ("같음" if all(ok) else "다름") if ok else "갈래 안 겹침"
|
||||
else:
|
||||
out["장비"] = "엑셀만" if EX else "우리만" if OX else "없음"
|
||||
EM = [m for m in e["자재"] if m.get("품")]
|
||||
OM = {k: q for k, q in v["M"].items() if q}
|
||||
if EM and OM:
|
||||
ok = []
|
||||
for m in EM:
|
||||
mine = [q for k, q in OM.items() if _named(mat_words(m["이름"]), k)]
|
||||
if mine:
|
||||
x = m["밑품"] if base and m.get("밑품") else m["품"]
|
||||
ok.append(any(near(x, q, t, tol(x)) for q in mine))
|
||||
out["자재"] = ("같음" if all(ok) else "다름") if ok else "이름 안 겹침"
|
||||
else:
|
||||
out["자재"] = "엑셀만" if EM else "없음"
|
||||
core = [out["직종"] == "같음"] if E else [False] if ov else []
|
||||
core += [out[k] == "같음" for k in ("장비", "자재") if out[k] in ("같음", "다름")]
|
||||
out["핵심"] = bool(core) and all(core)
|
||||
out["직종만"] = bool(E) and out["직종"] == "같음"
|
||||
return out
|
||||
|
||||
|
||||
def strong(e: dict, c: dict | None) -> bool:
|
||||
"""딱 맞음 — 핵심 + 엑셀 몫을 다 덮음(엑셀만 · 우리만 장비 없음 · 자재 다름 없음 ·
|
||||
「보통인부 하나뿐 · 장비 없음」 줄 아님)."""
|
||||
if not c or not c.get("핵심"):
|
||||
return False
|
||||
if c.get("장비") in ("엑셀만", "우리만", "갈래 안 겹침"):
|
||||
return False
|
||||
if any("엑셀만 장비" in d or "우리만 장비" in d for d in c.get("덧") or []):
|
||||
return False
|
||||
if c.get("자재") == "다름":
|
||||
return False
|
||||
labor = [k for k, x in e["인력"].items() if x.get("품")]
|
||||
return not (labor == ["보통인부"] and not any(m.get("시간") for m in e["장비"]))
|
||||
|
||||
|
||||
def gap(e: dict, v: dict | None, base: bool = False) -> float:
|
||||
"""닮음 거리 — 직종 묶음 어긋남 + 품 |ln 비| 평균 + 장비 시간 반 무게."""
|
||||
if v is None:
|
||||
return 99.0
|
||||
E = {k: (x["밑품"] if base else x["품"]) for k, x in e["인력"].items() if x.get("품")}
|
||||
ov = {k: q for k, q in v["L"].items() if q and q > 0}
|
||||
d = 0.0
|
||||
if E or ov:
|
||||
d += len(set(E) ^ set(ov)) / len(set(E) | set(ov))
|
||||
both = [min(1.0, abs(math.log(E[k] / ov[k]))) for k in set(E) & set(ov)]
|
||||
d += sum(both) / len(both) if both else 1.0
|
||||
EX = [m for m in e["장비"] if m.get("시간")]
|
||||
OX = [(set(a), q) for a, _, q in v["X"] if q]
|
||||
if EX and OX:
|
||||
hs = []
|
||||
for m in EX:
|
||||
mine = [q for a, q in OX if a & set(m["갈래"])]
|
||||
h = m["밑시간"] if base and m.get("밑시간") else m["시간"]
|
||||
hs.append(min(1.0, min(abs(math.log(h / q)) for q in mine)) if mine else 1.0)
|
||||
d += 0.5 * sum(hs) / len(hs)
|
||||
return d
|
||||
|
||||
|
||||
def csig(e: dict, v: dict | None, nd: int = 4) -> str | None:
|
||||
"""엑셀이 보이는 몫만의 품 서명 — 직종 · 엑셀 갈래와 겹치는 장비 · 엑셀 이름과 겹치는 자재."""
|
||||
if v is None:
|
||||
return None
|
||||
r = lambda x: float(f"{x:.{nd}g}") # noqa: E731
|
||||
et = set().union(*[set(m["갈래"]) for m in e["장비"]]) if e["장비"] else set()
|
||||
em = [mat_words(m["이름"]) for m in e["자재"] if m.get("품")]
|
||||
return json.dumps(
|
||||
[
|
||||
sorted((k, r(q)) for k, q in v["L"].items() if q),
|
||||
sorted((tuple(a), r(q)) for a, _, q in v["X"] if q and set(a) & et),
|
||||
sorted((k, r(q)) for k, q in v["M"].items() if q and any(_named(ws, k) for ws in em)),
|
||||
],
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def ours_qty(v: dict | None) -> dict[str, Any]:
|
||||
"""우리 품(화면 꼴) — {인력: {직종: 품}, 장비: [{갈래, 시간}], 자재: [{이름, 수량}]}."""
|
||||
if v is None:
|
||||
return {"인력": {}, "장비": [], "자재": []}
|
||||
return {
|
||||
"인력": {k: round(q, 6) for k, q in v["L"].items() if q},
|
||||
"장비": [{"갈래": list(a), "시간": round(q, 6)} for a, _, q in v["X"] if q],
|
||||
"자재": [{"이름": k, "수량": round(q, 6)} for k, q in v["M"].items() if q],
|
||||
}
|
||||
|
||||
|
||||
def _plain(value: Any) -> Any:
|
||||
if isinstance(value, Decimal):
|
||||
value = value.normalize()
|
||||
return int(value) if value == value.to_integral_value() else float(value)
|
||||
return value
|
||||
|
||||
|
||||
def find(code: str, efp: dict[str, Any], text: list[str] | None = None) -> dict[str, Any]:
|
||||
"""로직 하나의 옵션 거꾸로 찾기 → {수준, 판정, 입력, 갈리는칸, 견줌, 우리품, 거리, 섞기전}."""
|
||||
none = {"수준": 9, "판정": "없음", "입력": {}, "갈리는칸": [], "견줌": None}
|
||||
none.update(우리품=ours_qty(None), 거리=99.0, 섞기전=False, 직종같음=False)
|
||||
try:
|
||||
ex = ex_of(efp, text)
|
||||
cb, how, act, _, cls = plan(code, ex)
|
||||
except Exception: # noqa: BLE001 — 로직 없음 · 식 못 읽음
|
||||
return {**none, "판정": "못 셈"}
|
||||
best = (9, 9, 99.0, None, None)
|
||||
hits: dict[int, list] = {0: [], 1: [], 2: []}
|
||||
for g in cb:
|
||||
v = ours.run(code, g)
|
||||
if v is None or not (v["L"] or v["X"] or v["M"]):
|
||||
continue
|
||||
lvl = weak = 9
|
||||
for i, (_, t, rnd) in enumerate(modes()):
|
||||
c = compare(efp, v, t, rnd=rnd) or {}
|
||||
if c.get("핵심"):
|
||||
weak = min(weak, i)
|
||||
if strong(efp, c):
|
||||
hits[i].append((g, csig(efp, v)))
|
||||
lvl = min(lvl, i)
|
||||
d = gap(efp, v)
|
||||
if (lvl, weak, d) < best[:3]:
|
||||
best = (lvl, weak, d, g, v)
|
||||
lvl, _, d, g, v = best
|
||||
if g is None:
|
||||
return none
|
||||
got = [h for h in hits.get(lvl, [])] if lvl < 9 else []
|
||||
split = vary([h for h, _ in got], act, cls)
|
||||
if not got:
|
||||
verdict = "없음"
|
||||
elif len({s for _, s in got}) > 1:
|
||||
verdict = "여럿(다른 품)"
|
||||
else:
|
||||
verdict = "여럿(같은 품)" if split else "하나만"
|
||||
auto = m01.mau.values(ours.whole(), ours.whole().logic(code)) # 움직인 칸 + 자동값과 다른 칸
|
||||
base = compare(efp, v, fit.TOL[2], base=True) or {}
|
||||
labor = {k for k, x in efp["인력"].items() if x.get("품")}
|
||||
return {
|
||||
"수준": lvl,
|
||||
"판정": verdict,
|
||||
"입력": {n: _plain(x) for n, x in g.items() if n in act or str(x) != str(auto.get(n))},
|
||||
"모든입력": {n: _plain(x) for n, x in g.items()},
|
||||
"갈리는칸": split,
|
||||
"견줌": compare(efp, v, fit.TOL[2]),
|
||||
"직종같음": bool(v["L"]) and set(k for k, q in v["L"].items() if q) == labor,
|
||||
"우리품": ours_qty(v),
|
||||
"거리": round(d, 4),
|
||||
"섞기전": lvl == 9 and bool(base.get("핵심")),
|
||||
"어떻게": how,
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
"""M02 일위대가 맞춤 — 우리 돈 로직 지문 · 마스터 판 열쇠 · 디스크 캐시 · 엔진 셈 캐시
|
||||
(PLAN 338-1 · 설계 2-3).
|
||||
|
||||
지문 = 정적(호표 인력 → 직종 · 「로직(기계경비 …)」 → 장비 갈래 · 규격 · 재료 이름 · 덧줄 % ·
|
||||
이름 · 고르기 글 조건 낱말 · 기계경비 로직) + 변형(고르기 입력 곱 64 이하 모두 · 넘으면 하나씩 ·
|
||||
지역 · 조종원직종 · 보정작업 · 기종 목록 칸 뺌)마다 단위당 L(직종 인) · X(장비 시간) · M(자재) ·
|
||||
S(딸린 로직) · mix(인력비율 · 기계비율).
|
||||
캐시 = `tmp/m02_hopyo/우리지문_<판>.json`(환경 `AISLO_HOPYO_CACHE` 로 바꿈) · 판 = 마스터 첫 층 JSON
|
||||
sha256 묶음 · 처음 셈은 뒤 스레드(≈ 2 분) · 다음은 읽기만.
|
||||
엔진 = `Labor_Doc.run_line` 과 같은 길(자동값 + 입력 → `mf.run`) · 셈 잠금 하나(뒤 셈 · 요청 겹침) ·
|
||||
메모리 캐시. 원본(`resources/master_data`) 읽기만. 규칙은 336 실험(C4 `s2_ours_fp` · K5 `run`)
|
||||
그대로.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import itertools
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M01_MasterData import M01_MasterData_Store_Cache as m01_cache
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
|
||||
from M02_MasterTemplete.M02_Hopyo_Words import conds, mtypes, specnums
|
||||
|
||||
FP_VERSION = 1 # 지문 꼴이 바뀌면 올림 — 옛 캐시 버림
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
_PER = re.compile(r"원\s*/\s*(\d[\d,]*\.?\d*|\.\d+)?")
|
||||
_SUB = re.compile(r"^\s*로직\(\s*(\w+)\s*(?:,(.*))?\)\s*$")
|
||||
EQ_CODE = re.compile(r"^\d{4}-\d{4}$")
|
||||
_PCT = re.compile(r"\*\s*([\d.]+)\s*/\s*100")
|
||||
SKIP_IN = ("지역", "조종원직종", "보정작업")
|
||||
FIXED_MACH = {
|
||||
"GC000267": {"굴착기"},
|
||||
"GC000270": {"굴착기", "집게"},
|
||||
"GC000271": {"굴착기", "브레이커"},
|
||||
"GF000145": {"덤프트럭"},
|
||||
}
|
||||
RUN_CAP = 50_000 # ponytail: 엔진 셈 캐시 칸 수 — 넘으면 통째 비움(LRU 는 필요해지면)
|
||||
_ENGINE = threading.RLock() # ponytail: 셈 잠금 하나 — 엔진을 뒤 · 요청 스레드가 같이 부를 때
|
||||
_STATE: dict[str, Any] = {"판": None, "자국": None, "표": None, "스레드": None}
|
||||
_RUN: dict[tuple, Any] = {}
|
||||
_LOCK = threading.Lock()
|
||||
_NO_PRICE = {str(i): "-" for i in range(400)} # 고른 자재 = 없는 키 → 값 없음 · 찾기 안 함
|
||||
WHOLE_TTL = 2.0 # ponytail: 마스터 색인을 2 초 붙듦 — 줄마다 파일 자국을 다시 안 봄
|
||||
|
||||
|
||||
def folder() -> Path:
|
||||
return Path(os.environ.get("AISLO_HOPYO_CACHE") or ROOT / "tmp" / "m02_hopyo")
|
||||
|
||||
|
||||
# ── 마스터 판 열쇠 ─────────────────────────────────────────────────────
|
||||
def version() -> str:
|
||||
"""마스터 판 = 첫 층 *.json(밑줄 뺌) 이름 + sha256 16 묶음의 sha256 앞 16 — 자국이 같으면 다시
|
||||
안 셈."""
|
||||
stamps = m01_cache._stamps(Path(m01.FOLDER))
|
||||
if _STATE["자국"] != stamps:
|
||||
parts = [
|
||||
f"{name}:{m01.version_of((Path(m01.FOLDER) / name).read_bytes())}" for name in stamps
|
||||
]
|
||||
_STATE.update(자국=stamps, 판=hashlib.sha256("\n".join(parts).encode()).hexdigest()[:16])
|
||||
return _STATE["판"]
|
||||
|
||||
|
||||
def path_of(ver: str | None = None) -> Path:
|
||||
return folder() / f"우리지문_{ver or version()}.json"
|
||||
|
||||
|
||||
# ── 엔진 한 번 ─────────────────────────────────────────────────────────
|
||||
def whole() -> Any:
|
||||
now = time.monotonic()
|
||||
if _STATE.get("색인") is None or now - _STATE.get("색인때", 0) > WHOLE_TTL:
|
||||
_STATE.update(색인=m01.loaded()[1], 색인때=now)
|
||||
return _STATE["색인"]
|
||||
|
||||
|
||||
def is_mach_logic(row: dict[str, Any]) -> bool:
|
||||
return "시간당" in str(row.get("이름") or "")
|
||||
|
||||
|
||||
def eq_of(code: Any) -> tuple[set[str], set[float]]:
|
||||
one = whole().numbers.get("EQ", {}).get(str(code)) or {}
|
||||
if not one:
|
||||
return set(), set()
|
||||
return mtypes(f"{one.get('상세구분')} {one.get('이름')}"), specnums(one.get("규격"))
|
||||
|
||||
|
||||
def times_of(row: dict[str, Any]) -> float:
|
||||
per = _PER.findall(str(row.get("결과단위") or ""))
|
||||
return float(per[0].replace(",", "")) if per and per[0] else 1.0
|
||||
|
||||
|
||||
def _args(text: str | None) -> dict[str, str]:
|
||||
out = {}
|
||||
for part in re.split(r",(?![^()]*\))", text or ""):
|
||||
if "=" in part:
|
||||
k, v = part.split("=", 1)
|
||||
out[k.strip()] = v.strip()
|
||||
return out
|
||||
|
||||
|
||||
def _labor_names() -> dict[str, str]:
|
||||
w = whole()
|
||||
if _STATE.get("LB판") is not w:
|
||||
lb = {str(r.get("키")): str(r.get("이름")) for r in w.numbers.get("LB", {}).values()}
|
||||
_STATE.update(LB판=w, LB=lb)
|
||||
return _STATE["LB"]
|
||||
|
||||
|
||||
def _engine(code: str, given: dict[str, Any]) -> dict[str, Any]:
|
||||
"""{L, X, M, S, mix} — 줄 수량 ÷ 결과단위 수(단위당)."""
|
||||
w = whole()
|
||||
row = w.logic(code)
|
||||
times = times_of(row)
|
||||
by_name: dict[Any, dict] = {}
|
||||
for h in row.get("호표") or []:
|
||||
by_name.setdefault(h.get("이름"), h)
|
||||
auto = {k: v for k, v in m01.mau.values(w, row).items() if v is not None}
|
||||
with _ENGINE, m01.cm.mf.mp.choosing(_NO_PRICE): # 품만 씀 — 자재 값 찾기 건너뜀(수량 같음)
|
||||
got = m01.cm.mf.run(w, code, {**auto, **m01._dec(given)})
|
||||
mid, lb = got.get("중간") or {}, _labor_names()
|
||||
L: dict[str, float] = {}
|
||||
X: list[list] = []
|
||||
M: dict[str, float] = {}
|
||||
S: dict[str, float] = {}
|
||||
for line in got.get("줄") or []:
|
||||
h = by_name.get(line.get("이름"))
|
||||
if h is None:
|
||||
continue
|
||||
try:
|
||||
q = float(Decimal(str(line.get("수량") or 0))) / times
|
||||
except ArithmeticError:
|
||||
continue
|
||||
kind = h.get("종류")
|
||||
if kind == "인력":
|
||||
t = lb.get(str(h.get("요소")), str(h.get("이름")))
|
||||
L[t] = L.get(t, 0.0) + q
|
||||
elif kind == "로직":
|
||||
m = _SUB.match(str(h.get("요소") or ""))
|
||||
sub = m.group(1) if m else ""
|
||||
sub_row = w.logic(sub) if sub else {}
|
||||
if sub_row and is_mach_logic(sub_row):
|
||||
ts = set(FIXED_MACH.get(sub, set())) | mtypes(h.get("이름"))
|
||||
sp = specnums(h.get("이름"))
|
||||
for v in _args(m.group(2)).values():
|
||||
lit = v.strip("'\"")
|
||||
if not EQ_CODE.match(lit):
|
||||
lit = str(mid.get(lit) or given.get(lit) or auto.get(lit) or "")
|
||||
if EQ_CODE.match(lit):
|
||||
a, b = eq_of(lit)
|
||||
ts |= a
|
||||
sp |= b
|
||||
if q:
|
||||
X.append([sorted(ts), sorted(sp), q])
|
||||
elif q:
|
||||
S[sub] = S.get(sub, 0.0) + q
|
||||
elif kind == "재료" and q:
|
||||
M[str(h.get("이름"))] = M.get(str(h.get("이름")), 0.0) + q
|
||||
mix = {}
|
||||
for k in ("인력비율", "기계비율"):
|
||||
try:
|
||||
if mid.get(k) not in (None, ""):
|
||||
mix[k] = float(Decimal(str(mid.get(k))))
|
||||
except ArithmeticError:
|
||||
pass
|
||||
return {"L": L, "X": X, "M": M, "S": S, "mix": mix}
|
||||
|
||||
|
||||
def _mach_view(code: str, row: dict, given: dict, v: dict) -> dict:
|
||||
"""「기계 시간당 기계경비」 = 고른 기계(EQ 칸)의 갈래 · 규격 1 시간(조종원은 기계경비 속)."""
|
||||
auto = m01.mau.values(whole(), row)
|
||||
X = []
|
||||
for s in row.get("입력") or []:
|
||||
opts = s.get("고르기") or []
|
||||
if opts and all(isinstance(o, str) and EQ_CODE.match(o) for o in opts):
|
||||
ts, sp = eq_of(given.get(s["이름"], auto.get(s["이름"])))
|
||||
X.append([sorted(ts | FIXED_MACH.get(code, set())), sorted(sp), 1.0])
|
||||
if not X and FIXED_MACH.get(code):
|
||||
X = [[sorted(FIXED_MACH[code]), [], 1.0]]
|
||||
return {**v, "L": {}, "X": X}
|
||||
|
||||
|
||||
def run(code: str, given: dict[str, Any] | None = None) -> dict[str, Any] | None:
|
||||
"""엔진 한 번 → {L, X, M, S, mix} · 못 돌면 None(메모리 캐시)."""
|
||||
given = dict(given or {})
|
||||
key = (code, json.dumps(given, sort_keys=True, default=str, ensure_ascii=False))
|
||||
if key not in _RUN:
|
||||
try:
|
||||
v = _engine(code, given)
|
||||
row = whole().logic(code)
|
||||
v = _mach_view(code, row, given, v) if is_mach_logic(row) else v
|
||||
except Exception: # noqa: BLE001 — 못 도는 조합은 없음으로(엔진 예외가 여럿)
|
||||
v = None
|
||||
if len(_RUN) >= RUN_CAP:
|
||||
_RUN.clear()
|
||||
_RUN[key] = v
|
||||
return _RUN[key]
|
||||
|
||||
|
||||
# ── 지문 ───────────────────────────────────────────────────────────────
|
||||
def _static(code: str, row: dict[str, Any], unit: str) -> dict[str, Any]:
|
||||
picks = [s for s in row.get("입력") or [] if s.get("고르기")]
|
||||
labels, opt_mach = [], []
|
||||
for s in picks:
|
||||
names = s.get("고르기이름") or {}
|
||||
for v in s.get("고르기") or []:
|
||||
if isinstance(v, str) and EQ_CODE.match(v):
|
||||
ts, sp = eq_of(v)
|
||||
if ts:
|
||||
opt_mach.append([sorted(ts), sorted(sp)])
|
||||
else:
|
||||
labels.append(str(names.get(v) or v))
|
||||
lb = _labor_names()
|
||||
trades, mach, mats = set(), [], []
|
||||
for h in row.get("호표") or []:
|
||||
kind = h.get("종류")
|
||||
if kind == "인력":
|
||||
trades.add(lb.get(str(h.get("요소")), str(h.get("이름"))))
|
||||
elif kind == "로직":
|
||||
m = _SUB.match(str(h.get("요소") or ""))
|
||||
ts = set(FIXED_MACH.get(m.group(1) if m else "", set())) | mtypes(h.get("이름"))
|
||||
for v in _args(m.group(2) if m else "").values():
|
||||
if EQ_CODE.match(v.strip("'\"")):
|
||||
ts |= eq_of(v.strip("'\""))[0]
|
||||
if ts:
|
||||
mach.append([sorted(ts), sorted(specnums(h.get("이름")))])
|
||||
elif kind == "재료":
|
||||
el = h.get("요소")
|
||||
words = (
|
||||
(el.get("검색어") or el.get("이름") or el.get("상세구분"))
|
||||
if isinstance(el, dict)
|
||||
else ""
|
||||
)
|
||||
mats.append(f"{h.get('이름')} {words or ''}".strip())
|
||||
pct = [
|
||||
[str(d.get("이름")), float(v)]
|
||||
for d in row.get("덧줄") or []
|
||||
for v in _PCT.findall(str(d.get("식") or ""))
|
||||
]
|
||||
if is_mach_logic(row):
|
||||
mach.append([sorted(FIXED_MACH.get(code, set())), []])
|
||||
pick_text = " ".join(labels + [str(s.get("이름")) for s in picks])
|
||||
return {
|
||||
"로직": code,
|
||||
"이름": str(row.get("이름") or ""),
|
||||
"단위": unit,
|
||||
"직종": sorted(trades),
|
||||
"장비": mach,
|
||||
"장비고름": opt_mach,
|
||||
"자재": mats,
|
||||
"덧줄율": pct,
|
||||
"이름조건": {k: sorted(v) for k, v in conds(row.get("이름")).items()},
|
||||
"고름조건": {k: sorted(v) for k, v in conds(pick_text).items()},
|
||||
"기계경비": is_mach_logic(row),
|
||||
}
|
||||
|
||||
|
||||
def combos(row: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""변형 입력 — 고르기 곱 64 이하 모두 · 넘으면 하나씩(기본 + 칸마다)."""
|
||||
picks = []
|
||||
for s in row.get("입력") or []:
|
||||
opts = s.get("고르기") or []
|
||||
if not opts or any(w in str(s.get("이름")) for w in SKIP_IN):
|
||||
continue
|
||||
if any(isinstance(v, str) and EQ_CODE.match(v) for v in opts):
|
||||
continue
|
||||
picks.append((str(s["이름"]), list(opts)))
|
||||
if not picks:
|
||||
return [{}]
|
||||
if math.prod(len(o) for _, o in picks) <= 64:
|
||||
names = [n for n, _ in picks]
|
||||
return [dict(zip(names, vals)) for vals in itertools.product(*[o for _, o in picks])]
|
||||
return [{}] + [{n: v} for n, opts in picks for v in opts]
|
||||
|
||||
|
||||
def _sig(v: dict) -> str:
|
||||
r = lambda x: float(f"{x:.4g}") # noqa: E731
|
||||
return json.dumps(
|
||||
[
|
||||
sorted((k, r(q)) for k, q in v["L"].items()),
|
||||
sorted((tuple(a), tuple(b), r(q)) for a, b, q in v["X"]),
|
||||
sorted((k, r(q)) for k, q in v["M"].items()),
|
||||
],
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def fingerprint(code: str, unit: str) -> dict[str, Any]:
|
||||
"""로직 하나 → 정적 지문 + 변형(서로 다른 품 꼴만)."""
|
||||
row = whole().logic(code)
|
||||
fp = _static(code, row, unit)
|
||||
seen, variants, errors = set(), [], 0
|
||||
for given in combos(row) if not fp["기계경비"] else [{}]:
|
||||
try:
|
||||
v = _engine(code, given) # 기계경비 로직도 엔진 그대로(맞춤이 고르기 장비로 봄)
|
||||
except Exception: # noqa: BLE001 — 못 도는 조합은 셈만
|
||||
errors += 1
|
||||
continue
|
||||
if (k := _sig(v)) not in seen:
|
||||
seen.add(k)
|
||||
variants.append({"in": {n: str(x) for n, x in given.items()}, **v})
|
||||
return {**fp, "갈래수": len(variants), "오류": errors, "변형": variants}
|
||||
|
||||
|
||||
# ── 표 · 캐시 · 데움 ───────────────────────────────────────────────────
|
||||
def _read(ver: str) -> list[dict] | None:
|
||||
try:
|
||||
doc = json.loads(path_of(ver).read_bytes())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
ok = isinstance(doc, dict) and doc.get("판") == ver and doc.get("지문판") == FP_VERSION
|
||||
return doc.get("표") if ok else None
|
||||
|
||||
|
||||
def build(ver: str | None = None) -> list[dict]:
|
||||
"""1,438 돈 로직 지문을 셈 → 디스크 캐시(같은 판이면 덮음)."""
|
||||
ver = ver or version()
|
||||
table = [fingerprint(one["로직"], one["단위"]) for one in hopyo_match.targets()["로직"]]
|
||||
atomic_write_json(path_of(ver), {"판": ver, "지문판": FP_VERSION, "표": table})
|
||||
return table
|
||||
|
||||
|
||||
def table() -> list[dict] | None:
|
||||
"""우리 지문(판이 같은 것) · 준비 전 None — 디스크에 있으면 읽어 둠."""
|
||||
ver = version()
|
||||
with _LOCK:
|
||||
if _STATE.get("표판") != ver:
|
||||
_STATE.update(표=_read(ver), 표판=ver)
|
||||
return _STATE["표"]
|
||||
|
||||
|
||||
def ready() -> bool:
|
||||
return table() is not None
|
||||
|
||||
|
||||
def warm(build_missing: bool = False) -> threading.Thread | None:
|
||||
"""디스크 캐시 읽기(뒤) · `build_missing` 이면 없을 때 뒤 스레드로 셈 — 셈 중이면 그 스레드."""
|
||||
running = _STATE.get("스레드")
|
||||
if running is not None and running.is_alive():
|
||||
return running
|
||||
|
||||
def work() -> None:
|
||||
try:
|
||||
if table() is None and build_missing:
|
||||
ver = version()
|
||||
got = build(ver)
|
||||
with _LOCK:
|
||||
_STATE.update(표=got, 표판=ver)
|
||||
except Exception: # noqa: BLE001 — 마스터가 깨져도 서버를 막지 않음(보기에서 「준비 중」)
|
||||
pass
|
||||
|
||||
thread = threading.Thread(target=work, name="m02-hopyo-ours", daemon=True)
|
||||
_STATE["스레드"] = thread
|
||||
thread.start()
|
||||
return thread
|
||||
@@ -0,0 +1,177 @@
|
||||
"""M02 일위대가 맞춤 몫(PLAN 338-1 · 설계 2-2 · 2-7) — 단가산출 블록을 맨 윗 절(1. 2. 3.)
|
||||
· 작은 절(가. 나.)로 갈라 몫마다 노임(직종 → 인) · 장비(「굴착기|0.6」 → 시간) 벡터.
|
||||
|
||||
엑셀꼴 = 「직종 : 노임 × 식 = 금액」(식을 못 풀면 금액 ÷ 노임) · 장비 이름 줄 뒤 「노무비 : 단가 ÷ Q
|
||||
× 몫」 ·
|
||||
소계 · 합계 절 뺌. STmate = 자원 L · X(절 = 자원 `절` · 없으면 한 몫). 금액 안 씀.
|
||||
규칙은 336 실험(C5 `c01_trees.sections` · `sections_stm`) 그대로 · 장비 갈래는
|
||||
`M02_Hopyo_Words.mtypes`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import operator
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
|
||||
from M02_MasterTemplete.M02_Hopyo_Words import mtypes, nfkc
|
||||
|
||||
_SIZE = re.compile(r"(\d+(?:\.\d+)?)\s*(㎥|m3|톤|ton|t\b|kw|hp)", re.I)
|
||||
_OPS = {
|
||||
ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv,
|
||||
ast.Add: operator.add,
|
||||
ast.Sub: operator.sub,
|
||||
}
|
||||
_TRADE = re.compile(
|
||||
r"^[\s*·\-○◇]*([가-힣][가-힣\s]*?)\s*:\s*([\d,]+(?:\.\d+)?)\s*(.*?)(?:=\s*([\d,]+(?:\.\d+)?))?\s*$"
|
||||
)
|
||||
_SECTION = re.compile(r"^\s*(\d+)\s*\.\s*(.*)$")
|
||||
_EQ_COST = re.compile(r"^\s*노\s*무\s*비\s*:\s*([\d,]+(?:\.\d+)?)\s*(.*?)(?:=\s*[\d,.]+)?\s*$")
|
||||
_SKIP_SEC = re.compile(r"^(합\s*계|소\s*계|계)")
|
||||
_SUBLABEL = re.compile(r"^\s*[가-하]\s*\.\s*(.*)$")
|
||||
_COSTLINE = re.compile(r"^\s*(노\s*무\s*비|재\s*료\s*비|경\s*비)\s*:")
|
||||
|
||||
|
||||
def trade(name: Any) -> str | None:
|
||||
"""노임 이름 → 우리 인력 이름(빈칸 · 옛이름 · 괄호 글 풀이) · 아니면 None."""
|
||||
names = hopyo_basis.trade_names()
|
||||
flat = re.sub(r"\s+", "", str(name or ""))
|
||||
return names.get(flat) or names.get(re.sub(r"\(.*?\)", "", flat))
|
||||
|
||||
|
||||
def eq_key(text: Any) -> str | None:
|
||||
"""장비 글 → 「굴착기|0.6」(갈래 + 로 묶음 · 크기 없으면 「굴착기|」) · 장비 아니면 None."""
|
||||
kinds = mtypes(text)
|
||||
if not kinds:
|
||||
return None
|
||||
m = _SIZE.search(nfkc(text).replace(",", ""))
|
||||
size = (
|
||||
m.group(1).rstrip("0").rstrip(".") if m and "." in m.group(1) else m.group(1) if m else ""
|
||||
)
|
||||
return f"{'+'.join(sorted(kinds))}|{size}"
|
||||
|
||||
|
||||
def _eval(node: ast.AST) -> float:
|
||||
if isinstance(node, ast.Expression):
|
||||
return _eval(node.body)
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
|
||||
return float(node.value)
|
||||
if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
|
||||
return _OPS[type(node.op)](_eval(node.left), _eval(node.right))
|
||||
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
|
||||
return -_eval(node.operand)
|
||||
raise ValueError("식 아님")
|
||||
|
||||
|
||||
def qty_of(expr: str) -> float | None:
|
||||
"""「273,540 × 3인 ÷ 55㎥ × 2」 → 3/55×2 · 「59,020 ÷ 23.1 × 90%」 → 1/23.1×0.9."""
|
||||
s = re.sub(r"\([^()0-9]*\)", "", str(expr)).replace(",", "")
|
||||
s = re.sub(r"(\d+(?:\.\d+)?)\s*%", r"(\1/100)", s)
|
||||
s = s.replace("×", "*").replace("÷", "/").replace("X", "*").replace("x", "*")
|
||||
s = re.sub(r"/\s*(?=[^\d(\s.])", " ", s) # 「인/㎥」 의 / 는 단위
|
||||
s = re.sub(r"[^\d.+\-*/() ]", " ", s)
|
||||
s = re.sub(r"(?<=\d)\.(?!\d)", "", s) # 「4.」 → 4
|
||||
s = re.sub(r"\(\s*\)", " ", s).strip()
|
||||
m = re.match(r"\s*\d+(?:\.\d+)?", s)
|
||||
if not m:
|
||||
return None
|
||||
s = "1" + s[m.end() :]
|
||||
s = re.sub(r"(?<=[\d)])\s+(?=[\d(])", "*", s) # 빈칸으로 붙은 수는 곱
|
||||
try:
|
||||
v = _eval(ast.parse(s, mode="eval"))
|
||||
except (SyntaxError, ValueError, ZeroDivisionError):
|
||||
return None
|
||||
return v if 0 < v < 1e4 else None
|
||||
|
||||
|
||||
def _part(title: str, parent: str) -> dict[str, Any]:
|
||||
return {"제목": title, "절": parent, "노임": {}, "장비": {}}
|
||||
|
||||
|
||||
def sections(texts: list[str]) -> list[dict[str, Any]]:
|
||||
"""블록 몸 글 → 몫마다 {제목, 절, 노임, 장비} — 노임 · 장비가 하나도 없는 몫은 뺌."""
|
||||
out: list[dict[str, Any]] = []
|
||||
cur: dict[str, Any] | None = None
|
||||
eq, parent = None, ""
|
||||
for raw in texts:
|
||||
line = str(raw).rstrip()
|
||||
sec = _SECTION.match(line)
|
||||
if sec and not re.match(r"^\s*\d+\.\d", line):
|
||||
title = sec.group(2).strip()
|
||||
if _SKIP_SEC.match(title.replace(" ", "")):
|
||||
cur = None
|
||||
continue
|
||||
parent = title
|
||||
cur = _part(title, title)
|
||||
out.append(cur)
|
||||
eq = eq_key(title)
|
||||
continue
|
||||
sub = _SUBLABEL.match(line) if cur is not None else None
|
||||
if sub and not _SKIP_SEC.match(sub.group(1).replace(" ", "")):
|
||||
cur = _part(f"{parent} · {sub.group(1).strip()}", parent)
|
||||
out.append(cur)
|
||||
if cur is None:
|
||||
if out or not line.strip():
|
||||
continue
|
||||
cur = _part("", "")
|
||||
out.append(cur)
|
||||
if re.search(r"(소계|합계|인부소계)", line.replace(" ", "")):
|
||||
eq = None
|
||||
continue
|
||||
sub = _SUBLABEL.match(line)
|
||||
if sub:
|
||||
eq = eq_key(sub.group(1))
|
||||
elif not _COSTLINE.match(line) and (k := eq_key(line)):
|
||||
eq = k
|
||||
m = _EQ_COST.match(line)
|
||||
if m and eq:
|
||||
q = qty_of(f"{m.group(1)} {m.group(2)}")
|
||||
if q and q < 50:
|
||||
cur["장비"][eq] = cur["장비"].get(eq, 0) + q
|
||||
continue
|
||||
t = _TRADE.match(line)
|
||||
if t and (who := trade(t.group(1))):
|
||||
q = qty_of(f"{t.group(2)} {t.group(3)}")
|
||||
if q is None and t.group(4): # 식을 못 풀면 금액 ÷ 노임(품 되찾기만)
|
||||
q = float(t.group(4).replace(",", "")) / float(t.group(2).replace(",", ""))
|
||||
if q:
|
||||
cur["노임"][who] = cur["노임"].get(who, 0) + q
|
||||
return [s for s in out if s["노임"] or s["장비"]]
|
||||
|
||||
|
||||
def sections_stm(res: list[dict[str, Any]], texts: list[str]) -> list[dict[str, Any]]:
|
||||
"""STmate 자원(L · X) → 몫(자원 `절` = 글 줄 차례면 그 앞 맨 윗 절 · 글이면 그 제목 · 없으면 한
|
||||
몫)."""
|
||||
marks = []
|
||||
for at, t in enumerate(texts):
|
||||
sec = _SECTION.match(t)
|
||||
if sec and not re.match(r"^\s*\d+\.\d", t):
|
||||
if not _SKIP_SEC.match(sec.group(2).replace(" ", "")):
|
||||
marks.append((at, sec.group(2).strip()))
|
||||
|
||||
def which(where: Any) -> str:
|
||||
if isinstance(where, str):
|
||||
return where
|
||||
if isinstance(where, int) and not isinstance(where, bool):
|
||||
got = [m for m in marks if m[0] <= where]
|
||||
return got[-1][1] if got else ""
|
||||
return ""
|
||||
|
||||
secs: dict[str, dict[str, Any]] = {}
|
||||
seen = set()
|
||||
for x in res:
|
||||
q, kind = x.get("품"), x.get("갈래")
|
||||
if not q or kind not in ("L", "X") or (x.get("코드"), q, x.get("절")) in seen:
|
||||
continue
|
||||
seen.add((x.get("코드"), q, x.get("절")))
|
||||
title = which(x.get("절"))
|
||||
s = secs.setdefault(title, _part(title, title))
|
||||
if kind == "L" and (who := trade(x.get("이름"))):
|
||||
s["노임"][who] = s["노임"].get(who, 0) + float(q)
|
||||
elif kind == "X":
|
||||
k = eq_key(f"{x.get('이름') or ''} {x.get('규격') or ''}") or f"?{x.get('코드')}"
|
||||
s["장비"][k] = s["장비"].get(k, 0) + float(q)
|
||||
return [s for s in secs.values() if s["노임"] or s["장비"]]
|
||||
@@ -0,0 +1,200 @@
|
||||
"""M02 일위대가 맞춤 — 블록 하나 셈 · 판정(PLAN 338-1 · 설계 2-1 · 2-6 · 3-2).
|
||||
|
||||
블록 + 줄 → 엑셀 지문(`Finger`) → 이름 · 근거 차례(`Match.b_order`) → 지문 순위 섞기(`Fit`) ·
|
||||
같음 1위 고정(딱 맞음이 이김) → 섞인 줄 몫 먼저 · 조합(`Combine.solve`) → 위 다섯 옵션
|
||||
거꾸로(`Option`) →
|
||||
판정 맞음 · 다른 점 · 후보 + 후보 다섯(로직마다 옵션 판정 꼬리표).
|
||||
글 · 꼬리표는 숫자 사실만(엑셀 · 우리 품) — 「옛 판」 같은 까닭은 짓지 않음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
|
||||
from M02_MasterTemplete import M02_Hopyo_Combine as combine
|
||||
from M02_MasterTemplete import M02_Hopyo_Finger as finger
|
||||
from M02_MasterTemplete import M02_Hopyo_Fit as fit
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
|
||||
from M02_MasterTemplete import M02_Hopyo_Option as option
|
||||
|
||||
|
||||
def _num(x: float) -> str:
|
||||
return f"{x:.4g}"
|
||||
|
||||
|
||||
def excel_qty(efp: dict[str, Any]) -> dict[str, Any]:
|
||||
"""엑셀 품(화면 꼴) — {인력: {직종: 품}, 장비: [{갈래, 시간}], 자재: [{이름, 수량}]}."""
|
||||
return {
|
||||
"인력": {k: round(x["품"], 6) for k, x in efp["인력"].items() if x.get("품")},
|
||||
"장비": [{"갈래": m["갈래"], "시간": m["시간"]} for m in efp["장비"] if m.get("시간")],
|
||||
"자재": [{"이름": m["이름"], "수량": m["품"]} for m in efp["자재"] if m.get("품")],
|
||||
}
|
||||
|
||||
|
||||
def _public(efp: dict[str, Any]) -> dict[str, Any]:
|
||||
keep = ("인력", "장비", "자재", "출처", "조건", "옛판", "율", "계수")
|
||||
return {k: efp.get(k) for k in keep}
|
||||
|
||||
|
||||
def _label(code: str, values: dict[str, Any]) -> str:
|
||||
"""옵션 요약 — 고르기 글은 그대로 · 수 칸은 「칸 값」 · 기계 코드는 고르기이름."""
|
||||
specs = {s.get("이름"): s for s in hopyo_match._logic_specs(code)}
|
||||
out = []
|
||||
for name, v in values.items():
|
||||
names = (specs.get(name) or {}).get("고르기이름") or {}
|
||||
out.append(str(names.get(v) or v) if isinstance(v, str) else f"{name} {v}")
|
||||
return " · ".join(out)
|
||||
|
||||
|
||||
def _part_fp(part: dict[str, Any], x: float, line: dict[str, Any]) -> dict[str, Any]:
|
||||
"""조합 몫 → 옵션 찾기용 지문(몫 x 를 풀어 100% 품)."""
|
||||
fp = finger.empty(line)
|
||||
k = x or 1.0
|
||||
for t, q in (part.get("노임") or {}).items():
|
||||
fp["인력"][t] = {"품": q / k, "밑품": q / k, "율": [], "어떻게": ["몫"]}
|
||||
return fp
|
||||
|
||||
|
||||
def _diffs(efp: dict, found: dict) -> list[str]:
|
||||
"""숫자 사실 꼬리표 — 직종 품 다름 · 섞음 · 율 · 엑셀만 · 우리만 장비."""
|
||||
tags = []
|
||||
ours_l = (found.get("우리품") or {}).get("인력") or {}
|
||||
for t, x in efp["인력"].items():
|
||||
a, b = x.get("품"), ours_l.get(t)
|
||||
if a and b and not option.near(a, b, fit.TOL[2]):
|
||||
tags.append(f"{t} 엑셀 {_num(a)} · 우리 {_num(b)}")
|
||||
elif a and not b:
|
||||
tags.append(f"{t} 엑셀만 {_num(a)}")
|
||||
tags += [f"{t} 우리만 {_num(q)}" for t, q in ours_l.items() if t not in efp["인력"]]
|
||||
for x in efp["인력"].values():
|
||||
for v, note in x.get("율") or []:
|
||||
tag = f"섞음 — 엑셀이 × {_num(v)}" if note == "섞음" else f"율 {note} × {_num(v)}"
|
||||
if v and tag not in tags:
|
||||
tags.append(tag)
|
||||
tags += [d for d in ((found.get("견줌") or {}).get("덧") or []) if d not in tags]
|
||||
return tags
|
||||
|
||||
|
||||
def _state(efp: dict, code: str, found: dict | None, theirs: list) -> str:
|
||||
lvl = 9 if (found is None or fit.single(efp)) else found["수준"]
|
||||
if lvl <= 1:
|
||||
return "맞음"
|
||||
our = hopyo_basis.ours().get(code)
|
||||
clause_ok = bool(our and theirs and hopyo_match.clause(theirs, our)[0] >= 0.9)
|
||||
if lvl == 2 or (found and found.get("직종같음") and clause_ok):
|
||||
return "다른 점"
|
||||
return "후보"
|
||||
|
||||
|
||||
def _no_numbers(entry: dict, top: list[str], why: str) -> dict[str, Any]:
|
||||
entry["순위"] = [{"로직": k, "점수": None, "B": i} for i, k in enumerate(top[: fit.TOP_OPT])]
|
||||
entry["차례"] = top[: fit.TOP_OPT]
|
||||
entry["판정"] = {"상태": "후보", "글": why, "꼬리표": [why]}
|
||||
return entry
|
||||
|
||||
|
||||
def block(block_: dict | None, line: dict[str, Any], got: dict[str, Any]) -> dict[str, Any]:
|
||||
"""블록(또는 `{_지문}` 직접 줄 호표) + 줄 → 맞춤셈 한 칸(설계 3-2)."""
|
||||
if block_ and "_지문" in block_:
|
||||
efp, text = block_["_지문"], []
|
||||
else:
|
||||
efp, text = finger.excel(block_, line), (block_ or {}).get("글") or []
|
||||
border = hopyo_match.b_order(line, got, text)
|
||||
entry: dict[str, Any] = {"지문": _public(efp), "품": excel_qty(efp), "B": border["상태"]}
|
||||
entry.update(옵션={}, 조합=None)
|
||||
if not fit.has_numbers(efp):
|
||||
return _no_numbers(entry, border["top"], fit.NO_NUMBER)
|
||||
if fit.prepared() is None:
|
||||
return _no_numbers(entry, border["top"], "우리 로직 준비 중")
|
||||
e = fit.ex_prep(efp)
|
||||
pool = fit.rank(e)
|
||||
btop = border["top"]
|
||||
scores = {k: s for s, k, _, _ in pool}
|
||||
fused = fit.fuse(e, pool, btop)
|
||||
if border["상태"] == "같음": # 같음 1위 고정 — 지문 딱 맞음이 다른 로직이면 지문 1위
|
||||
first = pool[0][1] if fit.sure(e, pool) and pool[0][1] != btop[0] else btop[0]
|
||||
top = [first] + [k for k in fused if k != first][:4]
|
||||
else:
|
||||
top = fused
|
||||
parts = efp.get("몫") or []
|
||||
if parts and btop and combine.is_mixed(parts, line): # 섞인 줄 — 이름 · 근거 차례로 몫 먼저
|
||||
top = btop[: fit.TOP_OPT]
|
||||
top, combo = combine.solve(top, parts, line, got, border["합침"])
|
||||
opts = {k: option.find(k, efp, text) for k in top[: fit.TOP_OPT]}
|
||||
for c in combo:
|
||||
if c["로직"] not in opts and c["체크"]:
|
||||
part = next((p for p in parts if p.get("제목") == c["어디"]), None)
|
||||
pfp = _part_fp(part, c["몫"], line) if part else efp
|
||||
opts[c["로직"]] = option.find(c["로직"], pfp, text) if pfp["인력"] else None
|
||||
theirs = efp["출처"]
|
||||
chans = {k: ch for _, k, ch, _ in pool}
|
||||
entry["순위"] = [
|
||||
{
|
||||
"로직": k,
|
||||
"점수": round(scores[k], 3) if k in scores else None,
|
||||
"채널": {a: round(b, 3) for a, b in (chans.get(k) or {}).items() if b is not None},
|
||||
"B": btop.index(k) if k in btop else None,
|
||||
}
|
||||
for k in top[: fit.TOP_OPT]
|
||||
]
|
||||
entry["차례"] = top[: fit.TOP_OPT]
|
||||
entry["옵션"] = opts
|
||||
entry["조합"] = combo or None
|
||||
entry["판정"] = judge(efp, top, opts, theirs, got)
|
||||
entry["후보"] = candidates(efp, top, opts, theirs, got, scores)
|
||||
return entry
|
||||
|
||||
|
||||
def judge(efp: dict, top: list[str], opts: dict, theirs: list, got: dict) -> dict[str, Any]:
|
||||
"""1위 판정 {상태, 글, 꼬리표}."""
|
||||
if not top:
|
||||
return {"상태": "후보", "글": "후보 없음", "꼬리표": []}
|
||||
names = {x["로직"]: x["이름"] for x in got["로직"]}
|
||||
t1, found = top[0], opts.get(top[0]) or {}
|
||||
state = _state(efp, t1, opts.get(t1), theirs)
|
||||
exact = [k for k in top[: fit.TOP_OPT] if _state(efp, k, opts.get(k), theirs) == "맞음"]
|
||||
tags = []
|
||||
if state == "맞음":
|
||||
label = _label(t1, found.get("입력") or {})
|
||||
text = " · ".join(x for x in ("맞음 — " + t1, names.get(t1, ""), label) if x)
|
||||
if len(exact) > 1:
|
||||
tags.append("맞음 둘")
|
||||
if found.get("갈리는칸"):
|
||||
tags.append(f"{' · '.join(found['갈리는칸'])} 못 가름")
|
||||
return {"상태": state, "글": text, "꼬리표": tags}
|
||||
if state == "다른 점":
|
||||
tags = _diffs(efp, found)
|
||||
return {"상태": state, "글": " · ".join(["다른 점 — " + t1, *tags[:2]]), "꼬리표": tags}
|
||||
return {"상태": "후보", "글": f"후보 {len(top[: fit.TOP_OPT])}", "꼬리표": []}
|
||||
|
||||
|
||||
def candidates(
|
||||
efp: dict, top: list[str], opts: dict, theirs: list, got: dict, scores: dict
|
||||
) -> list[dict[str, Any]]:
|
||||
"""후보 다섯 — 로직마다 옵션 판정 꼬리표 · 「맞음 둘」이면 둘째 맞음이 맨 앞."""
|
||||
by = {x["로직"]: x for x in got["로직"]}
|
||||
table = hopyo_basis.ours()
|
||||
out = []
|
||||
for k in top[: fit.TOP_OPT]:
|
||||
found = opts.get(k) or {}
|
||||
state = _state(efp, k, opts.get(k), theirs)
|
||||
our = table.get(k)
|
||||
sc, clauses, why = hopyo_match.clause(theirs, our) if (our and theirs) else (0, [], "")
|
||||
one = by.get(k) or {"로직": k, "이름": "", "단위": ""}
|
||||
text = state if state != "다른 점" else " · ".join(["다른 점", *_diffs(efp, found)[:1]])
|
||||
out.append(
|
||||
{
|
||||
"로직": k,
|
||||
"이름": one["이름"],
|
||||
"단위": one["단위"],
|
||||
"점수": round(scores[k], 3) if k in scores else None,
|
||||
"판정": state,
|
||||
"글": text,
|
||||
"근거": {"조항": clauses, "까닭": why},
|
||||
}
|
||||
)
|
||||
first = [c for c in out[1:] if c["판정"] == "맞음"][:1]
|
||||
if out and out[0]["판정"] == "맞음" and first:
|
||||
out = [out[0], *first, *[c for c in out[1:] if c is not first[0]]]
|
||||
return out
|
||||
@@ -0,0 +1,247 @@
|
||||
"""M02 일위대가 맞춤 낱말 · 식 도우미(PLAN 338-1 · 설계 2-2) — 엑셀 지문 · 우리 지문이 같이 씀.
|
||||
|
||||
장비 갈래(`mtypes`) · 규격 수(`specnums`) · 조건 낱말(`conds` · `cond_score`) ·
|
||||
식 풀기(`parse_rhs` — 「단가 × 식 = 금액」 → 품 · 맨 앞 단가는 뗌 · 금액 안 씀) · 닮음(`close` ·
|
||||
`cosine` ·
|
||||
`jacc`). 값 · 문턱은 336 실험(C4 `fp_common`) 그대로.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Any
|
||||
|
||||
# ── 장비 갈래(엑셀 글 · 우리 EQ 상세구분 공통) ─────────────────────────────
|
||||
MACH = (
|
||||
("굴착기", ("굴착기", "굴삭기", "백호", "b/h", "backhoe", "back-hoe", "포크레인")),
|
||||
("덤프트럭", ("덤프",)),
|
||||
("크레인", ("크레인",)),
|
||||
("브레이커", ("브레이커", "브레이카")),
|
||||
("콘크리트펌프", ("펌프차", "펌프카", "콘크리트펌프", "콘크리트 펌프")),
|
||||
("롤러", ("롤러", "로울러")),
|
||||
("불도저", ("불도저", "불도우저")),
|
||||
("집게", ("집게",)),
|
||||
("살수차", ("살수", "물탱크")),
|
||||
("모터그레이더", ("그레이더",)),
|
||||
("래머", ("래머", "램머", "래 머")),
|
||||
("콤팩터", ("콤팩터", "컴팩터")),
|
||||
("진동기", ("진동기",)),
|
||||
("발전기", ("발전기",)),
|
||||
("공기압축기", ("공기압축기", "컴프레셔", "콤프레샤")),
|
||||
("드릴", ("드릴", "천공기")),
|
||||
("커터", ("커터",)),
|
||||
("화물차", ("화물자동차", "화물차", "카고")),
|
||||
("로더", ("로더",)),
|
||||
("지게차", ("지게차",)),
|
||||
("체인톱", ("체인톱",)),
|
||||
("트랙터", ("트랙터",)),
|
||||
("믹서", ("믹서",)),
|
||||
("분무기", ("분무기",)),
|
||||
)
|
||||
_SPECNUM = re.compile(r"(\d+(?:\.\d+)?)\s*(㎥|m3|톤|ton|t\b|m\b|㎾|kw|ℓ|mm|hp)", re.I)
|
||||
|
||||
|
||||
def nfkc(text: Any) -> str:
|
||||
return unicodedata.normalize("NFKC", str(text or ""))
|
||||
|
||||
|
||||
def mtypes(text: Any) -> set[str]:
|
||||
t = nfkc(text).lower()
|
||||
return {k for k, ws in MACH if any(w in t for w in ws)}
|
||||
|
||||
|
||||
def specnums(text: Any) -> set[float]:
|
||||
return {float(a) for a, _ in _SPECNUM.findall(nfkc(text))}
|
||||
|
||||
|
||||
# ── 조건 낱말(축) ──────────────────────────────────────────────────────
|
||||
SOIL = ("보통토사", "경질토사", "혼합토사", "자갈섞인토사", "사질토", "점질토", "토사")
|
||||
ROCK = ("리핑암", "발파암", "풍화암", "연암", "보통암", "경암", "극경암")
|
||||
AXES = {
|
||||
"토질": SOIL + ROCK,
|
||||
"메찰": ("메쌓기", "찰쌓기", "메붙임", "찰붙임", "메붙이기", "찰붙이기"),
|
||||
"형": ("원형", "사각", "타원형"),
|
||||
"근": ("무근", "철근"),
|
||||
"난이": ("제물치장", "매우복잡", "복잡", "보통", "간단"),
|
||||
"규모": ("소량", "소형", "소규모", "중규모", "대규모", "대형"),
|
||||
"방법": ("인력운반", "인력", "기계", "장비", "펌프"),
|
||||
}
|
||||
CLASH = ("토질", "메찰", "형") # 이름에 박힌 값이 엑셀과 어긋나면 깎는 축
|
||||
_ROCKISH = re.compile(r"(\(암\)|암\)|[^가-힣]암$|^암$|암절취|\b암\b)")
|
||||
|
||||
|
||||
def soil_family(word: str) -> str | None:
|
||||
if word in ROCK or word == "암":
|
||||
return "암"
|
||||
return "토사" if word in SOIL else None
|
||||
|
||||
|
||||
def conds(text: Any) -> dict[str, set[str]]:
|
||||
"""{축: {낱말}} + 횟수 · 개월 · Type."""
|
||||
t = re.sub(r"\s+", "", nfkc(text))
|
||||
out: dict[str, set[str]] = {}
|
||||
for ax, ws in AXES.items():
|
||||
hit, rest = set(), t
|
||||
for w in sorted(ws, key=len, reverse=True):
|
||||
if w in rest:
|
||||
hit.add(w)
|
||||
rest = rest.replace(w, " ")
|
||||
if ax == "토질" and _ROCKISH.search(t):
|
||||
hit.add("암")
|
||||
if hit:
|
||||
out[ax] = hit
|
||||
if n := re.findall(r"(\d)회", t):
|
||||
out["횟수"] = {f"{x}회" for x in n}
|
||||
if m := re.findall(r"(\d+)개월", t):
|
||||
out["개월"] = {f"{x}개월" for x in m}
|
||||
if ty := re.findall(r"type[-\s]*(ⅰ|ⅱ|ii|i)\b", t.lower()):
|
||||
out["type"] = {"Ⅱ" if x in ("ⅱ", "ii") else "Ⅰ" for x in ty}
|
||||
return out
|
||||
|
||||
|
||||
def cond_score(excel: dict, name_c: dict, opt_c: dict) -> tuple[float, int]:
|
||||
"""(맞음 비율, 어긋남) — 엑셀 조건 낱말이 우리 이름 · 고르기 글에 있나 · 이름 축 어긋남."""
|
||||
words = [(ax, w) for ax, ws in excel.items() for w in ws]
|
||||
if not words:
|
||||
return 0.0, 0
|
||||
hit = 0
|
||||
for ax, w in words:
|
||||
pool = name_c.get(ax, set()) | opt_c.get(ax, set())
|
||||
fams = {soil_family(x) for x in pool}
|
||||
if w in pool or (ax == "토질" and soil_family(w) in fams):
|
||||
hit += 1
|
||||
clash = 0
|
||||
for ax in CLASH:
|
||||
if ax in excel and ax in name_c:
|
||||
if ax == "토질":
|
||||
ef = {soil_family(w) for w in excel[ax]}
|
||||
of = {soil_family(w) for w in name_c[ax]}
|
||||
clash |= bool(ef and of and not ef & of)
|
||||
elif not excel[ax] & name_c[ax]:
|
||||
clash = 1
|
||||
return hit / len(words), clash
|
||||
|
||||
|
||||
# ── 식 풀기 — 「단가 × 식 = 금액」 → 품(단가로 나눈 몫 · 단가는 버림) ─────────
|
||||
_UNITFRAC = re.compile(
|
||||
r"(인|개|본|공|EA|ea|hr|ton|Ton|TON|kg|㎥|㎡|m3|m2|m|M|조|회)\s*/\s*"
|
||||
r"(EA|ea|㎥|㎡|m3|m2|m|M|ton|Ton|TON|t|hr|일|개|본|조|공㎥|㎥)"
|
||||
)
|
||||
_UNITTAIL = re.compile(
|
||||
r"(?<=[\d.)])\s*(hr|HR|EA|ea|kg|km|ton|Ton|TON|sec|m3|m2|mm|cm|M|m|t)(?![A-Za-z])"
|
||||
)
|
||||
_NOTE = re.compile(r"\(([^()]*[가-힣][^()]*)\)")
|
||||
_LEAD = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([*/])\s*(.+)$")
|
||||
_SAFE = re.compile(r"^[\d.+\-*/() ]+$")
|
||||
|
||||
|
||||
def _norm(s: str) -> str:
|
||||
s = nfkc(s).replace("×", "*").replace("✕", "*").replace("÷", "/")
|
||||
s = s.replace("x", "*").replace("X", "*")
|
||||
s = re.sub(r"(?<=\d),(?=\d{3})", "", s)
|
||||
return _UNITFRAC.sub("", s)
|
||||
|
||||
|
||||
def _split(rest: str, first_op: str) -> list[tuple[str, str]]:
|
||||
"""맨 바깥 * / 로 쪼갬 → [(연산, 조각)]."""
|
||||
out, depth, cur, op = [], 0, "", first_op
|
||||
for ch in rest:
|
||||
depth += (ch == "(") - (ch == ")")
|
||||
if depth == 0 and ch in "*/":
|
||||
out.append((op, cur))
|
||||
op, cur = ch, ""
|
||||
continue
|
||||
cur += ch
|
||||
out.append((op, cur))
|
||||
return out
|
||||
|
||||
|
||||
def _factor(text: str) -> tuple[float, bool, str] | None:
|
||||
"""조각 → (값, 율 표시, 메모) · 변수(P · Q …)면 None."""
|
||||
notes = _NOTE.findall(text)
|
||||
t = _NOTE.sub("", text)
|
||||
pct = "%" in t
|
||||
if re.search(r"\d\s*회", t): # 「× 2회」 — 횟수 곱(율로 가름)
|
||||
notes.append("회")
|
||||
t = re.sub(r"[가-힣㎥㎡㎝㎜℃˚°'\"]", "", t).replace("%", "")
|
||||
t = _UNITTAIL.sub("", t).strip()
|
||||
if not t or re.search(r"[A-Za-z]", t) or not _SAFE.match(t):
|
||||
return None
|
||||
try:
|
||||
v = eval(t, {"__builtins__": {}}, {}) # noqa: S307 — 숫자 · 연산 글만(_SAFE)
|
||||
except Exception: # noqa: BLE001 — 0 나눔 · 괄호 짝 틀림
|
||||
return None
|
||||
return float(v) / (100 if pct else 1), pct or bool(notes), " ".join(notes)
|
||||
|
||||
|
||||
def _rates_any(parts: list[str]) -> list[tuple[float, str]]:
|
||||
"""모든 조각의 율(메모 · %) — 풀 수 있는 조각만."""
|
||||
out: list[tuple[float, str]] = []
|
||||
for seg in parts:
|
||||
m = _LEAD.match(seg)
|
||||
if not m:
|
||||
continue
|
||||
for op, piece in _split(m.group(3), m.group(2)):
|
||||
f = _factor(piece)
|
||||
if f and f[1] and f[0] and all(abs(f[0] - v) > 1e-9 for v, _ in out):
|
||||
out.append((f[0] if op == "*" else 1 / f[0], f[2] or "%"))
|
||||
return out
|
||||
|
||||
|
||||
def parse_rhs(rhs: str) -> dict[str, Any] | None:
|
||||
"""「단가 × 식 [= 바꿔 쓴 식] = 금액」 → {단가, 품, 밑품, 율, 인자} · 못 읽으면 None."""
|
||||
parts = _norm(rhs).split("=")
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
every = _rates_any(parts[:-1])
|
||||
for seg in parts[:-1]:
|
||||
m = _LEAD.match(seg)
|
||||
if not m:
|
||||
continue
|
||||
facs = []
|
||||
for op, piece in _split(m.group(3), m.group(2)):
|
||||
f = _factor(piece)
|
||||
if f is None:
|
||||
break
|
||||
facs.append((op, *f))
|
||||
else:
|
||||
if not facs or any(v == 0 and op == "/" for op, v, _, _ in facs):
|
||||
continue
|
||||
full = base = 1.0
|
||||
rates = []
|
||||
for op, v, rate, note in facs:
|
||||
full = full * v if op == "*" else full / v
|
||||
if rate:
|
||||
rates.append([round(v if op == "*" else 1 / v, 6), note or "%"])
|
||||
else:
|
||||
base = base * v if op == "*" else base / v
|
||||
if not rates and every: # 바꿔 쓴 식엔 메모가 빠짐 — 앞 조각의 율로 밑품
|
||||
for v, _ in every:
|
||||
base /= v or 1
|
||||
rates = [[round(v, 6), note] for v, note in every]
|
||||
plain = [v if op == "*" else 1 / v for op, v, rate, _ in facs if not rate and v]
|
||||
return {"단가": float(m.group(1)), "품": full, "밑품": base, "율": rates, "인자": plain}
|
||||
return None
|
||||
|
||||
|
||||
# ── 닮음 ───────────────────────────────────────────────────────────────
|
||||
def close(a: Any, b: Any, tol: float = 0.15) -> float:
|
||||
"""두 양수 가까움 0~1 — ln 비 3% 안 = 1 · 그 밖은 떨어짐."""
|
||||
if not a or not b or a <= 0 or b <= 0:
|
||||
return 0.0
|
||||
d = abs(math.log(a / b))
|
||||
return 1.0 if d <= 0.03 else max(0.0, 1 - (d - 0.03) / (tol * 4))
|
||||
|
||||
|
||||
def cosine(a: dict, b: dict) -> float:
|
||||
num = sum(a.get(k, 0) * b.get(k, 0) for k in set(a) | set(b))
|
||||
da = math.sqrt(sum(v * v for v in a.values()))
|
||||
db = math.sqrt(sum(v * v for v in b.values()))
|
||||
return num / (da * db) if da and db else 0.0
|
||||
|
||||
|
||||
def jacc(a: Any, b: Any) -> float:
|
||||
a, b = set(a), set(b)
|
||||
return len(a & b) / len(a | b) if a | b else 0.0
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,254 @@
|
||||
"""338 골든 도우미 — 실물 엑셀(`resources/knowledge/original/실무문서`)을 지금 읽기로 읽어 정답 표
|
||||
(`fixtures/m02_hopyo_golden.json`)와 맞대고 지표(설계 7-3)를 셈. 읽기만.
|
||||
|
||||
블록 글 = HP 블록에 `글` 이 있으면 그것(나 창 2a 뒤) · 없으면 단가산출 시트 산출근거 열 글(엑셀꼴) ·
|
||||
거창 = 줄 칸을 이어 붙임. STmate 파일은 읽기(`M02_Hopyo_Excel.read_book`)가 되면 들어감 · 아니면 뺌.
|
||||
python resources/tester/helper_m02_hopyo_golden.py → 지표 찍음 · tmp/m02_338/golden.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Basis as hb # noqa: E402
|
||||
from M02_MasterTemplete import M02_Hopyo_Excel as hx # noqa: E402
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hm # noqa: E402
|
||||
from M02_MasterTemplete import M02_Hopyo_Verdict as verdict # noqa: E402
|
||||
|
||||
DOCS = ROOT / "resources/knowledge/original/실무문서"
|
||||
FIXTURE = Path(__file__).resolve().parent / "fixtures/m02_hopyo_golden.json"
|
||||
FILES = {
|
||||
"북부청임도": DOCS
|
||||
/ "2026_실무일위대가 모음/2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls",
|
||||
"북부청사방": DOCS
|
||||
/ "2026_실무일위대가 모음/2026년_북부지방산림청_사방사업(계류+댐)_설계내역서.xls",
|
||||
"거창": DOCS
|
||||
/ "01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx",
|
||||
"봉화": DOCS
|
||||
/ "04.산불 봉화/01. 내역서/2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx",
|
||||
"동부청영월": DOCS / "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx",
|
||||
"울진대흥": DOCS
|
||||
/ "2024년 간선임도(기번3-울진.대흥)(공통)"
|
||||
/ "2024년 간선임도 신설사업(기번3 울진.울진.대흥.산65외2).xlsx",
|
||||
"영덕계류": DOCS
|
||||
/ "20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/01.내역자료"
|
||||
/ "2025년 계류보전사업(기번1 영덕.병곡.영.산214)(변경).xlsx",
|
||||
"울진소광": DOCS
|
||||
/ "25년 산불진화임도(기번8)(울진소광)"
|
||||
/ "2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).xlsx",
|
||||
}
|
||||
LABELED = ("북부청임도", "북부청사방", "동부청영월")
|
||||
|
||||
|
||||
def labels() -> list[dict[str, Any]]:
|
||||
return json.loads(FIXTURE.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _row_text(row: list, start: int) -> str:
|
||||
cells = []
|
||||
for v in row[start:140]:
|
||||
if v is None or v == "":
|
||||
continue
|
||||
cells.append(str(int(v) if isinstance(v, float) and v == int(v) else v).strip())
|
||||
return " ".join(cells)
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def read(tag: str) -> dict[str, Any] | None:
|
||||
"""파일 → HP 꼴(블록 `글` 붙임) · 못 읽으면 None(STmate 읽기 전)."""
|
||||
path = FILES[tag]
|
||||
data = path.read_bytes()
|
||||
try:
|
||||
book = hx.read_book(path.name, data)
|
||||
except hx.ExcelError:
|
||||
return None
|
||||
if any(b.get("글") or b.get("자원") for b in book.get("단산") or []):
|
||||
return book
|
||||
calc = hx._sheets(path.name, data).get(hb.SHEET) or []
|
||||
_, col, _ = hb._columns(calc)
|
||||
join = tag == "거창"
|
||||
for b in book.get("단산") or []:
|
||||
a, z = b["행"]
|
||||
rows = calc[a:z]
|
||||
text = [_row_text(r, col) if join else (r[col] if col < len(r) else "") for r in rows]
|
||||
b["글"] = [str(t).rstrip() for t in text if isinstance(t, str) and t.strip()]
|
||||
return book
|
||||
|
||||
|
||||
def _key(line: dict[str, Any]) -> tuple:
|
||||
ref = line.get("참조") or {}
|
||||
no = ref.get("번") if ref.get("종류") == "단산" else None
|
||||
code = str(ref.get("코드") or line.get("코드") or "")
|
||||
return line.get("이름"), line.get("규격") or "", line.get("단위") or "", no, code[:6]
|
||||
|
||||
|
||||
def _lab_key(lab: dict[str, Any]) -> tuple:
|
||||
return lab["이름"], lab["규격"], lab["단위"], lab.get("단산"), str(lab.get("코드") or "")[:6]
|
||||
|
||||
|
||||
def line_of(lid: str) -> tuple[dict | None, dict[str, Any]]:
|
||||
"""정답 표 id → (블록, 줄) — 시험이 실물 줄 하나를 꺼냄."""
|
||||
lab = next(x for x in labels() if x["id"] == lid)
|
||||
book = read(lab["파일"])
|
||||
blocks = {int(b["번"]): b for b in book.get("단산") or []}
|
||||
for h in book["호표"]:
|
||||
for line in h["줄"]:
|
||||
if _key(line) == _lab_key(lab) or (
|
||||
not line.get("참조") and _key(line)[:3] == _lab_key(lab)[:3]
|
||||
):
|
||||
ref = line.get("참조") or {}
|
||||
return (blocks.get(int(ref["번"])) if ref.get("종류") == "단산" else None), line
|
||||
raise KeyError(lid)
|
||||
|
||||
|
||||
def evaluate(tags: tuple[str, ...] | None = None) -> dict[str, Any]:
|
||||
"""{줄: {id: 결과}, 호표: {(파일, 번): (ids, refs)}, 읽음: [파일], 시간}."""
|
||||
labs = labels()
|
||||
tags = tags or tuple(dict.fromkeys(lab["파일"] for lab in labs))
|
||||
got = hm.targets()
|
||||
out: dict[str, Any] = {"줄": {}, "호표": {}, "읽음": [], "못읽음": [], "시간": {}}
|
||||
for tag in tags:
|
||||
book = read(tag)
|
||||
if book is None:
|
||||
out["못읽음"].append(tag)
|
||||
continue
|
||||
out["읽음"].append(tag)
|
||||
want = {_lab_key(lab): lab for lab in labs if lab["파일"] == tag}
|
||||
loose = {k[:3]: lab for k, lab in want.items()}
|
||||
blocks = {int(b["번"]): b for b in book.get("단산") or []}
|
||||
t0 = time.time()
|
||||
for h in book["호표"]:
|
||||
ids, refs = [], []
|
||||
for line in h["줄"]:
|
||||
ref = line.get("참조") or {}
|
||||
if ref.get("종류") == "일위" and ref.get("번"):
|
||||
refs.append(int(ref["번"]))
|
||||
lab = want.get(_key(line)) or (loose.get(_key(line)[:3]) if not ref else None)
|
||||
if lab is None:
|
||||
continue
|
||||
ids.append(lab["id"])
|
||||
if lab["id"] in out["줄"]:
|
||||
continue
|
||||
block = blocks.get(int(ref["번"])) if ref.get("종류") == "단산" else None
|
||||
out["줄"][lab["id"]] = {"lab": lab, "셈": verdict.block(block, line, got)}
|
||||
out["호표"][f"{tag}|{h['번']}"] = (list(dict.fromkeys(ids)), refs)
|
||||
out["시간"][tag] = round(time.time() - t0, 1)
|
||||
return out
|
||||
|
||||
|
||||
def chosen_of(entry: dict[str, Any]) -> set[str]:
|
||||
"""줄이 고른 로직(T1) — 조합이면 체크한 것 · 아니면 1위."""
|
||||
combo = entry.get("조합") or []
|
||||
top = (entry.get("차례") or [None])[0]
|
||||
codes = {c["로직"] for c in combo if c["로직"] and c.get("체크")}
|
||||
return codes or ({top} if top else set())
|
||||
|
||||
|
||||
def _gather(res: dict, key: str, seen: tuple = ()) -> list[str]:
|
||||
tag = key.split("|")[0]
|
||||
if key not in res["호표"] or key in seen:
|
||||
return []
|
||||
ids, refs = res["호표"][key]
|
||||
out = list(ids)
|
||||
for no in refs:
|
||||
out += _gather(res, f"{tag}|{no}", (*seen, key))
|
||||
return list(dict.fromkeys(out))
|
||||
|
||||
|
||||
def metrics(res: dict[str, Any], group: set[str] | None = None) -> dict[str, Any]:
|
||||
"""설계 7-3 지표 — 사람 정답 줄 · 새 파일 · 조합 맞음(T1 · 덮을 수 있는 호표)."""
|
||||
rows = [r for r in res["줄"].values() if group is None or r["lab"]["무리"] in group]
|
||||
have = [r for r in rows if r["lab"]["정답"]]
|
||||
ok1 = lambda r: (r["셈"]["차례"] or [None])[0] in set(r["lab"]["정답"]) # noqa: E731
|
||||
state = lambda r: r["셈"]["판정"]["상태"] # noqa: E731
|
||||
opt_ok = 0
|
||||
for r in have:
|
||||
top = (r["셈"]["차례"] or [None])[0]
|
||||
found = (r["셈"]["옵션"] or {}).get(top) or {}
|
||||
if ok1(r) and found.get("수준", 9) <= 1 and state(r) == "맞음":
|
||||
opt_ok += 1
|
||||
out = {
|
||||
"줄": len(have),
|
||||
"1순위": sum(ok1(r) for r in have),
|
||||
"다섯 안": sum(bool(set(r["셈"]["차례"][:5]) & set(r["lab"]["정답"])) for r in have),
|
||||
"맞음": sum(state(r) == "맞음" for r in rows),
|
||||
"맞음 틀림": sum(state(r) == "맞음" and not ok1(r) for r in rows),
|
||||
"다른 점": sum(state(r) == "다른 점" for r in rows),
|
||||
"다른 점 틀림": sum(state(r) == "다른 점" and not ok1(r) for r in rows),
|
||||
"옵션까지": opt_ok,
|
||||
}
|
||||
keys = [k for k in res["호표"] if k.split("|")[0] in LABELED]
|
||||
whole = cover = 0
|
||||
by = res["줄"]
|
||||
for key in keys:
|
||||
ids = [
|
||||
i
|
||||
for i in _gather(res, key)
|
||||
if i in by and (group is None or by[i]["lab"]["무리"] in group)
|
||||
]
|
||||
if not ids:
|
||||
continue
|
||||
slots, chosen = [], set()
|
||||
for i in ids:
|
||||
lab = by[i]["lab"]
|
||||
if not lab["정답"]:
|
||||
slots.append(set())
|
||||
elif lab.get("복합") and lab["id"] != "S062":
|
||||
slots += [{x} for x in lab["복합"]]
|
||||
else:
|
||||
slots.append(set(lab["정답"]))
|
||||
chosen |= chosen_of(by[i]["셈"])
|
||||
if any(not s for s in slots):
|
||||
continue
|
||||
cover += 1
|
||||
alts = set().union(*slots)
|
||||
whole += all(chosen & s for s in slots) and not (chosen - alts)
|
||||
out["조합 호표"], out["조합 맞음"] = cover, whole
|
||||
return out
|
||||
|
||||
|
||||
def show(res: dict[str, Any]) -> dict[str, Any]:
|
||||
table = {
|
||||
"사람": metrics(res, {"사람"}),
|
||||
"새 파일": metrics(res, {"자동", "씨앗", "자동+씨앗"}),
|
||||
**{
|
||||
tag: metrics(
|
||||
{**res, "줄": {i: r for i, r in res["줄"].items() if r["lab"]["파일"] == tag}},
|
||||
{"사람"},
|
||||
)
|
||||
for tag in LABELED
|
||||
if tag in res["읽음"]
|
||||
},
|
||||
}
|
||||
return {"읽음": res["읽음"], "못읽음": res["못읽음"], "시간": res["시간"], "지표": table}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
t = time.time()
|
||||
res = evaluate(tuple(sys.argv[1:]) or None)
|
||||
summary = show(res)
|
||||
print(json.dumps(summary, ensure_ascii=False, indent=1))
|
||||
print("전체", round(time.time() - t, 1), "s")
|
||||
dump = {
|
||||
i: {
|
||||
"lab": r["lab"],
|
||||
"차례": r["셈"]["차례"],
|
||||
"판정": r["셈"]["판정"],
|
||||
"조합": r["셈"]["조합"],
|
||||
"B": r["셈"]["B"],
|
||||
}
|
||||
for i, r in res["줄"].items()
|
||||
}
|
||||
(ROOT / "tmp/m02_338").mkdir(parents=True, exist_ok=True)
|
||||
(ROOT / "tmp/m02_338/golden.json").write_text(
|
||||
json.dumps(dump, ensure_ascii=False, indent=1, default=str), "utf-8"
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
"""338-1 엑셀 지문(`M02_Hopyo_Finger` · `_Words` · `_Shares`) — 식 풀기 일곱 꼴 · 합판 지문 · 고임돌
|
||||
섞음 ·
|
||||
몫 · STmate 자원 · 기계 줄 · 직접 줄 호표. 실물 = 북부청 임도(읽기만)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Finger as finger
|
||||
from M02_MasterTemplete import M02_Hopyo_Shares as shares
|
||||
from M02_MasterTemplete.M02_Hopyo_Words import conds, mtypes, parse_rhs
|
||||
from resources.tester.helper_m02_hopyo_golden import line_of
|
||||
|
||||
approx = pytest.approx
|
||||
|
||||
|
||||
# ── 식 풀기 ────────────────────────────────────────────────────────────
|
||||
def test_식_단가_떼고_품():
|
||||
got = parse_rhs("273,540 × 3인 ÷ 55㎥ × 2 = 29,840.7")
|
||||
assert got["단가"] == 273540 and got["품"] == approx(0.10909, abs=1e-5) and got["율"] == []
|
||||
|
||||
|
||||
def test_식_퍼센트_율():
|
||||
got = parse_rhs("59,020 ÷ 23.1 × 90% = 2,299.5")
|
||||
assert got["품"] == approx(0.03896, abs=1e-5)
|
||||
assert got["밑품"] == approx(1 / 23.1) and got["율"] == [[0.9, "%"]]
|
||||
|
||||
|
||||
def test_식_인당_단위_섞음():
|
||||
got = parse_rhs("172,068 × 0.3인/㎥ × 10% = 5,162")
|
||||
assert got["품"] == approx(0.03) and got["밑품"] == approx(0.3)
|
||||
|
||||
|
||||
def test_식_바꿔_쓴_식은_앞_율을_이어받음():
|
||||
got = parse_rhs("215,907 × 4인 ÷ P × 1.2(할증) = 215,907 × 0.104 × 1.2 = 26,945")
|
||||
assert got["품"] == approx(0.1248) and got["밑품"] == approx(0.104)
|
||||
assert got["율"] == [[1.2, "할증"]]
|
||||
|
||||
|
||||
def test_식_횟수_품할증():
|
||||
got = parse_rhs("172,068 × 0.05인 × 2회 = 17,206")
|
||||
assert got["품"] == approx(0.1) and got["밑품"] == approx(0.05) and got["율"] == [[2.0, "회"]]
|
||||
got = parse_rhs("273,540 × 0.1 × 1.2(품할증) = 32,824")
|
||||
assert got["밑품"] == approx(0.1) and got["율"] == [[1.2, "품할증"]]
|
||||
|
||||
|
||||
def test_식_못_풀면_역산():
|
||||
assert parse_rhs("273,540 × P = 30,000") is None
|
||||
block = {"번": 1, "이름": "시험", "단위": "㎥", "글": [" 특별인부 : 273,540 × P = 27,354"]}
|
||||
fp = finger.excel(block, {"이름": "시험", "규격": "", "단위": "㎥"})
|
||||
assert fp["인력"]["특별인부"]["품"] == approx(0.1) and fp["인력"]["특별인부"]["어떻게"] == [
|
||||
"역산"
|
||||
]
|
||||
|
||||
|
||||
def test_장비_갈래_조건_낱말():
|
||||
assert mtypes("유압식 백호우 0.7㎥") == {"굴착기"} and mtypes("펌프카 41m") == {"콘크리트펌프"}
|
||||
assert conds("합판거푸집 4회 보통")["횟수"] == {"4회"}
|
||||
assert conds("구조물터파기(암)")["토질"] == {"암"}
|
||||
|
||||
|
||||
# ── 실물 지문 ──────────────────────────────────────────────────────────
|
||||
def test_합판_4회_지문():
|
||||
block, line = line_of("N001")
|
||||
fp = finger.excel(block, line)
|
||||
assert fp["인력"]["형틀목공"]["품"] == approx(0.1111, abs=1e-4)
|
||||
assert fp["인력"]["보통인부"]["품"] == approx(0.0444, abs=1e-4)
|
||||
assert "4회" in fp["조건"]["횟수"] and fp["출처"] == [["건설품셈", "6-3-1"]]
|
||||
assert {m["이름"][:4] for m in fp["자재"] if m["품"]} >= {"내수합판"}
|
||||
|
||||
|
||||
def test_레미콘_소량_지문_장비_시간():
|
||||
block, line = line_of("N000")
|
||||
fp = finger.excel(block, line)
|
||||
assert fp["인력"]["콘크리트공"]["품"] == approx(0.10909, abs=1e-5)
|
||||
assert [(m["갈래"], round(m["시간"], 2)) for m in fp["장비"]] == [(["굴착기"], 0.29)]
|
||||
|
||||
|
||||
def test_고임돌_섞음_율과_몫():
|
||||
block, line = line_of("N005")
|
||||
fp = finger.excel(block, line)
|
||||
labor = fp["인력"]["보통인부"]
|
||||
assert labor["밑품"] == approx(0.3, abs=1e-3) and labor["품"] == approx(0.03, abs=1e-3)
|
||||
assert any(note in ("%", "섞음") and v == approx(0.1) for v, note in labor["율"])
|
||||
parts = fp["몫"]
|
||||
assert any(p["노임"] and not p["장비"] for p in parts) # 인력 몫
|
||||
assert any(p["장비"] and not p["노임"] for p in parts) # 장비 몫
|
||||
|
||||
|
||||
def test_몫_절마다_노임_장비():
|
||||
texts = [
|
||||
" 1. 굴착기 0.6㎥ 채집(90%)",
|
||||
" 노무비 : 59,020 ÷ 23.1 × 90% = 2,299",
|
||||
" 2. 인력채집(10%)",
|
||||
" 보통인부 : 172,068 × 0.3인/㎥ × 10% = 5,162",
|
||||
" 3. 합 계",
|
||||
" 보통인부 : 172,068 × 1 = 172,068",
|
||||
]
|
||||
parts = shares.sections(texts)
|
||||
assert [p["제목"] for p in parts] == ["굴착기 0.6㎥ 채집(90%)", "인력채집(10%)"]
|
||||
assert parts[0]["장비"] == {"굴착기|0.6": approx(0.9 / 23.1)}
|
||||
assert parts[1]["노임"] == {"보통인부": approx(0.03)}
|
||||
|
||||
|
||||
def test_STmate_자원_지문():
|
||||
block = {
|
||||
"글": ["잔토처리 (기계100%) 건설표준품셈9-3", "q = 0.7 , K = 0.9"],
|
||||
"자원": [
|
||||
{"갈래": "L", "코드": "L00002", "이름": "보통인부", "규격": "", "품": 0.2},
|
||||
*[
|
||||
{
|
||||
"갈래": "X",
|
||||
"코드": "X00205",
|
||||
"이름": "굴삭기(무한궤도)",
|
||||
"규격": "0.7㎥",
|
||||
"품": 0.0229,
|
||||
}
|
||||
]
|
||||
* 3,
|
||||
{"갈래": "M", "코드": "M00001", "이름": "모래", "규격": "", "품": 1.2},
|
||||
],
|
||||
}
|
||||
fp = finger.excel(block, {"이름": "잔토처리", "규격": "토사", "단위": "M3"})
|
||||
assert fp["인력"] == {"보통인부": {"품": 0.2, "밑품": 0.2, "율": [], "어떻게": ["QTY"]}}
|
||||
assert [(m["갈래"], m["규격"], m["시간"]) for m in fp["장비"]] == [(["굴착기"], [0.7], 0.0229)]
|
||||
assert fp["자재"] == [{"이름": "모래", "품": 1.2, "밑품": 1.2}] and fp["계수"]["K"] == 0.9
|
||||
|
||||
|
||||
def test_기계_줄_블록_없음():
|
||||
fp = finger.excel(None, {"이름": "콘크리트 펌프차", "규격": "41m,80~95㎥/hr", "단위": "hr"})
|
||||
assert [(m["갈래"], m["시간"]) for m in fp["장비"]] == [(["콘크리트펌프"], 1.0)]
|
||||
assert 41.0 in fp["장비"][0]["규격"] and fp["인력"] == {}
|
||||
|
||||
|
||||
def test_직접_줄_호표():
|
||||
hopyo = {
|
||||
"이름": "돌붙임",
|
||||
"규격": "",
|
||||
"단위": "㎡",
|
||||
"줄": [
|
||||
{"이름": "석 공", "규격": "", "수량": 0.1, "단위": "인", "갈래": "기타"},
|
||||
{"이름": "보통인부", "규격": "", "수량": 0.05, "단위": "인", "갈래": "기타"},
|
||||
{"이름": "굴착기", "규격": "0.6㎥", "수량": 0.2, "단위": "hr", "갈래": "기계"},
|
||||
],
|
||||
}
|
||||
fp = finger.direct(hopyo)
|
||||
assert fp["인력"]["석공"]["품"] == 0.1 and fp["인력"]["보통인부"]["품"] == 0.05
|
||||
assert [(m["갈래"], m["시간"]) for m in fp["장비"]] == [(["굴착기"], 0.2)]
|
||||
assert finger.direct({**hopyo, "줄": hopyo["줄"][2:]}) is None # 노임 없으면 호표 통째 아님
|
||||
@@ -0,0 +1,122 @@
|
||||
"""338-1 순위 · 판정 · 조합 · 뒤 셈(`M02_Hopyo_Fit` · `_Verdict` · `_Combine`) — 같음 규칙 · 딱
|
||||
맞음이
|
||||
같음을 이김 · 맞음 · 다른 점 · 후보 · 철근 둘 · 고임돌 인력 10% · 맞춤셈 캐시 · 「셈 중」.
|
||||
실물 = 북부청 임도 · 사방(읽기만) · 층 · 캐시 폴더는 임시."""
|
||||
|
||||
import shutil
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Fit as fit
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hm
|
||||
from M02_MasterTemplete import M02_Hopyo_Ours as ours
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_Hopyo_Verdict as verdict
|
||||
from resources.tester.helper_m02_hopyo_golden import line_of, read
|
||||
from resources.tester.test_m02_hopyo_excel import master_hash
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def got():
|
||||
if not ours.ready():
|
||||
ours.build()
|
||||
return hm.targets()
|
||||
|
||||
|
||||
def _block(lid: str, got: dict, **change) -> dict:
|
||||
block, line = line_of(lid)
|
||||
if change:
|
||||
line = {**line, "근거": {}}
|
||||
block = {**block, "출처": []}
|
||||
return verdict.block(block, line, got)
|
||||
|
||||
|
||||
def test_같음_규칙_이름_같음_1위_고정(got):
|
||||
r = _block("N014", got)
|
||||
assert r["B"] == "같음" and r["차례"][0] == "GC000214" and r["판정"]["상태"] == "맞음"
|
||||
|
||||
|
||||
def test_딱_맞음이_같음을_이김(got):
|
||||
r = _block("N001", got, 근거=None) # 인용 조항을 지우면 이름 같음 = 산림 GF000160
|
||||
assert r["B"] == "같음" and hm.b_order(line_of("N001")[1], got)["상태"] == "후보"
|
||||
assert r["차례"][0] == "GC000211" and r["판정"]["상태"] == "맞음"
|
||||
|
||||
|
||||
def test_판정_셋(got):
|
||||
a = _block("N000", got)
|
||||
assert a["판정"]["상태"] == "맞음" and "소량감률 50" in a["판정"]["글"]
|
||||
assert a["후보"][0]["판정"] == "맞음" and a["후보"][0]["근거"]["까닭"] == "조항 같음"
|
||||
b = _block("N008", got)
|
||||
assert b["판정"]["상태"] == "다른 점" and b["차례"][0] == "GC000083"
|
||||
assert "특별인부 엑셀 0.3542 · 우리 0.3704" in b["판정"]["꼬리표"]
|
||||
c = _block("S084", got)
|
||||
assert c["판정"]["상태"] == "후보" and len(c["후보"]) == 5
|
||||
|
||||
|
||||
def test_합판_맞음_못_가름_꼬리표(got):
|
||||
r = _block("N001", got)
|
||||
assert r["판정"]["상태"] == "맞음"
|
||||
assert any(
|
||||
t.endswith("못 가름") and {"측압", "폼타이규격"} <= set(t.split())
|
||||
for t in r["판정"]["꼬리표"]
|
||||
)
|
||||
|
||||
|
||||
def test_조합_철근_가공_조립(got):
|
||||
r = _block("N003", got)
|
||||
assert {c["로직"] for c in r["조합"] if c["체크"]} == {"GC000206", "GC000207"}
|
||||
|
||||
|
||||
def test_조합_고임돌_인력_몫_먼저(got):
|
||||
r = _block("N005", got)
|
||||
assert r["차례"][0] == "GF000211"
|
||||
labor = next(c for c in r["조합"] if c["로직"] == "GF000211")
|
||||
assert labor["몫"] == 0.1 and labor["체크"]
|
||||
assert all(not c["체크"] for c in r["조합"] if c["로직"] in ("GC000267", "GC000268"))
|
||||
|
||||
|
||||
def test_숫자_없음_옛_HP(got):
|
||||
block, line = line_of("N000")
|
||||
r = verdict.block({**block, "글": []}, line, got)
|
||||
assert r["판정"]["상태"] == "후보" and r["판정"]["글"] == fit.NO_NUMBER
|
||||
assert r["차례"][0] == "GC000198" # 이름 · 근거 차례 그대로
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def layer(tmp_path, monkeypatch, got):
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
shutil.copy(ours.path_of(), cache / ours.path_of().name)
|
||||
monkeypatch.setenv("AISLO_HOPYO_CACHE", str(cache))
|
||||
book = read("북부청임도")
|
||||
small = {**book, "호표": book["호표"][:3]}
|
||||
brief = hopyo_store.save(tmp_path, "북부청.xls", small, {"id": "t"})
|
||||
return tmp_path, brief["키"], cache
|
||||
|
||||
|
||||
def test_뒤_셈_캐시_셈_중(layer):
|
||||
folder, key, cache = layer
|
||||
before = master_hash()
|
||||
assert fit.result(folder, key) is None
|
||||
first = fit.ensure(folder, key)
|
||||
assert first["끝"] is False and first["한"] == 0
|
||||
for _ in range(300):
|
||||
if fit.status(folder, key)["끝"]:
|
||||
break
|
||||
time.sleep(0.2)
|
||||
done = fit.status(folder, key)
|
||||
assert done["끝"] and done["한"] == done["모두"] > 0
|
||||
got = fit.result(folder, key)
|
||||
assert got["판"] == ours.version() and len(got["블록"]) == done["모두"]
|
||||
assert fit.cache_path(folder, key).parent == cache
|
||||
assert fit.ensure(folder, key)["끝"] # 두 번째는 캐시만 봄
|
||||
assert master_hash() == before
|
||||
|
||||
|
||||
def test_셈_단위_열쇠(got):
|
||||
book = read("북부청임도")
|
||||
units = fit.units({**book, "호표": book["호표"][:3]})
|
||||
keys = [k for k, _, _ in units]
|
||||
assert len(keys) == len(set(keys)) and keys[0].startswith("단산:")
|
||||
assert all(k.startswith(("단산:", "줄:", "호표:")) for k in keys)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""338-1 옵션 거꾸로 찾기(`M02_Hopyo_Option`) — 레미콘 소량 「하나만」 · 합판 갈리는 칸 · 무근 R
|
||||
맞음 ·
|
||||
구조물터파기 없음(다른 점) · 펌프차 기계 칸 · 견줌 · 반올림 폭. 실물 = 북부청 임도 ·
|
||||
사방(읽기만)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Finger as finger
|
||||
from M02_MasterTemplete import M02_Hopyo_Option as option
|
||||
from M02_MasterTemplete import M02_Hopyo_Ours as ours
|
||||
from resources.tester.helper_m02_hopyo_golden import line_of
|
||||
|
||||
|
||||
def _find(lid: str, code: str) -> dict:
|
||||
block, line = line_of(lid)
|
||||
return option.find(code, finger.excel(block, line), (block or {}).get("글"))
|
||||
|
||||
|
||||
def test_레미콘_소량_하나만():
|
||||
got = _find("N000", "GC000198")
|
||||
assert got["수준"] == 0 and got["판정"] == "하나만"
|
||||
assert got["입력"] == {"타설방법": "장비사용타설", "구조물": "철근구조물", "소량감률": 50}
|
||||
assert got["우리품"]["인력"] == {"콘크리트공": 0.109091, "보통인부": 0.036364}
|
||||
|
||||
|
||||
def test_합판_보통_갈리는_칸():
|
||||
got = _find("N001", "GC000211")
|
||||
assert got["수준"] == 0 and got["판정"] == "여럿(같은 품)"
|
||||
assert got["입력"]["유형"] == "보통" and got["입력"]["사용횟수"] == "4회"
|
||||
assert set(got["갈리는칸"]) == {"측압", "폼타이규격"}
|
||||
|
||||
|
||||
def test_무근_반올림_맞음():
|
||||
got = _find("N013", "GC000198")
|
||||
assert got["수준"] <= 1 and got["입력"]["구조물"] == "무근구조물"
|
||||
|
||||
|
||||
def test_구조물터파기_없음_가까운_옵션():
|
||||
got = _find("N008", "GC000083")
|
||||
assert got["수준"] == 9 and got["판정"] == "없음" and got["직종같음"]
|
||||
assert got["견줌"]["직종"] == "직종 같음" and got["거리"] < 0.1
|
||||
|
||||
|
||||
def test_펌프차_기계_칸():
|
||||
efp = finger.machine({"이름": "콘크리트 펌프차", "규격": "41m,80~95㎥/hr", "단위": "hr"})
|
||||
got = option.find("GC000268", efp)
|
||||
assert got["수준"] == 0
|
||||
code = got["입력"]["기계"]
|
||||
assert ours.eq_of(code)[0] == {"콘크리트펌프"} and 41.0 in option.eq_numbers(code)
|
||||
|
||||
|
||||
def test_없는_로직_못_셈():
|
||||
efp = finger.machine({"이름": "굴착기", "규격": "0.6㎥", "단위": "hr"})
|
||||
assert option.find("없는로직", efp)["판정"] == "못 셈"
|
||||
|
||||
|
||||
def test_반올림_폭_견줌():
|
||||
assert option.ru(0.15) == pytest.approx(0.005) and option.ru(0.1) == 0.0
|
||||
assert option.ru(0.109091) == 0.0
|
||||
e = {"인력": {"보통인부": {"품": 0.15, "밑품": 0.15}}, "장비": [], "자재": []}
|
||||
v = {"L": {"보통인부": 0.1545}, "X": [], "M": {}, "S": {}}
|
||||
assert not option.compare(e, v, 0.005)["핵심"] and option.compare(e, v, 0.005, rnd=True)["핵심"]
|
||||
assert not option.strong(e, option.compare(e, v, 0.03)) # 보통인부 하나뿐 = 우연 맞음
|
||||
@@ -0,0 +1,77 @@
|
||||
"""338-1 우리 로직 지문(`M02_Hopyo_Ours`) — 판 열쇠 · 캐시 쓰기 · 읽기 · 준비 전 None · 합판 품 ·
|
||||
기계경비 로직 · 섞기 전 변형 · 엔진 셈 캐시 · master_data 원본 그대로. 캐시 폴더는 임시."""
|
||||
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Fit as fit
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hm
|
||||
from M02_MasterTemplete import M02_Hopyo_Ours as ours
|
||||
from resources.tester.test_m02_hopyo_excel import master_hash
|
||||
|
||||
approx = pytest.approx
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache(tmp_path, monkeypatch):
|
||||
"""임시 캐시 폴더 + 로직 셋만 셈(1,438 대신) — 끝나면 메모리 표를 비움."""
|
||||
monkeypatch.setenv("AISLO_HOPYO_CACHE", str(tmp_path))
|
||||
few = [x for x in hm.targets()["로직"] if x["로직"] in ("GC000211", "GC000198", "GC000268")]
|
||||
monkeypatch.setattr(hm, "targets", lambda: {**hm._targets(id(ours.whole())), "로직": few})
|
||||
monkeypatch.setitem(ours._STATE, "표판", None)
|
||||
monkeypatch.setitem(ours._STATE, "표", None)
|
||||
yield tmp_path
|
||||
ours._STATE.update(표판=None, 표=None)
|
||||
|
||||
|
||||
def test_판_열쇠_같음():
|
||||
a, b = ours.version(), ours.version()
|
||||
assert a == b and len(a) == 16 and ours.path_of(a).name == f"우리지문_{a}.json"
|
||||
|
||||
|
||||
def test_캐시_쓰기_읽기_준비_전_none(cache):
|
||||
before = master_hash()
|
||||
assert ours.table() is None and not ours.ready()
|
||||
built = ours.build()
|
||||
assert [o["로직"] for o in built] == ["GC000198", "GC000211", "GC000268"]
|
||||
assert ours.path_of().parent == cache and ours.path_of().is_file()
|
||||
ours._STATE.update(표판=None, 표=None)
|
||||
assert [o["로직"] for o in ours.table()] == ["GC000198", "GC000211", "GC000268"]
|
||||
assert master_hash() == before
|
||||
|
||||
|
||||
def test_데움_뒤_스레드(cache):
|
||||
thread = ours.warm(build_missing=True)
|
||||
thread.join(timeout=120)
|
||||
assert ours.ready() and len(ours.table()) == 3
|
||||
|
||||
|
||||
def test_합판_보통_형틀목공():
|
||||
fp = ours.fingerprint("GC000211", "㎡")
|
||||
plain = next(v for v in fp["변형"] if v["in"].get("유형") == "보통")
|
||||
assert plain["L"]["형틀목공"] == approx(0.112, abs=1e-3)
|
||||
assert plain["L"]["보통인부"] == approx(0.045, abs=1e-3)
|
||||
assert fp["직종"] == ["보통인부", "형틀목공"] and not fp["기계경비"]
|
||||
assert fp["고름조건"]["횟수"] # 고르기 글 「4회사용시」 낱말
|
||||
|
||||
|
||||
def test_기계경비_로직은_고른_기계_1시간():
|
||||
v = ours.run("GC000268", {})
|
||||
assert v["L"] == {} and len(v["X"]) == 1 and v["X"][0][2] == 1.0
|
||||
assert ours.fingerprint("GC000268", "hr")["기계경비"]
|
||||
|
||||
|
||||
def test_섞기_전_변형():
|
||||
table = ours.table() or ours.build()
|
||||
mixed = next(
|
||||
o
|
||||
for o in table
|
||||
if any(0 < ((v.get("mix") or {}).get("인력비율") or 0) < 100 for v in o["변형"])
|
||||
)
|
||||
prep = fit._prep(mixed, {})
|
||||
assert any(v["in"] and v["in"].get("_섞기전") for v in prep["vs"])
|
||||
|
||||
|
||||
def test_엔진_셈_캐시():
|
||||
a = ours.run("GC000198", {"소량감률": 50})
|
||||
assert ours.run("GC000198", {"소량감률": 50}) is a # 같은 입력은 다시 안 셈
|
||||
assert ours.run("없는로직", {}) is None
|
||||
Reference in New Issue
Block a user