diff --git a/M02_MasterTemplete/M02_Hopyo_Basis.py b/M02_MasterTemplete/M02_Hopyo_Basis.py index 7a7705054..2dd647063 100644 --- a/M02_MasterTemplete/M02_Hopyo_Basis.py +++ b/M02_MasterTemplete/M02_Hopyo_Basis.py @@ -6,7 +6,8 @@ 출처 = 「…품셈」(오타 「품줌」) 뒤 20 글자 안 절 번호 — 앞 글에 산림 · 임도 = 산림품셈 · 그 밖 = 건설품셈 · 직종 = 「이름 : 수」 줄의 빈칸 뺀 이름이 우리 인력(LB) 이름 · 옛이름일 때만(지금 이름으로) · -글(338) = 머리 뺀 몸 줄 `GLINES` 줄 · 줄마다 `GCHARS` 자 안(맞춤 지문 재료). +글(338) = 머리 뺀 몸 줄 `GLINES` 줄 · 줄마다 `GCHARS` 자 안(맞춤 지문 재료) · 산출근거 열이 빈 줄은 +그 오른쪽 ~ 돈 머리(맨 오른쪽 묶음) 앞 칸을 빈칸으로 이어 붙임(거창 꼴 · 돈 머리 없으면 `WIDE` 열). 견줌(`candidates`) = 같은 책 조항 같음 1.0 · 윗마디 0.9(엑셀 6-2 ⊃ 우리 6-2-2) · 앞 마디 같음 0.5(조항이 안 맞으면 후보 아님 · `clause`) · 조항 오타 「13-114」 = 「13-11-4」 · 건설 공통 · 토목 덤 0.1 · 직종 겹침(보통인부 뺌) 0.5 · 단위 0.15 · 직종도 뜻 낱말도 안 겹치면 버림 · @@ -34,7 +35,7 @@ _HEAD = re.compile(r"^(\d+)\.\s*(.*?)\s*\[([^\[\]]*)\]\s*$") _BOOK = re.compile(r"품[셈줌]") _TRADE = re.compile(r"^[\s*·\-]*([가-힣][가-힣\s]*?)\s*:\s*[\d,]") _TYPO_CLAUSE = re.compile(r"^(\d+)-(\d)(\d)(\d)$") # 「13-114」 = 13-11-4 -GLINES, GCHARS = 200, 300 +GLINES, GCHARS, WIDE = 200, 300, 140 _TYPOS = (("깍기", "깎기"),) _PLAIN_WORDS = frozenset({"인력", "기계", "장비"}) # 방법 낱말 — 「터널굴착 1발파당 인력」 _MONEY = {"합계": "계", "노무비": "노무비", "재료비": "재료비", "경비": "경비"} @@ -45,6 +46,12 @@ def _flat(value: Any) -> str: # ── 엑셀 블록 읽기 ───────────────────────────────────────────────────── +def _cell_text(value: Any) -> str: + if isinstance(value, float) and value == int(value): + value = int(value) + return str(value).strip() if value is not None else "" + + @lru_cache(maxsize=2) def _trade_names(whole_id: int) -> dict[str, str]: """{빈칸 뺀 이름 · 옛이름: 지금 이름} — 우리 인력(LB).""" @@ -78,11 +85,15 @@ def sources(text: str) -> list[list[str]]: def _columns(rows: list[list[Any]]) -> tuple[int, int, dict[str, int]]: - """(자료 첫 줄, 산출근거 열, {계 · 노무비 · 재료비 · 경비: 열}) — 머리 없으면 (0, 1, {}).""" + """(자료 첫 줄, 산출근거 열, {계 · 노무비 · 재료비 · 경비: 열}) — 머리 없으면 (0, 1, {}) · + 돈 머리가 둘 묶음이면 맨 오른쪽(거창 꼴 · 값이 든 쪽).""" for at, row in enumerate(rows[:10]): flat = [_flat(v) for v in row] if "산출근거" in flat: - money = {key: flat.index(head) for head, key in _MONEY.items() if head in flat} + last = len(flat) - 1 + money = { + key: last - flat[::-1].index(head) for head, key in _MONEY.items() if head in flat + } return at + 1, flat.index("산출근거"), money return 0, 1, {} @@ -93,14 +104,19 @@ def read(rows: list[list[Any]] | None) -> dict[int, dict[str, Any]]: if not rows: return {} start, col, money = _columns(rows) + end = min(money.values()) if money and min(money.values()) > col + 1 else WIDE names = trade_names() blocks: dict[int, dict[str, Any]] = {} block: dict[str, Any] | None = None for at, row in enumerate(rows[start:], start + 1): text = row[col] if col < len(row) else None - if not isinstance(text, str) or not text.strip(): - continue - head = None if text[:1].isspace() else _HEAD.match(text) + head = None + if isinstance(text, str) and text.strip(): + head = None if text[:1].isspace() else _HEAD.match(text) + else: + text = " ".join(_cell_text(v) for v in row[col + 1 : end] if _cell_text(v)) + if not text: + continue if head: block = blocks.setdefault( int(head.group(1)), 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_Excel.py b/M02_MasterTemplete/M02_Hopyo_Excel.py index c55a1b89c..e103d1d07 100644 --- a/M02_MasterTemplete/M02_Hopyo_Excel.py +++ b/M02_MasterTemplete/M02_Hopyo_Excel.py @@ -7,7 +7,12 @@ 호표 줄 = 「제N호표」 · 호표 1 단위 값 = 금액 열 · 구성 줄 = 호표 칸 빔 · 품명 있음 · 끝 = 다음 호표. 갈래 = % · 호표(비고 일위N참조) · 단가산출(단산N참조) · 기계(단위 hr) · 별도계상(값 없음) · -자재(재료비만) · 기타 — 규격은 원문 그대로 둠. +자재(재료비만) · 노임(단위 인 + 우리 인력 이름) · 기타 — 규격은 원문 그대로 둠. +이음(338) = 비고 「단산 · 산근 · 단가산출 N(참조 · 호표)」 → 단산 N · 「일위 · 대가 · 일위대가 N」 → +호표 N(빈칸 무시) · 비고 빈 줄(노무비 · 경비 단가 있음)은 품명 + 단위가 같은 블록이 하나뿐이면 +이음(줄 `이음` = 「이름」) · 하이퍼링크 안 읽음. +STmate 꼴(「일위대가표」 · 「단가산출근거」 시트) = `M02_Hopyo_Stmate.read` · +문서 보안(DRM) 파일 = 400 글. 검산 = 줄 금액 floor(단가 × 수량 × 10) / 10 · 합계 단가 = 셋 합 · % 줄 = floor(바탕 × 율 / 100 × 10) / 10 · 호표 비목 = floor(줄 금액 합) · 총괄표 합계 — 어긋남은 글 목록(막지 않음). @@ -25,6 +30,7 @@ from decimal import ROUND_FLOOR, Decimal, InvalidOperation from typing import Any from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis +from M02_MasterTemplete import M02_Hopyo_Stmate as hopyo_stmate SHEET = "일위대가" SUMMARY = "일위대가총괄표" @@ -32,7 +38,9 @@ COSTS = ("노무비", "재료비", "경비") HEADS = ("호표", "품명", "규격", "수량", "단위", "비고") NO_SHEET = "일위대가 시트 없음" _HOPYO = re.compile(r"제\s*(\d+)\s*호표") -_REF = re.compile(r"^(단산|일위)\s*(\d+)\s*참조$") +_REF = re.compile(r"^(?:(단산|산근|단가산출)|(일위대가|일위|대가))(\d+)(?:참조|호표)?$") +DRM = "문서 보안(DRM)으로 잠긴 파일 — 보안을 푼 사본으로 저장해 올림" +_OLE = b"\xd0\xcf\x11\xe0" _MACHINE_UNITS = ("hr", "시간", "h") @@ -77,11 +85,15 @@ def _sheets(name: str, data: bytes) -> dict[str, list[list[Any]]]: wanted = (SHEET, SUMMARY, hopyo_basis.SHEET) if not data: raise ExcelError("빈 파일") + if b"DRMONE" in data[:256] and data[:4] not in (b"PK\x03\x04", _OLE): + raise ExcelError(DRM) try: if data[:4] == b"PK\x03\x04" or name.lower().endswith(".xlsx"): import openpyxl book = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True) + if hopyo_stmate.is_stmate([t.strip() for t in book.sheetnames]): + wanted = hopyo_stmate.SHEETS out = { ws.title.strip(): [list(row) for row in ws.iter_rows(values_only=True)] for ws in book.worksheets @@ -143,6 +155,8 @@ def kind_of(line: dict[str, Any]) -> str: return "단가산출" if unit in _MACHINE_UNITS: return "기계" + if unit == "인" and _flat(line.get("이름")) in hopyo_basis.trade_names(): + return "노임" if not any(price.get(k) for k in ("계", *COSTS)): return "별도계상" if price.get("재료비") and not price.get("노무비") and not price.get("경비"): @@ -208,10 +222,28 @@ def _summary(rows: list[list[Any]] | None) -> dict[int, Decimal]: return out +def _by_name(raw: list[list[dict[str, Any]]], blocks: dict[int, dict[str, Any]]) -> None: + """비고 빈 줄(노무비 · 경비 단가 있음) → 품명 + 단위가 같은 블록이 하나뿐이면 단산 이음.""" + index: dict[tuple[str, str], list[int]] = {} + for no, block in blocks.items(): + index.setdefault((_flat(block["이름"]), _flat(block["단위"]).lower()), []).append(no) + for line in (one for lines in raw for one in lines): + if line["참조"] or line["비고"] or line["갈래"] == "%": + continue + if not (line["단가"].get("노무비") or line["단가"].get("경비")): + continue + hit = index.get((_flat(line["이름"]), _flat(line["단위"]).lower())) or [] + if len(hit) == 1: + line["참조"], line["이음"] = {"종류": "단산", "번": hit[0]}, "이름" + line["갈래"] = kind_of(line) + + # ── 읽기 ────────────────────────────────────────────────────────────── def read_book(name: str, data: bytes) -> dict[str, Any]: """엑셀 → `{시트, 호표: [...], 검산: {어긋남: [글 …], 총괄표: 맞춘 수}}` — 값은 JSON 수.""" sheets = _sheets(name, data) + if hopyo_stmate.is_stmate(sheets): + return hopyo_stmate.read(sheets) rows = sheets.get(SHEET) if rows is None: raise ExcelError(NO_SHEET) @@ -238,7 +270,7 @@ def read_book(name: str, data: bytes) -> dict[str, Any]: if head or not name_ or not hopyos: continue note = _text(_cell(row, cols.get("비고"))) - ref = _REF.match(note.replace(" ", "")) + ref = _REF.match(_flat(note)) line = { "차례": len(raw[-1]), "이름": name_, @@ -248,7 +280,9 @@ def read_book(name: str, data: bytes) -> dict[str, Any]: "단가": _money(row, cols, 0), "금액": _money(row, cols, 1), "비고": note, - "참조": {"종류": ref.group(1), "번": int(ref.group(2))} if ref else None, + "참조": {"종류": "단산" if ref.group(1) else "일위", "번": int(ref.group(3))} + if ref + else None, } line["갈래"] = kind_of(line) line["행"] = hopyos[-1]["행"][1] = at @@ -256,6 +290,7 @@ def read_book(name: str, data: bytes) -> dict[str, Any]: if not hopyos: raise ExcelError(f"{SHEET} 시트에 호표(제N호표)가 없음") blocks = hopyo_basis.read(sheets.get(hopyo_basis.SHEET)) + _by_name(raw, blocks) for line in (one for lines in raw for one in lines): ref = line["참조"] or {} if ref.get("종류") == "단산" and (block := blocks.get(ref["번"])): 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_Grid.py b/M02_MasterTemplete/M02_Hopyo_Grid.py index 39af897fa..b41f477f2 100644 --- a/M02_MasterTemplete/M02_Hopyo_Grid.py +++ b/M02_MasterTemplete/M02_Hopyo_Grid.py @@ -1,11 +1,12 @@ -"""M02 일위대가 실무자료 엑셀꼴 격자(PLAN 329-1) — 올린 엑셀 「일위대가」 · 「단가산출」 시트 → +"""M02 일위대가 실무자료 엑셀꼴 격자(PLAN 329-1) — 올린 엑셀 「일위대가」 · 「단가산출」 시트 +(STmate = 「일위대가표」 · 「단가산출근거」 · 338) → A00 산출근거 스프레드시트 Workbook(`A00_Common/spreadsheet/spreadsheet_types.ts` 꼴 · 읽기 전용 보기). xls = xlrd `formatting_info` — 값 · 병합 · 열 폭(÷256 글자) · 행 높이(÷20 pt · 시트 기본과 다른 것만) · 서식 표(글꼴 · 굵게 · 맞춤 · 줄바꿈 · 테두리 · 채움 · 숫자 형식) · 틀고정(파일 값 · 없으면 머리 4행). -xlsx = 값만 · 넓은 시트는 머리 글(앞 4 줄) 마지막 열까지. +xlsx = 값만 · 넓은 시트는 머리 글(앞 4 줄) 마지막 열까지 · STmate 숨은 칸 글(「_x0005_`BDCOD…」) 뺌. 결과 `{책, 호표: [{번, 이름, 행}], 단산: [{번, 이름, 행}]}` — 행 = 엑셀 1부터 [시작, 끝]. """ @@ -18,8 +19,9 @@ from openpyxl.utils import get_column_letter from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel +from M02_MasterTemplete import M02_Hopyo_Stmate as hopyo_stmate -SHEETS = (hopyo_excel.SHEET, hopyo_basis.SHEET) +SHEETS = (hopyo_excel.SHEET, hopyo_basis.SHEET, hopyo_stmate.SHEET, hopyo_stmate.CALC) HEAD_ROWS = 4 _HALIGN = {1: "left", 2: "center", 3: "right", 6: "centerContinuous", 7: "distributed"} _VALIGN = {0: "top", 1: "center"} @@ -192,7 +194,7 @@ def _xlsx(name: str, data: bytes) -> dict[str, Any]: } for r, row in enumerate(rows) for c, v in enumerate(row[:width]) - if v not in (None, "") + if v not in (None, "") and not str(v).startswith("_x000") # STmate 숨은 칸 글 } sheets.append({"id": f"s{at}", "이름": title, "칸": cells, "틀고정": {"행": HEAD_ROWS}}) return {"서식": [{}], "시트": sheets} 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_Stmate.py b/M02_MasterTemplete/M02_Hopyo_Stmate.py new file mode 100644 index 000000000..5d3a6cdd8 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Stmate.py @@ -0,0 +1,264 @@ +"""M02 일위대가 실무자료 STmate 꼴 읽기(PLAN 338-2b) — 「일위대가표」 · 「단가산출근거」 시트 + +목록표(노무비 · 중기 · 재료비 · 경비 · 단가산출근거) → 엑셀꼴과 같은 HP 꼴 +(`M02_Hopyo_Excel.read_book`). + +일위대가표 = A 열 「제N호표」 줄(B~Z 열 「BDCOD|B…」 = 호표 코드) → 다음 줄 A 이름 · B 규격 · +D 단위 → 구성 줄(A 이름 · B 규격 · C 수량 · D 단위 · E 계 · G 노무비 · I 재료비 · K 경비 단가 · +다음 열 금액 · M 비고) → 「합계」 줄에서 끝. +줄 코드 = M~Z 열에서 `_CODE` 꼴 첫 것(판마다 열 수가 달라도 읽힘 · 하이퍼링크 안 읽음) · +코드 없는 줄 = 호표 `근거글`. +갈래 = D 단가산출(참조 단산 · 번 = 코드 숫자) · B 호표(참조 일위 · 번 = 호표 코드 표) · L 노임 · +X 기계 · M 자재(비고 「별산」 · 규격 「별도계상」 · 값 없음 = 별도계상) · S · W 별도계상 · +PR · 단위 % = % · 그 밖 기타. +단가산출근거 = 「BDCOD|D…」 머리 → 다음 머리 · 「총계」 앞까지 한 블록 · 글 = B 열(머리 이름 줄 · +소계 · 계 뺌) · 자원 = M 열 「X00000_n」 줄(품 = H 열 · 이름 · 규격 = X 열 「이름 / 규격」 · +비면 목록표 · 기계 셋 줄(노무비 · 재료비 · 경비)은 하나로 · 절 = 그 앞 번호 절 제목) · +머리 이름 · 규격 · 단위 = 단가산출근거목록표 · 출처 = 블록 글 `Basis.sources` · +직종 = 자원 L 이름(우리 인력 · 옛이름 = 지금 이름). +검산 안 함 — `검산 {어긋남: [], 총괄표: 0}`. +""" + +from __future__ import annotations + +import re +from typing import Any + +from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis +from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel # 서로 부름 — 부를 때만 씀 + +SHEET = "일위대가표" +CALC = "단가산출근거" +LISTS = ("단가산출근거목록표", "노무비목록표", "중기목록표", "재료비목록표", "경비목록표") +SHEETS = (SHEET, CALC, *LISTS) +_CODE = re.compile(r"^(?:COD\|)?([A-Z]{1,2}\d{5}|PR[A-Z]?\d+|[TC]\d+)(?:_\d+)?(?: →)?$") +_META = re.compile(r"(? bool: + return SHEET in names and CALC in names + + +def _cell(row: list[Any], col: int) -> Any: + return row[col] if col < len(row) else None + + +def _s(value: Any) -> str: + return hopyo_excel._text(value) + + +def _flat(value: Any) -> str: + return re.sub(r"\s+", "", str(value or "")) + + +def _code(row: list[Any], cols: Any = CODE_COLS) -> str | None: + for col in cols: + hit = _CODE.match(_s(_cell(row, col))) + if hit: + return hit.group(1) + return None + + +def _lists(sheets: dict[str, list[list[Any]]]) -> dict[str, dict[str, str]]: + """목록표 → {코드: {이름, 규격, 단위}} — B 이름 · C 규격 · D 단위 · 코드 = 숨은 칸 + 「COD|…」 · 없으면 코드 꼴 첫 칸(판마다 A 열 · 뒤 열).""" + out: dict[str, dict[str, str]] = {} + for title in LISTS: + for row in sheets.get(title) or []: + meta = _META.search(" ".join(_s(v) for v in row)) + code = meta.group(1) if meta else _code(row, range(len(row))) + if code and _s(_cell(row, 1)): + out[code] = { + "이름": _s(row[1]), + "규격": _s(_cell(row, 2)), + "단위": _s(_cell(row, 3)), + } + return out + + +# ── 단가산출근거 블록 ────────────────────────────────────────────────── +def _blocks(rows: list[list[Any]], table: dict[str, dict[str, str]]) -> dict[str, dict[str, Any]]: + """{D 코드: {번, 코드, 이름, 규격, 단위, 행, 출처, 직종, 글, 자원}}.""" + names = hopyo_basis.trade_names() + out: dict[str, dict[str, Any]] = {} + block: dict[str, Any] | None = None + title = "" + for at, row in enumerate(rows, 1): + head = next( + (hit for v in row[:8] if (hit := _HEAD_D.search(_s(v)))), None + ) # 머리 = G 열(판마다 B 열 글이 다름) + if head: + code = head.group(1) + info = table.get(code) or {} + block = out.setdefault( + code, + { + "번": int(code[1:]), + "코드": code, + "이름": info.get("이름", ""), + "규격": info.get("규격", ""), + "단위": info.get("단위", ""), + "행": [at, at], + "글": [], + "자원": [], + }, + ) + title = "" + continue + if block is None: + continue + first = _flat(_cell(row, 0)) + if first.startswith("총계"): + block["행"][1] = at + block = None + continue + text = _s(_cell(row, 1)) + res = _RES.match(_s(_cell(row, RES_COL))) + code = res.group(1) + res.group(2) if res else None + if not code: + alt = _RES_A.match(first) or _RES_G.search(_s(_cell(row, 6))) + code = alt.group(1) if alt else None + if not text and not code: + continue + block["행"][1] = at + if _s(_cell(row, RES_COL)) == block["코드"]: # 머리 이름 줄 「이름 규격 / 단위」 + if not block["이름"]: + name, _, unit = text.rpartition("/") + block["이름"], block["단위"] = name.strip() or text, unit.strip() + continue + if text and _flat(text) not in ("소계", "계"): + if len(block["글"]) < hopyo_basis.GLINES: + block["글"].append(text[: hopyo_basis.GCHARS]) + if (sec := _SECTION.match(text)) and not re.match(r"^\s*\d+\.\d", text): + title = sec.group(1).strip() + if not code: + continue + last = block["자원"][-1] if block["자원"] else None + if code[0] == "X" and last and last["코드"] == code and last["절"] == title: + continue # 기계 노무비 · 재료비 · 경비 셋 줄 = 하나 + name, _, spec = _s(_cell(row, NAME_COL)).partition("/") + info = table.get(code) or {} + qty = hopyo_excel._num(_cell(row, QTY_COL)) + block["자원"].append( + { + "갈래": code[0], + "코드": code, + "이름": name.strip() or info.get("이름", ""), + "규격": spec.strip() if name.strip() else info.get("규격", ""), + "품": float(qty) if qty is not None else None, + "절": title, + } + ) + for block in out.values(): + block["출처"] = hopyo_basis.sources(" ".join(block["글"])) + trades = [names.get(_flat(r["이름"])) for r in block["자원"] if r["갈래"] == "L"] + block["직종"] = list(dict.fromkeys(t for t in trades if t)) + return out + + +# ── 일위대가표 ──────────────────────────────────────────────────────── +def _kind(code: str, line: dict[str, Any]) -> str: + if code.startswith("PR") or _flat(line["단위"]) == "%": + return "%" + if code[0] == "M": + apart = line["비고"].startswith("별산") or line["규격"] == "별도계상" + return "별도계상" if apart or not any(line["단가"].values()) else "자재" + return _KIND.get(code[0], "기타") + + +def _hopyos(rows: list[list[Any]]) -> tuple[list[dict[str, Any]], list[list[dict[str, Any]]]]: + hopyos: list[dict[str, Any]] = [] + raw: list[list[dict[str, Any]]] = [] + want_head = done = False + for at, row in enumerate(rows, 1): + first = _s(_cell(row, 0)) + hit = hopyo_excel._HOPYO.fullmatch(_flat(first)) + if hit: + code = next((m.group(1) for v in row[1:26] if (m := _HEAD_B.search(_s(v)))), None) + hopyos.append({"번": int(hit.group(1)), "코드": code, "행": [at, at], "근거글": []}) + raw.append([]) + want_head, done = True, False + continue + if not hopyos or done: + continue + hopyo = hopyos[-1] + if want_head: + hopyo.update(이름=first, 규격=_s(_cell(row, 1)), 단위=_s(_cell(row, 3))) + hopyo["금액"] = {} + want_head = False + continue + if _flat(first).startswith("합계") or _flat(first) == "계": + hopyo["금액"] = hopyo_excel._money(row, _COLS, 1) + done = True # 끝 — 다음 호표까지 줄 안 받음 + continue + if not first: + continue + code = _code(row) + hopyo["행"][1] = at + if not code: + if re.search(r"[0-9A-Za-z가-힣]", first): + hopyo["근거글"].append(f"{first} {_s(_cell(row, 1))}".strip()) + continue + if code[0] in "TC" and code[1].isdigit(): + continue # 계 · 소계 · 계약단가 + line = { + "차례": len(raw[-1]), + "이름": first, + "규격": _s(_cell(row, 1)), + "수량": hopyo_excel._num(_cell(row, 2)), + "단위": _s(_cell(row, 3)), + "단가": hopyo_excel._money(row, _COLS, 0), + "금액": hopyo_excel._money(row, _COLS, 1), + "비고": _s(_cell(row, NOTE)), + "코드": code, + "행": at, + } + line["갈래"] = _kind(code, line) + line["참조"] = ( + {"종류": "단산", "번": int(code[1:]), "코드": code} + if code[0] == "D" + else {"종류": "일위", "번": None, "코드": code} + if code[0] == "B" + else None + ) + raw[-1].append(line) + return hopyos, raw + + +def read(sheets: dict[str, list[list[Any]]]) -> dict[str, Any]: + """STmate 시트 → HP 꼴 `{꼴, 시트, 호표, 단산, 검산}`(값은 JSON 수).""" + hopyos, raw = _hopyos(sheets.get(SHEET) or []) + if not hopyos: + raise hopyo_excel.ExcelError(f"{SHEET} 시트에 호표(제N호표)가 없음") + table = _lists(sheets) + blocks = _blocks(sheets.get(CALC) or [], table) + by_code = {h["코드"]: h["번"] for h in hopyos if h["코드"]} + for line in (one for lines in raw for one in lines): + ref = line["참조"] or {} + if ref.get("종류") == "일위": + ref["번"] = by_code.get(ref["코드"]) + elif ref.get("종류") == "단산" and (block := blocks.get(ref["코드"])): + line["근거"] = {k: block[k] for k in ("출처", "직종", "행")} + out = [] + for hopyo, lines in zip(hopyos, raw, strict=True): + for key in ("이름", "규격", "단위"): + hopyo.setdefault(key, "") + if not hopyo["근거글"]: + del hopyo["근거글"] + out.append({**hopyo_excel._json(hopyo), "줄": [hopyo_excel._json(x) for x in lines]}) + keep = ("번", "코드", "이름", "규격", "단위", "행", "출처", "직종", "글", "자원") + return { + "꼴": "STmate", + "시트": SHEET, + "호표": out, + "단산": [{k: b[k] for k in keep} for b in blocks.values()], + "검산": {"어긋남": [], "총괄표": 0}, + } 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/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css index 8e771bad7..9229291f9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css @@ -49,8 +49,8 @@ font-variant-numeric: tabular-nums; } -/* 줄 표 — 갈래 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 입력값 · 환산 · 단추 = 줄바꿈 없음(글자가 세로로 안 - 갈라짐 · 폭 = 글 폭) · 품명 · 규격 · 대상이 남는 폭 · 칸 여백 좁힘(공용 표보다 앞서게 두 갈래 이름) */ +/* 확인 창 줄 표(338) — 체크 · 갈래 · 수량 · 단위 · 상태 · 단추 = 줄바꿈 없음(글자가 세로로 안 갈라짐 · + 폭 = 글 폭) · 품명 · 규격 · 우리 로직 · 옵션 · 품 비교가 남는 폭 · 칸 여백 좁힘(공용 표보다 앞서게 두 갈래 이름) */ .m02-hopyo__table.m02-hopyo__lines :is(th, td) { padding: var(--spacing-8); } @@ -58,19 +58,17 @@ .m02-hopyo__table.m02-hopyo__lines :is(th, td):is( :nth-child(1), - :nth-child(4), + :nth-child(2), :nth-child(5), :nth-child(6), :nth-child(7), - :nth-child(9), - :nth-child(10), :nth-child(11) ) { white-space: nowrap; } -/* 입력값 — 길면 줄임표(전체 글은 칸 풍선 도움말) */ -.m02-hopyo__table.m02-hopyo__lines td:nth-child(9) { +/* 옵션 · 품 비교 — 길면 줄임표(전체 글은 칸 풍선 도움말) */ +.m02-hopyo__table.m02-hopyo__lines td:is(:nth-child(9), :nth-child(10)) { max-width: 24em; overflow: hidden; text-overflow: ellipsis; @@ -89,23 +87,56 @@ white-space: nowrap; } -.m02-hopyo__state.is-기억, -.m02-hopyo__state.is-같음, +/* 상태 색(338) — 맞음 초록 · 별칭 파랑 · 다른 점 주황 · 후보 회색(테두리 기본) · 없음 빨강 */ +.m02-hopyo__state.is-맞음, .m02-hopyo__state.is-호표 { border-color: var(--color-success); color: var(--color-success); } -.m02-hopyo__state.is-후보 { +.m02-hopyo__state.is-별칭 { + border-color: var(--color-info); + color: var(--color-info); +} + +.m02-hopyo__state.is-다른점 { border-color: var(--color-warning); color: var(--color-warning); } +.m02-hopyo__state.is-후보, +.m02-hopyo__state.is-뺌, +.m02-hopyo__state.is-노임 { + color: var(--color-text-secondary); +} + .m02-hopyo__state.is-없음 { border-color: var(--color-danger); color: var(--color-danger); } +/* 우리 로직 칸 — 대상 여럿 = 칸 안 여러 줄(이름 · 키 · 몫 · 배 칸 · [옵션]) */ +.m02-hopyo__parts { + min-width: 14em; +} + +.m02-hopyo__part { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); +} + +.m02-hopyo__part + .m02-hopyo__part { + margin-top: var(--spacing-4); +} + +.m02-hopyo__whole { + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); +} + .m02-hopyo__times { width: 6em; } @@ -150,6 +181,22 @@ margin-bottom: var(--spacing-8); } +.m02-hopyo__cand { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); +} + +/* 별칭 장부(338) — 위 층 줄 = 회색 읽기만 · 고친 줄 = 왼쪽 띠 */ +.m02-hopyo-ledger__row.is-upper { + color: var(--color-text-muted); +} + +.m02-hopyo-ledger__row.is-edited > td:first-child { + box-shadow: inset 3px 0 0 var(--color-accent); +} + /* 엑셀 보기 창(329) — 왼쪽 읽기 전용 시트 · 오른쪽 호표 목록 · 불러오기 결과 */ .m02-hopyo-sheet__dialog > .ui-modal__body { overflow: hidden; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts index 12312fba2..9cd7fc52e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -1,7 +1,8 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_Api.ts - * 일위대가 실무자료(PLAN 324) 서버 길 — `/api/m02/layers/{층}/hopyo…` · 층 = 화면 층(`Api_Fetch` 지금 층) · - * 올리기 = FormData(숨은 file 입력) · 그 밖 = 공용 `call`(서버 까닭 글 그대로). + * 일위대가 실무자료(PLAN 324 · 338) 서버 길 — `/api/m02/layers/{층}/hopyo…` · 층 = 화면 층(`Api_Fetch` 지금 층) · + * 올리기 = FormData(숨은 file 입력) · 그 밖 = 공용 `call`(서버 까닭 글 그대로) · + * 맞춤 응답 꼴 = 설계 3-3(상태 · 체크 제안 · 대상 목록 · 품 비교) · 만들기 몸 = 확인한 줄만(3-6) · 별칭 장부(3-5). * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; @@ -33,7 +34,7 @@ export interface HopyoBrief { 규격: string; 단위: string; 금액: Money; - /** [들어감 수, 줄 수] */ + /** [체크 제안 수, 줄 수] */ 맞춤: [number, number]; /** 일위대가 시트 행 [시작, 끝](엑셀 1부터 · 329 · 옛 파일 없음) */ 행?: [number, number]; @@ -75,7 +76,7 @@ export interface HopyoLineBasis { 행: [number, number]; } -/** 맞춤 대상 — 로직 · 자재 · 다른 호표 */ +/** 후보 · 고른 대상 글 — 로직 · 자재 · 다른 호표 */ export interface HopyoTarget { 로직?: string; 자재?: string; @@ -86,17 +87,55 @@ export interface HopyoTarget { 점수?: number; /** 근거 맞춤(330 · 옛 파일 · 근거 없는 줄은 없음) */ 근거?: HopyoBasisHit; + /** 후보 판정(338) — 맞음 · 다른 점 · 후보 */ + 판정?: "맞음" | "다른 점" | "후보"; + 글?: string; } +/** 맞춤 대상 하나(338 · 로직 여럿 가능) — 배 = 환산 × 몫 · 우리 수량 = 엑셀 수량 × 배 */ +export interface HopyoPart extends HopyoTarget { + 입력값: Record; + 못읽음?: string[]; + /** 칸마다 값 출처 — 별칭 · 블록 · 규격 · 자동값 */ + 출처?: Record; + 환산?: { 배: number | null; 갈래: string } | null; + 몫?: number | null; + 배: number | null; + /** 블록 절 제목(조합 덧 로직) */ + 어디?: string; +} + +/** 품 묶음 — 직종 품 · 장비 시간 · 자재 수량 */ +export interface HopyoQty { + 인력?: Record; + 장비?: { 갈래: string | string[]; 시간: number }[]; + 자재?: { 이름: string; 수량: number }[]; +} + +export type HopyoState = + "맞음" | "별칭" | "다른 점" | "후보" | "없음" | "뺌" | "호표" | "%" | "노임"; + export interface HopyoMatch { 열쇠: string; - 상태: "기억" | "같음" | "후보" | "없음" | "빼기" | "호표" | "%"; - 대상: HopyoTarget | null; - 입력값: Record; - 못읽음: string[]; - 환산: { 배: number | null; 갈래: string } | null; - 들어감: boolean; + 상태: HopyoState; + /** 처음 체크 제안(확인 대기 — 들어감 아님) */ + 체크: boolean; + 대상: HopyoPart[]; 후보?: HopyoTarget[]; + 글?: string; + 꼬리표?: string[]; + 품?: { 엑셀: HopyoQty; 우리: HopyoQty; 같음: Record }; + 갈리는칸?: string[]; + 별칭?: { 층: Layer; 주인: string | null; 규격다름?: boolean; 근거다름?: boolean }; + 뺌?: string; +} + +/** 숫자 판정 셈 — 끝 · 한 블록 · 모두 · 까닭(우리 로직 준비 중 등) */ +export interface HopyoCalc { + 끝: boolean; + 한: number; + 모두: number; + 까닭?: string; } export interface HopyoLine { @@ -115,14 +154,38 @@ export interface HopyoLine { } export interface HopyoOne extends HopyoFile { - 호표: HopyoBrief & { 줄: HopyoLine[] }; + 호표: HopyoBrief & { + 줄: HopyoLine[]; + /** 직접 줄 호표 통째 제안(노임 · 장비를 바로 적은 호표) */ + 통째?: { 대상: HopyoPart[]; 글?: string; 체크: boolean } | null; + 셈?: HopyoCalc; + /** 만들 층에 이 호표로 만든 사전조합 */ + 만든?: { 키: string; 확인: boolean } | null; + }; } +/** 미리 셈(저장 안 함) — 고른 대상 · 옵션으로 우리 품 */ export interface HopyoGuess { - 대상: HopyoTarget; - 입력값: Record; - 못읽음: string[]; - 환산: { 배: number | null; 갈래: string }; + 대상: HopyoPart[]; + 품?: HopyoMatch["품"]; + 글?: string; +} + +/** 만들기 몸 대상 하나 — 로직 · 자재 · 호표 중 하나(금액 · 품 칸 없음 — 서버가 셈) */ +export interface BuildTarget { + 로직?: string; + 자재?: string; + 호표?: number; + 입력값?: Record; + 배: number; + 몫?: number | null; +} + +/** 확인한 줄 — 대상 목록 또는 뺌 까닭 */ +export interface BuildLine { + 차례: number; + 대상?: BuildTarget[]; + 뺌?: "우리에 없음" | "안 씀"; } export interface HopyoMissing { @@ -141,22 +204,45 @@ export interface HopyoBuilt { 다시씀: boolean; 빠진줄: HopyoMissing[]; 비교: { 엑셀: number | null; 우리: number | null; 차이: number | null; 까닭: string | null }; + 장부?: { 씀: number }; } -/** [로직 추가] 「실무 호표」 줄 하나 */ +/** [로직 추가] 「실무 호표」 줄 하나 — 확인 = 문서 층에 확인된 사전조합 */ export interface HopyoPickRow extends Omit { 층: Layer; 주인: string | null; 키: string; 파일: string; + 확인?: LaborRef | null; } -/** 확정 한 줄 — 로직 · 자재 · 빼기 · null(지움) */ -export type HopyoMemory = - | { 로직: string; 입력값: Record; 환산: number } - | { 자재: string; 입력값: Record; 환산: number } - | { 빼기: true } - | null; +/** 별칭 장부 짝 하나 */ +export interface LedgerPair { + 로직?: string; + 자재?: string; + 입력값: Record; + 배: number; + 몫: number | null; +} + +/** 별칭 장부 한 줄(쓰기 꼴) */ +export interface LedgerEntry { + 원래: { 품명: string; 규격: string; 단위: string }; + 갈래: "짝" | "우리에 없음" | "안 씀"; + 짝: LedgerPair[]; + 엑셀조항?: unknown[]; +} + +/** 별칭 장부 보기 한 줄 — 층 · 고침(이 층 · 쓰기 권한) */ +export interface LedgerRow extends LedgerEntry { + 열쇠: string; + 누가: { id: number; 이름: string | null } | null; + 때: string | null; + 처음파일: string | null; + 층: Layer; + 주인: string | null; + 고침: boolean; +} const enc = encodeURIComponent; /** 지금 화면 층(구조물 조합 층) */ @@ -177,39 +263,58 @@ export const readHopyo = (scope: HopyoScope, key: string): Promise => export const readHopyoGrid = (scope: HopyoScope, key: string): Promise => call(at(scope, `/hopyo-grid/${enc(key)}`)); -export const readHopyoOne = (scope: HopyoScope, key: string, no: number): Promise => - call(at(scope, `/hopyo/${enc(key)}/${no}`)); +/** 호표 하나 — `to` = 만들 층(호표 참조 줄 체크 · 만든 · 없으면 파일 층) */ +export const readHopyoOne = ( + scope: HopyoScope, + key: string, + no: number, + to?: HopyoScope, +): Promise => { + const want = to ? `?to=${to.layer}${to.owner ? `&to_owner=${enc(to.owner)}` : ""}` : ""; + return call(at(scope, `/hopyo/${enc(key)}/${no}${want}`)); +}; +/** 미리 셈(저장 안 함) — 고른 대상 목록 · 옵션 → 우리 품 */ export const guessHopyo = ( scope: HopyoScope, key: string, no: number, 차례: number, - 대상: HopyoTarget, + 대상: BuildTarget[], ): Promise => call(at(scope, `/hopyo/${enc(key)}/${no}/guess`), { method: "POST", body: JSON.stringify({ 차례, 대상 }), }); -export const saveHopyoMemory = ( +export const readLedger = async (scope: HopyoScope): Promise => + (await call<{ 줄: LedgerRow[] }>(at(scope, "/hopyo-ledger"))).줄; + +/** 별칭 장부 [저장] — 고친 줄 · null(지움) · 이 층만 */ +export const saveLedger = async ( scope: HopyoScope, - 줄: Record, -): Promise => - call(at(scope, "/hopyo-memory"), { method: "POST", body: JSON.stringify({ 줄 }) }); + 줄: Record, +): Promise => + ( + await call<{ 줄: LedgerRow[] }>(at(scope, "/hopyo-ledger"), { + method: "PUT", + body: JSON.stringify({ 줄 }), + }) + ).줄; export const deleteHopyo = (scope: HopyoScope, key: string): Promise => call(at(scope, `/hopyo/${enc(key)}`), { method: "DELETE" }); -/** 실무 호표 → `target` 층 사전조합(파일 층 = `from`) */ +/** [확인하고 사전조합 만들기] — 확인한 줄만 · 직접 줄 호표 = `통째` → `target` 층 사전조합(파일 층 = `from`) */ export const buildHopyo = ( target: HopyoScope, from: { 층: Layer; 주인: string | null; 키: string; 번: number }, - 새로 = false, + 줄: BuildLine[], + 통째?: { 대상: BuildTarget[] } | null, ): Promise => call(at(target, "/hopyo-build"), { method: "POST", - body: JSON.stringify({ ...from, 새로 }), + body: JSON.stringify(통째 ? { ...from, 통째 } : { ...from, 줄 }), }); export const listHopyoPicks = async (scope: HopyoScope): Promise => diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts new file mode 100644 index 000000000..7087db5b9 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts @@ -0,0 +1,680 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_Check.ts + * 일위대가 실무자료 확인 창(PLAN 338-3 · 설계 4-1) — 호표 하나의 줄마다 서버 맞춤(상태 · 체크 제안 · + * 우리 로직 · 옵션 · 품 비교)을 보이고, 사용자가 체크한 줄만 [확인하고 사전조합 만들기]로 서버에 보냄. + * · 자리 = 실무자료 본 칸(호표 누름) · [로직 추가] 「실무 호표」 모달 · 엑셀 보기 창 펼침 [확인 창 열기] + * · 머리 = 호표 · 「제안 n / 줄 m」 · 숫자 판정 셈 중이면 3 초마다 다시 읽음(끝나면 멈춤 · 만진 줄은 그대로) + * · 줄 = [✓] · 갈래 · 품명 · 규격 · 수량 · 단위 · 상태(맞음 · 별칭 · 다른 점 · 후보) · 우리 로직(여럿 · 몫 · 배) · + * 옵션(칸=값 · 출처 · 못 가름) · 품 비교(엑셀 → 우리 ✓ / ≠) · [고르기] [원문 보기] [엑셀에서 보기] · 뺌 까닭 + * · 대상 · 옵션을 바꾸면 서버 `guess`(저장 안 함 · 줄마다 최신 응답만)가 우리 품을 다시 셈 + * · 캐시 = sessionStorage `m02-hopyo-check:{층}:{주인}:{키}:{번}`(조작 즉시 · 만들기 성공하면 지움) · 자동 저장 없음 + * · 화면 잠금은 [확인하고 사전조합 만들기] 때만 · 쓰기 단추 = 만들 층 쓰기 권한만. + * 계산은 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) — 화면은 보이기 · 체크 · 고름만. + * ========================================================================== */ + +import { + createButton, + el, + hideLoadingOverlay, + showConfirmDialog, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { loadingError, showLoading } from "@ui/ui_template_loading"; +import { openModal } from "@ui/ui_template_modal"; +import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source"; +import { + buildHopyo, + guessHopyo, + readHopyoOne, + type BuildLine, + type BuildTarget, + type HopyoBuilt, + type HopyoLine, + type HopyoMatch, + type HopyoOne, + type HopyoPart, + type HopyoScope, +} from "./M02_MasterTemplete_Hopyo_Api"; +import { + builtWord, + inputsText, + openHopyoChoose, + sourceText, + targetText, +} from "./M02_MasterTemplete_Hopyo_Choose"; +import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet"; +import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api"; +import { openLaborInput } from "./M02_MasterTemplete_Labor_Input"; +import "./M02_MasterTemplete_Hopyo.css"; + +export const POLL_MS = 3000; +const OUT = ["우리에 없음", "안 씀"] as const; + +/** 확인 창 캐시 — 사용자가 만진 것만(서버 제안 위에 덮어 보임) */ +export interface CheckDraft { + 체크: Record; + 대상: Record; + 뺌: Record; + 품: Record; + 통째?: boolean; +} + +const empty = (): CheckDraft => ({ 체크: {}, 대상: {}, 뺌: {}, 품: {} }); +export const draftKey = (scope: HopyoScope, key: string, no: number): string => + `m02-hopyo-check:${scope.layer}:${scope.owner ?? ""}:${key}:${no}`; + +export function loadDraft(k: string): CheckDraft { + try { + const raw = sessionStorage.getItem(k); + return raw ? { ...empty(), ...(JSON.parse(raw) as Partial) } : empty(); + } catch { + return empty(); + } +} +export function saveDraft(k: string, d: CheckDraft): void { + try { + sessionStorage.setItem(k, JSON.stringify(d)); + } catch { + /* 캐시 못 씀 = 이 화면에만 남음 */ + } +} +export function dropDraft(k: string): void { + try { + sessionStorage.removeItem(k); + } catch { + /* 없음 */ + } +} + +/** 줄의 지금 대상 — 사용자가 고른 것(캐시) · 없으면 서버 제안 */ +export const partsOf = (line: HopyoLine, d: CheckDraft): HopyoPart[] => { + const got = d.대상[line.차례] ?? line.맞춤.대상; + return Array.isArray(got) ? got : []; // 옛 응답(대상 하나) = 대상 없음 +}; +/** 체크 — 사용자가 만진 줄은 캐시 · 아니면 서버 제안 */ +export const isChecked = (line: HopyoLine, d: CheckDraft): boolean => + d.체크[line.차례] ?? !!line.맞춤.체크; +/** 체크할 수 있음 — % 줄 아님 · 뺌 까닭 또는 대상(배 0 넘음) */ +export function checkable(line: HopyoLine, d: CheckDraft): boolean { + if (line.맞춤.상태 === "%") return false; + if (d.뺌[line.차례]) return true; + const parts = partsOf(line, d); + return parts.length > 0 && parts.every((p) => p.배 !== null && p.배 > 0); +} + +/** 대상 → 만들기 몸 대상(이름 · 품 같은 보이기 칸은 안 보냄) */ +export function toTarget(p: HopyoPart): BuildTarget { + if (p.호표 !== undefined) return { 호표: p.호표, 배: p.배 ?? 1 }; + const head = p.로직 ? { 로직: p.로직, 입력값: p.입력값 ?? {} } : { 자재: p.자재 }; + return { ...head, 배: p.배 ?? 1, ...(p.몫 != null ? { 몫: p.몫 } : {}) }; +} + +/** 체크한 줄만 → 만들기 몸 `줄` */ +export const bodyOf = (lines: HopyoLine[], d: CheckDraft): BuildLine[] => + lines + .filter((l) => isChecked(l, d) && checkable(l, d)) + .map((l) => + d.뺌[l.차례] + ? { 차례: l.차례, 뺌: d.뺌[l.차례] as BuildLine["뺌"] } + : { 차례: l.차례, 대상: partsOf(l, d).map(toTarget) }, + ); + +/** 빠질 줄 까닭별 수 — 확인 안 함 · 뺌 · % 줄 */ +export function leftOut(lines: HopyoLine[], d: CheckDraft): Record { + const out: Record = {}; + for (const l of lines) { + const why = + l.맞춤.상태 === "%" + ? "% 줄" + : !isChecked(l, d) || !checkable(l, d) + ? "확인 안 함" + : d.뺌[l.차례] + ? "뺌" + : null; + if (why) out[why] = (out[why] ?? 0) + 1; + } + return out; +} + +const n4 = (v: number | undefined): string => + v === undefined ? "–" : String(Math.round(v * 10000) / 10000); + +/** 품 비교 글 — 「콘크리트공 0.1091 → 0.1091 ✓ · 굴착기 0.29 → 0.31 ≠」 */ +export function compareText(q: HopyoMatch["품"] | undefined): string { + if (!q) return ""; + const ex = q.엑셀.인력 ?? {}; + const ours = q.우리.인력 ?? {}; + const mark = (k: string): string => (q.같음[k] ? "✓" : "≠"); + const out = [...new Set([...Object.keys(ex), ...Object.keys(ours)])].map( + (k) => `${k} ${n4(ex[k])} → ${n4(ours[k])} ${mark(k)}`, + ); + const name = (g: string | string[]): string => [g].flat().join("+"); + for (const x of q.엑셀.장비 ?? []) { + const o = (q.우리.장비 ?? []).find((y) => name(y.갈래) === name(x.갈래)); + out.push(`${name(x.갈래)} ${n4(x.시간)} → ${n4(o?.시간)} ${mark([x.갈래].flat()[0])}`); + } + return out.join(" · "); +} + +/** 줄마다 최신 요청만 — `ask(줄)` 이 돌려준 함수가 그 뒤 같은 줄 요청이 없을 때만 true */ +export function newest(): (key: number) => () => boolean { + const last = new Map(); + return (key) => { + const my = (last.get(key) ?? 0) + 1; + last.set(key, my); + return () => last.get(key) === my; + }; +} + +/** [맞음 · 별칭 모두 체크] 대상 줄 */ +export const sureLines = (lines: HopyoLine[], d: CheckDraft): HopyoLine[] => + lines.filter((l) => ["맞음", "별칭"].includes(l.맞춤.상태) && checkable(l, d)); + +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); +const won = (v: number | null | undefined): string => + v === null || v === undefined ? "" : v.toLocaleString("ko-KR"); +const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "error"); +const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text }); +const td = (text: string, cls = ""): HTMLElement => + el("td", { text, className: cls, attrs: { title: text } }); +const openSheet = (o: HopyoSheetOptions): void => + void import("./M02_MasterTemplete_Hopyo_Sheet").then((m) => m.openHopyoSheet(o), fail); + +export interface CheckOptions { + /** 실무자료 파일 층 */ + scope: HopyoScope; + key: string; + no: number; + /** 사전조합을 만들 층(없으면 파일 층) */ + target?: HopyoScope; + /** 만들 층 쓰기 권한 — 없으면 체크 · 만들기 없음(보기만) */ + canWrite: boolean; + /** [← 호표 목록](본 칸) */ + onBack?: () => void; + /** 만들었음 — [로직 추가] 참조 줄 · 모달 닫기 */ + onBuilt?: (got: HopyoBuilt, hopyo: HopyoOne["호표"]) => void; +} + +/** 확인 창 몸 — 본 칸 · 모달이 같이 씀 */ +export function buildHopyoCheck(opt: CheckOptions): HTMLElement { + const root = el("div", { + className: "m02-hopyo m02-hopyo-check", + attrs: { "data-hopyo-check": `${opt.key}|${opt.no}` }, + }); + const target = opt.target ?? opt.scope; + const k = draftKey(opt.scope, opt.key, opt.no); + let d = loadDraft(k); + let got: HopyoOne | null = null; + let built: HopyoBuilt | null = null; + let seq = 0; + let timer = 0; + const ask = newest(); // 줄마다 최신 미리 셈만 + const keep = (): void => saveDraft(k, d); + + async function load(quiet = false): Promise { + const my = ++seq; + const stop = quiet ? () => {} : showLoading(root); + try { + const one = await readHopyoOne(opt.scope, opt.key, opt.no, opt.target); + if (my !== seq) return; + got = one; + draw(); + } catch (error) { + if (my === seq && !quiet) root.replaceChildren(loadingError(why(error), () => void load())); + } finally { + stop(); + } + } + + /** 대상 바꿈 → 캐시 · 체크 · 서버 미리 셈(최신 응답만) */ + function setParts(line: HopyoLine, parts: HopyoPart[]): void { + d.대상[line.차례] = parts; + d.체크[line.차례] = true; + delete d.뺌[line.차례]; + keep(); + draw(); + const mine = ask(line.차례); + guessHopyo(opt.scope, opt.key, opt.no, line.차례, parts.map(toTarget)).then((g) => { + if (!mine()) return; + if (Array.isArray(g.대상)) d.대상[line.차례] = g.대상; + if (g.품) d.품[line.차례] = g.품; + keep(); + draw(); + }, fail); + } + + function editOptions(line: HopyoLine, at: number): void { + const parts = partsOf(line, d); + const p = parts[at]; + if (!p?.로직) return; + fetchLogicInputs([p.로직]).then( + (res) => + openLaborInput({ + title: `${p.로직} ${p.이름 ?? ""}`.trim(), + logic: res.로직[0] ?? null, + values: (p.입력값 ?? {}) as never, + memo: null, + readOnly: !opt.canWrite, + done: (r) => + setParts( + line, + parts.map((x, i) => (i === at ? { ...x, 입력값: r.입력값 } : x)), + ), + }), + fail, + ); + } + + /** [원문 보기] — 엑셀 인용 조항 · 우리 로직 출처 두 갈래 */ + async function showSource(line: HopyoLine): Promise { + const excel = (line.근거?.출처 ?? []).map((x) => x.join(" ").trim()).filter(Boolean); + const codes = partsOf(line, d).flatMap((p) => (p.로직 ? [p.로직] : [])); + const ours = codes.length + ? (await fetchLogicInputs(codes).catch(() => ({ 로직: [] }))).로직.flatMap((x) => + x.출처 ? [`${x.로직} — ${x.출처}`] : [], + ) + : []; + openModal({ + title: `원문 — ${line.이름}`, + closeLabel: "닫기", + wide: true, + dialogClass: "m02-modal m02-hopyo__src", + mount: (body) => { + const part = (head: string, list: string[], cut: (s: string) => string): void => { + body.append(el("h4", { text: head })); + if (!list.length) body.append(note("없음")); + for (const s of list) { + const view = createSourceView({ + path: "/m02/logic-lib/source", + pdf: "/m02/logic-lib/source-pdf", + }); + view.show(cut(s)); + body.append(note(s), view.root); + } + }; + part("엑셀 인용 조항", excel, (s) => s); + part("우리 로직 출처", ours, (s) => s.split(" — ").slice(1).join(" — ")); + }, + }); + } + + function partCell(line: HopyoLine): HTMLElement { + const parts = partsOf(line, d); + return el("td", { + className: "m02-hopyo__parts", + children: parts.map((p, at) => { + const times = el("input", { + className: "ui-input ui-input--sm m02-hopyo__times", + attrs: { type: "number", step: "any", min: "0", value: p.배 == null ? "" : String(p.배) }, + }) as HTMLInputElement; + times.title = "배 — 우리 수량 = 엑셀 수량 × 배"; + times.disabled = !opt.canWrite; + times.addEventListener("change", () => { + const v = times.value === "" ? null : Number(times.value); + d.대상[line.차례] = parts.map((x, i) => (i === at ? { ...x, 배: v } : x)); + keep(); + draw(); + }); + const share = p.몫 != null ? ` · 몫 ${p.몫}` : ""; + return el("div", { + className: "m02-hopyo__part", + children: [ + el("span", { + text: `${targetText(p)} ${p.로직 ?? p.자재 ?? ""}${share}`.trim(), + attrs: { title: p.어디 ?? "" }, + }), + times, + ...(p.로직 + ? [ + createButton({ + label: "옵션", + variant: "ghost", + size: "sm", + onClick: () => editOptions(line, at), + }), + ] + : []), + ], + }); + }), + }); + } + + function lineRow(line: HopyoLine): HTMLElement { + const m = line.맞춤; + const parts = partsOf(line, d); + const box = el("input", { attrs: { type: "checkbox", "data-check": String(line.차례) } }); + const input = box as HTMLInputElement; + input.checked = isChecked(line, d) && checkable(line, d); + input.disabled = !opt.canWrite || !checkable(line, d); + input.addEventListener("change", () => { + d.체크[line.차례] = input.checked; + keep(); + draw(); + }); + const out = el("select", { + className: "ui-input ui-input--sm", + attrs: { title: "뺌 — 사전조합에 안 넣고 장부에 남김" }, + children: [ + el("option", { text: "넣음", attrs: { value: "" } }), + ...OUT.map((x) => el("option", { text: `뺌 — ${x}`, attrs: { value: x } })), + ], + }) as HTMLSelectElement; + out.value = d.뺌[line.차례] ?? ""; + out.disabled = !opt.canWrite || m.상태 === "%"; + out.addEventListener("change", () => { + if (out.value) d.뺌[line.차례] = out.value; + else delete d.뺌[line.차례]; + d.체크[line.차례] = !!out.value || d.체크[line.차례]; + keep(); + draw(); + }); + const sheet = (행: [number, number]): void => + openSheet({ + scope: opt.scope, + key: opt.key, + 파일: got?.파일 ?? "", + canWrite: opt.canWrite, + target, + 단산행: 행, + }); + const acts = [ + ...(opt.canWrite && m.상태 !== "%" + ? [ + createButton({ + label: "고르기", + variant: "ghost", + size: "sm", + onClick: () => + openHopyoChoose({ + line, + openSheet: sheet, + done: (ts) => + setParts( + line, + ts.map((t) => ({ ...t, 입력값: t.입력값 ?? {}, 몫: t.몫 ?? null })), + ), + }), + }), + ] + : []), + createButton({ + label: "원문 보기", + variant: "ghost", + size: "sm", + onClick: () => void showSource(line), + }), + ...(line.근거 + ? [ + createButton({ + label: "엑셀에서 보기", + variant: "ghost", + size: "sm", + onClick: () => sheet(line.근거!.행), + }), + ] + : []), + out, + ]; + const tags = [m.글, ...(m.꼬리표 ?? []), sourceText(line.근거)].filter(Boolean).join("\n"); + const opts = parts + .filter((p) => p.로직) + .map((p) => inputsText(p.입력값 ?? {}, p.못읽음 ?? [], p.출처)) + .filter(Boolean); + if (m.갈리는칸?.length) opts.push(`⚠ 못 가름: ${m.갈리는칸.join(", ")}`); + const alias = m.별칭?.규격다름 ? " · 규격 다름" : m.별칭?.근거다름 ? " · 근거 다름" : ""; + return el("tr", { + className: `m02-hopyo__line is-${input.checked ? "in" : "out"}`, + attrs: { "data-line": String(line.차례), "data-state": m.상태 }, + children: [ + el("td", { children: [box] }), + td(line.갈래), + td(line.이름), + td(line.규격), + td(line.수량 === null ? "" : String(line.수량), "m02-hopyo__num"), + td(line.단위), + el("td", { + attrs: { title: tags }, + children: [ + el("span", { + className: `m02-hopyo__state is-${m.상태.replace(" ", "")}`, + text: `${m.상태}${alias}`, + }), + ], + }), + partCell(line), + td(opts.join(" / ")), + td(compareText(d.품[line.차례] ?? m.품)), + el("td", { className: "m02-hopyo__acts", children: acts }), + ], + }); + } + + async function make(h: HopyoOne["호표"]): Promise { + const whole = !!h.통째 && (d.통째 ?? h.통째.체크); + const lines = whole ? [] : bodyOf(h.줄, d); + const out = leftOut(h.줄, d); + const put = lines.filter((x) => x.대상).length; + const outs = lines.length - put; + const ask = whole + ? `호표 통째로 만듦 — ${h.통째!.대상.map(targetText).join(" · ")}` + : `넣을 줄 ${put} · 뺌 ${outs} · 빠질 줄 ${Object.values(out).reduce((a, b) => a + b, 0)}` + + (Object.keys(out).length + ? `(${Object.entries(out) + .map(([a, b]) => `${a} ${b}`) + .join(" · ")})` + : "") + + " — 확인하고 사전조합을 만듦"; + if (!(await showConfirmDialog(ask, "확인하고 만들기"))) return; + const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: opt.no }; + showLoadingOverlay(); + try { + const res = await buildHopyo( + target, + from, + lines, + whole ? { 대상: h.통째!.대상.map(toTarget) } : null, + ); + dropDraft(k); + d = empty(); + built = res; + const miss = res.빠진줄.length; + showToast(`${res.참조.키} ${builtWord(res)}${miss ? ` — 빠진 줄 ${miss}` : ""}`, "success"); + opt.onBuilt?.(res, h); + await load(true); + } catch (error) { + fail(error); + } finally { + hideLoadingOverlay(); + } + } + + function builtBlock(res: HopyoBuilt): HTMLElement { + const c = res.비교; + const rows = res.빠진줄.map((x) => + el("tr", { + children: [ + td(x.이름), + td(x.규격), + td(x.수량 === null ? "" : String(x.수량), "m02-hopyo__num"), + td(x.단위), + td(won(x.금액), "m02-hopyo__num"), + td(x.까닭), + ], + }), + ); + return el("div", { + className: "m02-hopyo__block m02-hopyo__built", + attrs: { "data-built": res.참조.키 }, + children: [ + note(res.다시씀 ? `있던 ${res.참조.키} 를 확인한 내용으로 덮음` : `${res.참조.키} 만듦`), + ...(rows.length + ? [ + el("h4", { text: `빠진 줄 ${rows.length}` }), + el("table", { + className: "ui-general-block__table m02-hopyo__table", + children: [ + el("thead", { + children: [ + el("tr", { + children: ["품명", "규격", "수량", "단위", "엑셀 금액", "까닭"].map((t) => + el("th", { text: t }), + ), + }), + ], + }), + el("tbody", { children: rows }), + ], + }), + ] + : []), + note( + `값 비교(참고) — 엑셀 ${won(c.엑셀)} · 우리 ${won(c.우리 === null ? null : Math.round(c.우리))}` + + ` · 차이 ${won(c.차이)}${c.까닭 ? ` · ${c.까닭}` : ""}`, + ), + ], + }); + } + + function draw(): void { + if (!got) return; + const h = got.호표; + const checked = h.줄.filter((l) => isChecked(l, d) && checkable(l, d)).length; + const calc = h.셈; + window.clearTimeout(timer); + if (calc && !calc.끝) + timer = window.setTimeout(() => root.isConnected && void load(true), POLL_MS); + const tools: HTMLElement[] = []; + if (opt.canWrite) { + const sure = sureLines(h.줄, d); + const all = createButton({ label: `맞음 · 별칭 모두 체크 (${sure.length})`, size: "sm" }); + all.disabled = !sure.length; + all.addEventListener("click", () => { + for (const l of sure) d.체크[l.차례] = true; + keep(); + draw(); + }); + const whole = !!h.통째 && (d.통째 ?? h.통째.체크); + const go = createButton({ label: "확인하고 사전조합 만들기", variant: "filled", size: "sm" }); + go.disabled = !whole && checked < 1; + go.setAttribute("data-hopyo-make", ""); + go.addEventListener("click", () => void make(h)); + tools.push(all, go); + } + const back = opt.onBack + ? [createButton({ label: "← 호표 목록", variant: "ghost", size: "sm", onClick: opt.onBack })] + : []; + const head = el("div", { + className: "m02-hopyo__bar", + children: [ + ...back, + el("strong", { text: `제${h.번}호표 ${h.이름}` }), + note(h.규격), + note(h.단위), + note(`엑셀 단가 ${won(h.금액.계)}`), + note(`제안 ${h.맞춤[0]} / 줄 ${h.맞춤[1]} · 체크 ${checked}`), + ...(calc && !calc.끝 + ? [note(`숫자 판정 셈 중 ${calc.한}/${calc.모두}${calc.까닭 ? ` · ${calc.까닭}` : ""}`)] + : []), + ...(h.만든 ? [note(`만든 사전조합 ${h.만든.키}${h.만든.확인 ? " · 확인함" : ""}`)] : []), + createButton({ + label: "엑셀에서 보기", + size: "sm", + onClick: () => + openSheet({ + scope: opt.scope, + key: opt.key, + 파일: got!.파일, + canWrite: opt.canWrite, + target, + 호표: opt.no, + }), + }), + ...tools, + ], + }); + const whole = h.통째 + ? (() => { + const box = el("input", { + attrs: { type: "checkbox", "data-hopyo-whole": "" }, + }) as HTMLInputElement; + box.checked = d.통째 ?? h.통째.체크; + box.disabled = !opt.canWrite; + box.addEventListener("change", () => { + d.통째 = box.checked; + keep(); + draw(); + }); + return [ + el("label", { + className: "m02-hopyo__bar m02-hopyo__whole", + children: [ + box, + note( + `통째 = ${h.통째.대상.map(targetText).join(" · ")}${h.통째.글 ? ` — ${h.통째.글}` : ""}`, + ), + ], + }), + ]; + })() + : []; + const heads = [ + "", + "갈래", + "품명", + "규격", + "수량", + "단위", + "상태", + "우리 로직 · 배", + "옵션", + "품 비교", + "", + ]; + root.replaceChildren( + head, + ...whole, + h.줄.length + ? el("div", { + className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head", + children: [ + el("table", { + className: "ui-general-block__table m02-hopyo__table m02-hopyo__lines", + children: [ + el("thead", { + children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], + }), + el("tbody", { children: h.줄.map(lineRow) }), + ], + }), + ], + }) + : note("구성 줄 없음"), + ...(built ? [builtBlock(built)] : []), + ); + } + + void load(); + return root; +} + +/** 확인 창 모달 — [로직 추가] 「실무 호표」 · 엑셀 보기 창 [확인 창 열기] · 만들면 닫음 */ +export function openHopyoCheck(opt: CheckOptions & { title?: string }): void { + openModal({ + title: opt.title ?? `확인 — 제${opt.no}호표`, + closeLabel: "닫기", + wide: true, + dialogClass: "m02-modal m02-hopyo__check-modal", + mount: (body, close) => + body.append( + buildHopyoCheck({ + ...opt, + onBuilt: (got, h) => { + opt.onBuilt?.(got, h); + close(); + }, + }), + ), + }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts new file mode 100644 index 000000000..26046f7fc --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts @@ -0,0 +1,231 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_Choose.ts + * 일위대가 실무자료 고르기 창(PLAN 338-3 · 설계 4-2 · 옛 `Hopyo_View.choose` 를 옮김) + 글 도우미. + * · 위 = 엑셀 블록 품(직종 · 장비 · 자재) · 인용 조항 · [엑셀에서 보기](단가산출 그 행) + * · 후보 다섯 = 이름 · 키 · 판정 꼬리표(맞음 / 다른 점 / 후보) · 글 · 근거 꼬리표(근거 맞음 초록) · 금액 비 없음 + * · 고름 = [고름](한 로직) · 또는 체크 + 몫 칸 → [고른 n개 함께](조합) · 옵션은 부르는 쪽이 서버 `guess` 로 채움 + * · [다른 로직 찾기] = `openLogicPicker` 에 찾기 글 미리 넣음(엑셀 인용 조항 번호 → 없으면 품명 첫 뜻 낱말) + * 저장 안 함 — 고른 것은 부르는 쪽 캐시(확인 창)로만 감. + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import type { + BuildTarget, + HopyoBasisHit, + HopyoLine, + HopyoLineBasis, + HopyoQty, + HopyoTarget, +} from "./M02_MasterTemplete_Hopyo_Api"; +import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; +import { pickMaterial } from "./M02_Material_Pick"; +import "./M02_MasterTemplete_Hopyo.css"; + +const MATERIAL = new Set(["자재", "별도계상"]); +const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text }); +const num = (v: number): string => String(Math.round(v * 10000) / 10000); + +/** 대상 글 — 「이름 [단위]」 · 자재는 규격도 · 호표 = 「제N호표」 */ +export const targetText = (t: HopyoTarget | null | undefined): string => { + if (!t) return ""; + if (t.호표 !== undefined) return `제${t.호표}호표 ${t.단위 ? `[${t.단위}]` : ""}`.trim(); + const spec = t.규격 ? ` ${t.규격}` : ""; + return `${t.이름 ?? t.로직 ?? t.자재 ?? ""}${spec}${t.단위 ? ` [${t.단위}]` : ""}`; +}; + +/** 입력값 글 — 「이름=값」 · 칸 출처(블록 · 규격 · 별칭) · 못 가르는 칸 표시 */ +export const inputsText = ( + values: Record, + unread: string[], + from: Record = {}, +): string => + [ + Object.entries(values) + .map(([k, v]) => `${k}=${String(v)}${from[k] && from[k] !== "자동값" ? `(${from[k]})` : ""}`) + .join(", "), + unread.length ? `⚠ 못 가름: ${unread.join(", ")}` : "", + ] + .filter(Boolean) + .join(" · "); + +/** 만들기 결과 낱말 — 있던 것을 덮음 · 새로 만듦 */ +export const builtWord = (got: { 다시씀: boolean }): string => + got.다시씀 ? "있던 것을 확인한 내용으로 덮음" : "만듦"; + +/** 근거 맞음 — 조항 같음(앞 마디 아님) · 단위 맞음 · 직종 겹침 + * (줄 근거 직종이 보통인부뿐이거나 없으면 — 기계 등 — 직종은 안 봄) */ +export const basisOk = (b: HopyoBasisHit | undefined, line?: HopyoLineBasis): boolean => + !!b && + b.까닭.startsWith("조항 같음") && + b.단위 && + (b.직종.length > 0 || !(line?.직종 ?? []).some((x) => x !== "보통인부")); + +/** 후보 근거 꼬리표 — 「조항 건설 공통 3-2-1 · 직종 특별인부 · 단위 같음」 · 근거 없으면 "" */ +export const basisTag = (b: HopyoBasisHit | undefined): string => + b + ? [ + b.조항.length ? `조항 ${b.조항.join(", ")}` : "", + b.직종.length ? `직종 ${b.직종.join(", ")}` : "", + b.단위 ? "단위 같음" : "", + ] + .filter(Boolean) + .join(" · ") + : ""; + +/** 엑셀 근거 줄 — 「출처 건설표준품셈 3-2-1 · 직종 특별인부 · 단가산출 93–101행」 · 없으면 "" */ +export const sourceText = (b: HopyoLineBasis | undefined): string => + b + ? [ + b.출처.length ? `출처 ${b.출처.map((x) => x.join(" ").trim()).join(", ")}` : "", + b.직종.length ? `직종 ${b.직종.join(", ")}` : "", + `단가산출 ${b.행[0] === b.행[1] ? b.행[0] : `${b.행[0]}–${b.행[1]}`}행`, + ] + .filter(Boolean) + .join(" · ") + : ""; + +/** 품 글 — 「콘크리트공 0.1091 · 굴착기 0.29hr · 레미콘 1.02」 */ +export const qtyText = (q: HopyoQty | undefined): string => + q + ? [ + ...Object.entries(q.인력 ?? {}).map(([k, v]) => `${k} ${num(v)}`), + ...(q.장비 ?? []).map((x) => `${[x.갈래].flat().join("+")} ${num(x.시간)}hr`), + ...(q.자재 ?? []).map((x) => `${x.이름} ${num(x.수량)}`), + ].join(" · ") + : ""; + +/** [다른 로직 찾기] 미리 넣을 글 — 엑셀 인용 조항 번호 → 없으면 품명 괄호 밖 첫 뜻 낱말 */ +export function searchHint(line: Pick): string { + const clause = line.근거?.출처?.find((x) => x[1])?.[1]; + if (clause) return clause; + return /[가-힣A-Za-z]{2,}/.exec(line.이름.replace(/\([^()]*\)/g, " "))?.[0] ?? ""; +} + +/** 고른 후보 → 만들기 대상(배 1 · 몫 있으면 배 = 몫) */ +export const pickedTargets = (picked: { c: HopyoTarget; 몫: number | null }[]): BuildTarget[] => + picked.map(({ c, 몫 }) => ({ + ...(c.로직 ? { 로직: c.로직 } : { 자재: c.자재 }), + 입력값: {}, + 배: 몫 ?? 1, + 몫, + })); + +export interface ChooseOptions { + line: HopyoLine; + /** [엑셀에서 보기] — 단가산출 그 행 */ + openSheet?: (행: [number, number]) => void; + done: (targets: BuildTarget[]) => void; +} + +/** 고르기 창 — 엑셀 품 · 인용 조항 · 후보 다섯(하나 또는 여럿 · 몫) · 다른 로직 · 자재 찾기 */ +export function openHopyoChoose(opt: ChooseOptions): void { + const { line } = opt; + const material = MATERIAL.has(line.갈래); + openModal({ + title: `고르기 — ${line.이름} ${line.규격}`.trim(), + closeLabel: "닫기", + dialogClass: "m02-modal m02-hopyo__choose", + mount: (body, close) => { + const finish = (targets: BuildTarget[]): void => { + close(); + opt.done(targets); + }; + const picked = new Map(); + const together = createButton({ label: "고른 0개 함께", size: "sm", disabled: true }); + const paint = (): void => { + together.querySelector(".ui-btn__label")!.textContent = `고른 ${picked.size}개 함께`; + together.disabled = picked.size < 2; + }; + together.addEventListener("click", () => + finish( + pickedTargets( + [...picked].map(([c, box]) => ({ c, 몫: box.value === "" ? null : Number(box.value) })), + ), + ), + ); + const list = (line.맞춤.후보 ?? []).map((c) => { + const share = el("input", { + className: "ui-input ui-input--sm m02-hopyo__times", + attrs: { type: "number", step: "0.05", min: "0", max: "1", placeholder: "몫" }, + }) as HTMLInputElement; + const box = el("input", { attrs: { type: "checkbox", title: "함께 고름(조합)" } }); + box.addEventListener("change", () => { + if ((box as HTMLInputElement).checked) picked.set(c, share); + else picked.delete(c); + paint(); + }); + const tag = basisTag(c.근거); + return el("div", { + className: "m02-hopyo__cand", + attrs: { "data-cand": c.로직 ?? c.자재 ?? "" }, + children: [ + box, + createButton({ + label: `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}`, + variant: "ghost", + size: "sm", + onClick: () => finish(pickedTargets([{ c, 몫: null }])), + }), + ...(c.판정 + ? [ + el("span", { + className: `m02-hopyo__state is-${c.판정.replace(" ", "")}`, + text: c.판정, + }), + ] + : []), + ...(c.글 ? [note(c.글)] : []), + ...(tag && c.근거 + ? [ + el("span", { + className: `m02-hopyo__basis${basisOk(c.근거, line.근거) ? " is-ok" : ""}`, + text: tag, + attrs: { title: `${c.근거.까닭} · 점수 ${c.근거.점수}` }, + }), + ] + : []), + share, + ], + }); + }); + const excel = qtyText(line.맞춤.품?.엑셀); + const basis = line.근거; + const head = el("div", { + className: "m02-hopyo__bar m02-hopyo__source", + children: [ + ...(excel ? [note(`엑셀 품 — ${excel}`)] : []), + ...(basis ? [note(`엑셀 근거 — ${sourceText(basis)}`)] : []), + ...(basis && opt.openSheet + ? [ + createButton({ + label: "엑셀에서 보기", + variant: "ghost", + size: "sm", + onClick: () => opt.openSheet?.(basis.행), + }), + ] + : []), + ], + }); + const more = createButton({ + label: material ? "다른 자재 찾기…" : "다른 로직 찾기…", + size: "sm", + onClick: () => { + close(); + if (material) pickMaterial((x) => opt.done([{ 자재: x.코드, 배: 1 }])); + else + openLogicPicker((x) => opt.done([{ 로직: x.키, 입력값: {}, 배: 1 }]), undefined, { + query: searchHint(line), + }); + }, + }); + body.append( + ...(head.childElementCount ? [head] : []), + note(list.length ? "후보 — 하나 고름 · 여럿은 체크(몫 칸) 뒤 [함께]" : "가까운 것 없음"), + el("div", { className: "m02-hopyo__cands", children: list }), + el("div", { className: "m02-hopyo__bar", children: [together, more] }), + ); + }, + }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts new file mode 100644 index 000000000..73dd80f8c --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts @@ -0,0 +1,358 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_Ledger.ts + * 일위대가 실무자료 별칭 장부 화면(PLAN 338-3 · 설계 4-4) — 본 칸 · 좌측 판 [별칭 장부] 로 엶. + * · 표 = 엑셀 품명 · 규격 · 단위 | 갈래 | 우리 로직(여럿) · 옵션 · 배 · 몫 | 누가 · 때 · 처음 파일 | 층(위 층 회색 읽기만) + * · 할 일 = 찾기 · 갈래 거름 · 갈래 바꿈 · [로직 바꿈] · [로직 더함] · 배 · 몫 · [옵션] · [지우기] · + * 위 층 줄 [내 층으로 고쳐 씀] + * · 고친 것 = sessionStorage `m02-hopyo-ledger:{층}:{주인}` → [저장] 때만 `PUT`(서버가 짝을 다시 검사) + * · 안 둠 = 발주처 칸 · 자동 올림 · 자동 합침 · 쓰인 횟수 · 회사로 올리기. + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { loadingError, showLoading } from "@ui/ui_template_loading"; +import { + readLedger, + saveLedger, + type HopyoScope, + type LedgerEntry, + type LedgerPair, + type LedgerRow, +} from "./M02_MasterTemplete_Hopyo_Api"; +import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api"; +import { openLaborInput } from "./M02_MasterTemplete_Labor_Input"; +import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; +import "./M02_MasterTemplete_Hopyo.css"; + +export const LEDGER_TITLE = "별칭 장부"; +const KINDS = ["짝", "우리에 없음", "안 씀"] as const; +const LAYER_TEXT: Record = { system: "마스터", company: "회사", personal: "개인" }; +type Edits = Record; +/** 화면 한 줄 — 서버 줄 위에 고친 것 */ +export type LedgerView = LedgerRow & { 고친?: boolean }; + +export const ledgerKey = (scope: HopyoScope): string => + `m02-hopyo-ledger:${scope.layer}:${scope.owner ?? ""}`; +function loadEdits(k: string): Edits { + try { + return JSON.parse(sessionStorage.getItem(k) ?? "{}") as Edits; + } catch { + return {}; + } +} +function saveEdits(k: string, edits: Edits): void { + try { + if (Object.keys(edits).length) sessionStorage.setItem(k, JSON.stringify(edits)); + else sessionStorage.removeItem(k); + } catch { + /* 캐시 못 씀 = 이 화면에만 남음 */ + } +} + +/** 쓰기 꼴 — 보기 칸(층 · 누가 · 때 …) 뺌 */ +export const entryOf = (row: LedgerEntry): LedgerEntry => ({ + 원래: row.원래, + 갈래: row.갈래, + 짝: + row.갈래 === "짝" + ? row.짝.map((p) => ({ ...p, 입력값: p.입력값 ?? {}, 몫: p.몫 ?? null })) + : [], + 엑셀조항: row.엑셀조항 ?? [], +}); + +/** 서버 줄 + 고친 것 → 화면 줄 — 이 층 줄은 고친 값 · 지운 것 뺌 · 위 층에서 옮긴 줄은 이 층 줄로 더함 */ +export function mergeRows(rows: LedgerRow[], edits: Edits, scope: HopyoScope): LedgerView[] { + const mine = rows.filter((r) => r.고침); + const out: LedgerView[] = []; + for (const r of mine) { + if (!(r.열쇠 in edits)) out.push(r); + else if (edits[r.열쇠]) out.push({ ...r, ...edits[r.열쇠]!, 고친: true }); + } + for (const [key, entry] of Object.entries(edits)) { + if (!entry || mine.some((r) => r.열쇠 === key)) continue; + out.push({ + ...entry, + 열쇠: key, + 누가: null, + 때: null, + 처음파일: null, + 층: scope.layer, + 주인: scope.owner, + 고침: true, + 고친: true, + }); + } + return [...out, ...rows.filter((r) => !r.고침)]; +} + +/** 짝 글 — 「GC000198 × 1 · 몫 0.1 · 소량감률=50」 */ +export const pairText = (p: LedgerPair): string => + [ + `${p.로직 ?? p.자재 ?? ""} × ${p.배}`, + p.몫 != null ? `몫 ${p.몫}` : "", + Object.entries(p.입력값 ?? {}) + .map(([k, v]) => `${k}=${String(v)}`) + .join(", "), + ] + .filter(Boolean) + .join(" · "); + +/** 찾기 · 갈래 거름 */ +export const filterRows = (rows: LedgerView[], q: string, kind: string): LedgerView[] => { + const want = q.trim().toLowerCase(); + return rows.filter( + (r) => + (!kind || r.갈래 === kind) && + (!want || + `${r.원래.품명} ${r.원래.규격} ${r.짝.map(pairText).join(" ")}` + .toLowerCase() + .includes(want)), + ); +}; + +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); +const td = (text: string, cls = ""): HTMLElement => + el("td", { text, className: cls, attrs: { title: text } }); +const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text }); + +export interface LedgerOptions { + scope: HopyoScope; + /** 이 층 쓰기 권한 — 없으면 보기만 */ + canWrite: boolean; +} + +export function buildHopyoLedger(opt: LedgerOptions): HTMLElement { + const root = el("div", { + className: "m02-hopyo m02-hopyo-ledger", + attrs: { "data-hopyo-ledger": "" }, + }); + const k = ledgerKey(opt.scope); + let edits = loadEdits(k); + let rows: LedgerRow[] = []; + const search = el("input", { + className: "ui-input ui-input--sm m02-hopyo__search", + attrs: { type: "search", placeholder: "품명 · 규격 · 로직 찾기" }, + }) as HTMLInputElement; + const kind = el("select", { + className: "ui-input ui-input--sm", + children: [ + el("option", { text: "갈래 전체", attrs: { value: "" } }), + ...KINDS.map((x) => el("option", { text: x, attrs: { value: x } })), + ], + }) as HTMLSelectElement; + const save = createButton({ label: "저장", variant: "filled", size: "sm" }); + const drop = createButton({ label: "고친 것 버림", variant: "ghost", size: "sm" }); + const body = el("tbody"); + const keep = (): void => { + saveEdits(k, edits); + paint(); + }; + const edit = (r: LedgerView, next: Partial): void => { + edits[r.열쇠] = entryOf({ ...entryOf(r), ...next }); + keep(); + }; + + function pairCell(r: LedgerView, mine: boolean): HTMLElement { + const pairs: HTMLElement[] = r.짝.map((p, at) => { + const parts: HTMLElement[] = [el("span", { text: pairText(p) })]; + if (mine) { + const times = el("input", { + className: "ui-input ui-input--sm m02-hopyo__times", + attrs: { type: "number", step: "any", min: "0", value: String(p.배), title: "배" }, + }) as HTMLInputElement; + times.addEventListener("change", () => + edit(r, { 짝: r.짝.map((x, i) => (i === at ? { ...x, 배: Number(times.value) } : x)) }), + ); + parts.push(times); + if (p.로직) + parts.push( + createButton({ + label: "옵션", + variant: "ghost", + size: "sm", + onClick: () => + void fetchLogicInputs([p.로직!]).then( + (got) => + openLaborInput({ + title: p.로직!, + logic: got.로직[0] ?? null, + values: p.입력값 as never, + memo: null, + readOnly: false, + done: (res) => + edit(r, { + 짝: r.짝.map((x, i) => (i === at ? { ...x, 입력값: res.입력값 } : x)), + }), + }), + (error) => showToast(why(error), "error"), + ), + }), + createButton({ + label: "×", + variant: "ghost", + size: "sm", + onClick: () => edit(r, { 짝: r.짝.filter((_, i) => i !== at) }), + }), + ); + } + return el("div", { className: "m02-hopyo__part", children: parts }); + }); + if (mine && r.갈래 === "짝") + pairs.push( + createButton({ + label: "로직 더함", + variant: "ghost", + size: "sm", + onClick: () => + openLogicPicker((x) => + edit(r, { 짝: [...r.짝, { 로직: x.키, 입력값: {}, 배: 1, 몫: null }] }), + ), + }), + ); + return el("td", { className: "m02-hopyo__parts", children: pairs }); + } + + function rowOf(r: LedgerView): HTMLElement { + const mine = opt.canWrite && r.고침; + const kindCell = mine + ? (() => { + const s = el("select", { + className: "ui-input ui-input--sm", + children: KINDS.map((x) => el("option", { text: x, attrs: { value: x } })), + }) as HTMLSelectElement; + s.value = r.갈래; + s.addEventListener("change", () => + edit(r, { 갈래: s.value as LedgerEntry["갈래"], 짝: s.value === "짝" ? r.짝 : [] }), + ); + return el("td", { children: [s] }); + })() + : td(r.갈래); + const acts: HTMLElement[] = []; + if (mine) + acts.push( + createButton({ + label: "지우기", + variant: "danger", + size: "sm", + onClick: () => { + edits[r.열쇠] = null; + keep(); + }, + }), + ); + else if (opt.canWrite && !rows.some((x) => x.고침 && x.열쇠 === r.열쇠) && !edits[r.열쇠]) + acts.push( + createButton({ + label: "내 층으로 고쳐 씀", + variant: "ghost", + size: "sm", + onClick: () => { + edits[r.열쇠] = entryOf(r); + keep(); + }, + }), + ); + const when = [r.누가?.이름, r.때?.replace("T", " ").slice(0, 16), r.처음파일] + .filter(Boolean) + .join(" · "); + return el("tr", { + className: `m02-hopyo-ledger__row${r.고침 ? "" : " is-upper"}${r.고친 ? " is-edited" : ""}`, + attrs: { "data-ledger": r.열쇠 }, + children: [ + td(r.원래.품명), + td(r.원래.규격), + td(r.원래.단위), + kindCell, + pairCell(r, mine), + td(when), + td(r.고침 ? "내 층" : (LAYER_TEXT[r.층] ?? r.층)), + el("td", { className: "m02-hopyo__acts", children: acts }), + ], + }); + } + + function paint(): void { + const all = mergeRows(rows, edits, opt.scope); + const shown = filterRows(all, search.value, kind.value); + const n = Object.keys(edits).length; + save.querySelector(".ui-btn__label")!.textContent = n ? `저장 (${n})` : "저장"; + save.disabled = drop.disabled = !n; + body.replaceChildren( + ...(shown.length + ? shown.map(rowOf) + : [el("tr", { children: [el("td", { text: "장부 줄 없음", attrs: { colspan: "8" } })] })]), + ); + } + search.addEventListener("input", paint); + kind.addEventListener("change", paint); + drop.addEventListener("click", () => { + edits = {}; + keep(); + }); + save.addEventListener("click", async () => { + save.disabled = true; + try { + rows = await saveLedger(opt.scope, edits); + edits = {}; + showToast("장부 저장", "success"); + keep(); + } catch (error) { + showToast(`저장 못 함 — ${why(error)}`, "error"); + paint(); + } + }); + + async function load(): Promise { + const stop = showLoading(root); + try { + rows = await readLedger(opt.scope); + } catch (error) { + root.replaceChildren(loadingError(why(error), () => void load())); + return; + } finally { + stop(); + } + root.replaceChildren( + el("div", { + className: "m02-hopyo__bar", + children: [ + el("strong", { text: LEDGER_TITLE }), + note("엑셀 이름 → 우리 로직 + 옵션 + 배 · 확인하고 만들 때 남음 · 아래 층이 이김"), + search, + kind, + ...(opt.canWrite ? [save, drop] : []), + ], + }), + el("div", { + className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head", + children: [ + el("table", { + className: "ui-general-block__table m02-hopyo__table", + children: [ + el("thead", { + children: [ + el("tr", { + children: [ + "엑셀 품명", + "규격", + "단위", + "갈래", + "우리 로직 · 배 · 몫 · 옵션", + "누가 · 때 · 처음 파일", + "층", + "", + ].map((t) => el("th", { text: t })), + }), + ], + }), + body, + ], + }), + ], + }), + ); + paint(); + } + + void load(); + return root; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts index c460ce6a3..def3c122d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts @@ -1,13 +1,13 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_Pick.ts - * [로직 추가] 창의 「실무 호표」 줄(PLAN 324-4) — 일위대가 문서 층 · 위 층 실무자료의 호표마다 한 줄(맞춤 n/m) · - * 고르면 서버가 그 호표를 문서 층 사전조합으로 만들고(`/hopyo-build` · 있던 것은 다시 씀) `lib.pick` 으로 - * 참조 줄(별칭 = 호표 품명) · 빠진 줄이 있으면 알림 · 파일마다 「엑셀로 보기 · 여러 호표」 줄(329 엑셀 보기 창 · - * 고른 호표마다 참조 줄). 프로젝트 층엔 없음. + * [로직 추가] 창의 「실무 호표」 줄(PLAN 324-4 · 338-3) — 일위대가 문서 층 · 위 층 실무자료의 호표마다 한 줄(제안 n/m) · + * 고름 = 문서 층에 확인된 사전조합이 있으면 그것을 바로 참조 · 없으면 확인 창 모달(`Hopyo_Check` · 늦게 불러옴) → + * [확인하고 사전조합 만들기] → `lib.pick` 참조 줄(별칭 = 호표 품명) · 확인 없이 만들지 않음 · + * 파일마다 「엑셀로 보기 · 여러 호표」 줄(329 엑셀 보기 창 · 고른 호표마다 참조 줄). 프로젝트 층엔 없음. * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; -import { buildHopyo, listHopyoPicks, type HopyoPickRow } from "./M02_MasterTemplete_Hopyo_Api"; +import { listHopyoPicks, type HopyoPickRow } from "./M02_MasterTemplete_Hopyo_Api"; import type { LibraryPick, PickExtra } from "./M02_MasterTemplete_Labor_Pick"; import { tc } from "./M02_MasterTemplete_Labor_Text"; @@ -73,21 +73,29 @@ export function hopyoExtra(row: HopyoPickRow, lib: LibraryPick): PickExtra { tag: "실무 호표", name: [row.이름, row.규격].filter(Boolean).join(" "), sub: `${row.파일} · 제${row.번}호표 · ${LAYER_TEXT[row.층] ?? row.층}`, - unit: `${row.단위} · 맞춤 ${row.맞춤[0]}/${row.맞춤[1]}`, + unit: `${row.단위} · ${row.확인 ? `확인 ${row.확인.키}` : `제안 ${row.맞춤[0]}/${row.맞춤[1]}`}`, onPick: () => void pickHopyo(row, lib), }; } -/** 고름 — 서버가 사전조합을 만들고(다시 씀) 참조 줄을 더함 */ +/** 고름 — 확인된 사전조합이면 바로 참조 · 아니면 확인 창 모달 → 만든 뒤 참조 */ export async function pickHopyo(row: HopyoPickRow, lib: LibraryPick): Promise { + const alias = row.이름.slice(0, ALIAS_MAX).trim() || null; + if (row.확인) { + showToast(`「${row.이름}」 → 확인한 사전조합 ${row.확인.키}`, "success"); + return lib.pick(row.확인, row.이름, alias); + } try { - const from = { 층: row.층, 주인: row.주인, 키: row.키, 번: row.번 }; - const got = await buildHopyo({ layer: lib.layer, owner: lib.owner }, from); - const miss = got.빠진줄.length; - const head = `「${row.이름}」 → 사전조합 ${got.참조.키}${got.다시씀 ? "(있던 것)" : ""}`; - if (miss) showToast(`${head} · 빠진 줄 ${miss} — 실무자료 화면에서 맞춤`, "warning", 6000); - else showToast(head, "success"); - lib.pick(got.참조, got.이름, row.이름.slice(0, ALIAS_MAX).trim() || null); + const check = await import("./M02_MasterTemplete_Hopyo_Check"); + check.openHopyoCheck({ + title: `확인 — ${row.파일} 제${row.번}호표 ${row.이름}`, + scope: { layer: row.층, owner: row.주인 }, + key: row.키, + no: row.번, + target: { layer: lib.layer, owner: lib.owner }, + canWrite: true, + onBuilt: (got) => lib.pick(got.참조, got.이름, alias), + }); } catch (error) { showToast(error instanceof Error ? error.message : String(error), "error"); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts index e547de48e..ab8f851b5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -1,15 +1,23 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_Sheet.ts - * 일위대가 실무자료 엑셀 보기 창(PLAN 329) — 큰 모달 · 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거 - * 스프레드시트 · 탭 일위대가 · 단가산출) · 오른쪽 = 호표 목록(체크 · 찾기 · 맞춤 n/m) + [고른 n개 불러오기]. + * 일위대가 실무자료 엑셀 보기 창(PLAN 329 · 338-3) — 큰 모달 · 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거 + * 스프레드시트 · 탭 일위대가 · 단가산출) · 오른쪽 = 호표 목록(체크 · 찾기 · 제안 n/m) + [고른 n개 확인하고 만들기]. * · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 영역 고름 - * · 불러오기 = 고른 호표마다 차례로 사전조합 만들기(`/hopyo-build`) → 결과 표(사전조합 키 · 빠진 줄 · 값 차이) · + * · ① [고른 n개 확인하고 만들기] = 고른 호표마다 서버 보기 → 펼침 표(넣을 줄 = 체크 제안 · 빠질 줄 · 셈 중 · + * [확인 창 열기]) ② [이대로 n개 만들기] = 하위 호표부터 차례로 `/hopyo-build`(몸 = 그 호표 체크 줄) — + * 펼쳐 보이고 한 번 누름 = 사용자 확인 · 결과 표(사전조합 키 · 빠진 줄 · 값 차이) · * `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄) - * · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 불러오기는 됨 + * · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 확인하고 만들기는 됨 * ⚠ 부르는 쪽은 `import()` 로 엶 · 스프레드시트도 열 때 불러옴(다른 화면 번들에 안 섞이게). * ========================================================================== */ -import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { + createButton, + el, + hideLoadingOverlay, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import type { Sheet } from "../A00_Common/spreadsheet/spreadsheet_types"; import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; @@ -17,13 +25,24 @@ import { buildHopyo, readHopyo, readHopyoGrid, + readHopyoOne, type HopyoBrief, type HopyoBuilt, type HopyoGrid, + type HopyoOne, type HopyoScope, type HopyoSpan, } from "./M02_MasterTemplete_Hopyo_Api"; -import { builtWord } from "./M02_MasterTemplete_Hopyo_View"; +import { + bodyOf, + draftKey, + leftOut, + loadDraft, + openHopyoCheck, + partsOf, + toTarget, +} from "./M02_MasterTemplete_Hopyo_Check"; +import { builtWord } from "./M02_MasterTemplete_Hopyo_Choose"; import "./M02_MasterTemplete_Hopyo.css"; export interface HopyoSheetOptions { @@ -31,7 +50,7 @@ export interface HopyoSheetOptions { scope: HopyoScope; key: string; 파일: string; - /** 만들 층 쓰기 권한 — 없으면 체크 · 불러오기 없음(보기만) */ + /** 만들 층 쓰기 권한 — 없으면 체크 · 만들기 없음(보기만) */ canWrite: boolean; /** 사전조합을 만들 층(없으면 `scope` · [로직 추가] = 문서 층) */ target?: HopyoScope; @@ -68,6 +87,19 @@ export function lastColumn(sheet: Pick): string { return best; } +/** 만들 차례 — 하위 호표(호표 참조 줄이 가리키는 고른 호표)부터 · 순환은 남은 차례대로 */ +export function buildOrder(ones: { 번: number; 아래: number[] }[]): number[] { + const left = new Map(ones.map((x) => [x.번, x.아래.filter((n) => n !== x.번)])); + const out: number[] = []; + while (left.size) { + const ready = [...left].find(([, kids]) => kids.every((n) => !left.has(n))); + const no = ready ? ready[0] : [...left.keys()][0]; + out.push(no); + left.delete(no); + } + return out; +} + /** 단가산출 행 → 행 전체 범위 A1(`4:9`) */ export const rowsText = (행: number | [number, number]): string => typeof 행 === "number" ? `${행}:${행}` : `${행[0]}:${행[1]}`; @@ -94,7 +126,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { }) as HTMLInputElement; const list = el("div", { className: "m02-hopyo-sheet__list" }); const count = note(""); - const go = createButton({ label: "고른 0개 불러오기", variant: "filled", size: "sm" }); + const go = createButton({ label: "고른 0개 확인하고 만들기", variant: "filled", size: "sm" }); go.disabled = true; const result = el("div", { className: "m02-hopyo-sheet__result" }); const right = el("div", { @@ -122,7 +154,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { }); const paintGo = (): void => { - go.querySelector(".ui-btn__label")!.textContent = `고른 ${checked.size}개 불러오기`; + go.querySelector(".ui-btn__label")!.textContent = `고른 ${checked.size}개 확인하고 만들기`; go.disabled = !checked.size; }; @@ -226,57 +258,147 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { } } - async function importPicked(): Promise { - const nos = [...checked].sort((a, b) => a - b); - const target = opt.target ?? opt.scope; - const rows: HTMLElement[] = []; - let ok = 0; - go.disabled = true; - for (const no of nos) { - const h = briefs.find((x) => x.번 === no); - if (!h || closed) continue; - const head = el("td", { text: `제${no}호표 ${h.이름}`.trim() }); - try { - const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: no }; - const got = await buildHopyo(target, from); - ok += 1; - opt.onImport?.(got, h); - rows.push( - el("tr", { - attrs: { "data-built": got.참조.키 }, - children: [ - head, - el("td", { text: `${got.참조.키} · ${builtWord(got, false)}` }), - el("td", { text: String(got.빠진줄.length), className: "m02-hopyo__num" }), - el("td", { text: won(got.비교.차이), className: "m02-hopyo__num" }), - ], - }), - ); - } catch (error) { - rows.push(el("tr", { children: [head, el("td", { text: `실패 — ${why(error)}` })] })); - } - result.replaceChildren( - el("table", { - className: "ui-general-block__table m02-hopyo__table", - children: [ - el("thead", { - children: [ - el("tr", { - children: ["호표", "사전조합", "빠진 줄", "값 차이"].map((t) => - el("th", { text: t }), - ), - }), - ], - }), - el("tbody", { children: rows }), - ], + const table = (heads: string[], rows: HTMLElement[]): HTMLElement => + el("table", { + className: "ui-general-block__table m02-hopyo__table", + children: [ + el("thead", { + children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], }), - ); + el("tbody", { children: rows }), + ], + }); + + /** 호표 하나의 몸 — 확인 창 캐시 위 체크 제안 · 통째면 줄 없음 */ + function bodyFor(one: HopyoOne): Parameters[2] { + const h = one.호표; + const d = loadDraft(draftKey(opt.scope, opt.key, h.번)); + return h.통째 && (d.통째 ?? h.통째.체크) ? [] : bodyOf(h.줄, d); + } + function wholeFor(one: HopyoOne): Parameters[3] { + const h = one.호표; + const d = loadDraft(draftKey(opt.scope, opt.key, h.번)); + return h.통째 && (d.통째 ?? h.통째.체크) ? { 대상: h.통째.대상.map(toTarget) } : null; + } + + /** ① 고른 호표마다 서버 보기 → 펼침 표 */ + async function review(): Promise { + const target = opt.target ?? opt.scope; + const nos = [...checked].sort((a, b) => a - b); + go.disabled = true; + const ones: HopyoOne[] = []; + for (const no of nos) { + if (closed) return; + try { + ones.push(await readHopyoOne(opt.scope, opt.key, no, target)); + } catch (error) { + showToast(`제${no}호표 — ${why(error)}`, "error"); + } } paintGo(); - showToast(`불러옴 ${ok} / ${nos.length}`, ok === nos.length ? "success" : "warning"); + const rows = ones.map((one) => { + const h = one.호표; + const d = loadDraft(draftKey(opt.scope, opt.key, h.번)); + const out = Object.entries(leftOut(h.줄, d)); + const calc = h.셈 && !h.셈.끝 ? `셈 중 ${h.셈.한}/${h.셈.모두}` : ""; + const brief = briefs.find((x) => x.번 === h.번) ?? h; + return el("tr", { + attrs: { "data-review": String(h.번) }, + children: [ + el("td", { text: `제${h.번}호표 ${h.이름}`.trim() }), + el("td", { + text: wholeFor(one) ? "통째" : String(bodyFor(one).filter((x) => x.대상).length), + className: "m02-hopyo__num", + }), + el("td", { text: out.map(([a, b]) => `${a} ${b}`).join(" · ") }), + el("td", { text: calc }), + el("td", { + children: [ + createButton({ + label: "확인 창 열기", + variant: "ghost", + size: "sm", + onClick: () => + openHopyoCheck({ + scope: opt.scope, + key: opt.key, + no: h.번, + target, + canWrite: opt.canWrite, + onBuilt: (got) => opt.onImport?.(got, brief), + }), + }), + ], + }), + ], + }); + }); + const ready = ones.filter((one) => bodyFor(one).length || wholeFor(one)); + const all = createButton({ + label: `이대로 ${ready.length}개 만들기`, + variant: "filled", + size: "sm", + disabled: !ready.length, + onClick: () => void makeAll(ready), + }); + all.setAttribute("data-hopyo-make-all", ""); + result.replaceChildren( + table(["호표", "넣을 줄", "빠질 줄", "숫자 판정", ""], rows), + el("div", { + className: "m02-hopyo__bar", + children: [all, note("넣을 줄 = 체크 제안 · 확인 창에서 고친 것")], + }), + ); } - go.addEventListener("click", () => void importPicked()); + + /** ② 한 번 누름 = 사용자 확인 — 하위 호표부터 차례로 만들기 */ + async function makeAll(ones: HopyoOne[]): Promise { + const target = opt.target ?? opt.scope; + const by = new Map(ones.map((one) => [one.호표.번, one])); + const order = buildOrder( + ones.map((one) => { + const d = loadDraft(draftKey(opt.scope, opt.key, one.호표.번)); + const kids = one.호표.줄.flatMap((l) => + partsOf(l, d).flatMap((p) => (p.호표 !== undefined ? [p.호표] : [])), + ); + return { 번: one.호표.번, 아래: kids }; + }), + ); + const rows: HTMLElement[] = []; + let ok = 0; + showLoadingOverlay(); + try { + for (const no of order) { + const one = by.get(no)!; + const h = briefs.find((x) => x.번 === no) ?? one.호표; + const head = el("td", { text: `제${no}호표 ${h.이름}`.trim() }); + try { + const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: no }; + const got = await buildHopyo(target, from, bodyFor(one), wholeFor(one)); + ok += 1; + opt.onImport?.(got, h); + rows.push( + el("tr", { + attrs: { "data-built": got.참조.키 }, + children: [ + head, + el("td", { text: `${got.참조.키} · ${builtWord(got)}` }), + el("td", { text: String(got.빠진줄.length), className: "m02-hopyo__num" }), + el("td", { text: won(got.비교.차이), className: "m02-hopyo__num" }), + ], + }), + ); + } catch (error) { + rows.push(el("tr", { children: [head, el("td", { text: `실패 — ${why(error)}` })] })); + } + } + } finally { + hideLoadingOverlay(); + } + result.replaceChildren(table(["호표", "사전조합", "빠진 줄", "값 차이(참고)"], rows)); + showToast(`만듦 ${ok} / ${order.length}`, ok === order.length ? "success" : "warning"); + } + go.addEventListener("click", () => void review()); void load(); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts index 3bd583efb..ef48f51ca 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts @@ -3,11 +3,13 @@ * 좌측 「일위대가 실무자료」 컨테이너(PLAN 324-4 · 일위대가 사전조합 아래) — 층 = 구조물 조합 층(화면 층) · * [엑셀 올리기](숨은 file 입력 .xls · .xlsx · 쓰기 권한 없으면 꺼짐) + 파일 줄 → 누르면 본 화면 * (`M02_MasterTemplete_Hopyo_View`) · 고름 표시 = 본 화면이 메인 칸에 붙어 있는 동안만 · - * 올린 직후 엑셀 보기 창(329 · `M02_MasterTemplete_Hopyo_Sheet` 늦게 불러옴)을 자동으로 엶. + * 올린 직후 엑셀 보기 창(329 · `M02_MasterTemplete_Hopyo_Sheet` 늦게 불러옴)을 자동으로 엶 · + * 파일 목록 아래 [별칭 장부](338-3) → 본 칸 장부 화면(`M02_MasterTemplete_Hopyo_Ledger`). * ========================================================================== */ import { createButton, el, showToast } from "@ui/ui_template_elements"; import { listHopyo, pageScope, uploadHopyo, type HopyoFile } from "./M02_MasterTemplete_Hopyo_Api"; +import { buildHopyoLedger, LEDGER_TITLE } from "./M02_MasterTemplete_Hopyo_Ledger"; import { buildHopyoView } from "./M02_MasterTemplete_Hopyo_View"; export const HOPYO_TITLE = "일위대가 실무자료"; @@ -48,9 +50,19 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { onClick: () => input.click(), }); upload.title = opt.canWrite ? "설계내역서 엑셀(일위대가 시트)" : "이 층 쓰기 권한 없음"; + const ledger = el("button", { + className: "m02-side__item ui-sidebar-row", + text: LEDGER_TITLE, + attrs: { + type: "button", + title: "엑셀 이름 → 우리 로직 짝 보기 · 고치기", + "data-hopyo-ledger-open": "", + }, + }); + ledger.addEventListener("click", () => void showLedger()); const body = el("div", { className: "m02-hopyo-side", - children: [el("div", { className: "m02-side__row", children: [upload, input] }), list], + children: [el("div", { className: "m02-side__row", children: [upload, input] }), list, ledger], }); let files: HopyoFile[] = []; let open: { key: string; view: HTMLElement } | null = null; @@ -75,6 +87,7 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { }); function paintList(): void { + ledger.classList.toggle("is-active", open?.key === LEDGER_TITLE); list.replaceChildren( ...(files.length ? files.map((f) => { @@ -107,6 +120,16 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { paintList(); } + async function showLedger(): Promise { + if (open?.key === LEDGER_TITLE && open.view.isConnected) return; + if (!(await opt.confirmLeave())) return; + const view = buildHopyoLedger({ scope, canWrite: opt.canWrite }); + opt.show(`${HOPYO_TITLE} — ${LEDGER_TITLE}`, view); + opt.onOpen(); + open = { key: LEDGER_TITLE, view }; + paintList(); + } + async function refresh(): Promise { const mine = ++seq; try { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts index 4af98e977..b7f58f5e4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts @@ -1,58 +1,31 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_View.ts - * 일위대가 실무자료 본 화면(PLAN 324-4) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 맞춤 n/m · 호표 표 + 찾기 · - * [지우기] / 호표 하나: 줄 표(갈래 · 품명 · 규격 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 대상 · 입력값 · 환산 · - * [고르기] [확정] [빼기]) · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기] · - * [엑셀로 보기] · [엑셀에서 보기](329 엑셀 보기 창 · 늦게 불러옴 · 호표 화면은 그 호표 고른 채) · - * 고르기 창 후보 근거 꼬리표 · 엑셀 근거 줄 + [엑셀에서 보기](단가산출 그 행) · [근거 맞음 모두 확정](330). + * 일위대가 실무자료 본 화면(PLAN 324-4 · 338-3) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 제안 n/m · 호표 표 + 찾기 · + * [지우기] · [엑셀로 보기](329 엑셀 보기 창 · 늦게 불러옴) / 호표 하나 = 확인 창(`M02_MasterTemplete_Hopyo_Check` · + * 체크한 줄만 [확인하고 사전조합 만들기]) — 즉시 확정 · 바로 만들기 단추 없음. * 맞춤 · 만들기는 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) · 쓰기 단추는 층 쓰기 권한만. * ========================================================================== */ import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { loadingError, showLoading } from "@ui/ui_template_loading"; -import { openModal } from "@ui/ui_template_modal"; import { - buildHopyo, deleteHopyo, - guessHopyo, readHopyo, - readHopyoOne, - saveHopyoMemory, - type HopyoBasisHit, - type HopyoBuilt, - type HopyoGuess, - type HopyoLine, - type HopyoLineBasis, - type HopyoMemory, - type HopyoOne, type HopyoScope, - type HopyoTarget, type HopyoView, } from "./M02_MasterTemplete_Hopyo_Api"; +import { buildHopyoCheck } from "./M02_MasterTemplete_Hopyo_Check"; import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet"; -import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; -import { pickMaterial } from "./M02_Material_Pick"; import "./M02_MasterTemplete_Hopyo.css"; export interface HopyoViewOptions { scope: HopyoScope; - /** 층 쓰기 권한 — 없으면 확정 · 만들기 · 지우기 단추 없음 */ + /** 층 쓰기 권한 — 없으면 체크 · 만들기 · 지우기 단추 없음 */ canWrite: boolean; /** 파일을 지웠음 — 좌측 목록 다시 */ onDeleted: () => void; } -const STATE: Record = { - 기억: "기억", - 같음: "같음", - 후보: "후보", - 없음: "없음", - 빼기: "뺌", - 호표: "호표", - "%": "% 줄", -}; -const MATERIAL = new Set(["자재", "별도계상"]); - const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); const won = (v: number | null | undefined): string => v === null || v === undefined ? "" : v.toLocaleString("ko-KR"); @@ -61,12 +34,12 @@ const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "er const openSheet = (o: HopyoSheetOptions): void => void import("./M02_MasterTemplete_Hopyo_Sheet").then((m) => m.openHopyoSheet(o), fail); -const grid = (heads: string[], body: HTMLElement, cls = ""): HTMLElement => +const grid = (heads: string[], body: HTMLElement): HTMLElement => el("div", { className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head", children: [ el("table", { - className: `ui-general-block__table m02-hopyo__table ${cls}`.trim(), + className: "ui-general-block__table m02-hopyo__table", children: [ el("thead", { children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], @@ -76,87 +49,10 @@ const grid = (heads: string[], body: HTMLElement, cls = ""): HTMLElement => }), ], }); -const table = (heads: string[], rows: HTMLElement[]): HTMLElement => - grid(heads, el("tbody", { children: rows })); const td = (text: string, cls = ""): HTMLElement => el("td", { text, className: cls, attrs: { title: text } }); const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text }); -/** 대상 글 — 「이름 [단위]」 · 자재는 규격도 · 호표 = 「제N호표」 */ -export const targetText = (t: HopyoTarget | null | undefined): string => { - if (!t) return ""; - if (t.호표 !== undefined) return `제${t.호표}호표 ${t.단위 ? `[${t.단위}]` : ""}`.trim(); - const spec = t.규격 ? ` ${t.규격}` : ""; - return `${t.이름 ?? t.로직 ?? t.자재 ?? ""}${spec}${t.단위 ? ` [${t.단위}]` : ""}`; -}; - -/** 입력값 글 — 「이름=값」 · 못 읽은 입력 표시 */ -export const inputsText = (values: Record, unread: string[]): string => - [ - Object.entries(values) - .map(([k, v]) => `${k}=${String(v)}`) - .join(", "), - unread.length ? `⚠ 못 읽음: ${unread.join(", ")}` : "", - ] - .filter(Boolean) - .join(" · "); - -/** 만들기 결과 낱말 — [새로 만들기] = 「새로 만들어 덮음」 · 있던 것 = 「다시 씀」 · 그 밖 「만듦」 */ -export const builtWord = (got: Pick, fresh: boolean): string => - fresh ? "새로 만들어 덮음" : got.다시씀 ? "다시 씀" : "만듦"; - -/** 확정 한 줄 몸 — 대상 · 입력값 · 환산(0 넘는 수) · 없으면 null */ -export function memoryOf( - target: HopyoTarget | null, - values: Record, - times: number | null, -): HopyoMemory { - if (!target || times === null || !(times > 0)) return null; - if (target.로직) return { 로직: target.로직, 입력값: values, 환산: times }; - if (target.자재) return { 자재: target.자재, 입력값: values, 환산: times }; - return null; -} - -/** 근거 맞음(330 [근거 맞음 모두 확정]) — 조항 같음(앞 마디 아님) · 단위 맞음 · 직종 겹침 - * (줄 근거 직종이 보통인부뿐이거나 없으면 — 기계 등 — 직종은 안 봄) */ -export const basisOk = (b: HopyoBasisHit | undefined, line?: HopyoLineBasis): boolean => - !!b && - b.까닭.startsWith("조항 같음") && - b.단위 && - (b.직종.length > 0 || !(line?.직종 ?? []).some((x) => x !== "보통인부")); - -/** 후보 근거 꼬리표 — 「조항 건설 공통 3-2-1 · 직종 특별인부 · 단위 같음」 · 근거 없으면 "" */ -export const basisTag = (b: HopyoBasisHit | undefined): string => - b - ? [ - b.조항.length ? `조항 ${b.조항.join(", ")}` : "", - b.직종.length ? `직종 ${b.직종.join(", ")}` : "", - b.단위 ? "단위 같음" : "", - ] - .filter(Boolean) - .join(" · ") - : ""; - -/** 엑셀 근거 줄 — 「출처 건설표준품셈 3-2-1 · 직종 특별인부 · 단가산출 93–101행」 · 없으면 "" */ -export const sourceText = (b: HopyoLineBasis | undefined): string => - b - ? [ - b.출처.length ? `출처 ${b.출처.map((x) => x.join(" ").trim()).join(", ")}` : "", - b.직종.length ? `직종 ${b.직종.join(", ")}` : "", - `단가산출 ${b.행[0] === b.행[1] ? b.행[0] : `${b.행[0]}–${b.행[1]}`}행`, - ] - .filter(Boolean) - .join(" · ") - : ""; - -/** [근거 맞음 모두 확정] 대상 — 「후보」 줄의 지금 대상이 근거 맞으면 그것 · 아니면 근거 맞는 첫 후보 · 없으면 null */ -export function basisPick(line: HopyoLine): HopyoTarget | null { - const m = line.맞춤; - if (m.상태 !== "후보") return null; - if (m.대상 && basisOk(m.대상.근거, line.근거)) return m.대상; - return (m.후보 ?? []).find((c) => basisOk(c.근거, line.근거)) ?? null; -} - export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement { const view = el("div", { className: "m02-hopyo", attrs: { "data-hopyo-view": key } }); let nav = 0; // 늦게 온 응답 버림 @@ -223,7 +119,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement note(`시트 ${file.시트}`), note(`호표 ${file.호표수}`), note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"), - note(`맞춤 ${file.맞춤[0]} / ${file.맞춤[1]}`), + note(`제안 ${file.맞춤[0]} / 줄 ${file.맞춤[1]}`), createButton({ label: "엑셀로 보기", size: "sm", @@ -235,7 +131,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement }), ...(wrong.length ? [el("p", { className: "m02-hopyo__warn", text: wrong.join("\n") })] : []), search, - grid(["호표", "품명", "규격", "단위", "엑셀 단가", "맞춤"], body), + grid(["호표", "품명", "규격", "단위", "엑셀 단가", "제안 / 줄"], body), ); } @@ -245,7 +141,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement variant: "danger", size: "sm", onClick: async () => { - const ask = `실무자료 「${file.파일}」을 지움 — 만든 사전조합 · 맞춤 기억은 그대로`; + const ask = `실무자료 「${file.파일}」을 지움 — 만든 사전조합 · 별칭 장부는 그대로`; if (!(await showConfirmDialog(ask, "지우기"))) return; try { await deleteHopyo(opt.scope, key); @@ -257,358 +153,20 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement }, }); - /* ── 호표 하나 — 줄 맞춤 ────────────────────────────────────── */ - async function drawOne(no: number, built?: HopyoBuilt & { 새로: boolean }): Promise { - const got = await load( - () => readHopyoOne(opt.scope, key, no), - () => void drawOne(no), - ); - if (!got) return; - const h = got.호표; - const picked = new Map(); // 고른 대상(확정 전) - const back = createButton({ label: "← 호표 목록", variant: "ghost", size: "sm" }); - back.addEventListener("click", () => void drawFile()); - const rows = el("tbody"); - const paintRows = (): void => { - rows.replaceChildren(...h.줄.map((line) => lineRow(got, line, picked, paintRows))); - }; - paintRows(); - const same = h.줄.filter((x) => x.맞춤.상태 === "같음" && x.맞춤.환산?.배 != null); - const tools: HTMLElement[] = []; - if (opt.canWrite) { - const all = createButton({ label: `같음 모두 확정 (${same.length})`, size: "sm" }); - all.disabled = !same.length; - all.addEventListener("click", () => void confirmAll(no, same)); - tools.push(all); - // 근거(330) — 옛 파일 · 근거 없는 호표면 단추 없음 - if (h.줄.some((x) => x.근거 || x.맞춤.후보?.some((c) => c.근거))) { - const fit = h.줄.filter((x) => basisPick(x)); - const ok = createButton({ label: `근거 맞음 모두 확정 (${fit.length})`, size: "sm" }); - ok.title = "조항 · 직종 · 단위가 모두 맞는 후보만"; - ok.disabled = !fit.length; - ok.addEventListener("click", () => void confirmBasis(no, fit)); - tools.push(ok); - } - const make = createButton({ label: "사전조합 만들기", variant: "filled", size: "sm" }); - make.addEventListener("click", () => void build(no, false)); - tools.push(make); - } - const missing = h.줄.filter((x) => !x.맞춤.들어감); + /* ── 호표 하나 — 확인 창 ────────────────────────────────────── */ + function drawOne(no: number): void { + nav += 1; // 파일 표 늦은 응답 버림 view.replaceChildren( - el("div", { - className: "m02-hopyo__bar", - children: [ - back, - el("strong", { text: `제${h.번}호표 ${h.이름}` }), - note(h.규격), - note(h.단위), - note(`엑셀 단가 ${won(h.금액.계)}`), - note(`맞춤 ${h.맞춤[0]} / ${h.맞춤[1]}`), - createButton({ - label: "엑셀에서 보기", - size: "sm", - onClick: () => - openSheet({ - scope: opt.scope, - key, - 파일: got.파일, - canWrite: opt.canWrite, - 호표: no, - }), - }), - ...tools, - ], + buildHopyoCheck({ + scope: opt.scope, + key, + no, + canWrite: opt.canWrite, + onBack: () => void drawFile(), }), - h.줄.length - ? grid( - [ - "갈래", - "품명", - "규격", - "수량", - "단위", - "엑셀 단가", - "맞춤", - "대상", - "입력값", - "환산", - "", - ], - rows, - "m02-hopyo__lines", - ) - : el("p", { className: "m02-hopyo__note", text: "구성 줄 없음" }), - ...(missing.length ? [missingBlock(missing)] : []), - ...(built ? [builtBlock(no, built)] : []), ); } - function lineRow( - got: HopyoOne, - line: HopyoLine, - picked: Map, - repaint: () => void, - ): HTMLElement { - const m = line.맞춤; - const mine = picked.get(line.차례); - const target = mine?.대상 ?? m.대상; - const values = mine?.입력값 ?? m.입력값; - const conv = mine?.환산 ?? m.환산; - const fixed = ["%", "호표", "빼기"].includes(m.상태); - const times = el("input", { - className: "ui-input ui-input--sm m02-hopyo__times", - attrs: { - type: "number", - step: "any", - min: "0", - value: conv?.배 != null ? String(conv.배) : "", - }, - }) as HTMLInputElement; - times.disabled = !opt.canWrite || fixed || !target; - const state = mine ? "고름" : (STATE[m.상태] ?? m.상태); - const actions: HTMLElement[] = []; - if (opt.canWrite && m.상태 === "빼기") { - actions.push( - createButton({ - label: "되살림", - variant: "ghost", - size: "sm", - onClick: () => void remember(got.호표.번, { [m.열쇠]: null }), - }), - ); - } else if (opt.canWrite && !["%", "호표"].includes(m.상태)) { - actions.push( - createButton({ - label: "고르기", - variant: "ghost", - size: "sm", - onClick: () => - choose(line, got.파일, (t) => - guessHopyo(opt.scope, key, got.호표.번, line.차례, t).then( - (g) => (picked.set(line.차례, g), repaint()), - fail, - ), - ), - }), - createButton({ - label: "확정", - size: "sm", - disabled: !target, - onClick: () => { - const body = memoryOf(target, values, times.value === "" ? null : Number(times.value)); - if (!body) return showToast("대상과 0 넘는 환산을 넣어야 확정", "error"); - void remember(got.호표.번, { [m.열쇠]: body }); - }, - }), - createButton({ - label: "빼기", - variant: "ghost", - size: "sm", - onClick: () => void remember(got.호표.번, { [m.열쇠]: { 빼기: true } }), - }), - ); - } - return el("tr", { - className: `m02-hopyo__line is-${m.들어감 ? "in" : "out"}`, - attrs: { "data-line": String(line.차례), "data-state": m.상태 }, - children: [ - td(line.갈래), - td(line.이름), - td(line.규격), - td(line.수량 === null ? "" : String(line.수량), "m02-hopyo__num"), - td(line.단위), - td(won(line.단가.계), "m02-hopyo__num"), - el("td", { - children: [el("span", { className: `m02-hopyo__state is-${m.상태}`, text: state })], - }), - td(targetText(target)), - td(inputsText(values, mine?.못읽음 ?? m.못읽음)), - el("td", { - children: [times, note(conv?.갈래 ? ` ${conv.갈래}` : "")], - }), - el("td", { className: "m02-hopyo__acts", children: actions }), - ], - }); - } - - /** 대상 고르기 — 엑셀 근거 줄 · 후보 5(근거 꼬리표) + 다른 로직 · 자재 찾기 */ - function choose(line: HopyoLine, 파일: string, done: (t: HopyoTarget) => void): void { - const material = MATERIAL.has(line.갈래); - openModal({ - title: `고르기 — ${line.이름} ${line.규격}`.trim(), - closeLabel: "닫기", - dialogClass: "m02-modal m02-hopyo__choose", - mount: (body, close) => { - const pick = (t: HopyoTarget): void => { - close(); - done(t); - }; - const list = (line.맞춤.후보 ?? []).map((c) => { - const button = createButton({ - label: `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}`, - variant: "ghost", - size: "sm", - onClick: () => pick(c), - }); - const tag = basisTag(c.근거); - if (tag && c.근거) - button.append( - el("span", { - className: `m02-hopyo__basis${basisOk(c.근거, line.근거) ? " is-ok" : ""}`, - text: tag, - attrs: { title: `${c.근거.까닭} · 점수 ${c.근거.점수}` }, - }), - ); - return button; - }); - const basis = line.근거; - const source = basis - ? [ - el("div", { - className: "m02-hopyo__bar m02-hopyo__source", - children: [ - note(`엑셀 근거 — ${sourceText(basis)}`), - createButton({ - label: "엑셀에서 보기", - variant: "ghost", - size: "sm", - onClick: () => - openSheet({ - scope: opt.scope, - key, - 파일, - canWrite: opt.canWrite, - 단산행: basis.행, - }), - }), - ], - }), - ] - : []; - const more = createButton({ - label: material ? "다른 자재 찾기…" : "다른 로직 찾기…", - size: "sm", - onClick: () => { - close(); - if (material) pickMaterial((x) => done({ 자재: x.코드 })); - else openLogicPicker((x) => done({ 로직: x.키 })); - }, - }); - body.append( - ...source, - el("p", { className: "m02-hopyo__note", text: list.length ? "후보" : "가까운 것 없음" }), - el("div", { className: "m02-hopyo__cands", children: list }), - more, - ); - }, - }); - } - - async function remember(no: number, 줄: Record): Promise { - try { - await saveHopyoMemory(opt.scope, 줄); - await drawOne(no); - } catch (error) { - fail(error); - } - } - - async function confirmAll(no: number, lines: HopyoLine[]): Promise { - const body: Record = {}; - for (const x of lines) { - const one = memoryOf(x.맞춤.대상, x.맞춤.입력값, x.맞춤.환산?.배 ?? null); - if (one) body[x.맞춤.열쇠] = one; - } - await remember(no, body); - } - - /** 근거 맞는 후보로 확정 — 지금 대상이 아니면 입력값 · 환산을 서버에서 다시 읽음 */ - async function confirmBasis(no: number, lines: HopyoLine[]): Promise { - const body: Record = {}; - for (const x of lines) { - const c = basisPick(x); - const m = x.맞춤; - if (!c) continue; - const g = - c === m.대상 - ? { 대상: c, 입력값: m.입력값, 환산: m.환산 } - : await guessHopyo(opt.scope, key, no, x.차례, c).catch(() => null); - const one = g && memoryOf(g.대상, g.입력값, g.환산?.배 ?? null); - if (one) body[m.열쇠] = one; - } - if (!Object.keys(body).length) return showToast("확정할 줄 없음 — 환산을 못 읽음", "warning"); - await remember(no, body); - } - - async function build(no: number, fresh: boolean): Promise { - try { - const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: key, 번: no }; - const got = await buildHopyo(opt.scope, from, fresh); - const word = builtWord(got, fresh); - const miss = got.빠진줄.length; - showToast(miss ? `${word} — 빠진 줄 ${miss}` : word, miss ? "warning" : "success"); - await drawOne(no, { ...got, 새로: fresh }); - } catch (error) { - fail(error); - } - } - - const missingBlock = (lines: HopyoLine[]): HTMLElement => - el("div", { - className: "m02-hopyo__block", - children: [ - el("h4", { text: `빠진 줄 ${lines.length} — 사전조합에 안 들어감` }), - table( - ["품명", "규격", "수량", "단위", "엑셀 금액", "까닭"], - lines.map((x) => - el("tr", { - children: [ - td(x.이름), - td(x.규격), - td(x.수량 === null ? "" : String(x.수량), "m02-hopyo__num"), - td(x.단위), - td(won(x.금액.계), "m02-hopyo__num"), - td(STATE[x.맞춤.상태] ?? x.맞춤.상태), - ], - }), - ), - ), - ], - }); - - function builtBlock(no: number, got: HopyoBuilt & { 새로: boolean }): HTMLElement { - const c = got.비교; - const again = got.다시씀 - ? [ - note( - got.새로 - ? `사전조합 ${got.참조.키} 를 새로 만들어 덮음` - : `있던 사전조합 ${got.참조.키} 를 다시 씀`, - ), - createButton({ - label: "새로 만들기", - variant: "danger", - size: "sm", - onClick: async () => { - const ask = `사전조합 ${got.참조.키} 를 지금 맞춤으로 다시 만들어 덮음`; - if (await showConfirmDialog(ask, "새로 만들기")) await build(no, true); - }, - }), - ] - : [note(`새 사전조합 ${got.참조.키}`)]; - return el("div", { - className: "m02-hopyo__block m02-hopyo__built", - attrs: { "data-built": got.참조.키 }, - children: [ - el("div", { className: "m02-hopyo__bar", children: again }), - note( - `값 비교 — 엑셀 ${won(c.엑셀)} · 우리 ${won(c.우리 === null ? null : Math.round(c.우리))}` + - ` · 차이 ${won(c.차이)}${c.까닭 ? ` · ${c.까닭}` : ""}`, - ), - ], - }); - } - void drawFile(); return view; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index 1ac24376e..bccd33756 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -80,7 +80,12 @@ const LAYER_TEXT = { personal: "Lib_Personal", } as const; -export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: LibraryPick): void { +/** `pre.query` = 찾기 칸에 미리 넣을 글(338 실무자료 고르기 창 · 인용 조항 번호 · 품명 낱말) */ +export function openLogicPicker( + done: (logic: LogicSummary) => void, + lib?: LibraryPick, + pre?: { query?: string }, +): void { const subs: SubBrief[] = []; let classes: ReturnType = []; // 사전조합 새 분류(149-2) — 서고 줄이 있을 때만 let all: LogicSummary[] = []; @@ -112,6 +117,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra filters: [...(kindSel ? [kindSel.root] : []), sub.root, detail.root], extra: [layerBar], }); + if (pre?.query) finder.search.value = pre.query; const find = (): void => { const q = finder.search.value.trim().toLowerCase(); 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_check.cjs b/resources/tester/helper_m02_hopyo_check.cjs new file mode 100644 index 000000000..0af39f459 --- /dev/null +++ b/resources/tester/helper_m02_hopyo_check.cjs @@ -0,0 +1,184 @@ +/* M02 일위대가 실무자료 확인 창(338-3) 헬퍼 — `Hopyo_Check` · `Hopyo_Choose` · `Hopyo_Ledger` · `Hopyo_Sheet` + * 도우미와 `Hopyo_Api` 길을 트랜스파일해 Node 로 돌림 · fetch 대역이 부른 길 · 몸을 모음 · sessionStorage 대역 · + * stdin = {lines, rows, ledger} · stdout = {check, choose, ledger, sheet, calls, pick} JSON. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api" } }; +// DOM · 모듈 메타를 쓰는 창은 대역(도우미 · 길만 시험) +const STUB_FILES = [ + "M02_MasterTemplete_Labor_Pick", + "M02_Material_Pick", + "M02_MasterTemplete_Labor_Api", + "M02_MasterTemplete_Labor_Input", + "M01_MasterData_UI_LogicLab_Source", +]; +const opened = []; +const stub = new Proxy( + {}, + { + get: (_t, k) => + k === "currentLanguageIndex" + ? () => 0 + : k === "openModal" + ? (o) => opened.push(o.title) + : () => {}, + }, +); +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (STUB_FILES.some((name) => request.endsWith(name))) return stub; + if (request.startsWith(".") && !request.endsWith(".css")) + return loadTs(`${path.resolve(path.dirname(full), request)}.ts`); + return stub; + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const store = new Map(); +global.sessionStorage = { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, String(v)), + removeItem: (k) => store.delete(k), +}; + +(async () => { + const input = JSON.parse(fs.readFileSync(0, "utf8")); + const calls = []; + global.fetch = async (url, init = {}) => { + calls.push({ + url, + method: init.method ?? "GET", + body: init.body ? JSON.parse(init.body) : null, + }); + const body = url.includes("/hopyo-picks") || url.includes("/hopyo-ledger") ? { 줄: [] } : {}; + return { status: 200, ok: true, json: async () => body }; + }; + const M02 = path.join(ROOT, "M02_MasterTemplete"); + const check = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Check.ts")); + const choose = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Choose.ts")); + const ledger = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Ledger.ts")); + const sheet = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Sheet.ts")); + const api = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Api.ts")); + const pick = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Pick.ts")); + const { lines } = input; + const scope = { layer: "personal", owner: null }; + const key = check.draftKey(scope, "HP-000001", 3); + + // 서버 제안 그대로 → 사용자가 만짐(체크 끔 · 뺌 · 고른 대상) → 캐시 다시 읽음 + const fresh = check.loadDraft(key); + const suggested = check.bodyOf(lines, fresh); + const d = check.loadDraft(key); + d.체크[1] = false; // 맞음 줄 체크 끔 + d.뺌[3] = "우리에 없음"; + d.체크[3] = true; + d.대상[4] = [{ 로직: "GF000211", 이름: "고임돌", 입력값: { 유형: "보통" }, 배: 0.1, 몫: 0.1 }]; + d.체크[4] = true; + check.saveDraft(key, d); + const again = check.loadDraft(key); + const ask = check.newest(); + const a = ask(1); + const b = ask(1); + const c = ask(2); + const out = { + check: { + key, + suggested, + edited: check.bodyOf(lines, again), + leftOut: check.leftOut(lines, again), + checkable: lines.map((l) => check.checkable(l, fresh)), + sure: check.sureLines(lines, fresh).map((l) => l.차례), + compare: check.compareText(lines[1].맞춤.품), + toTarget: [ + check.toTarget({ 호표: 46, 이름: "고화매트", 입력값: {}, 배: 2.3 }), + check.toTarget({ 자재: "MT1", 이름: "레미콘", 입력값: {}, 배: 1, 몫: null }), + ], + newest: [a(), b(), c()], + dropped: (check.dropDraft(key), store.has(key)), + }, + choose: { + hint: [ + choose.searchHint({ + 이름: "레미콘타설(장비사용)", + 근거: { 출처: [["건설품셈", "6-1-1"]], 직종: [], 행: [1, 1] }, + }), + choose.searchHint({ 이름: "(소)고임돌 채집 및 운반" }), + ], + picked: choose.pickedTargets([ + { c: { 로직: "GC000206" }, 몫: null }, + { c: { 로직: "GF000211" }, 몫: 0.1 }, + ]), + inputs: choose.inputsText({ 소량감률: 50, 지역: "서울" }, ["측압"], { + 소량감률: "블록", + 지역: "자동값", + }), + word: [choose.builtWord({ 다시씀: false }), choose.builtWord({ 다시씀: true })], + qty: choose.qtyText(lines[1].맞춤.품.엑셀), + target: choose.targetText({ 호표: 46, 단위: "㎡" }), + }, + ledger: (() => { + const rows = input.ledger; + const edits = { + "가|b|m3": null, // 지움 + "나||m3": { 원래: { 품명: "나", 규격: "", 단위: "m3" }, 갈래: "안 씀", 짝: [] }, + "다||개": ledger.entryOf(rows[2]), // 위 층 → 내 층 + }; + const merged = ledger.mergeRows(rows, edits, { layer: "personal", owner: "7/42" }); + return { + key: ledger.ledgerKey({ layer: "company", owner: "7" }), + merged: merged.map((r) => [r.열쇠, r.층, r.고침, !!r.고친, r.갈래]), + filter: ledger.filterRows(merged, "MT1", "").map((r) => r.열쇠), + kind: ledger.filterRows(merged, "", "안 씀").map((r) => r.열쇠), + pair: ledger.pairText({ 로직: "GC000198", 입력값: { 소량감률: 50 }, 배: 1, 몫: 0.1 }), + entry: ledger.entryOf({ ...rows[0], 갈래: "우리에 없음" }), + }; + })(), + sheet: { + order: sheet.buildOrder([ + { 번: 45, 아래: [46] }, + { 번: 46, 아래: [] }, + { 번: 3, 아래: [] }, + { 번: 7, 아래: [8] }, + { 번: 8, 아래: [7] }, + ]), + }, + }; + // 길 — 만들기(줄 · 통째) · 호표 하나(만들 층) · 미리 셈 · 장부 + const from = { 층: "system", 주인: null, 키: "HP-000001", 번: 3 }; + const target = { layer: "company", owner: "7" }; + await api.buildHopyo(target, from, suggested); + await api.buildHopyo(target, from, [], { 대상: [{ 로직: "GF000225", 배: 1 }] }); + await api.readHopyoOne({ layer: "system", owner: null }, "HP-000001", 3, target); + await api.guessHopyo(scope, "HP-000001", 3, 4, [ + { 로직: "GF000211", 입력값: {}, 배: 0.1, 몫: 0.1 }, + ]); + await api.readLedger(target); + await api.saveLedger(target, { "가|b|m3": null }); + out.calls = calls.splice(0); + // [로직 추가] 실무 호표 — 확인된 것 = 바로 참조 · 아닌 것 = 확인 창(만들기 길 안 부름) + const picked = []; + const lib = { + layer: "company", + owner: "7", + pick: (ref, name, alias) => picked.push({ ref, name, alias }), + }; + for (const row of input.rows) await pick.pickHopyo(row, lib); + await new Promise((r) => setTimeout(r, 0)); + out.pick = { picked, calls: calls.splice(0), opened }; + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + process.stderr.write(String(error?.stack ?? error)); + process.exit(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/helper_m02_hopyo_pick.cjs b/resources/tester/helper_m02_hopyo_pick.cjs index fb5daaa03..3434f4991 100644 --- a/resources/tester/helper_m02_hopyo_pick.cjs +++ b/resources/tester/helper_m02_hopyo_pick.cjs @@ -1,5 +1,5 @@ -/* M02 일위대가 실무자료(324-4) 헬퍼 — `M02_MasterTemplete_Hopyo_Pick.ts`(고르기 창 「실무 호표」 덧줄) · - * `M02_MasterTemplete_Hopyo_View.ts` 글 도우미 · `M02_MasterTemplete_Hopyo_Sheet.ts` 엑셀 보기 창 도우미(329 · 행 → 호표)를 +/* M02 일위대가 실무자료(324-4 · 338-3) 헬퍼 — `M02_MasterTemplete_Hopyo_Pick.ts`(고르기 창 「실무 호표」 덧줄) · + * `M02_MasterTemplete_Hopyo_Choose.ts` 글 도우미 · `M02_MasterTemplete_Hopyo_Sheet.ts` 엑셀 보기 창 도우미(329 · 행 → 호표)를 * 트랜스파일해 Node 로 돌림 · fetch 대역이 부른 길 · 몸을 모음 · * stdin = {rows, built} · stdout = {calls, kinds, rows, picked, project, view, sheet} JSON. */ const fs = require("fs"); @@ -8,8 +8,14 @@ const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "t const ROOT = path.join(__dirname, "..", ".."); const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api" } }; -// 본 화면이 부르는 고르기 창 · 자재 창은 DOM · 모듈 메타를 씀 — 대역(글 도우미만 시험) -const STUB_FILES = ["M02_MasterTemplete_Labor_Pick", "M02_Material_Pick"]; +// 고르기 창 · 자재 창 · 입력 창 · 원문 창은 DOM · 모듈 메타를 씀 — 대역(글 도우미만 시험) +const STUB_FILES = [ + "M02_MasterTemplete_Labor_Pick", + "M02_Material_Pick", + "M02_MasterTemplete_Labor_Api", + "M02_MasterTemplete_Labor_Input", + "M01_MasterData_UI_LogicLab_Source", +]; const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? () => 0 : () => {}) }); const cache = new Map(); function loadTs(file) { @@ -55,7 +61,7 @@ function loadTs(file) { const rows = (await wrapped.more()).map(({ onPick: _, ...r }) => r); await pick.pickHopyo(input.rows[0], lib); const project = pick.withHopyo({ ...lib, layer: "project" }); - const view = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_View.ts")); + const view = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Choose.ts")); const sheet = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Sheet.ts")); const api = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Api.ts")); await api.readHopyoGrid({ layer: "company", owner: "7" }, "HP-000001"); @@ -76,17 +82,6 @@ function loadTs(file) { view.targetText({ 호표: 46, 단위: "㎡" }), ], inputs: view.inputsText({ 횟수: "4회사용시", 지역: "서울" }, ["보정작업"]), - memory: [ - view.memoryOf({ 로직: "GF000101" }, { 지역: "서울" }, 1), - view.memoryOf({ 자재: "MT1" }, {}, 0.5), - view.memoryOf({ 로직: "GF000101" }, {}, 0), - view.memoryOf(null, {}, 1), - ], - word: [ - view.builtWord({ 다시씀: false }, false), - view.builtWord({ 다시씀: true }, false), - view.builtWord({ 다시씀: true }, true), - ], }, basis: (() => { const hit = { @@ -99,9 +94,6 @@ function loadTs(file) { const half = { ...hit, 직종: [] }; const trade = { 출처: [["건설품셈", "3-2-1"]], 직종: ["특별인부", "보통인부"], 행: [1, 1] }; const plain = { ...trade, 직종: ["보통인부"] }; - const line = (상태, 대상, 후보) => ({ 근거: trade, 맞춤: { 상태, 대상, 후보 } }); - const a = { 로직: "GF000101", 근거: half }; - const b = { 로직: "GC000083", 근거: hit }; return { ok: [hit, half, { ...hit, 단위: false }, { ...hit, 까닭: "앞 마디 같음" }, undefined].map( (b) => view.basisOk(b, trade), @@ -114,12 +106,6 @@ function loadTs(file) { view.sourceText({ 출처: [], 직종: [], 행: [7, 7] }), view.sourceText(undefined), ], - pick: [ - line("후보", a, [a, b]), - line("후보", b, [b]), - line("같음", b, [b]), - line("후보", a, [a]), - ].map((x) => view.basisPick(x)?.로직 ?? null), }; })(), sheet: { diff --git a/resources/tester/helper_m02_hopyo_seam.cjs b/resources/tester/helper_m02_hopyo_seam.cjs index 55616cebd..705d02497 100644 --- a/resources/tester/helper_m02_hopyo_seam.cjs +++ b/resources/tester/helper_m02_hopyo_seam.cjs @@ -1,15 +1,21 @@ /* M02 일위대가 실무자료 이음매(329 · 330) 헬퍼 — 실제 서버 응답(격자 · 호표 맞춤)을 - * `M02_MasterTemplete_Hopyo_Sheet.ts`(행 → 호표 · 영역 A1) · `M02_MasterTemplete_Hopyo_View.ts` - * (근거 꼬리표 · 엑셀 근거 줄 · 근거 맞음 대상)에 먹임 · - * stdin = {grid, lines, all} · stdout = {at, span, rows, lines: [{이름, source, tag, ok, pick}], - * count: {top, pick, half}} JSON — count = 「후보」 줄 1순위 근거 맞음 · 근거 맞음 대상 · 그 가운데 앞 마디. */ + * `M02_MasterTemplete_Hopyo_Sheet.ts`(행 → 호표 · 영역 A1) · `M02_MasterTemplete_Hopyo_Choose.ts` + * (근거 꼬리표 · 엑셀 근거 줄 · 근거 맞음)에 먹임 · + * stdin = {grid, lines, all} · stdout = {at, span, rows, lines: [{이름, source, tag, ok}], + * count: {top}} JSON — count = 「후보」 줄 1순위 근거 맞음. */ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); const ROOT = path.join(__dirname, "..", ".."); const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api" } }; -const STUB_FILES = ["M02_MasterTemplete_Labor_Pick", "M02_Material_Pick"]; +const STUB_FILES = [ + "M02_MasterTemplete_Labor_Pick", + "M02_Material_Pick", + "M02_MasterTemplete_Labor_Api", + "M02_MasterTemplete_Labor_Input", + "M01_MasterData_UI_LogicLab_Source", +]; const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? () => 0 : () => {}) }); const cache = new Map(); function loadTs(file) { @@ -34,7 +40,7 @@ function loadTs(file) { const input = JSON.parse(fs.readFileSync(0, "utf8")); const M02 = path.join(ROOT, "M02_MasterTemplete"); const sheet = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Sheet.ts")); -const view = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_View.ts")); +const view = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Choose.ts")); const { grid } = input; const ilwi = grid.책.시트.find((s) => s.이름 === "일위대가"); const three = grid.호표.find((h) => h.번 === 3); @@ -49,15 +55,11 @@ const out = { source: view.sourceText(line.근거), tag: view.basisTag(top?.근거), ok: view.basisOk(top?.근거, line.근거), - pick: view.basisPick(line)?.로직 ?? null, }; }), }; const open = (input.all ?? []).filter((line) => line.맞춤.상태 === "후보"); -const picks = open.map((line) => view.basisPick(line)).filter(Boolean); out.count = { top: open.filter((line) => view.basisOk((line.맞춤.후보 ?? [])[0]?.근거, line.근거)).length, - pick: picks.length, - half: picks.filter((c) => !c.근거.까닭.startsWith("조항 같음")).length, }; process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_m02_hopyo_basis.py b/resources/tester/test_m02_hopyo_basis.py index 8bc0a880b..1adb7f3be 100644 --- a/resources/tester/test_m02_hopyo_basis.py +++ b/resources/tester/test_m02_hopyo_basis.py @@ -4,6 +4,7 @@ 후보 93 → GC000083 · 21 → GC000198 · 64 → GC000143 위 둘 · 49 → GC000085 · 잔토처리에 철골재 철거 없음 · 근거 없는 줄 그대로 · 상태 「후보」(자동 확정 없음) · 블록 글 HP(338-2a · 268 · 139 이음 그대로) · `clause` 윗마디 0.9 · 조항 오타 13-114 · +거창 꼴 흩어진 칸 이어 붙임(단산 줄 21 · 출처 17 · 직종 21 · 338-2b) · master_data 그대로. """ @@ -152,3 +153,19 @@ def test_조항_윗마디_오타(north): assert hb.sources("[산림사업표준품셈 9-14]") == [["산림품셈", "9-14"]] stone = next(b for b in north["단산"] if b["번"] == 63) assert stone["출처"] == [["산림품셈", "13-11-4"]] + + +def test_거창_흩어진_칸(): + path = ( + base.ROOT + / "resources/knowledge/original/실무문서/01.산불거창(거창북상병곡)/01.내역서" + / "00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx" + ) + book = hx.read_book(path.name, path.read_bytes()) + lines = [x for h in book["호표"] for x in h["줄"] if x["갈래"] == "단가산출"] + assert len(lines) == 21 and all(x.get("근거") for x in lines) + assert sum(1 for x in lines if x["근거"]["출처"]) == 17 + assert sum(1 for x in lines if x["근거"]["직종"]) == 21 + first = book["단산"][0] + assert first["이름"] == "측구터파기(토사)" and first["직종"] == ["보통인부"] + assert any(t.startswith("보통인부 : 172,068") for t in first["글"]) diff --git a/resources/tester/test_m02_hopyo_excel.py b/resources/tester/test_m02_hopyo_excel.py index 6e6b14fdf..7129e28de 100644 --- a/resources/tester/test_m02_hopyo_excel.py +++ b/resources/tester/test_m02_hopyo_excel.py @@ -2,7 +2,8 @@ 견본 = `resources/knowledge/original/실무문서/2026_실무일위대가 모음/`(북부청 임도 · 사방) · 검산 어긋남 0 · 호표 값 · 시트 없음 400 · xlsx 길 · 역할 표 · 엉터리 · 20 MB · 지우기 · -양식 목록에 안 섞임 · +양식 목록에 안 섞임 · 이음 넓힘(산근 · 대가 · 호표 · 이름으로 이음) · 노임 갈래 · +문서 보안 400(338) · master_data 원본 그대로(sha256). 층은 임시 폴더. """ @@ -207,3 +208,54 @@ def test_마스터_층은_마스터_폴더(client): client.who.update(role="USER") assert client.get(URL.format("system")).json()["줄"][0]["키"] == "HP-000001" # 읽기 = 누구나 assert client.delete(URL.format("system") + "/HP-000001").status_code == 403 + + +def _linked_xlsx() -> bytes: + """338 — 비고 「산근 2 호표」 · 「대가1」 · 빈 비고 + 이름 같은 블록 · 노임 줄.""" + import openpyxl + + book = openpyxl.Workbook() + ws = book.active + ws.title = "일위대가" + ws.append([None, "호표", "품명", "규격", "수량", "단위", "합계", None, "노무비", None, "비고"]) + ws.append([None, None, None, None, None, None, "단가", "금액", "단가", "금액"]) + ws.append([None, "제1호표", "표지석", "", None, "개소", None, 1000, None, 1000]) + ws.append([None, None, "터파기", "토사", 1, "㎥", 100, 100, 100, 100, "산근 2 호표"]) + ws.append([None, None, "되메우기", "", 1, "㎥", 100, 100, 100, 100, None]) + ws.append([None, None, "보통인부", "", 1, "인", 100, 100, 100, 100, None]) + ws.append([None, None, "호표 줄", "", 1, "개소", 100, 100, 100, 100, "대가1"]) + calc = book.create_sheet("단가산출") + calc.append([None, "산출근거", "합계", "노무비"]) + calc.append([None, "2.터파기[㎥]"]) + calc.append([None, " [건설공사표준품셈 3-2-1]"]) + calc.append([None, "3.되메우기[㎥]"]) + calc.append([None, " 보통인부 : 100 × 1 = 100"]) + out = io.BytesIO() + book.save(out) + return out.getvalue() + + +def test_이음_넓힘_노임(): + got = hx.read_book("a.xlsx", _linked_xlsx()) + lines = {x["이름"]: x for x in got["호표"][0]["줄"]} + assert lines["터파기"]["참조"] == {"종류": "단산", "번": 2} and "이음" not in lines["터파기"] + assert lines["터파기"]["근거"]["출처"] == [["건설품셈", "3-2-1"]] + assert lines["되메우기"]["참조"] == {"종류": "단산", "번": 3} # 빈 비고 · 이름 같은 블록 하나 + assert lines["되메우기"]["이음"] == "이름" and lines["되메우기"]["갈래"] == "단가산출" + assert lines["되메우기"]["근거"]["직종"] == ["보통인부"] + assert lines["보통인부"]["갈래"] == "노임" and "이음" not in lines["보통인부"] + assert lines["호표 줄"]["참조"] == {"종류": "일위", "번": 1} + for note, want in ( + ("단산12참조", ("단산", 12)), + ("단가산출 3", ("단산", 3)), + ("일위대가4호표", ("일위", 4)), + ("일위 5 참조", ("일위", 5)), + ): + hit = hx._REF.match(hx._flat(note)) + assert ("단산" if hit.group(1) else "일위", int(hit.group(3))) == want + assert hx._REF.match("단산참조") is None and hx._REF.match("D96") is None + + +def test_문서_보안_400(): + with pytest.raises(hx.ExcelError, match="문서 보안"): + hx.read_book("a.xlsx", bytes([0x9B]) + b" DRMONE This Document is secured" + bytes(64)) 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_grid.py b/resources/tester/test_m02_hopyo_grid.py index 58c6cac90..4db5efaa0 100644 --- a/resources/tester/test_m02_hopyo_grid.py +++ b/resources/tester/test_m02_hopyo_grid.py @@ -2,6 +2,7 @@ 북부청 임도 xls — 병합 11 · 15열 · B20 「제3호표」 · `#,##0` · 호표 68 행 범위 안 겹침 · 제3호표 [20, 24] · 올리면 격자도 · 200 / 옛 파일 404 · 지우면 격자도 · 역할 · xlsx 값만 · +STmate 격자(일위대가표 · 단가산출근거 · 숨은 칸 글 뺌 · 338) · master_data 그대로. """ @@ -131,3 +132,22 @@ def test_xlsx_값만(): assert sheet["칸"]["B5"] == {"값": "제 1 호표"} and sheet["칸"]["E6"] == {"값": 0.5} assert "K6" not in sheet["칸"] # 머리 글(앞 4 줄) 마지막 열 J 까지 assert grid["호표"] == [{"번": 1, "이름": "표지석", "행": [5, 6]}] and grid["단산"] == [] + + +def test_STmate_격자(): + from resources.tester.test_m02_hopyo_stmate import _path + + path = _path("영월") + data = path.read_bytes() + parsed = hx.read_book(path.name, data) + grid = hg.read(path.name, data, parsed) + assert [s["이름"] for s in grid["책"]["시트"]] == ["일위대가표", "단가산출근거"] + cells = _sheet(grid, "일위대가표")["칸"] + one = grid["호표"][0] + assert one == {"번": 1, "이름": "녹생토(종자배합)", "행": [5, 10]} + assert cells[f"A{one['행'][0]}"]["값"].strip().startswith("제") + assert cells["A10"]["값"] == "보통인부" + calc = _sheet(grid, "단가산출근거")["칸"] + block = next(b for b in grid["단산"] if b["번"] == 2) + assert calc[f"B{block['행'][0] + 1}"]["값"].startswith("구조물터파기(인력)") + assert not any(str(c.get("값")).startswith("_x000") for c in {**cells, **calc}.values()) 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 diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index 3eef9079e..6492f4e37 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -1,12 +1,15 @@ -"""일위대가 실무자료 화면(PLAN 324-4) — 좌측 판 · 본 화면 · [로직 추가] 「실무 호표」 줄. +"""일위대가 실무자료 화면(PLAN 324-4 · 338-3) — 좌측 판 · 본 화면 · 확인 창 · 고르기 창 · +별칭 장부 · [로직 추가] 「실무 호표」 줄 · 엑셀 보기 창. -- 고르기 창 덧줄(Node · fetch 대역): 이 층 · 위 층 호표 줄(맞춤 n/m) · 종류 고르기 · - 고르면 문서 층에 만들기 길 → `lib.pick` 참조 줄(별칭 = 호표 품명) · 프로젝트 층엔 없음 · - 본 화면 글 도우미 -- 고르기 창 근거(330-3): 꼬리표 · 엑셀 근거 줄 · [근거 맞음 모두 확정] 대상 -- 엑셀 보기 창(329): 행 → 호표 · 호표 영역 A1 · 격자 길 · 여는 자리 넷 · 늦게 불러옴 -- 소스: 좌측 판 자리(사전조합 아래 · 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐 · - 숨은 file 입력) · Labor_Detail 줄 수 그대로 · 새 파일 이름 · 700 줄 · 색 = 공용 토큰 +- 확인 창 도우미(Node · fetch · sessionStorage 대역 · `helper_m02_hopyo_check.cjs`): + 체크 → 몸(체크한 줄만 · 뺌 · 고른 대상 · 몫 · 배 · 호표) · 빠질 줄 까닭 · 캐시 키 · + 최신 응답만 · 품 비교 · 만들기 · 통째 · 만들 층 · 미리 셈 · 장부 길 · 장부 화면 합침 · + 하위 호표 먼저 · 확인된 것 = 바로 참조 +- 고르기 창 덧줄(`helper_m02_hopyo_pick.cjs`): 이 층 · 위 층 호표 줄 · 종류 고르기 · + 프로젝트 층엔 없음 · 글 도우미 · 근거 꼬리표 · 엑셀 보기 창 행 → 호표 +- 소스: 옛 즉시 확정 · 바로 만들기 없음(saveHopyoMemory · [확정] · [같음 모두 확정] · + [근거 맞음 모두 확정] · [사전조합 만들기]) · `buildHopyo(` 는 확인 창 · 엑셀 보기 창 펼침에서만 · + 좌측 판 자리 · 700 줄 · 색 = 공용 토큰 """ import json @@ -17,45 +20,181 @@ from pathlib import Path HERE = os.path.dirname(os.path.abspath(__file__)) M02 = Path(HERE).parents[1] / "M02_MasterTemplete" -NEW = ("Hopyo_Api", "Hopyo_Pick", "Hopyo_Side", "Hopyo_View", "Hopyo_Sheet") +NEW = ( + "Hopyo_Api", + "Hopyo_Pick", + "Hopyo_Side", + "Hopyo_View", + "Hopyo_Sheet", + "Hopyo_Check", + "Hopyo_Choose", + "Hopyo_Ledger", +) +ROW = { + "층": "system", + "주인": None, + "키": "HP-000001", + "파일": "북부청.xls", + "번": 3, + "이름": "임도표지석", + "규격": "910×600×150", + "단위": "개소", + "맞춤": [2, 4], +} +SURE = {"로직": "GC000198", "이름": "장비사용타설", "입력값": {"소량감률": 50}, "배": 1, "몫": None} +QTY = { + "엑셀": {"인력": {"콘크리트공": 0.1091}, "장비": [{"갈래": "굴착기", "시간": 0.29}]}, + "우리": {"인력": {"콘크리트공": 0.1091}, "장비": [{"갈래": "굴착기", "시간": 0.31}]}, + "같음": {"콘크리트공": True, "굴착기": False}, +} def _text(name: str) -> str: return (M02 / name).read_text(encoding="utf-8") -def _run() -> dict: - row = { - "층": "system", - "주인": None, - "키": "HP-000001", - "파일": "북부청.xls", - "번": 3, - "이름": "임도표지석", - "규격": "910×600×150", - "단위": "개소", - "맞춤": [2, 4], - } - built = { - "참조": {"층": "company", "주인": "7", "키": "SC-000009"}, - "이름": "임도표지석", - "다시씀": False, - "빠진줄": [{"차례": 0}], - "비교": {}, - } +def _node(helper: str, payload: dict) -> dict: proc = subprocess.run( - ["node", os.path.join(HERE, "helper_m02_hopyo_pick.cjs")], - input=json.dumps({"rows": [row], "built": built}, ensure_ascii=False), + ["node", os.path.join(HERE, helper)], + input=json.dumps(payload, ensure_ascii=False), capture_output=True, text=True, encoding="utf-8", - check=True, + check=False, ) + assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout) -def test_고르기_창_실무_호표_줄과_고름(): - got = _run() +def _line(no, state, check, parts, **extra): + match = {"열쇠": f"줄{no}||m3", "상태": state, "체크": check, "대상": parts, **extra} + return {"차례": no, "이름": f"줄{no}", "규격": "", "수량": 1, "단위": "㎥", "갈래": "단가산출", + "맞춤": match} # fmt: skip + + +def _check() -> dict: + lines = [ + _line(0, "후보", False, [{"자재": "MT004258", "입력값": {}, "배": 1}]), + _line(1, "맞음", True, [SURE], 품=QTY), + _line(2, "별칭", True, [{"로직": "GF000101", "입력값": {}, "배": 1}]), + _line(3, "다른 점", False, [{"로직": "GF000103", "입력값": {}, "배": 1}]), + _line(4, "후보", False, []), + _line(5, "%", False, []), + _line(6, "호표", True, [{"호표": 46, "입력값": {}, "배": 2.3}]), + _line(7, "노임", False, [{"로직": "GF000300", "입력값": {}, "배": None}]), + ] + ledger = [ + {"열쇠": "가|b|m3", "원래": {"품명": "가", "규격": "b", "단위": "m3"}, "갈래": "짝", + "짝": [{"로직": "GC000198", "입력값": {}, "배": 1, "몫": None}], "층": "personal", + "주인": "7/42", "고침": True}, + {"열쇠": "나||m3", "원래": {"품명": "나", "규격": "", "단위": "m3"}, "갈래": "짝", + "짝": [{"로직": "GF000101", "입력값": {}, "배": 1, "몫": None}], "층": "personal", + "주인": "7/42", "고침": True}, + {"열쇠": "다||개", "원래": {"품명": "다", "규격": "", "단위": "개"}, "갈래": "짝", + "짝": [{"자재": "MT1", "입력값": {}, "배": 1, "몫": None}], "층": "system", + "주인": None, "고침": False}, + ] # fmt: skip + rows = [ + {**ROW, "확인": {"층": "company", "주인": "7", "키": "SC-000009"}}, + {**ROW, "번": 4, "이름": "배수관", "확인": None}, + ] + return _node("helper_m02_hopyo_check.cjs", {"lines": lines, "ledger": ledger, "rows": rows}) + + +def test_확인_창_체크한_줄만_몸(): + got = _check()["check"] + assert got["key"] == "m02-hopyo-check:personal::HP-000001:3" + assert got["suggested"] == [ # 서버 체크 제안 그대로 · 배 없는 줄 · % 줄 · 후보는 안 감 + {"차례": 1, "대상": [{"로직": "GC000198", "입력값": {"소량감률": 50}, "배": 1}]}, + {"차례": 2, "대상": [{"로직": "GF000101", "입력값": {}, "배": 1}]}, + {"차례": 6, "대상": [{"호표": 46, "배": 2.3}]}, + ] + assert got["edited"] == [ # 캐시 — 맞음 줄 체크 끔 · 뺌 · 고른 대상(몫 · 배) + {"차례": 2, "대상": [{"로직": "GF000101", "입력값": {}, "배": 1}]}, + {"차례": 3, "뺌": "우리에 없음"}, + { + "차례": 4, + "대상": [{"로직": "GF000211", "입력값": {"유형": "보통"}, "배": 0.1, "몫": 0.1}], + }, + {"차례": 6, "대상": [{"호표": 46, "배": 2.3}]}, + ] + assert got["leftOut"] == {"확인 안 함": 3, "뺌": 1, "% 줄": 1} + assert got["checkable"] == [True, True, True, True, False, False, True, False] + assert got["sure"] == [1, 2] # [맞음 · 별칭 모두 체크] + assert got["compare"] == "콘크리트공 0.1091 → 0.1091 ✓ · 굴착기 0.29 → 0.31 ≠" + assert got["toTarget"] == [{"호표": 46, "배": 2.3}, {"자재": "MT1", "배": 1}] + assert got["newest"] == [False, True, True] # 같은 줄 늦은 응답 버림 · 다른 줄은 따로 + assert got["dropped"] is False # 만들기 성공 = 캐시 지움 + + +def test_고르기_창_도우미(): + got = _check()["choose"] + assert got["hint"] == ["6-1-1", "고임돌"] # 인용 조항 번호 → 없으면 품명 첫 뜻 낱말 + assert got["picked"] == [ + {"로직": "GC000206", "입력값": {}, "배": 1, "몫": None}, + {"로직": "GF000211", "입력값": {}, "배": 0.1, "몫": 0.1}, + ] + assert got["inputs"] == "소량감률=50(블록), 지역=서울 · ⚠ 못 가름: 측압" + assert got["word"] == ["만듦", "있던 것을 확인한 내용으로 덮음"] + assert got["qty"] == "콘크리트공 0.1091 · 굴착기 0.29hr" + assert got["target"] == "제46호표 [㎡]" + + +def test_별칭_장부_화면_합침(): + got = _check()["ledger"] + assert got["key"] == "m02-hopyo-ledger:company:7" + assert got["merged"] == [ + ["나||m3", "personal", True, True, "안 씀"], # 고친 줄 + ["다||개", "personal", True, True, "짝"], # 위 층 → 내 층으로 고쳐 씀 + ["다||개", "system", False, False, "짝"], # 위 층 = 그대로 읽기만 + ] # 지운 줄(가) 없음 + assert got["filter"] == ["다||개", "다||개"] and got["kind"] == ["나||m3"] + assert got["pair"] == "GC000198 × 1 · 몫 0.1 · 소량감률=50" + assert got["entry"]["짝"] == [] and "층" not in got["entry"] + + +def test_길_만들기_통째_만들_층_미리_셈_장부(): + calls = _check()["calls"] + build = "/api/m02/layers/company/hopyo-build?owner=7" + head = {"층": "system", "주인": None, "키": "HP-000001", "번": 3} + assert calls[0]["url"] == build and calls[0]["body"]["줄"][0]["차례"] == 1 + assert {k: calls[0]["body"][k] for k in head} == head and "새로" not in calls[0]["body"] + assert calls[1]["body"] == {**head, "통째": {"대상": [{"로직": "GF000225", "배": 1}]}} + assert calls[2] == { + "url": "/api/m02/layers/system/hopyo/HP-000001/3?to=company&to_owner=7", + "method": "GET", + "body": None, + } + assert calls[3]["url"] == "/api/m02/layers/personal/hopyo/HP-000001/3/guess" + assert calls[3]["body"]["대상"][0]["몫"] == 0.1 + assert calls[4]["url"] == "/api/m02/layers/company/hopyo-ledger?owner=7" + assert calls[5]["method"] == "PUT" and calls[5]["body"] == {"줄": {"가|b|m3": None}} + + +def test_로직_추가_확인된_것은_바로_참조_아니면_확인_창(): + got = _check()["pick"] + assert got["picked"] == [ + { + "ref": {"층": "company", "주인": "7", "키": "SC-000009"}, + "name": "임도표지석", + "alias": "임도표지석", + } + ] + assert got["calls"] == [] # 확인 없이 만들지 않음 + assert got["opened"] == ["확인 — 북부청.xls 제4호표 배수관"] + + +def test_엑셀_보기_창_하위_호표_먼저(): + assert _check()["sheet"]["order"] == [46, 45, 3, 7, 8] # 순환은 남은 차례 + + +def _pick() -> dict: + row = {**ROW, "확인": {"층": "company", "주인": "7", "키": "SC-000009"}} + return _node("helper_m02_hopyo_pick.cjs", {"rows": [row], "built": {}}) + + +def test_고르기_창_실무_호표_줄(): + got = _pick() assert got["calls"][0] == { "url": "/api/m02/layers/company/hopyo-picks?owner=7", "method": "GET", @@ -69,7 +208,7 @@ def test_고르기_창_실무_호표_줄과_고름(): "tag": "실무 호표", "name": "임도표지석 910×600×150", "sub": "북부청.xls · 제3호표 · 마스터", - "unit": "개소 · 맞춤 2/4", + "unit": "개소 · 확인 SC-000009", }, { "kind": "hopyo", @@ -80,119 +219,16 @@ def test_고르기_창_실무_호표_줄과_고름(): "unit": "호표 1", }, ] - build = got["calls"][1] - assert build["url"] == "/api/m02/layers/company/hopyo-build?owner=7" # 만드는 층 = 문서 층 - assert build["body"] == { - "층": "system", - "주인": None, - "키": "HP-000001", - "번": 3, - "새로": False, - } - assert got["picked"] == [ - { - "ref": {"층": "company", "주인": "7", "키": "SC-000009"}, - "name": "임도표지석", - "alias": "임도표지석", - } - ] + assert got["picked"][0]["ref"]["키"] == "SC-000009" assert got["project"] is True # 프로젝트 층 = 덧줄 없음 -def test_본_화면_글_도우미(): - view = _run()["view"] +def test_글_도우미와_근거_꼬리표(): + got = _pick() + view = got["view"] assert view["target"] == ["구조물터파기 [㎥]", "레미콘 25-21-120 [㎥]", "제46호표 [㎡]"] - assert view["inputs"] == "횟수=4회사용시, 지역=서울 · ⚠ 못 읽음: 보정작업" - assert view["memory"] == [ - {"로직": "GF000101", "입력값": {"지역": "서울"}, "환산": 1}, - {"자재": "MT1", "입력값": {}, "환산": 0.5}, - None, # 환산 0 = 확정 못 함 - None, # 대상 없음 - ] - assert view["word"] == ["만듦", "다시 씀", "새로 만들어 덮음"] # 알림 · 판 글 - - -def test_줄_표_줄바꿈_없음과_고르기_창_머리(): - css = _text("M02_MasterTemplete_Hopyo.css") - rule = css.split(".m02-hopyo__table.m02-hopyo__lines\n :is(th, td):is(")[1].split("}")[0] - cols = [int(n) for n in re.findall(r":nth-child\((\d+)\)", rule)] - assert cols == [1, 4, 5, 6, 7, 9, 10, 11] and "white-space: nowrap;" in rule - heads = ["갈래", "품명", "규격", "수량", "단위", "엑셀 단가", "맞춤", "대상", "입력값", "환산"] - view = _text("M02_MasterTemplete_Hopyo_View.ts") - flat = re.sub(r"\s+", "", view) - assert re.sub(r"\s+", "", ",".join(f'"{h}"' for h in heads)) in flat # 열 차례 그대로 - top = css.split(".m02-hopyo__choose .ui-modal__top > .ui-btn {")[1].split("}")[0] - assert "flex-shrink: 0;" in top and "white-space: nowrap;" in top - assert '"가까운 것 없음"' in view and "builtWord(got, fresh)" in view - - -def test_좌측_판_자리와_권한(): - side = _text("M02_MasterTemplete_UI_Side.ts") - assert 'import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";' in side - host = side.split('className: "m02-side__lists ui-sidebar-panel"')[1] - assert host.index("precomboSec,") < host.index("section(hopyo.title, hopyo.body),") - assert host.index("section(hopyo.title, hopyo.body),") < host.index("extras ?") - assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한 - assert "hopyo.paint();" in side.split("function paint(): void {")[1] - names = set(re.findall(r"\b\w*[Pp]ractice\w*\b", side)) - assert names <= {"buildPracticeSection", "practice", "M02_MasterTemplete_Practice"} # 옛 이름만 - assert "LaborSection" not in side and "labor?." not in side and "picked" not in side - hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts") - assert 'accept: ".xls,.xlsx"' in hopyo_side and "input.hidden = true;" in hopyo_side - assert "disabled: !opt.canWrite," in hopyo_side and "pageScope()" in hopyo_side - - -def test_로직_추가_한_곳과_파일_규칙(): - detail = _text("M02_MasterTemplete_Labor_Detail.ts") - assert len(detail.splitlines()) == 693 # 줄 수 그대로(제자리 고침) - assert "withHopyo(library)," in detail - assert 'cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null' in detail - pick = _text("M02_MasterTemplete_Labor_Pick.ts") - assert "more?: () => Promise;" in pick and "...later" in pick - for name in NEW: - text = _text(f"M02_MasterTemplete_{name}.ts") - assert len(text.splitlines()) <= 700, name - css = _text("M02_MasterTemplete_Hopyo.css") - assert "var(--color-" in css and not re.search(r"#[0-9a-fA-F]{3,6}\b", css) - assert '"./M02_MasterTemplete_Hopyo.css"' in _text("M02_MasterTemplete_Hopyo_View.ts") - - -def test_엑셀_보기_창_행과_호표(): - got = _run() - assert got["calls"][2] == { - "url": "/api/m02/layers/company/hopyo-grid/HP-000001?owner=7", - "method": "GET", - "body": None, - } - sheet = got["sheet"] - assert sheet["at"] == [None, 3, 3, 3, 4, 4, None] # 22행 → 제3호표 · 범위 밖 = 없음 - assert sheet["span"] == "B20:O24" # 제3호표 영역 = B열 ~ 끝 열(열 폭 정보 O) - assert sheet["last"] == ["AA", "A"] - assert sheet["rows"] == ["4345:4345", "93:101"] # 단가산출 행 전체 - - -def test_엑셀_보기_창_여는_자리와_늦게_불러옴(): - lazy = 'import("./M02_MasterTemplete_Hopyo_Sheet")' - side = _text("M02_MasterTemplete_Hopyo_Side.ts") - assert lazy in side.split("await show(got);")[1].split("})")[0] # 올린 직후 자동 - view = _text("M02_MasterTemplete_Hopyo_View.ts") - assert lazy in view and '"엑셀로 보기"' in view and '"엑셀에서 보기"' in view - assert "호표: no" in view # 호표 화면 = 그 호표 고른 채 - pick = _text("M02_MasterTemplete_Hopyo_Pick.ts") - assert lazy in pick and "lib.pick(got.참조" in pick - static = r'^import (?!type )[^(;]*"\./M02_MasterTemplete_Hopyo_Sheet"' - for text in (side, view, pick): - assert not re.search(static, text, re.M) # 정적 import 없음(타입만) - sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts") - assert 'import("../A00_Common/spreadsheet/spreadsheet")' in sheet - assert not re.search( - r'^import \{[^}]*\} from "\.\./A00_Common/spreadsheet/spreadsheet"', sheet, re.M - ) - assert "readOnly: true" in sheet and "wide: true" in sheet - - -def test_고르기_창_근거_꼬리표와_근거_맞음(): - basis = _run()["basis"] + assert view["inputs"] == "횟수=4회사용시, 지역=서울 · ⚠ 못 가름: 보정작업" + basis = got["basis"] assert basis["ok"] == [True, False, False, False, False] # 조항 같음 · 직종 · 단위 모두 assert basis["plain"] == [True, True] # 줄 직종이 보통인부뿐 · 없음 = 직종 안 봄 assert basis["tag"] == [ @@ -205,8 +241,104 @@ def test_고르기_창_근거_꼬리표와_근거_맞음(): "단가산출 7행", "", ] - # 지금 대상이 근거 안 맞으면 근거 맞는 후보 · 「후보」 줄만 · 근거 맞는 것 없으면 없음 - assert basis["pick"] == ["GC000083", "GC000083", None, None] + + +def test_엑셀_보기_창_행과_호표(): + got = _pick() + assert got["calls"][1] == { + "url": "/api/m02/layers/company/hopyo-grid/HP-000001?owner=7", + "method": "GET", + "body": None, + } + sheet = got["sheet"] + assert sheet["at"] == [None, 3, 3, 3, 4, 4, None] # 22행 → 제3호표 · 범위 밖 = 없음 + assert sheet["span"] == "B20:O24" # 제3호표 영역 = B열 ~ 끝 열(열 폭 정보 O) + assert sheet["last"] == ["AA", "A"] + assert sheet["rows"] == ["4345:4345", "93:101"] # 단가산출 행 전체 + + +# ── 소스 ────────────────────────────────────────────────────────────── +def test_옛_즉시_확정과_바로_만들기_없음(): + texts = {name: _text(f"M02_MasterTemplete_{name}.ts") for name in NEW} + every = "\n".join(texts.values()) + for gone in ("saveHopyoMemory", "hopyo-memory", "HopyoMemory", "memoryOf", "basisPick"): + assert gone not in every, gone + for label in ( + "같음 모두 확정", + "근거 맞음 모두 확정", + '"확정"', + '"사전조합 만들기"', + "불러오기", + ): + assert label not in every, label + users = sorted(name for name, text in texts.items() if "buildHopyo(" in text) + assert users == ["Hopyo_Check", "Hopyo_Sheet"] # 확인 창 · 펼침 뒤 한 번 + check = texts["Hopyo_Check"] + assert '"확인하고 사전조합 만들기"' in check and "맞음 · 별칭 모두 체크" in check + assert "showLoadingOverlay()" in check and "dropDraft(k)" in check + assert "m02-hopyo-check:" in check and "POLL_MS" in check + sheet = texts["Hopyo_Sheet"] + assert "확인하고 만들기" in sheet and "이대로 ${ready.length}개 만들기" in sheet + assert "확인 창 열기" in sheet and "buildOrder(" in sheet + pick = texts["Hopyo_Pick"] + assert "if (row.확인)" in pick and 'import("./M02_MasterTemplete_Hopyo_Check")' in pick + view = texts["Hopyo_View"] + assert "buildHopyoCheck(" in view and "choose(" not in view + ledger = texts["Hopyo_Ledger"] + assert "m02-hopyo-ledger:" in ledger and "saveLedger(" in ledger + for gone in ("발주처", "회사로 올리기", "쓰인 횟수"): + assert gone not in ledger.split("*/", 1)[1] # 머리 글 「안 둠」 밖 + + +def test_좌측_판_자리와_권한(): + side = _text("M02_MasterTemplete_UI_Side.ts") + assert 'import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";' in side + host = side.split('className: "m02-side__lists ui-sidebar-panel"')[1] + assert host.index("precomboSec,") < host.index("section(hopyo.title, hopyo.body),") + assert host.index("section(hopyo.title, hopyo.body),") < host.index("extras ?") + assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한 + assert "hopyo.paint();" in side.split("function paint(): void {")[1] + hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts") + assert 'accept: ".xls,.xlsx"' in hopyo_side and "input.hidden = true;" in hopyo_side + assert "disabled: !opt.canWrite," in hopyo_side and "pageScope()" in hopyo_side + assert "buildHopyoLedger({ scope, canWrite: opt.canWrite })" in hopyo_side # [별칭 장부] + assert hopyo_side.index("list, ledger]") > 0 + + +def test_로직_추가_한_곳과_파일_규칙(): + detail = _text("M02_MasterTemplete_Labor_Detail.ts") + assert len(detail.splitlines()) == 693 # 줄 수 그대로 + assert "withHopyo(library)," in detail + pick = _text("M02_MasterTemplete_Labor_Pick.ts") + assert "more?: () => Promise;" in pick and "...later" in pick + assert "if (pre?.query) finder.search.value = pre.query;" in pick # 찾기 글 미리 넣음 + for name in NEW: + text = _text(f"M02_MasterTemplete_{name}.ts") + assert len(text.splitlines()) <= 700, name + css = _text("M02_MasterTemplete_Hopyo.css") + assert "var(--color-" in css and not re.search(r"#[0-9a-fA-F]{3,6}\b", css) + for state in ("맞음", "별칭", "다른점", "후보"): + assert f".m02-hopyo__state.is-{state}" in css + assert '"./M02_MasterTemplete_Hopyo.css"' in _text("M02_MasterTemplete_Hopyo_Check.ts") + + +def test_엑셀_보기_창_여는_자리와_늦게_불러옴(): + lazy = 'import("./M02_MasterTemplete_Hopyo_Sheet")' + side = _text("M02_MasterTemplete_Hopyo_Side.ts") + assert lazy in side.split("await show(got);")[1].split("})")[0] # 올린 직후 자동 view = _text("M02_MasterTemplete_Hopyo_View.ts") - assert "단산행: basis.행" in view and "근거 맞음 모두 확정" in view - assert "x.근거 || x.맞춤.후보?.some((c) => c.근거)" in view # 옛 파일 = 단추 없음 + assert lazy in view and '"엑셀로 보기"' in view + check = _text("M02_MasterTemplete_Hopyo_Check.ts") + assert lazy in check and '"엑셀에서 보기"' in check and "호표: opt.no" in check + assert "단산행: 행" in check + pick = _text("M02_MasterTemplete_Hopyo_Pick.ts") + assert lazy in pick + static = r'^import (?!type )[^(;]*"\./M02_MasterTemplete_Hopyo_Sheet"' + for text in (side, view, pick, check): + assert not re.search(static, text, re.M) # 정적 import 없음(타입만) + sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts") + assert 'import("../A00_Common/spreadsheet/spreadsheet")' in sheet + assert not re.search( + r'^import \{[^}]*\} from "\.\./A00_Common/spreadsheet/spreadsheet"', sheet, re.M + ) + assert "readOnly: true" in sheet and "wide: true" in sheet diff --git a/resources/tester/test_m02_hopyo_seam.py b/resources/tester/test_m02_hopyo_seam.py index 54ac54462..58b1aa8a6 100644 --- a/resources/tester/test_m02_hopyo_seam.py +++ b/resources/tester/test_m02_hopyo_seam.py @@ -2,8 +2,8 @@ 화면 도우미(`helper_m02_hopyo_seam.cjs` → Hopyo_Sheet · Hopyo_View)에 먹임. 행 22 → 제3호표 · 25 빈 줄 · 26 → 제4호표 · 영역 B20:O24 · 단산93 행 4229:4244 · -엑셀 근거 줄 · 근거 꼬리표 · 근거 맞음 대상 · 북부청 「후보」 줄 1순위 근거 맞음 = 서버 기준 123 · -앞 마디 같음 0(330 덧고침 2). +엑셀 근거 줄 · 근거 꼬리표 · 근거 맞음 · 북부청 「후보」 줄 1순위 근거 맞음 = 서버 기준 123 +(338 — 근거 맞음 모두 확정 단추 없앰 · 도우미는 고르기 창 `Hopyo_Choose`). """ import json @@ -42,14 +42,12 @@ def test_실제_응답_화면_도우미(client): res = client.get(f"{URL.format('personal')}/HP-000001/{one['번']}").json() every += [x for x in res["호표"]["줄"] if x["갈래"] == "단가산출"] got = _node({"grid": grid, "lines": lines, "all": every}) - assert got["count"]["top"] == 123 and got["count"]["half"] == 0 - assert got["count"]["pick"] >= 123 + assert got["count"]["top"] == 123 assert got["at"] == [None, 3, 3, 3, None, 4] # 25행 = 호표 사이 빈 줄 assert got["span"] == "B20:O24" and got["rows"] == "4229:4244" by = {x["이름"]: x for x in got["lines"]} dig = by["구조물터파기(인력100%)"] assert dig["source"] == "출처 건설품셈 3-2-1 · 직종 특별인부 · 단가산출 4229–4244행" assert dig["tag"] == "조항 건설품셈 공통 3-2-1 · 직종 특별인부 · 단위 같음" - assert dig["ok"] is True and dig["pick"] == "GC000083" - assert by["비탈면 보호공"]["pick"] == "GC000143" + assert dig["ok"] is True assert by["흙깍기(토사)"]["ok"] is True # 엑셀 직종 없는 줄(기계) = 조항 같음 + 단위 diff --git a/resources/tester/test_m02_hopyo_stmate.py b/resources/tester/test_m02_hopyo_stmate.py new file mode 100644 index 000000000..671a2daf9 --- /dev/null +++ b/resources/tester/test_m02_hopyo_stmate.py @@ -0,0 +1,217 @@ +"""일위대가 실무자료 STmate 꼴 읽기(PLAN 338-2b · `M02_Hopyo_Stmate`). + +여섯 파일 올라감(봉화 45 · 영월 23 · 울진대흥 설계 · 착공 34 · 영덕 9 · 울진소광 25 호표) · +봉화 줄 216(구성 209 + 근거글 7) · 단산 103 · 노임 30 · 호표 22 · 줄 코드 갈래 · +노임 이름 어긋남 0 · 블록 자원 품 · 이음 열쇠(M~Z 열 코드 꼴 첫 것) · +문서 보안(장수 · 진안) 400 글 · 원본 엑셀 · master_data 그대로 · +영월 정답 자리(`y_ids` — 338 실험 C1 `labels_y` Y000~Y035 = D · X 고유 줄 차례 · 골든 시험이 씀). +""" + +import hashlib +from pathlib import Path + +import pytest + +from M02_MasterTemplete import M02_Hopyo_Basis as hb +from M02_MasterTemplete import M02_Hopyo_Excel as hx +from M02_MasterTemplete import M02_Hopyo_Stmate as st +from M02_MasterTemplete import M02_Hopyo_Store as hs +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester import test_m02_hopyo_excel as base +from resources.tester.test_m02_hopyo_excel import ROOT, _post, master_hash + +client = base.client # 공용 고정물(임시 층) +DOCS = ROOT / "resources" / "knowledge" / "original" / "실무문서" +FILES = { + "봉화": "04.산불 봉화/01. 내역서/2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx", + "영월": "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx", + "대흥설계": "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/" + "2024년 간선임도사업(기번3).xlsx", + "대흥착공": "2024년 간선임도(기번3-울진.대흥)(공통)/" + "2024년 간선임도 신설사업(기번3 울진.울진.대흥.산65외2).xlsx", + "영덕": "20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/01.내역자료/" + "2025년 계류보전사업(기번1 영덕.병곡.영.산214)(변경).xlsx", + "소광": "25년 산불진화임도(기번8)(울진소광)/" + "2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).xlsx", +} +HOPYOS = {"봉화": 45, "영월": 23, "대흥설계": 34, "대흥착공": 34, "영덕": 9, "소광": 25} +LOCKED = { + "장수": "02.산불 장수/01.내역서/" + "00.단가변경_26년_산불_무주(장수,장수,덕산,산54-1)내역+수량.xlsx", + "진안": "03.산불진안/01.내역서/00.단가변경_26년_산불_무주(진안,백운,백암,산1-2)내역+수량.xlsx", +} + + +def _path(tag: str) -> Path: + return DOCS / {**FILES, **LOCKED}[tag] + + +@pytest.fixture(scope="module") +def books() -> dict[str, dict]: + return {tag: hx.read_book(_path(tag).name, _path(tag).read_bytes()) for tag in FILES} + + +def _lines(book: dict) -> list[dict]: + return [x for h in book["호표"] for x in h["줄"]] + + +def test_여섯_파일_호표_수(books): + assert {tag: len(b["호표"]) for tag, b in books.items()} == HOPYOS + for book in books.values(): + assert (book["꼴"], book["시트"]) == ("STmate", "일위대가표") + assert book["검산"] == {"어긋남": [], "총괄표": 0} + assert len(_lines(books["대흥설계"])) == len(_lines(books["대흥착공"])) == 171 # 같은 설계 + + +def test_봉화_줄_갈래(books): + bong = books["봉화"] + lines = _lines(bong) + kinds = {k: sum(1 for x in lines if x["갈래"] == k) for k in ("단가산출", "노임", "호표", "%")} + assert kinds == {"단가산출": 103, "노임": 30, "호표": 22, "%": 7} + assert len(lines) + sum(len(h.get("근거글") or []) for h in bong["호표"]) == 216 + assert all(x["코드"] for x in lines) + for book in books.values(): + for x in _lines(book): + if x["갈래"] == "호표": + assert x["참조"]["번"] in {h["번"] for h in book["호표"]} + if x["갈래"] == "단가산출": + assert x["참조"] == {"종류": "단산", "번": int(x["코드"][1:]), "코드": x["코드"]} + assert "근거" in x # 블록 모두 이어짐 + by = {h["번"]: h for h in bong["호표"]} + assert by[13]["근거글"] == ["건설표준품셈 7-2-2(찰붙임)"] # 코드 없는 줄 = 호표 근거글 + assert hb.sources(" ".join(by[13]["근거글"])) == [["건설품셈", "7-2-2"]] + + +def test_영월_줄과_블록(books): + yw = books["영월"] + one = yw["호표"][0] + assert (one["번"], one["코드"], one["이름"], one["단위"]) == ( + 1, + "B00260", + "녹생토(종자배합)", + "M3", + ) + assert one["행"] == [5, 10] and one["금액"]["계"] == 155184 + assert [(x["이름"], x["코드"], x["갈래"], x["수량"]) for x in one["줄"]] == [ + ("산림종자", "M00024", "자재", 0.25), + ("산림종자", "M00211", "자재", 0.25), + ("인공토양(녹생토)", "M00832", "자재", 1.1), + ("보통인부", "L00002", "노임", 0.43), + ] + two = {x["이름"]: x for x in yw["호표"][1]["줄"]} + assert two["국가지점번호판 검증수수료"]["갈래"] == "별도계상" # 비고 「별산」 + assert two["구조물터파기(인력)"]["근거"] == { + "출처": [["건설품셈", "3-1-3"]], + "직종": ["보통인부"], + "행": [5, 54], + } + block = next(b for b in yw["단산"] if b["코드"] == "D00002") + assert (block["번"], block["이름"], block["규격"], block["단위"]) == ( + 2, + "구조물터파기(인력)", + "경질토사", + "M3", + ) + assert block["자원"] == [ + {"갈래": "L", "코드": "L00002", "이름": "보통인부", "규격": "", "품": 0.26, "절": ""} + ] + assert any("건설표준품셈3-1-3" in t for t in block["글"]) + + +def test_기계_자원_하나로(books): + block = next(b for b in books["봉화"]["단산"] if b["코드"] == "D00002") + machines = [r for r in block["자원"] if r["갈래"] == "X"] + assert len(machines) == 1 # 노무비 · 재료비 · 경비 셋 줄 + assert machines[0]["이름"] == "굴삭기(무한궤도)" and machines[0]["규격"] == "0.2㎥" + assert machines[0]["품"] == pytest.approx(0.0636537237529) + + +def test_노임_이름_어긋남_0(books): + names = hb.trade_names() + for tag in FILES: + sheets = hx._sheets(_path(tag).name, _path(tag).read_bytes()) + table = st._lists(sheets) + res = [r for b in books[tag]["단산"] for r in b["자원"] if r["갈래"] == "L" and r["이름"]] + assert res + assert sum(1 for r in res if r["코드"] in table) >= len(res) // 2 + for r in res: + if r["코드"] in table: # 목록표에 있는 직종 — X 열 이름과 같음 + assert st._flat(r["이름"]) == st._flat(table[r["코드"]]["이름"]) + if table.get(r["코드"], {}).get("단위") == "인": # L 코드 % 줄(기구손료) 뺌 + assert st._flat(r["이름"]) in names, (tag, r) + labor = [x for x in _lines(books[tag]) if x["갈래"] == "노임"] + assert all(st._flat(x["이름"]) in names for x in labor) + + +def test_이음_열쇠_코드_칸(): + row = [None] * 26 + row[12], row[14], row[15] = "별산M00985", "M00985_1", "T7_1" + assert st._code(row) == "M00985" + row = [None] * 26 + row[25] = "D01352 →" # O 열 없는 판 + assert st._code(row) == "D01352" + row = [None] * 47 + row[14], row[13] = "PRA01_1", "_x0007_`COD|PR_x0005_`EXI" + assert st._code(row) == "PRA01" + assert ( + st._code(["계", None, None, None, None, None, None, None] + [None] * 6 + ["T7_1"]) == "T7" + ) + assert st._code([None] * 12 + ["D96"]) is None # 비고 글은 코드 아님 + + +def test_문서_보안_400(client): + for tag in LOCKED: + data = _path(tag).read_bytes() + with pytest.raises(hx.ExcelError, match="문서 보안"): + hx.read_book(_path(tag).name, data) + res = _post(client, data=data, name=_path(tag).name) + assert res.status_code == 400 and res.json()["detail"] == hx.DRM + + +def test_여섯_파일_올라감(client): + before = master_hash() + sources = {tag: hashlib.sha256(_path(tag).read_bytes()).hexdigest() for tag in FILES} + for at, tag in enumerate(FILES, 1): + res = _post(client, data=_path(tag).read_bytes(), name=_path(tag).name) + assert res.status_code == 200, res.text + assert res.json()["호표수"] == HOPYOS[tag] + doc = hs.read(layers.personal_dir(7, 42), f"HP-{at:06d}") + assert doc["꼴"] == "STmate" + assert sum(1 for b in doc["단산"] if not b["글"]) <= 3 # 봉화 무대처리 · 흙 쌓 기 = 값 0 + assert any(b["자원"] for b in doc["단산"]) + first = doc["호표"][0]["번"] + view = client.get(f"{base.URL.format('personal')}/HP-{at:06d}/{first}") + assert view.status_code == 200, view.text # 맞춤 보기가 STmate 줄을 받음 + assert {tag: hashlib.sha256(_path(tag).read_bytes()).hexdigest() for tag in FILES} == sources + assert master_hash() == before + + +def y_ids(book: dict) -> dict[str, dict]: + """영월 정답 자리 — D · X 줄을 (이름, 규격, 단위, 코드) 고유로 처음 나온 차례 → + `{"Y000": {호표, 차례, 열쇠}}`(338 실험 C1 `s09_stmate` 와 같은 번).""" + out: dict[str, dict] = {} + seen: set[tuple] = set() + for hopyo in book["호표"]: + for line in hopyo["줄"]: + key = (line["이름"], line["규격"], line["단위"], line["코드"]) + if line["코드"][0] in "DX" and key not in seen: + seen.add(key) + out[f"Y{len(out):03d}"] = {"호표": hopyo["번"], "차례": line["차례"], "열쇠": key} + return out + + +def test_영월_정답_자리(books): + got = y_ids(books["영월"]) + assert len(got) == 36 # 정답 34 + 우리에 없음 둘(Y004 · Y007) + assert got["Y000"] == { + "호표": 2, + "차례": 1, + "열쇠": ("무근콘크리트타설", "인력운반타설", "M3", "D02058"), + } + assert got["Y005"]["열쇠"] == ("크레인(타이어)", "10톤", "시간", "X02104") + assert got["Y010"]["열쇠"][3] == "D00060" and got["Y035"]["열쇠"][3] == "D01810" + by = {h["번"]: h for h in books["영월"]["호표"]} + for one in got.values(): + line = by[one["호표"]]["줄"][one["차례"]] + assert (line["이름"], line["규격"], line["단위"], line["코드"]) == one["열쇠"] + assert line["갈래"] == "기계" or "근거" in line # 단산 줄은 블록 이음