diff --git a/M02_MasterTemplete/M02_Hopyo_Combine.py b/M02_MasterTemplete/M02_Hopyo_Combine.py new file mode 100644 index 000000000..25f3c4450 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Combine.py @@ -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] diff --git a/M02_MasterTemplete/M02_Hopyo_Finger.py b/M02_MasterTemplete/M02_Hopyo_Finger.py new file mode 100644 index 000000000..b5ee84a45 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Finger.py @@ -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[^::=]{1,40}?)\s*[::]\s*(?P.*)$" +) +_MAT = re.compile( + r"^\s*(?P[^\d\s::=][^::=]*?[A-Za-z가-힣)\]])\s+(?P[\d,]+(?:\.\d+)?\s*[×xX*÷/].*=.*)$" +) +_PCT = re.compile(r"([가-힣]+)의\s*([\d.]+)\s*%") +_COEF = re.compile( + r"(? 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 diff --git a/M02_MasterTemplete/M02_Hopyo_Fit.py b/M02_MasterTemplete/M02_Hopyo_Fit.py new file mode 100644 index 000000000..3d74d5389 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Fit.py @@ -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/맞춤__<판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 diff --git a/M02_MasterTemplete/M02_Hopyo_Match.py b/M02_MasterTemplete/M02_Hopyo_Match.py index c45f24f13..d1dd71ef1 100644 --- a/M02_MasterTemplete/M02_Hopyo_Match.py +++ b/M02_MasterTemplete/M02_Hopyo_Match.py @@ -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]: """기억을 볼 층 폴더 — 같은 층 → 위 층(개인 → 회사 → 마스터).""" diff --git a/M02_MasterTemplete/M02_Hopyo_Option.py b/M02_MasterTemplete/M02_Hopyo_Option.py new file mode 100644 index 000000000..f6b4a6c44 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Option.py @@ -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"(? 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"(? 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, + } diff --git a/M02_MasterTemplete/M02_Hopyo_Ours.py b/M02_MasterTemplete/M02_Hopyo_Ours.py new file mode 100644 index 000000000..667958381 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Ours.py @@ -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 diff --git a/M02_MasterTemplete/M02_Hopyo_Shares.py b/M02_MasterTemplete/M02_Hopyo_Shares.py new file mode 100644 index 000000000..bbf87d026 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Shares.py @@ -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["장비"]] diff --git a/M02_MasterTemplete/M02_Hopyo_Verdict.py b/M02_MasterTemplete/M02_Hopyo_Verdict.py new file mode 100644 index 000000000..0539033e0 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Verdict.py @@ -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 diff --git a/M02_MasterTemplete/M02_Hopyo_Words.py b/M02_MasterTemplete/M02_Hopyo_Words.py new file mode 100644 index 000000000..8820efa1c --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Words.py @@ -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 diff --git a/resources/tester/fixtures/m02_hopyo_golden.json b/resources/tester/fixtures/m02_hopyo_golden.json new file mode 100644 index 000000000..7750fc8a1 --- /dev/null +++ b/resources/tester/fixtures/m02_hopyo_golden.json @@ -0,0 +1,3380 @@ +[ + { + "id": "N000", + "파일": "북부청임도", + "이름": "레미콘타설(장비사용)", + "규격": "철근구조물-소량 타설", + "단위": "㎥", + "갈래": "단가산출", + "단산": 46, + "무리": "사람", + "정답": [ + "GC000198" + ], + "허용": [ + "GF000155" + ], + "짐작": false, + "복합": null, + "줄수": 13 + }, + { + "id": "N001", + "파일": "북부청임도", + "이름": "합판거푸집", + "규격": "4회", + "단위": "㎡", + "갈래": "단가산출", + "단산": 24, + "무리": "사람", + "정답": [ + "GC000211" + ], + "허용": [ + "GF000160" + ], + "짐작": false, + "복합": null, + "줄수": 11 + }, + { + "id": "N002", + "파일": "북부청임도", + "이름": "기초잡석채집,운반,다짐", + "규격": "발파암활용,덤프15톤,L=500m", + "단위": "㎥", + "갈래": "단가산출", + "단산": 33, + "무리": "사람", + "정답": [ + "GC000110", + "GF000168" + ], + "허용": [], + "짐작": true, + "복합": null, + "줄수": 18 + }, + { + "id": "N003", + "파일": "북부청임도", + "이름": "철근가공조립(현장)", + "규격": "Type-Ⅰ(소량 시공)", + "단위": "Ton", + "갈래": "단가산출", + "단산": 47, + "무리": "사람", + "정답": [ + "GC000206", + "GC000207" + ], + "허용": [ + "GF000159" + ], + "짐작": false, + "복합": [ + "GC000206", + "GC000207" + ], + "줄수": 12 + }, + { + "id": "N004", + "파일": "북부청임도", + "이름": "야면석채집 및 운반(계곡내)", + "규격": "굴삭기0.6㎥,35cm,4.5톤,L=500m", + "단위": "㎡", + "갈래": "단가산출", + "단산": 6, + "무리": "사람", + "정답": [ + "GF000214" + ], + "허용": [], + "짐작": true, + "복합": null, + "줄수": 1 + }, + { + "id": "N005", + "파일": "북부청임도", + "이름": "고임돌 채집 및 운반", + "규격": "굴삭기0.6㎥(90%),4.5톤,L=500m", + "단위": "㎥", + "갈래": "단가산출", + "단산": 3, + "무리": "사람", + "정답": [ + "GF000211" + ], + "허용": [ + "GF000212" + ], + "짐작": true, + "복합": null, + "줄수": 1 + }, + { + "id": "N006", + "파일": "북부청임도", + "이름": "야면석소운반", + "규격": "L=35cm", + "단위": "㎡", + "갈래": "단가산출", + "단산": 10, + "무리": "사람", + "정답": [ + "GF000280" + ], + "허용": [ + "GF000279" + ], + "짐작": true, + "복합": null, + "줄수": 3 + }, + { + "id": "N007", + "파일": "북부청임도", + "이름": "야면석찰붙임", + "규격": "L3=35cm적용,장비사용", + "단위": "㎡", + "갈래": "단가산출", + "단산": 39, + "무리": "사람", + "정답": [ + "GF000225" + ], + "허용": [ + "GC000263" + ], + "짐작": false, + "복합": null, + "줄수": 7 + }, + { + "id": "N008", + "파일": "북부청임도", + "이름": "구조물터파기(인력100%)", + "규격": "토사", + "단위": "㎥", + "갈래": "단가산출", + "단산": 93, + "무리": "사람", + "정답": [ + "GC000083" + ], + "허용": [ + "GF000101" + ], + "짐작": false, + "복합": null, + "줄수": 4 + }, + { + "id": "N009", + "파일": "북부청임도", + "이름": "구조물되메우기(인력100%)", + "규격": "", + "단위": "㎥", + "갈래": "단가산출", + "단산": 12, + "무리": "사람", + "정답": [ + "GF000113" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 4 + }, + { + "id": "N010", + "파일": "북부청임도", + "이름": "잔토처리(인력100%)", + "규격": "", + "단위": "㎥", + "갈래": "단가산출", + "단산": 13, + "무리": "사람", + "정답": [], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 4 + }, + { + "id": "N011", + "파일": "북부청임도", + "이름": "야면석채집및운반(계곡내)", + "규격": "굴삭기0.6㎥,35cm,4.5톤,L=500m", + "단위": "㎡", + "갈래": "단가산출", + "단산": 6, + "무리": "사람", + "정답": [ + "GF000214" + ], + "허용": [], + "짐작": true, + "복합": null, + "줄수": 7 + }, + { + "id": "N012", + "파일": "북부청임도", + "이름": "고임돌채집및운반", + "규격": "굴삭기0.6㎥(90%),4.5톤,L=500m", + "단위": "㎥", + "갈래": "단가산출", + "단산": 3, + "무리": "사람", + "정답": [ + "GF000211" + ], + "허용": [ + "GF000212" + ], + "짐작": true, + "복합": null, + "줄수": 17 + }, + { + "id": "N013", + "파일": "북부청임도", + "이름": "레미콘타설(장비사용)", + "규격": "무근구조물", + "단위": "㎥", + "갈래": "단가산출", + "단산": 22, + "무리": "사람", + "정답": [ + "GC000198" + ], + "허용": [ + "GF000155" + ], + "짐작": false, + "복합": null, + "줄수": 13 + }, + { + "id": "N014", + "파일": "북부청임도", + "이름": "유로폼", + "규격": "보통,0~7m", + "단위": "㎡", + "갈래": "단가산출", + "단산": 26, + "무리": "사람", + "정답": [ + "GC000214" + ], + "허용": [ + "GF000210" + ], + "짐작": false, + "복합": null, + "줄수": 13 + }, + { + "id": "N015", + "파일": "북부청임도", + "이름": "문양거푸집", + "규격": "", + "단위": "㎡", + "갈래": "단가산출", + "단산": 18, + "무리": "사람", + "정답": [ + "GC000215" + ], + "허용": [ + "GF000161" + ], + "짐작": false, + "복합": null, + "줄수": 4 + }, + { + "id": "N016", + "파일": "북부청임도", + "이름": "P.V.C파이프설치", + "규격": "Φ50mm", + "단위": "M", + "갈래": "단가산출", + "단산": 37, + "무리": "사람", + "정답": [ + "GF000192" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 11 + }, + { + "id": "N017", + "파일": "북부청임도", + "이름": "다웰바설치", + "규격": "Φ32×1000", + "단위": "EA", + "갈래": "단가산출", + "단산": 38, + "무리": "사람", + "정답": [ + "GC000226" + ], + "허용": [ + "GF000201" + ], + "짐작": false, + "복합": null, + "줄수": 4 + }, + { + "id": "N018", + "파일": "북부청임도", + "이름": "휠라설치", + "규격": "T=15mm,1000×1500mm", + "단위": "㎡", + "갈래": "단가산출", + "단산": 41, + "무리": "사람", + "정답": [ + "GC000227" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 4 + }, + { + "id": "N019", + "파일": "북부청임도", + "이름": "철근가공조립(현장)", + "규격": "연결철근,Type-Ⅰ×30%적용", + "단위": "Ton", + "갈래": "단가산출", + "단산": 29, + "무리": "사람", + "정답": [ + "GC000206", + "GC000207" + ], + "허용": [ + "GF000159" + ], + "짐작": false, + "복합": [ + "GC000206", + "GC000207" + ], + "줄수": 4 + }, + { + "id": "N020", + "파일": "북부청임도", + "이름": "큰돌채집운반", + "규격": "공사중 발생석 활용 50%적용", + "단위": "㎡", + "갈래": "단가산출", + "단산": 30, + "무리": "사람", + "정답": [ + "GF000213" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 4 + }, + { + "id": "N021", + "파일": "북부청임도", + "이름": "큰돌쌓기(메쌓기)", + "규격": "직경40cm이상~60cm미만", + "단위": "㎡", + "갈래": "단가산출", + "단산": 32, + "무리": "사람", + "정답": [ + "GF000226" + ], + "허용": [ + "GC000260" + ], + "짐작": false, + "복합": null, + "줄수": 2 + }, + { + "id": "N022", + "파일": "북부청임도", + "이름": "뒷채움돌채집운반뒷채움(발파)", + "규격": "굴삭기0.6㎥(90%),4.5톤,L=500m", + "단위": "㎥", + "갈래": "단가산출", + "단산": 2, + "무리": "사람", + "정답": [ + "GC000106" + ], + "허용": [ + "GF000218" + ], + "짐작": true, + "복합": null, + "줄수": 11 + }, + { + "id": "N023", + "파일": "북부청임도", + "이름": "큰돌쌓기(찰쌓기)_배수공", + "규격": "직경40cm이상~60cm미만", + "단위": "㎡", + "갈래": "단가산출", + "단산": 31, + "무리": "사람", + "정답": [ + "GF000227" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 2 + }, + { + "id": "N024", + "파일": "북부청임도", + "이름": "레미콘타설(인력운반)", + "규격": "무근구조물-소량 타설", + "단위": "㎥", + "갈래": "단가산출", + "단산": 21, + "무리": "사람", + "정답": [ + "GC000198" + ], + "허용": [ + "GF000155" + ], + "짐작": false, + "복합": null, + "줄수": 3 + }, + { + "id": "N025", + "파일": "북부청임도", + "이름": "합판거푸집", + "규격": "6회", + "단위": "㎡", + "갈래": "단가산출", + "단산": 25, + "무리": "사람", + "정답": [ + "GC000211" + ], + "허용": [ + "GF000160" + ], + "짐작": false, + "복합": null, + "줄수": 12 + }, + { + "id": "N026", + "파일": "북부청임도", + "이름": "도로반사경설치", + "규격": "D=1000(STS)", + "단위": "개소", + "갈래": "단가산출", + "단산": 42, + "무리": "사람", + "정답": [ + "GC000310" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "N027", + "파일": "북부청임도", + "이름": "야면석메붙임", + "규격": "L3 =35cm내외,장비사용", + "단위": "㎡", + "갈래": "단가산출", + "단산": 88, + "무리": "사람", + "정답": [ + "GF000224" + ], + "허용": [ + "GC000262" + ], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "N028", + "파일": "북부청임도", + "이름": "큰돌메붙임", + "규격": "직경40cm이상~60cm미만", + "단위": "㎡", + "갈래": "단가산출", + "단산": 92, + "무리": "사람", + "정답": [ + "GF000229" + ], + "허용": [ + "GC000262" + ], + "짐작": false, + "복합": null, + "줄수": 2 + }, + { + "id": "N029", + "파일": "북부청임도", + "이름": "큰돌찰붙임", + "규격": "직경40cm이상~60cm미만", + "단위": "㎡", + "갈래": "단가산출", + "단산": 91, + "무리": "사람", + "정답": [ + "GF000230" + ], + "허용": [ + "GC000263" + ], + "짐작": false, + "복합": null, + "줄수": 4 + }, + { + "id": "N030", + "파일": "북부청임도", + "이름": "레미콘타설(장비사용)", + "규격": "소형구조물", + "단위": "㎥", + "갈래": "단가산출", + "단산": 57, + "무리": "사람", + "정답": [ + "GC000198" + ], + "허용": [ + "GF000155" + ], + "짐작": false, + "복합": null, + "줄수": 2 + }, + { + "id": "N031", + "파일": "북부청임도", + "이름": "야면석찰쌓기", + "규격": "L3=35cm적용,장비사용", + "단위": "㎡", + "갈래": "단가산출", + "단산": 71, + "무리": "사람", + "정답": [ + "GF000222" + ], + "허용": [ + "GC000261" + ], + "짐작": false, + "복합": null, + "줄수": 2 + }, + { + "id": "N032", + "파일": "북부청임도", + "이름": "면고르기", + "규격": "", + "단위": "㎡", + "갈래": "단가산출", + "단산": 43, + "무리": "사람", + "정답": [ + "GC000111" + ], + "허용": [ + "GF000124" + ], + "짐작": false, + "복합": null, + "줄수": 4 + }, + { + "id": "N033", + "파일": "북부청임도", + "이름": "대형브레이카", + "규격": "+굴착기(0.7㎥)", + "단위": "hr", + "갈래": "기계", + "무리": "사람", + "정답": [ + "GC000271" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 3 + }, + { + "id": "N034", + "파일": "북부청임도", + "이름": "와이어메시바닥깔기", + "규격": "#6100×100", + "단위": "㎡", + "갈래": "단가산출", + "단산": 60, + "무리": "사람", + "정답": [ + "GC000665" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "N035", + "파일": "북부청임도", + "이름": "원형돌망태 설치 및 돌채움", + "규격": "#8,45×45 cm", + "단위": "㎡", + "갈래": "단가산출", + "단산": 62, + "무리": "사람", + "정답": [ + "GF000328" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 6 + }, + { + "id": "N036", + "파일": "북부청임도", + "이름": "조약돌채집및운반", + "규격": "굴삭기0.6㎥(90%),4.5톤,L=500m", + "단위": "㎥", + "갈래": "단가산출", + "단산": 61, + "무리": "사람", + "정답": [ + "GF000211" + ], + "허용": [], + "짐작": true, + "복합": null, + "줄수": 5 + }, + { + "id": "N037", + "파일": "북부청임도", + "이름": "돌망태형 옹벽설치", + "규격": "사각돌망태", + "단위": "㎡", + "갈래": "단가산출", + "단산": 63, + "무리": "사람", + "정답": [ + "GF000235" + ], + "허용": [ + "GC000342" + ], + "짐작": false, + "복합": null, + "줄수": 4 + }, + { + "id": "N038", + "파일": "북부청임도", + "이름": "콘크리트포장(인력)", + "규격": "T=20cm, 종단15%미만", + "단위": "㎥", + "갈래": "단가산출", + "단산": 65, + "무리": "사람", + "정답": [ + "GC000297" + ], + "허용": [ + "GF000162" + ], + "짐작": false, + "복합": null, + "줄수": 4 + }, + { + "id": "N039", + "파일": "북부청임도", + "이름": "비탈면 보호공", + "규격": "평떼", + "단위": "㎡", + "갈래": "단가산출", + "단산": 64, + "무리": "사람", + "정답": [ + "GC000143" + ], + "허용": [ + "GF000032" + ], + "짐작": false, + "복합": null, + "줄수": 6 + }, + { + "id": "N040", + "파일": "북부청임도", + "이름": "콘크리트포장(인력)", + "규격": "T=20cm,종단15%이상", + "단위": "㎥", + "갈래": "단가산출", + "단산": 66, + "무리": "사람", + "정답": [ + "GC000297" + ], + "허용": [ + "GF000162" + ], + "짐작": false, + "복합": null, + "줄수": 3 + }, + { + "id": "N041", + "파일": "북부청임도", + "이름": "고화매트 설치", + "규격": "", + "단위": "㎡", + "갈래": "단가산출", + "단산": 70, + "무리": "사람", + "정답": [], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "N042", + "파일": "북부청임도", + "이름": "큰돌쌓기(찰쌓기)", + "규격": "직경40cm이상~60cm미만", + "단위": "㎡", + "갈래": "단가산출", + "단산": 90, + "무리": "사람", + "정답": [ + "GF000227" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "N043", + "파일": "북부청임도", + "이름": "레미콘타설(장비사용)", + "규격": "철근구조물", + "단위": "㎥", + "갈래": "단가산출", + "단산": 23, + "무리": "사람", + "정답": [ + "GC000198" + ], + "허용": [ + "GF000155" + ], + "짐작": false, + "복합": null, + "줄수": 7 + }, + { + "id": "N044", + "파일": "북부청임도", + "이름": "강관동바리", + "규격": "암거용-1개월사용", + "단위": "공/㎥", + "갈래": "단가산출", + "단산": 73, + "무리": "사람", + "정답": [ + "GC000015" + ], + "허용": [ + "GF000189" + ], + "짐작": false, + "복합": null, + "줄수": 7 + }, + { + "id": "N045", + "파일": "북부청임도", + "이름": "철근가공조립(현장)", + "규격": "Type-ll", + "단위": "Ton", + "갈래": "단가산출", + "단산": 72, + "무리": "사람", + "정답": [ + "GC000206", + "GC000207" + ], + "허용": [ + "GF000159" + ], + "짐작": false, + "복합": [ + "GC000206", + "GC000207" + ], + "줄수": 7 + }, + { + "id": "N046", + "파일": "북부청임도", + "이름": "강관비계", + "규격": "10m이하,3개월사용", + "단위": "㎡", + "갈래": "단가산출", + "단산": 74, + "무리": "사람", + "정답": [ + "GC000022" + ], + "허용": [ + "GF000188" + ], + "짐작": false, + "복합": null, + "줄수": 6 + }, + { + "id": "N047", + "파일": "북부청임도", + "이름": "흙깍기(토사)", + "규격": "굴삭기1.0㎥,보통토사", + "단위": "㎥", + "갈래": "단가산출", + "단산": 49, + "무리": "사람", + "정답": [ + "GC000085" + ], + "허용": [ + "GF000089" + ], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "N048", + "파일": "북부청임도", + "이름": "잔토정리", + "규격": "굴삭기0.6㎥", + "단위": "㎥", + "갈래": "단가산출", + "단산": 56, + "무리": "사람", + "정답": [], + "허용": [], + "짐작": true, + "복합": null, + "줄수": 1 + }, + { + "id": "N049", + "파일": "북부청임도", + "이름": "잡철물 제작설치", + "규격": "규격철물 설치(일반철재)", + "단위": "Ton", + "갈래": "단가산출", + "단산": 82, + "무리": "사람", + "정답": [ + "GC000676" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "N050", + "파일": "북부청임도", + "이름": "도장(철재면2회)", + "규격": "방청1,유성2", + "단위": "㎡", + "갈래": "단가산출", + "단산": 95, + "무리": "사람", + "정답": [ + "GC000705", + "GC000714", + "GC000712" + ], + "허용": [], + "짐작": false, + "복합": [ + "GC000705", + "GC000714", + "GC000712" + ], + "줄수": 1 + }, + { + "id": "N051", + "파일": "북부청임도", + "이름": "임도안전표지판설치", + "규격": "삼각,900mm", + "단위": "개소", + "갈래": "단가산출", + "단산": 81, + "무리": "사람", + "정답": [ + "GC000308" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "S052", + "파일": "북부청사방", + "이름": "레미콘타설(장비사용)", + "규격": "철근구조물", + "단위": "㎥", + "갈래": "단가산출", + "단산": 22, + "무리": "사람", + "정답": [ + "GC000198" + ], + "허용": [ + "GF000155" + ], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "S053", + "파일": "북부청사방", + "이름": "레미콘타설(장비사용)", + "규격": "무근구조물", + "단위": "㎥", + "갈래": "단가산출", + "단산": 20, + "무리": "사람", + "정답": [ + "GC000198" + ], + "허용": [ + "GF000155" + ], + "짐작": false, + "복합": null, + "줄수": 4 + }, + { + "id": "S054", + "파일": "북부청사방", + "이름": "유로폼(Euro Form)", + "규격": "6회, 보통", + "단위": "㎡", + "갈래": "단가산출", + "단산": 38, + "무리": "사람", + "정답": [ + "GC000214" + ], + "허용": [ + "GF000210" + ], + "짐작": false, + "복합": null, + "줄수": 2 + }, + { + "id": "S055", + "파일": "북부청사방", + "이름": "철근가공조립(현장)", + "규격": "Type-Ⅱ", + "단위": "Ton", + "갈래": "단가산출", + "단산": 53, + "무리": "사람", + "정답": [ + "GC000206", + "GC000207" + ], + "허용": [ + "GF000159" + ], + "짐작": false, + "복합": [ + "GC000206", + "GC000207" + ], + "줄수": 1 + }, + { + "id": "S056", + "파일": "북부청사방", + "이름": "강관비계", + "규격": "(10m이하)", + "단위": "㎡", + "갈래": "단가산출", + "단산": 4, + "무리": "사람", + "정답": [ + "GC000022" + ], + "허용": [ + "GF000188" + ], + "짐작": false, + "복합": null, + "줄수": 2 + }, + { + "id": "S057", + "파일": "북부청사방", + "이름": "강관동바리", + "규격": "(2.5m이하)", + "단위": "공/㎥", + "갈래": "단가산출", + "단산": 3, + "무리": "사람", + "정답": [ + "GC000015" + ], + "허용": [ + "GF000189" + ], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "S058", + "파일": "북부청사방", + "이름": "유로폼(Euro Form)", + "규격": "6회, 간단", + "단위": "㎡", + "갈래": "단가산출", + "단산": 37, + "무리": "사람", + "정답": [ + "GC000214" + ], + "허용": [ + "GF000210" + ], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "S059", + "파일": "북부청사방", + "이름": "철근가공조립(현장)", + "규격": "Type-Ⅰ(소량)", + "단위": "Ton", + "갈래": "단가산출", + "단산": 52, + "무리": "사람", + "정답": [ + "GC000206", + "GC000207" + ], + "허용": [ + "GF000159" + ], + "짐작": false, + "복합": [ + "GC000206", + "GC000207" + ], + "줄수": 3 + }, + { + "id": "S060", + "파일": "북부청사방", + "이름": "철강재운반(할증포함)", + "규격": "(구역화물)", + "단위": "Ton", + "갈래": "단가산출", + "단산": 49, + "무리": "사람", + "정답": [], + "허용": [], + "짐작": true, + "복합": null, + "줄수": 1 + }, + { + "id": "S061", + "파일": "북부청사방", + "이름": "철골설치", + "규격": "", + "단위": "Ton", + "갈래": "단가산출", + "단산": 51, + "무리": "사람", + "정답": [ + "GC000563" + ], + "허용": [ + "GC000561" + ], + "짐작": true, + "복합": null, + "줄수": 1 + }, + { + "id": "S062", + "파일": "북부청사방", + "이름": "철골가공조립(공장)", + "규격": "(대형)", + "단위": "Ton", + "갈래": "단가산출", + "단산": 50, + "무리": "사람", + "정답": [ + "GC000559" + ], + "허용": [ + "GC000560" + ], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "S063", + "파일": "북부청사방", + "이름": "잡철물제작", + "규격": "", + "단위": "Ton", + "갈래": "단가산출", + "단산": 42, + "무리": "사람", + "정답": [ + "GC000676" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 2 + }, + { + "id": "S064", + "파일": "북부청사방", + "이름": "합판거푸집(6회)", + "규격": "", + "단위": "㎡", + "갈래": "단가산출", + "단산": 67, + "무리": "사람", + "정답": [ + "GC000211" + ], + "허용": [ + "GF000160" + ], + "짐작": false, + "복합": null, + "줄수": 7 + }, + { + "id": "S065", + "파일": "북부청사방", + "이름": "와이어메시 바닥깔기", + "규격": "", + "단위": "㎡", + "갈래": "단가산출", + "단산": 33, + "무리": "사람", + "정답": [ + "GC000665" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 2 + }, + { + "id": "S066", + "파일": "북부청사방", + "이름": "구조물터파기(토사)", + "규격": "", + "단위": "㎥", + "갈래": "단가산출", + "단산": 11, + "무리": "사람", + "정답": [ + "GC000089" + ], + "허용": [ + "GC000088" + ], + "짐작": false, + "복합": null, + "줄수": 18 + }, + { + "id": "S067", + "파일": "북부청사방", + "이름": "구조물되메우기 및 다짐", + "규격": "", + "단위": "㎥", + "갈래": "단가산출", + "단산": 8, + "무리": "사람", + "정답": [ + "GC000109" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 6 + }, + { + "id": "S068", + "파일": "북부청사방", + "이름": "잔토처리", + "규격": "", + "단위": "㎥", + "갈래": "단가산출", + "단산": 39, + "무리": "사람", + "정답": [], + "허용": [], + "짐작": true, + "복합": null, + "줄수": 18 + }, + { + "id": "S069", + "파일": "북부청사방", + "이름": "레미콘타설(장비사용)", + "규격": "무근구조물-소량타설", + "단위": "㎥", + "갈래": "단가산출", + "단산": 21, + "무리": "사람", + "정답": [ + "GC000198" + ], + "허용": [ + "GF000155" + ], + "짐작": false, + "복합": null, + "줄수": 7 + }, + { + "id": "S070", + "파일": "북부청사방", + "이름": "합판거푸집(4회)", + "규격": "", + "단위": "㎡", + "갈래": "단가산출", + "단산": 66, + "무리": "사람", + "정답": [ + "GC000211" + ], + "허용": [ + "GF000160" + ], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "S071", + "파일": "북부청사방", + "이름": "기초잡석다짐 및 채집운반", + "규격": "(9:1)(0.6㎥+4.5Ton)", + "단위": "㎥", + "갈래": "단가산출", + "단산": 12, + "무리": "사람", + "정답": [ + "GC000110", + "GF000168" + ], + "허용": [], + "짐작": true, + "복합": null, + "줄수": 1 + }, + { + "id": "S072", + "파일": "북부청사방", + "이름": "배수관부설", + "규격": "(D=1000mm)", + "단위": "M", + "갈래": "단가산출", + "단산": 26, + "무리": "사람", + "정답": [ + "GC000404" + ], + "허용": [ + "GF000176", + "GF000179" + ], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "S073", + "파일": "북부청사방", + "이름": "철골공", + "규격": "", + "단위": "인", + "갈래": "기타", + "무리": "사람", + "정답": [], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "S074", + "파일": "북부청사방", + "이름": "특별인부", + "규격": "", + "단위": "인", + "갈래": "기타", + "무리": "사람", + "정답": [], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "S075", + "파일": "북부청사방", + "이름": "레미콘타설(인력운반)", + "규격": "무근구조물-소량타설", + "단위": "㎥", + "갈래": "단가산출", + "단산": 19, + "무리": "사람", + "정답": [ + "GC000198" + ], + "허용": [ + "GF000155" + ], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "S076", + "파일": "북부청사방", + "이름": "구조물터파기", + "규격": "인력", + "단위": "㎥", + "갈래": "단가산출", + "단산": 9, + "무리": "사람", + "정답": [ + "GC000083" + ], + "허용": [ + "GF000101" + ], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "S077", + "파일": "북부청사방", + "이름": "구조물되메우기", + "규격": "인력", + "단위": "㎥", + "갈래": "단가산출", + "단산": 7, + "무리": "사람", + "정답": [ + "GF000113" + ], + "허용": [], + "짐작": true, + "복합": null, + "줄수": 1 + }, + { + "id": "S078", + "파일": "북부청사방", + "이름": "잔토처리", + "규격": "인력", + "단위": "㎥", + "갈래": "단가산출", + "단산": 40, + "무리": "사람", + "정답": [], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "S079", + "파일": "북부청사방", + "이름": "원형돌망태조립설치", + "규격": "산림사업품셈", + "단위": "조", + "갈래": "단가산출", + "단산": 35, + "무리": "사람", + "정답": [ + "GF000328" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "S080", + "파일": "북부청사방", + "이름": "원형돌망태채움", + "규격": "산림사업품셈", + "단위": "조", + "갈래": "단가산출", + "단산": 36, + "무리": "사람", + "정답": [ + "GF000328" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "S081", + "파일": "북부청사방", + "이름": "조약돌채집운반", + "규격": "(9:1)(0.6㎥+4.5Ton)", + "단위": "㎥", + "갈래": "단가산출", + "단산": 43, + "무리": "사람", + "정답": [ + "GF000211" + ], + "허용": [], + "짐작": true, + "복합": null, + "줄수": 1 + }, + { + "id": "S082", + "파일": "북부청사방", + "이름": "구조물터파기(암)", + "규격": "", + "단위": "㎥", + "갈래": "단가산출", + "단산": 10, + "무리": "사람", + "정답": [ + "GC000090" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 2 + }, + { + "id": "S083", + "파일": "북부청사방", + "이름": "배수관부설", + "규격": "(D=500mm)", + "단위": "M", + "갈래": "단가산출", + "단산": 27, + "무리": "사람", + "정답": [ + "GC000404" + ], + "허용": [ + "GF000176", + "GF000179" + ], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "S084", + "파일": "북부청사방", + "이름": "레미콘타설(무근펌프카)(130㎥)", + "규격": "41m(80-95㎥/hr)", + "단위": "㎥", + "갈래": "단가산출", + "단산": 18, + "무리": "사람", + "정답": [ + "GC000201" + ], + "허용": [ + "GF000182" + ], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "S085", + "파일": "북부청사방", + "이름": "콘크리트 펌프차", + "규격": "41m,80~95㎥/hr", + "단위": "hr", + "갈래": "기계", + "무리": "사람", + "정답": [ + "GC000268", + "GC000269", + "GC000275" + ], + "허용": [], + "짐작": true, + "복합": null, + "줄수": 1 + }, + { + "id": "S086", + "파일": "북부청사방", + "이름": "문양거푸집", + "규격": "", + "단위": "㎡", + "갈래": "단가산출", + "단산": 25, + "무리": "사람", + "정답": [ + "GC000215" + ], + "허용": [ + "GF000161" + ], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "S087", + "파일": "북부청사방", + "이름": "지수판 설치", + "규격": "산림사업품셈", + "단위": "M", + "갈래": "단가산출", + "단산": 46, + "무리": "사람", + "정답": [ + "GF000196" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "S088", + "파일": "북부청사방", + "이름": "P.V.C 파이프 설치", + "규격": "산림사업품셈", + "단위": "M", + "갈래": "단가산출", + "단산": 1, + "무리": "사람", + "정답": [ + "GF000192" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "S089", + "파일": "북부청사방", + "이름": "떼붙임(평떼)", + "규격": "산림사업품셈", + "단위": "㎡", + "갈래": "단가산출", + "단산": 17, + "무리": "사람", + "정답": [ + "GF000025" + ], + "허용": [ + "GF000032" + ], + "짐작": false, + "복합": null, + "줄수": 2 + }, + { + "id": "S090", + "파일": "북부청사방", + "이름": "큰돌메쌓기", + "규격": "40cm-60cm,(산림사업품셈)", + "단위": "㎡", + "갈래": "단가산출", + "단산": 60, + "무리": "사람", + "정답": [ + "GF000226" + ], + "허용": [ + "GC000260" + ], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "S091", + "파일": "북부청사방", + "이름": "고임돌채집및운반", + "규격": "(9:1)(0.6㎥+4.5Ton)", + "단위": "㎥", + "갈래": "단가산출", + "단산": 5, + "무리": "사람", + "정답": [ + "GF000211" + ], + "허용": [ + "GF000212" + ], + "짐작": true, + "복합": null, + "줄수": 9 + }, + { + "id": "S092", + "파일": "북부청사방", + "이름": "뒷채움잡석채집운반다짐", + "규격": "(9:1)(0.6㎥+4.5Ton)", + "단위": "㎥", + "갈래": "단가산출", + "단산": 16, + "무리": "사람", + "정답": [ + "GC000106" + ], + "허용": [ + "GF000218" + ], + "짐작": true, + "복합": null, + "줄수": 4 + }, + { + "id": "S093", + "파일": "북부청사방", + "이름": "큰돌찰쌓기", + "규격": "40cm-60cm,(산림사업품셈)", + "단위": "㎡", + "갈래": "단가산출", + "단산": 62, + "무리": "사람", + "정답": [ + "GF000227" + ], + "허용": [ + "GC000261" + ], + "짐작": false, + "복합": null, + "줄수": 10 + }, + { + "id": "S094", + "파일": "북부청사방", + "이름": "큰돌채집운반", + "규격": "산림사업품셈", + "단위": "㎡", + "갈래": "단가산출", + "단산": 63, + "무리": "사람", + "정답": [ + "GF000213" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 5 + }, + { + "id": "S095", + "파일": "북부청사방", + "이름": "뒷채움잡석구입운반", + "규격": "", + "단위": "㎥", + "갈래": "단가산출", + "단산": 13, + "무리": "사람", + "정답": [ + "GF000135", + "GF000284" + ], + "허용": [], + "짐작": true, + "복합": [ + "GF000135", + "GF000284" + ], + "줄수": 1 + }, + { + "id": "S096", + "파일": "북부청사방", + "이름": "뒷채움잡석뒷채움", + "규격": "", + "단위": "㎥", + "갈래": "단가산출", + "단산": 15, + "무리": "사람", + "정답": [ + "GC000106" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 2 + }, + { + "id": "S097", + "파일": "북부청사방", + "이름": "뒷채움잡석구입운반", + "규격": "(창봉 기번3)(2차)", + "단위": "㎥", + "갈래": "단가산출", + "단산": 14, + "무리": "사람", + "정답": [ + "GF000135", + "GF000284" + ], + "허용": [], + "짐작": true, + "복합": [ + "GF000135", + "GF000284" + ], + "줄수": 1 + }, + { + "id": "S098", + "파일": "북부청사방", + "이름": "큰돌메붙임", + "규격": "40cm-60cm,(산림사업품셈)", + "단위": "㎡", + "갈래": "단가산출", + "단산": 59, + "무리": "사람", + "정답": [ + "GF000229" + ], + "허용": [ + "GC000262" + ], + "짐작": false, + "복합": null, + "줄수": 2 + }, + { + "id": "S099", + "파일": "북부청사방", + "이름": "큰돌찰붙임", + "규격": "40cm-60cm,(산림사업품셈)", + "단위": "㎡", + "갈래": "단가산출", + "단산": 61, + "무리": "사람", + "정답": [ + "GF000230" + ], + "허용": [ + "GC000263" + ], + "짐작": false, + "복합": null, + "줄수": 7 + }, + { + "id": "Y000", + "파일": "동부청영월", + "이름": "무근콘크리트타설", + "규격": "인력운반타설", + "단위": "M3", + "갈래": "단가산출", + "코드": "D02058", + "무리": "사람", + "정답": [ + "GC000198" + ], + "허용": [ + "GF000155" + ], + "짐작": false, + "복합": null, + "줄수": 3 + }, + { + "id": "Y001", + "파일": "동부청영월", + "이름": "합판거푸집(6회)", + "규격": "간단", + "단위": "M2", + "갈래": "단가산출", + "코드": "D00056", + "무리": "사람", + "정답": [ + "GC000211" + ], + "허용": [ + "GF000160" + ], + "짐작": false, + "복합": null, + "줄수": 6 + }, + { + "id": "Y002", + "파일": "동부청영월", + "이름": "구조물터파기(인력)", + "규격": "경질토사", + "단위": "M3", + "갈래": "단가산출", + "코드": "D00002", + "무리": "사람", + "정답": [ + "GF000101" + ], + "허용": [ + "GC000083" + ], + "짐작": true, + "복합": null, + "줄수": 3 + }, + { + "id": "Y003", + "파일": "동부청영월", + "이름": "구조물되메우기", + "규격": "(인력)", + "단위": "M3", + "갈래": "단가산출", + "코드": "D00007", + "무리": "사람", + "정답": [ + "GF000113" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 4 + }, + { + "id": "Y004", + "파일": "동부청영월", + "이름": "잔토처리", + "규격": "인력100%", + "단위": "M3", + "갈래": "단가산출", + "코드": "D00009", + "무리": "사람", + "정답": [], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 4 + }, + { + "id": "Y005", + "파일": "동부청영월", + "이름": "크레인(타이어)", + "규격": "10톤", + "단위": "시간", + "갈래": "기계", + "코드": "X02104", + "무리": "사람", + "정답": [ + "GC000268", + "GC000269", + "GC000275" + ], + "허용": [], + "짐작": true, + "복합": null, + "줄수": 1 + }, + { + "id": "Y006", + "파일": "동부청영월", + "이름": "구조물터파기", + "규격": "토사(B/H 90%+인력10%)", + "단위": "M3", + "갈래": "단가산출", + "코드": "D00003", + "무리": "사람", + "정답": [ + "GF000101" + ], + "허용": [ + "GC000089" + ], + "짐작": true, + "복합": null, + "줄수": 1 + }, + { + "id": "Y007", + "파일": "동부청영월", + "이름": "잔토처리", + "규격": "토사(B/H 100%)", + "단위": "M3", + "갈래": "단가산출", + "코드": "D00010", + "무리": "사람", + "정답": [], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "Y008", + "파일": "동부청영월", + "이름": "콘크리트타설", + "규격": "임도품셈 5-5", + "단위": "M3", + "갈래": "단가산출", + "코드": "D00037", + "무리": "사람", + "정답": [ + "GC000198" + ], + "허용": [ + "GF000155", + "GF000166" + ], + "짐작": true, + "복합": null, + "줄수": 6 + }, + { + "id": "Y009", + "파일": "동부청영월", + "이름": "유로폼 설치,해체(복잡)", + "규격": "수직고7.0m 이하", + "단위": "M2", + "갈래": "단가산출", + "코드": "D00059", + "무리": "사람", + "정답": [ + "GC000214" + ], + "허용": [ + "GF000210" + ], + "짐작": false, + "복합": null, + "줄수": 5 + }, + { + "id": "Y010", + "파일": "동부청영월", + "이름": "철근가공조립(현장)", + "규격": "TYPE Ⅰ", + "단위": "Ton", + "갈래": "단가산출", + "코드": "D00060", + "무리": "사람", + "정답": [ + "GC000206", + "GC000207" + ], + "허용": [ + "GF000159" + ], + "짐작": false, + "복합": [ + "GC000206", + "GC000207" + ], + "줄수": 6 + }, + { + "id": "Y011", + "파일": "동부청영월", + "이름": "기초잡석채집및운반부설", + "규격": "4.5톤(L=500)", + "단위": "M3", + "갈래": "단가산출", + "코드": "D00600", + "무리": "사람", + "정답": [ + "GF000168" + ], + "허용": [ + "GC000110" + ], + "짐작": true, + "복합": null, + "줄수": 11 + }, + { + "id": "Y012", + "파일": "동부청영월", + "이름": "임도안전표지판설치", + "규격": "삼각,D=900", + "단위": "개소", + "갈래": "단가산출", + "코드": "D01926", + "무리": "사람", + "정답": [ + "GC000308" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "Y013", + "파일": "동부청영월", + "이름": "야면석 채집 운반", + "규격": "(25x25x35㎝)장비", + "단위": "M2", + "갈래": "단가산출", + "코드": "D00411", + "무리": "사람", + "정답": [ + "GF000214" + ], + "허용": [], + "짐작": true, + "복합": null, + "줄수": 4 + }, + { + "id": "Y014", + "파일": "동부청영월", + "이름": "야면석찰붙임", + "규격": "뒷길이 35Cm", + "단위": "M2", + "갈래": "단가산출", + "코드": "D00644", + "무리": "사람", + "정답": [ + "GC000263" + ], + "허용": [ + "GF000225" + ], + "짐작": false, + "복합": null, + "줄수": 3 + }, + { + "id": "Y015", + "파일": "동부청영월", + "이름": "고임돌채집운반", + "규격": "4.5톤덤프(L=500)", + "단위": "M3", + "갈래": "단가산출", + "코드": "D01361", + "무리": "사람", + "정답": [ + "GF000211" + ], + "허용": [ + "GF000212" + ], + "짐작": true, + "복합": null, + "줄수": 5 + }, + { + "id": "Y016", + "파일": "동부청영월", + "이름": "잡석운반및다짐", + "규격": "", + "단위": "M3", + "갈래": "단가산출", + "코드": "D01928", + "무리": "사람", + "정답": [ + "GF000218" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 2 + }, + { + "id": "Y017", + "파일": "동부청영월", + "이름": "면고르기", + "규격": "", + "단위": "M2", + "갈래": "단가산출", + "코드": "D01927", + "무리": "사람", + "정답": [ + "GF000124" + ], + "허용": [ + "GC000111" + ], + "짐작": false, + "복합": null, + "줄수": 2 + }, + { + "id": "Y018", + "파일": "동부청영월", + "이름": "구조물터파기", + "규격": "인력(경질토사)", + "단위": "M3", + "갈래": "단가산출", + "코드": "D01942", + "무리": "사람", + "정답": [ + "GF000101" + ], + "허용": [ + "GC000083" + ], + "짐작": true, + "복합": null, + "줄수": 1 + }, + { + "id": "Y019", + "파일": "동부청영월", + "이름": "도로반사경설치", + "규격": "D=1000(STS)", + "단위": "개소", + "갈래": "단가산출", + "코드": "D01759", + "무리": "사람", + "정답": [ + "GC000310" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "Y020", + "파일": "동부청영월", + "이름": "철근콘크리트타설", + "규격": "장비사용타설", + "단위": "M3", + "갈래": "단가산출", + "코드": "D02071", + "무리": "사람", + "정답": [ + "GC000198" + ], + "허용": [ + "GF000155" + ], + "짐작": false, + "복합": null, + "줄수": 2 + }, + { + "id": "Y021", + "파일": "동부청영월", + "이름": "유로폼 설치,해체(보통)", + "규격": "수직고7.0m 이하", + "단위": "M2", + "갈래": "단가산출", + "코드": "D00058", + "무리": "사람", + "정답": [ + "GC000214" + ], + "허용": [ + "GF000210" + ], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "Y022", + "파일": "동부청영월", + "이름": "지수판 설치(PVC 용접)", + "규격": "200×5t", + "단위": "m", + "갈래": "단가산출", + "코드": "D01854", + "무리": "사람", + "정답": [ + "GC000224" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "Y023", + "파일": "동부청영월", + "이름": "배수관부설및접합(P.V.C)", + "규격": "Ø 50 MM - VG2 일반관", + "단위": "m", + "갈래": "단가산출", + "코드": "D01855", + "무리": "사람", + "정답": [ + "GC000393" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "Y024", + "파일": "동부청영월", + "이름": "야면석소운반(L=35CM)", + "규격": "(L=20m)", + "단위": "M2", + "갈래": "단가산출", + "코드": "D01466", + "무리": "사람", + "정답": [ + "GF000280" + ], + "허용": [ + "GF000279" + ], + "짐작": true, + "복합": null, + "줄수": 2 + }, + { + "id": "Y025", + "파일": "동부청영월", + "이름": "콘크리트소운반", + "규격": "(L=20m)", + "단위": "M3", + "갈래": "단가산출", + "코드": "D00450", + "무리": "사람", + "정답": [ + "GF000279", + "GF000281" + ], + "허용": [], + "짐작": true, + "복합": null, + "줄수": 1 + }, + { + "id": "Y026", + "파일": "동부청영월", + "이름": "야면석메붙임", + "규격": "뒷길이 35Cm", + "단위": "M2", + "갈래": "단가산출", + "코드": "D01843", + "무리": "사람", + "정답": [ + "GF000224", + "GC000262" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "Y027", + "파일": "동부청영월", + "이름": "측구수로관부설", + "규격": "500∼700kg미만", + "단위": "m", + "갈래": "단가산출", + "코드": "D00258", + "무리": "사람", + "정답": [ + "GC000251" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "Y028", + "파일": "동부청영월", + "이름": "콘크리트타설", + "규격": "장비사용타설(소형)", + "단위": "M3", + "갈래": "단가산출", + "코드": "D01844", + "무리": "사람", + "정답": [ + "GC000198" + ], + "허용": [ + "GF000155" + ], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "Y029", + "파일": "동부청영월", + "이름": "합판거푸집(4회)", + "규격": "보통", + "단위": "M2", + "갈래": "단가산출", + "코드": "D01841", + "무리": "사람", + "정답": [ + "GC000211" + ], + "허용": [ + "GF000160" + ], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "Y030", + "파일": "동부청영월", + "이름": "혼합골재부설", + "규격": "영월-산불", + "단위": "M3", + "갈래": "단가산출", + "코드": "D02001", + "무리": "사람", + "정답": [ + "GF000136" + ], + "허용": [], + "짐작": true, + "복합": null, + "줄수": 1 + }, + { + "id": "Y031", + "파일": "동부청영월", + "이름": "콘크리트포장(인력)", + "규격": "T=20㎝", + "단위": "M3", + "갈래": "단가산출", + "코드": "D01748", + "무리": "사람", + "정답": [ + "GF000162" + ], + "허용": [ + "GC000297" + ], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "Y032", + "파일": "동부청영월", + "이름": "콘크리트포장절단공", + "규격": "임도", + "단위": "M", + "갈래": "단가산출", + "코드": "D01752", + "무리": "사람", + "정답": [ + "GF000163" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "Y033", + "파일": "동부청영월", + "이름": "뒷채움돌채집운반부설", + "규격": "4.5톤 덤프트럭(L=500m)", + "단위": "M3", + "갈래": "단가산출", + "코드": "D01519", + "무리": "사람", + "정답": [ + "GF000218" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 2 + }, + { + "id": "Y034", + "파일": "동부청영월", + "이름": "큰돌메쌓기", + "규격": "뒷길이 0.6~0.8m", + "단위": "M2", + "갈래": "단가산출", + "코드": "D00081", + "무리": "사람", + "정답": [ + "GF000226" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 2 + }, + { + "id": "Y035", + "파일": "동부청영월", + "이름": "큰돌채집운반(뒷길이 0.6~0.8m)", + "규격": "장비(80%) + 인력(20%)", + "단위": "M2", + "갈래": "단가산출", + "코드": "D01810", + "무리": "사람", + "정답": [ + "GF000213" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 2 + }, + { + "id": "Y115", + "파일": "동부청영월", + "이름": "파형강관부설", + "규격": "Φ800mm", + "단위": "M", + "갈래": "단가산출", + "코드": "D00071", + "무리": "자동", + "정답": [ + "GC000404" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "B001", + "파일": "봉화", + "이름": "레미콘타설(장비)", + "규격": "무근,소형", + "단위": "m3", + "갈래": "단가산출", + "코드": "D00096", + "무리": "자동+씨앗", + "정답": [ + "GC000198" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 11 + }, + { + "id": "B002", + "파일": "봉화", + "이름": "합판거푸집(간단)", + "규격": "간단", + "단위": "m2", + "갈래": "단가산출", + "코드": "D00102", + "무리": "씨앗", + "정답": [ + "GC000211" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 2 + }, + { + "id": "B004", + "파일": "봉화", + "이름": "구조물 터파기(토사)", + "규격": "굴삭기 0.2m3", + "단위": "m3", + "갈래": "단가산출", + "코드": "D00002", + "무리": "씨앗", + "정답": [ + "GC000088" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 4 + }, + { + "id": "B005", + "파일": "봉화", + "이름": "구조물 되메우기", + "규격": "굴삭기 0.2m3", + "단위": "m3", + "갈래": "단가산출", + "코드": "D00007", + "무리": "씨앗", + "정답": [ + "GF000113" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 4 + }, + { + "id": "B010", + "파일": "봉화", + "이름": "기초다짐 및 뒤채움", + "규격": "소형 장비사용 시공", + "단위": "M3", + "갈래": "단가산출", + "코드": "D00008", + "무리": "씨앗", + "정답": [ + "GF000218" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 6 + }, + { + "id": "B011", + "파일": "봉화", + "이름": "유로폼 설치 및 해체", + "규격": "간단", + "단위": "m2", + "갈래": "단가산출", + "코드": "D00093", + "무리": "자동+씨앗", + "정답": [ + "GC000214" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 8 + }, + { + "id": "B012", + "파일": "봉화", + "이름": "레미콘타설(장비)", + "규격": "무근", + "단위": "m3", + "갈래": "단가산출", + "코드": "D00095", + "무리": "자동+씨앗", + "정답": [ + "GC000198" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 6 + }, + { + "id": "B013", + "파일": "봉화", + "이름": "콘크리트믹서사용", + "규격": "0.45 m3", + "단위": "m3", + "갈래": "단가산출", + "코드": "D00089", + "무리": "씨앗", + "정답": [ + "GF000156" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 4 + }, + { + "id": "B014", + "파일": "봉화", + "이름": "유로폼 설치 및 해체", + "규격": "보통", + "단위": "m2", + "갈래": "단가산출", + "코드": "D00094", + "무리": "자동+씨앗", + "정답": [ + "GC000214" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 9 + }, + { + "id": "B015", + "파일": "봉화", + "이름": "철근가공 및 조립", + "규격": "Type-Ⅰ-1,현장가공조립", + "단위": "ton", + "갈래": "단가산출", + "코드": "D00098", + "무리": "씨앗", + "정답": [ + "GC000206", + "GC000207" + ], + "허용": [], + "짐작": false, + "복합": [ + "GC000206", + "GC000207" + ], + "줄수": 9 + }, + { + "id": "B017", + "파일": "봉화", + "이름": "콘크리트(기계비빔)", + "규격": "무근구조물", + "단위": "M3", + "갈래": "단가산출", + "코드": "D00012", + "무리": "씨앗", + "정답": [ + "GF000156" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "B018", + "파일": "봉화", + "이름": "구조물터파기(육상토사0~1m)", + "규격": "기계90%+인력10%(굴삭기 0.7M3)", + "단위": "M3", + "갈래": "단가산출", + "코드": "D00023", + "무리": "씨앗", + "정답": [ + "GF000101" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "B023", + "파일": "봉화", + "이름": "철근가공및조립(현장)", + "규격": "일반(Type-Ⅰ-1)", + "단위": "Ton", + "갈래": "단가산출", + "코드": "D00081", + "무리": "씨앗", + "정답": [ + "GC000206", + "GC000207" + ], + "허용": [], + "짐작": false, + "복합": [ + "GC000206", + "GC000207" + ], + "줄수": 1 + }, + { + "id": "B024", + "파일": "봉화", + "이름": "합판거푸집", + "규격": "3회(복잡)", + "단위": "M2", + "갈래": "단가산출", + "코드": "D00077", + "무리": "자동+씨앗", + "정답": [ + "GC000211" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "B026", + "파일": "봉화", + "이름": "평떼붙임", + "규격": "", + "단위": "m2", + "갈래": "단가산출", + "코드": "D00090", + "무리": "씨앗", + "정답": [ + "GC000143" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "B028", + "파일": "봉화", + "이름": "레미콘타설(장비)", + "규격": "철근", + "단위": "m3", + "갈래": "단가산출", + "코드": "D00097", + "무리": "자동+씨앗", + "정답": [ + "GC000198" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "B029", + "파일": "봉화", + "이름": "레미콘타설(장비)", + "규격": "무근,버림", + "단위": "m3", + "갈래": "단가산출", + "코드": "D00103", + "무리": "자동", + "정답": [ + "GC000198" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "B030", + "파일": "봉화", + "이름": "철근가공 및 조립", + "규격": "Type-Ⅱ-1,현장가공조립", + "단위": "ton", + "갈래": "단가산출", + "코드": "D00099", + "무리": "씨앗", + "정답": [ + "GC000206", + "GC000207" + ], + "허용": [], + "짐작": false, + "복합": [ + "GC000206", + "GC000207" + ], + "줄수": 1 + }, + { + "id": "B031", + "파일": "봉화", + "이름": "강관동바리", + "규격": "암거용-3개월사용", + "단위": "공/㎥", + "갈래": "단가산출", + "코드": "D00113", + "무리": "씨앗", + "정답": [ + "GC000015" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "B032", + "파일": "봉화", + "이름": "강관비계", + "규격": "3개월사용", + "단위": "M2", + "갈래": "단가산출", + "코드": "D00112", + "무리": "씨앗", + "정답": [ + "GC000022" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "U002", + "파일": "울진대흥", + "이름": "콘크리트비빔", + "규격": "채움재", + "단위": "m3", + "갈래": "단가산출", + "코드": "D02200", + "무리": "씨앗", + "정답": [ + "GF000156" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 9 + }, + { + "id": "U003", + "파일": "울진대흥", + "이름": "유로폼 설치 및 해체", + "규격": "간단", + "단위": "m2", + "갈래": "단가산출", + "코드": "D02195", + "무리": "자동+씨앗", + "정답": [ + "GC000214" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 5 + }, + { + "id": "U005", + "파일": "울진대흥", + "이름": "구조물터파기(토사)", + "규격": "굴삭기0.7m3", + "단위": "m3", + "갈래": "단가산출", + "코드": "D01343", + "무리": "씨앗", + "정답": [ + "GF000101" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "U006", + "파일": "울진대흥", + "이름": "다지기", + "규격": "굴삭기0.7m3", + "단위": "m3", + "갈래": "단가산출", + "코드": "D02139", + "무리": "씨앗", + "정답": [ + "GC000105" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "U007", + "파일": "울진대흥", + "이름": "야면석채집", + "규격": "L3=45cm내외", + "단위": "m2", + "갈래": "단가산출", + "코드": "D01129", + "무리": "씨앗", + "정답": [ + "GF000214" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 6 + }, + { + "id": "U012", + "파일": "울진대흥", + "이름": "철근가공 및 조립", + "규격": "Type-Ⅱ-2,현장가공조립", + "단위": "ton", + "갈래": "단가산출", + "코드": "D02251", + "무리": "씨앗", + "정답": [ + "GC000206", + "GC000207" + ], + "허용": [], + "짐작": false, + "복합": [ + "GC000206", + "GC000207" + ], + "줄수": 1 + }, + { + "id": "U013", + "파일": "울진대흥", + "이름": "유로폼 설치 및 해체", + "규격": "보통", + "단위": "m2", + "갈래": "단가산출", + "코드": "D02246", + "무리": "자동+씨앗", + "정답": [ + "GC000214" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "U014", + "파일": "울진대흥", + "이름": "콘크리트믹서사용", + "규격": "0.45 m3", + "단위": "m3", + "갈래": "단가산출", + "코드": "D00075", + "무리": "씨앗", + "정답": [ + "GF000156" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "U015", + "파일": "울진대흥", + "이름": "콘크리트포장(B-type)", + "규격": "t=20cm", + "단위": "m2", + "갈래": "단가산출", + "코드": "D02248", + "무리": "씨앗", + "정답": [ + "GF000162" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "U016", + "파일": "울진대흥", + "이름": "레미콘타설", + "규격": "무근(장비사용타설)", + "단위": "m3", + "갈래": "단가산출", + "코드": "D02262", + "무리": "자동", + "정답": [ + "GC000198" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 3 + }, + { + "id": "K004", + "파일": "영덕계류", + "이름": "뒤채움", + "규격": "소형장비", + "단위": "m3", + "갈래": "단가산출", + "코드": "D01426", + "무리": "자동", + "정답": [ + "GC000106" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "G000", + "파일": "울진소광", + "이름": "유로폼 설치 및 해체", + "규격": "보통", + "단위": "m2", + "갈래": "단가산출", + "코드": "D01352", + "무리": "자동+씨앗", + "정답": [ + "GC000214" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "G003", + "파일": "울진소광", + "이름": "유로폼 설치 및 해체", + "규격": "간단", + "단위": "m2", + "갈래": "단가산출", + "코드": "D01351", + "무리": "자동+씨앗", + "정답": [ + "GC000214" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "G005", + "파일": "울진소광", + "이름": "구조물터파기(토사)", + "규격": "굴착기0.6m3", + "단위": "m3", + "갈래": "단가산출", + "코드": "D01456", + "무리": "씨앗", + "정답": [ + "GC000088" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 8 + }, + { + "id": "G006", + "파일": "울진소광", + "이름": "구조물되메우기", + "규격": "굴착기0.6m3", + "단위": "m3", + "갈래": "단가산출", + "코드": "D01474", + "무리": "씨앗", + "정답": [ + "GF000113" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 6 + }, + { + "id": "G011", + "파일": "울진소광", + "이름": "막자갈채우기", + "규격": "150mm내외", + "단위": "m3", + "갈래": "단가산출", + "코드": "D00867", + "무리": "자동", + "정답": [ + "GC000107" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 3 + }, + { + "id": "G012", + "파일": "울진소광", + "이름": "레미콘타설", + "규격": "무근(장비)", + "단위": "m3", + "갈래": "단가산출", + "코드": "D01336", + "무리": "자동+씨앗", + "정답": [ + "GC000198" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 3 + }, + { + "id": "G016", + "파일": "울진소광", + "이름": "콘크리포장(T=20cm)포설", + "규격": "인력포설", + "단위": "m3", + "갈래": "단가산출", + "코드": "D01354", + "무리": "자동", + "정답": [ + "GC000297" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "G018", + "파일": "울진소광", + "이름": "평떼붙이기", + "규격": "", + "단위": "m2", + "갈래": "단가산출", + "코드": "D01329", + "무리": "자동", + "정답": [ + "GC000143" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "G020", + "파일": "울진소광", + "이름": "콘크리트믹서사용", + "규격": "0.45 m3", + "단위": "m3", + "갈래": "단가산출", + "코드": "D00083", + "무리": "씨앗", + "정답": [ + "GF000156" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "G033", + "파일": "울진소광", + "이름": "수축줄눈(컷팅)", + "규격": "콘크리트포장", + "단위": "m", + "갈래": "단가산출", + "코드": "D01488", + "무리": "자동", + "정답": [ + "GC000301" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "C004", + "파일": "거창", + "이름": "유로폼", + "규격": "보통,0~7m", + "단위": "㎡", + "갈래": "단가산출", + "단산": 30, + "무리": "자동+씨앗", + "정답": [ + "GC000214" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 5 + }, + { + "id": "C005", + "파일": "거창", + "이름": "철근가공및조립(현장)", + "규격": "Type-Ⅱ-1", + "단위": "Ton", + "갈래": "단가산출", + "단산": 38, + "무리": "씨앗", + "정답": [ + "GC000206", + "GC000207" + ], + "허용": [], + "짐작": false, + "복합": [ + "GC000206", + "GC000207" + ], + "줄수": 4 + }, + { + "id": "C013", + "파일": "거창", + "이름": "레미콘타설(장비사용타설)", + "규격": "철근구조물", + "단위": "㎥", + "갈래": "단가산출", + "단산": 40, + "무리": "씨앗", + "정답": [ + "GC000198" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "C014", + "파일": "거창", + "이름": "합판거푸집", + "규격": "4회", + "단위": "㎡", + "갈래": "단가산출", + "단산": 41, + "무리": "씨앗", + "정답": [ + "GC000211" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "C016", + "파일": "거창", + "이름": "강관동바리", + "규격": "2.5m이하(1개월)", + "단위": "공㎥", + "갈래": "단가산출", + "단산": 42, + "무리": "씨앗", + "정답": [ + "GC000015" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "C017", + "파일": "거창", + "이름": "잡철물제작및설치(현장)", + "규격": "일반철재", + "단위": "Ton", + "갈래": "단가산출", + "단산": 43, + "무리": "씨앗", + "정답": [ + "GC000821" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "C018", + "파일": "거창", + "이름": "안내표지판 설치", + "규격": "단주식", + "단위": "개소", + "갈래": "단가산출", + "단산": 44, + "무리": "씨앗", + "정답": [ + "GC000308" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "C019", + "파일": "거창", + "이름": "레미콘타설(인력운반타설)", + "규격": "소형구조물", + "단위": "㎥", + "갈래": "단가산출", + "단산": 37, + "무리": "씨앗", + "정답": [ + "GF000155" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + }, + { + "id": "C020", + "파일": "거창", + "이름": "합판거푸집", + "규격": "6회, 간단", + "단위": "㎡", + "갈래": "단가산출", + "단산": 45, + "무리": "씨앗", + "정답": [ + "GC000211" + ], + "허용": [], + "짐작": false, + "복합": null, + "줄수": 1 + } +] diff --git a/resources/tester/helper_m02_hopyo_golden.py b/resources/tester/helper_m02_hopyo_golden.py new file mode 100644 index 000000000..7ced12420 --- /dev/null +++ b/resources/tester/helper_m02_hopyo_golden.py @@ -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" + ) diff --git a/resources/tester/test_m02_hopyo_finger.py b/resources/tester/test_m02_hopyo_finger.py new file mode 100644 index 000000000..b0e48acce --- /dev/null +++ b/resources/tester/test_m02_hopyo_finger.py @@ -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 # 노임 없으면 호표 통째 아님 diff --git a/resources/tester/test_m02_hopyo_fit.py b/resources/tester/test_m02_hopyo_fit.py new file mode 100644 index 000000000..a3b18050b --- /dev/null +++ b/resources/tester/test_m02_hopyo_fit.py @@ -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) diff --git a/resources/tester/test_m02_hopyo_option.py b/resources/tester/test_m02_hopyo_option.py new file mode 100644 index 000000000..1eb81da0c --- /dev/null +++ b/resources/tester/test_m02_hopyo_option.py @@ -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)) # 보통인부 하나뿐 = 우연 맞음 diff --git a/resources/tester/test_m02_hopyo_ours.py b/resources/tester/test_m02_hopyo_ours.py new file mode 100644 index 000000000..4027d9d66 --- /dev/null +++ b/resources/tester/test_m02_hopyo_ours.py @@ -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