diff --git a/M02_MasterTemplete/M02_Hopyo_Match.py b/M02_MasterTemplete/M02_Hopyo_Match.py new file mode 100644 index 000000000..fc8136a1e --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Match.py @@ -0,0 +1,488 @@ +"""M02 일위대가 실무자료 맞춤(PLAN 324-2) — 엑셀 구성 줄 → 우리 돈 로직 · 자재 · 입력값 · 환산. + +차례 = ① 층 기억(같은 층 → 위 층 · 확정분) ② 이름 같음(정규화) · +씨앗(일위대가조합.json 메모 · 읽기만) +③ 정리 이름 같음(괄호 · 기계 낱말) ④ 후보 5(글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤) — +③ ④ 는 사용자 확정(층 기억) 뒤에만 들어감. +줄 갈래: 단가산출 · 기계 · 기타 = 로직 / 자재 · 별도계상 = 자재(M01 자재품목) / 호표 = 그 호표 / +% = 안 들어감(로직 덧줄이 셈). +규격 → 입력값 = 고르기 글이 규격(품명 괄호 글 포함)에 있으면 그 값(기계 = 고르기이름) · +이름 붙은 수 D= H= H: B= T= L= L3= 는 입력 단위로 · 못 읽으면 빈 값(자동값) + 표시. +환산(우리 수량 = 엑셀 수량 × 배): 같은 단위 1 · 같은 성격 = 단위표 · ㎡→㎥ × T · m→㎡ × B · +m→㎥ × B × T = 「추정」(확정 뒤에만) · 두 단위 로직 = 단위 입력으로 고르고 1 · 그 밖 = 사용자 입력. +M01 은 읽기만(로직 별칭 안 씀). +""" + +from __future__ import annotations + +import json +import re +import unicodedata +from datetime import datetime +from decimal import Decimal +from functools import lru_cache +from pathlib import Path +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 +from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store +from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store + +mf = m01.cm.mf +mp = mf.mp +mm = mp.mm +SEED_NAME = "일위대가조합.json" +LOGIC_KINDS = ("단가산출", "기계", "기타") +MATERIAL_KINDS = ("자재", "별도계상") +READY = ("기억", "같음", "호표") +MACH_WORDS = ( + ("불도우저", "불도저"), + ("굴삭기", "굴착기"), + ("브레이카", "브레이커"), + ("로울러", "롤러"), + ("크로울러", "크롤러"), +) +_SEED_TAIL = re.compile(r"\s*\(실무 수는 옛 판 값\)\s*$") +_PAREN = re.compile(r"\([^()]*\)") +_THOUSANDS = re.compile(r"(=\d{1,3}),(\d{3})(?!\d)") +_NAMED = re.compile(r"(? str: + """기억 열쇠 = 품명 | 규격 | 단위(정규화).""" + return "|".join( + ( + mm.plain(line.get("이름")), + mm.plain_spec(line.get("규격")), + mm.plain_unit(line.get("단위")), + ) + ) + + +def tidy(name: Any) -> str: + """정리 이름 — 괄호 글 · 기계 옛 낱말 · 빈칸.""" + text = str(name or "") + for old, new in MACH_WORDS: + text = text.replace(old, new) + return mm.plain(_PAREN.sub("", text)) + + +def _spec_text(text: Any) -> str: + out = str(text or "") + for old, new in MACH_WORDS: + out = out.replace(old, new) + return mm.plain_spec(out).replace(",", "").replace("~", "∼") + + +def numbers(spec: Any) -> dict[str, tuple[Decimal, str | None]]: + """규격의 이름 붙은 수 `{글자: (값, 단위)}` — 천 단위 쉼표(`D=1,000mm`)를 먼저 붙임.""" + text = _THOUSANDS.sub(r"\1\2", unicodedata.normalize("NFKC", str(spec or ""))) + return { + letter.upper(): (Decimal(value), unit.lower() if unit else None) + for letter, value, unit in _NAMED.findall(text) + } + + +def _in_unit(value: Decimal, have: str | None, want: Any) -> Decimal: + """길이 값을 입력 단위로 — 한쪽이 모르는 단위면 그대로.""" + scale = mp.UNITS["길이"] + want = mm.plain_unit(want) if want else None + if have in scale and want in scale: + return value * Decimal(scale[have]) / Decimal(scale[want]) + return value + + +def _json(value: Decimal) -> int | float: + value = value.normalize() + return int(value) if value == value.to_integral_value() else float(value) + + +def _meters(found: tuple[Decimal, str | None] | None) -> Decimal | None: + if found is None: + return None + return _in_unit(found[0], found[1] or "m", "m") + + +# ── 규격 → 입력값 ───────────────────────────────────────────────────── +def spec_inputs(line: dict[str, Any], specs: list[dict[str, Any]]) -> tuple[dict, list[str]]: + """(입력값, 못 읽은 입력 이름) — 고르기 글 · 고르기이름 · 이름 붙은 수 · 두 단위.""" + raw = f"{line.get('규격') or ''} {' '.join(_PAREN.findall(str(line.get('이름'))))}" + pool = _spec_text(raw) + named = numbers(line.get("규격")) + tokens = [_spec_text(w) for w in re.split(r"[,\s/()]+", raw)] + unit = mm.plain_unit(line.get("단위")) + values, unread = {}, [] + for spec in specs: + name = str(spec.get("이름") or "") + picks = spec.get("고르기") or [] + labels = spec.get("고르기이름") or {} + words = [(w, letter) for letter, ws in LETTERS.items() for w in ws if w in name] + letter = max(words, key=lambda x: len(x[0]))[1] if words else None + got: Any = None + if letter in named: + value = _json(_in_unit(*named[letter], spec.get("단위"))) + got = value if not picks or value in picks else None + if got is None and picks: + hits = [] + for opt in picks: + label = str(labels.get(opt) or opt) + for text in (label, _PAREN.sub("", label)): + flat = _spec_text(text) + if len(flat) > 1 and (flat in pool or (unit and flat == f"{unit}당")): + hits.append((len(flat), opt)) + # 규격 낱말이 고르기 글 머리(「4회」 → 「4회사용시」) + hits += [(len(w), opt) for w in tokens if len(w) > 1 and flat.startswith(w)] + got = max(hits, key=lambda x: x[0])[1] if hits else None + if got is not None: + values[name] = got + elif spec.get("자동값") in (None, ""): + unread.append(name) + return values, unread + + +# ── 환산 ───────────────────────────────────────────────────────────── +def factor(have: Any, want: Any, spec: Any = "") -> dict[str, Any]: + """`{배, 갈래}` — 우리 수량 = 엑셀 수량 × 배 · 배 None = 사용자 입력.""" + units = [u.strip() for u in str(want or "").split("·") if u.strip()] + plain = mm.plain_unit(have) + if len(units) > 1: + ok = plain in {mm.plain_unit(u) for u in units} + return {"배": 1 if ok else None, "갈래": "두 단위" if ok else "입력"} + if plain == mm.plain_unit(want): + return {"배": 1, "갈래": "같음"} + a, b = mp.kind_of(have), mp.kind_of(want) + if a is not None and a == b: + scale = mp.UNITS[a] + ratio = Decimal(scale[plain]) / Decimal(scale[mm.plain_unit(want)]) + return {"배": _json(ratio), "갈래": "단위표"} + found = numbers(spec) + width, thick = _meters(found.get("B")), _meters(found.get("T")) + by = {("넓이", "부피"): thick, ("길이", "넓이"): width} + if (a, b) == ("길이", "부피") and width is not None and thick is not None: + by[(a, b)] = width * thick + got = by.get((a, b)) + if got: + return {"배": _json(got), "갈래": "추정"} + return {"배": None, "갈래": "입력"} + + +def fits(have: Any, want: Any) -> bool: + """단위가 맞음 — 같음 · 같은 성격 · 두 단위 중 하나(후보 점수 덤).""" + units = {mm.plain_unit(u) for u in str(want or "").split("·") if u.strip()} + return mm.plain_unit(have) in units or mp.same_kind(have, want) + + +# ── 우리 쪽 목록(한 번 읽어 둠) ───────────────────────────────────────── +def _grams(text: str) -> set[str]: + return {text[i : i + 2] for i in range(len(text) - 1)} or {text} + + +@lru_cache(maxsize=2) +def _targets(whole_id: int) -> dict[str, Any]: + """로직(돈) · 자재품목 — 정규 이름 · 정리 이름 · 묶음 · 단위.""" + files, whole = m01.loaded() + rows = [r for d in files.values() if d.get("그룹") == "로직" for r in d.get("줄") or []] + pills = combo_store.combo_pills({"담은로직": [{"로직": r.get("키")} for r in rows]})["물량"] + unit_of = {code: pill["단위"] for pill in pills for code in pill["로직"]} # 돈 로직만 + logics, mats = [], [] + for row in rows: + if str(row.get("키")) in unit_of: + name = str(row.get("이름") or "") + logics.append( + { + "로직": str(row["키"]), + "이름": name, + "단위": unit_of[str(row["키"])], + "산림": "산림" in str(row.get("구분") or ""), + "plain": mm.plain(name), + "tidy": tidy(name), + } + ) + for key, row in whole.index.get(mm.MARKET, {}).items(): + name = str(row.get("이름") or "") + mats.append( + { + "자재": str(key), + "이름": name, + "규격": str(row.get("규격") or ""), + "단위": str(row.get("단위") or ""), + "plain": mm.plain(name), + "spec": mm.plain_spec(row.get("규격")), + "tidy": tidy(re.sub(r"-(사급|관급)$", "", name.strip())), + } + ) + names: dict[str, dict[str, list]] = {"로직": {}, "자재": {}} + for pool, group in (("로직", logics), ("자재", mats)): + for one in group: + one["grams"] = _grams(one["tidy"]) + names[pool].setdefault(one["plain"], []).append(one) + return {"로직": logics, "자재": mats, "이름": names, "씨앗": _seeds()} + + +def targets() -> dict[str, Any]: + return _targets(id(m01.loaded()[1])) + + +def _seeds() -> dict[str, set[str]]: + """씨앗 `{정규 실무 글: {로직 키}}` — 일위대가조합.json 담은로직 메모(읽기만).""" + try: + doc = json.loads((Path(m01.FOLDER) / SEED_NAME).read_bytes()) + except (OSError, ValueError): + return {} + out: dict[str, set[str]] = {} + for row in doc.get("줄") or []: + for one in row.get("담은로직") or []: + memo = _SEED_TAIL.sub("", str(one.get("메모") or "")) + if memo and one.get("로직"): + out.setdefault(mm.plain(memo), set()).add(str(one["로직"])) + return out + + +def _public(one: dict[str, Any]) -> dict[str, Any]: + keep = ("로직", "자재", "이름", "규격", "단위") + return {k: one[k] for k in keep if k in one} + + +def _exact(line: dict[str, Any], pool: str, got: dict[str, Any]) -> list[dict[str, Any]]: + """② 이름 같음 · 씨앗 — 겹치면 여럿(부르는 쪽이 후보로 돌림).""" + name, spec = mm.plain(line.get("이름")), mm.plain_spec(line.get("규격")) + same = got["이름"][pool].get(name) or [] + if pool == "자재": + return [m for m in same if m["spec"] == spec] or (same if len(same) == 1 else []) + if same: + return same + codes = got["씨앗"].get(mm.plain(f"{line.get('이름')}{line.get('규격')}")) or got["씨앗"].get( + name + ) + return [x for x in got["로직"] if x["로직"] in (codes or ())] + + +def candidates( + line: dict[str, Any], pool: str, got: dict[str, Any] | None = None, limit: int = 5 +) -> list[dict[str, Any]]: + """③ 정리 이름 같음 먼저 · ④ 글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 — 위 `limit`.""" + me = tidy(line.get("이름")) + grams, unit = _grams(me), line.get("단위") + text = mm.plain_spec(f"{line.get('이름')}{line.get('규격')}") + scored, seen = [], set() + for one in (got or targets())[pool]: + if not grams & one["grams"]: + continue + score = 2 * len(grams & one["grams"]) / (len(grams) + len(one["grams"])) + score += 1 if one["tidy"] == me else 0 + score += 0.15 if fits(unit, one["단위"]) else 0 + score += 0.05 if one.get("산림") else 0 + score += 0.5 if one.get("spec") and one["spec"] in text else 0 + scored.append((score, one)) + scored.sort(key=lambda x: -x[0]) + out = [] + for score, one in scored: + label = (one["이름"], one.get("규격"), one["단위"]) + if label in seen: + continue + seen.add(label) + out.append({**_public(one), "점수": round(score, 3)}) + if len(out) == limit: + break + return out + + +# ── 층 기억 ─────────────────────────────────────────────────────────── +def chain(folder: Path) -> list[Path]: + """기억을 볼 층 폴더 — 같은 층 → 위 층(개인 → 회사 → 마스터).""" + layer, owner = refs.where(folder) + out = [Path(folder)] + if layer == "personal": + out.append(refs.folder_of("company", str(owner).split("/")[0])) + if layer in ("personal", "company"): + out.append(Path(store.FOLDER)) + return out + + +def memories(folder: Path) -> list[dict[str, dict[str, Any]]]: + return [hopyo_store.memory(one) for one in chain(folder)] + + +def recall(line: dict[str, Any], memo: list[dict[str, dict[str, Any]]]) -> dict[str, Any] | None: + key = line_key(line) + return next((one[key] for one in memo if key in one), None) + + +# ── 줄 하나 맞춤 ─────────────────────────────────────────────────────── +def _logic_specs(code: str) -> list[dict[str, Any]]: + got = labor_inputs.logic_inputs([code])["로직"] + return got[0]["입력"] if got else [] + + +def _target_of(remembered: dict[str, Any], got: dict[str, Any]) -> dict[str, Any] | None: + if remembered.get("로직"): + return next((x for x in got["로직"] if x["로직"] == remembered["로직"]), None) + if remembered.get("자재"): + return next((x for x in got["자재"] if x["자재"] == remembered["자재"]), None) + return None + + +def match_line( + line: dict[str, Any], + memo: list[dict[str, dict[str, Any]]], + units: dict[int, str], + full: bool = True, + got: dict[str, Any] | None = None, +) -> dict[str, Any]: + """줄 → `{열쇠, 상태, 대상, 입력값, 못읽음, 환산, 들어감, 후보?}` — + `units` = {호표 번: 단위}(일위N 참조) · `full` = 입력값 · 후보까지(아니면 상태 · 환산만).""" + out: dict[str, Any] = {"열쇠": line_key(line), "대상": None, "입력값": {}, "못읽음": []} + kind = line.get("갈래") + pool = "자재" if kind in MATERIAL_KINDS else "로직" + remembered = recall(line, memo) + got = got or targets() + if kind == "%": + return {**out, "상태": "%", "환산": None, "들어감": False} + if remembered and remembered.get("빼기"): + return {**out, "상태": "빼기", "환산": None, "들어감": False} + if kind == "호표": + no = int((line.get("참조") or {}).get("번") or 0) + conv = factor(line.get("단위"), units.get(no), line.get("규격")) + target = {"호표": no, "단위": units.get(no)} + ready = no in units and conv["배"] is not None and conv["갈래"] != "추정" + return {**out, "상태": "호표", "대상": target, "환산": conv, "들어감": ready} + if remembered and (target := _target_of(remembered, got)): + conv = {"배": remembered.get("환산"), "갈래": "기억"} + state = {"상태": "기억", "대상": _public(target), "입력값": remembered.get("입력값") or {}} + return {**out, **state, "환산": conv, "들어감": conv["배"] is not None} + found = _exact(line, pool, got) + if len(found) == 1: + target = found[0] + conv = factor(line.get("단위"), target["단위"], line.get("규격")) + out.update(상태="같음", 대상=_public(target), 환산=conv) + out["들어감"] = conv["배"] is not None and conv["갈래"] != "추정" + if full and "로직" in target: + out["입력값"], out["못읽음"] = spec_inputs(line, _logic_specs(target["로직"])) + return out + out.update(상태="후보" if found else "없음", 환산=None, 들어감=False) + if not full: + return out + top = [{**_public(x), "점수": 9} for x in found] + same = [_public(x) for x in found] + rest = [ + c + for c in candidates(line, pool, got) + if {k: v for k, v in c.items() if k != "점수"} not in same + ] + out["후보"] = (top + rest)[:5] + return {**out, "상태": "후보" if out["후보"] else "없음"} + + +def guess(folder: Path, key: str, no: int, at: int, target: dict[str, Any]) -> dict[str, Any]: + """후보 · 고른 대상 하나 미리 채움 — `{대상, 입력값, 못읽음, 환산}`(확정 전 화면 보기).""" + line = _line(folder, key, no, at) + found = _target_of(target if isinstance(target, dict) else {}, targets()) + if found is None: + raise store.StoreError(400, "돈 로직 · 우리 자재가 아님") + target = _public(found) + conv = factor(line.get("단위"), target.get("단위"), line.get("규격")) + if not target.get("로직"): + return {"대상": target, "입력값": {}, "못읽음": [], "환산": conv} + values, unread = spec_inputs(line, _logic_specs(str(target["로직"]))) + return {"대상": target, "입력값": values, "못읽음": unread, "환산": conv} + + +def _line(folder: Path, key: str, no: int, at: int) -> dict[str, Any]: + doc = hopyo_store.read(folder, key) + hopyo = next((h for h in doc.get("호표") or [] if int(h["번"]) == int(no)), None) + lines = (hopyo or {}).get("줄") or [] + if not 0 <= int(at) < len(lines): + raise store.StoreError(404, f"없는 줄 「제{no}호표 {at}」") + return lines[int(at)] + + +def match_hopyo( + hopyo: dict[str, Any], + memo: list[dict[str, dict[str, Any]]], + units: dict[int, str], + full: bool = True, + got: dict[str, Any] | None = None, +) -> dict[str, Any]: + """호표 하나 — 줄마다 맞춤 · `맞춤` = [들어감 수, 줄 수].""" + got = got or targets() + lines = [ + {**one, "맞춤": match_line(one, memo, units, full, got)} for one in hopyo.get("줄") or [] + ] + ready = sum(1 for one in lines if one["맞춤"]["들어감"]) + return {**hopyo, "줄": lines, "맞춤": [ready, len(lines)]} + + +def units_of(doc: dict[str, Any]) -> dict[int, str]: + return {int(h["번"]): str(h.get("단위") or "") for h in doc.get("호표") or []} + + +def view(folder: Path, key: str, no: int | None = None) -> dict[str, Any]: + """실무자료 보기 — `no` 없으면 호표마다 맞춤 수만 · 있으면 그 호표 줄마다 맞춤 · 후보.""" + doc = hopyo_store.read(folder, key) + memo, units = memories(folder), units_of(doc) + if no is None: + got = targets() + hopyos = [match_hopyo(h, memo, units, False, got) for h in doc.get("호표") or []] + brief = [{k: h[k] for k in ("번", "이름", "규격", "단위", "금액", "맞춤")} for h in hopyos] + ready = [sum(h["맞춤"][0] for h in hopyos), sum(h["맞춤"][1] for h in hopyos)] + return {**hopyo_store.brief(doc), "검산": doc.get("검산"), "호표": brief, "맞춤": ready} + hopyo = next((h for h in doc.get("호표") or [] if int(h["번"]) == int(no)), None) + if hopyo is None: + raise store.StoreError(404, f"없는 호표 「제{no}호표」") + return {**hopyo_store.brief(doc), "호표": match_hopyo(hopyo, memo, units)} + + +# ── 확정 저장 ───────────────────────────────────────────────────────── +def confirm(folder: Path, entries: dict[str, Any]) -> dict[str, Any]: + """확정 · 빼기 · 지움 → 층 기억 — `{열쇠: {로직|자재|빼기, 입력값, 환산} | None}`. + 로직 = 돈 로직 · 자재 = M01 자재품목 · 환산 = 0 넘는 수(빼기는 안 봄).""" + if not isinstance(entries, dict) or not entries: + raise store.StoreError(400, "확정할 줄이 없음") + got = targets() + logics = {x["로직"] for x in got["로직"]} + mats = {x["자재"] for x in got["자재"]} + stamp = datetime.now().isoformat(timespec="seconds") + clean: dict[str, Any] = {} + for key, raw in entries.items(): + if raw is None: + clean[str(key)] = None + continue + if not isinstance(raw, dict) or str(key).count("|") != 2: + raise store.StoreError(400, f"확정 꼴이 틀림 「{key}」") + if raw.get("빼기") is True: + clean[str(key)] = {"빼기": True, "확정": stamp} + continue + code, mat = str(raw.get("로직") or ""), str(raw.get("자재") or "") + if (code and code not in logics) or (mat and mat not in mats) or bool(code) == bool(mat): + raise store.StoreError(400, f"돈 로직 · 우리 자재 하나만 「{key}」") + times = raw.get("환산") + number = None if isinstance(times, bool) else _number(times) + if number is None or number <= 0: + raise store.StoreError(400, f"환산은 0 넘는 수 「{key}」") + values = raw.get("입력값") or {} + if not isinstance(values, dict): + raise store.StoreError(400, f"입력값이 묶음 아님 「{key}」") + head = {"로직": code} if code else {"자재": mat} + clean[str(key)] = {**head, "입력값": values, "환산": _json(number), "확정": stamp} + return {"기억": hopyo_store.remember(folder, clean)} + + +def _number(raw: Any) -> Decimal | None: + try: + got = Decimal(str(raw).replace(",", "").strip()) + except ArithmeticError: + return None + return got if got.is_finite() else None diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index a6007ed72..8d89ade41 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -19,6 +19,7 @@ from pydantic import BaseModel from common_util.common_util_auth import verify_session from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel +from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Labor_Ref as refs @@ -208,9 +209,61 @@ def hopyo_upload( def hopyo_read( layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) ) -> dict: - """실무자료 하나(호표 · 줄 · 검산).""" + """실무자료 하나 — 검산 · 호표마다 `맞춤` [들어감, 줄 수] · 파일 `맞춤` 합.""" folder = _root(session, layer, owner) - return _call(folder, hopyo_store.read, folder, key) + return _call(folder, hopyo_match.view, folder, key) + + +@router.get("/layers/{layer}/hopyo/{key}/{no}") +def hopyo_one( + layer: Layer, + key: str, + no: int, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """호표 하나 — 줄마다 맞춤(상태 · 대상 · 입력값 · 환산 · 들어감 · 후보 5).""" + folder = _root(session, layer, owner) + return _call(folder, hopyo_match.view, folder, key, no) + + +class GuessBody(BaseModel): + """`차례` = 호표 안 줄(0부터) · `대상` = {로직} | {자재}.""" + + 차례: int + 대상: dict[str, Any] + + +@router.post("/layers/{layer}/hopyo/{key}/{no}/guess") +def hopyo_guess( + layer: Layer, + key: str, + no: int, + body: GuessBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """고른 대상 미리 채움(저장 안 함) — 규격 → 입력값 · 환산.""" + folder = _root(session, layer, owner) + return _call(folder, hopyo_match.guess, folder, key, no, body.차례, body.대상) + + +class MemoryBody(BaseModel): + """`줄` = {줄 열쇠: {로직|자재, 입력값, 환산} | {빼기: true} | null(지움)}.""" + + 줄: dict[str, Any] + + +@router.post("/layers/{layer}/hopyo-memory") +def hopyo_memory( + layer: Layer, + body: MemoryBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """맞춤 확정 · 빼기 → 층 기억(층 쓰기) — 다음부터 같은 줄은 자동.""" + folder = _root(session, layer, owner, write=True) + return _call(folder, hopyo_match.confirm, folder, body.줄) @router.delete("/layers/{layer}/hopyo/{key}") diff --git a/resources/tester/test_m02_hopyo_match.py b/resources/tester/test_m02_hopyo_match.py new file mode 100644 index 000000000..2f1179e0c --- /dev/null +++ b/resources/tester/test_m02_hopyo_match.py @@ -0,0 +1,167 @@ +"""일위대가 실무자료 맞춤(PLAN 324-2 · `M02_Hopyo_Match`) — 규격 → 입력값 · 단위 · 환산 · +맞춤 차례 · 기억 우선 · 길(보기 · 미리 채움 · 확정) · master_data 원본 그대로. 층은 임시 폴더. +""" + +import pytest + +from M02_MasterTemplete import M02_Hopyo_Match as hm +from M02_MasterTemplete import M02_Template_Layers as layers + +# 올리기 길 시험의 `client` 마당을 같이 씀 +# ruff: noqa: F811 +from resources.tester.test_m02_hopyo_excel import URL, _post, client, master_hash # noqa: F401 + +mm = hm.mm + + +# ── 규격 · 단위 · 환산 ───────────────────────────────────────────────── +def test_규격_이름_붙은_수(): + assert hm.numbers("D=1,000mm,일반형") == {"D": (1000, "mm")} + assert hm.numbers("H:1.2m") == {"H": (hm.Decimal("1.2"), "m")} + assert {k: float(v) for k, (v, _) in hm.numbers("B=2.3,H=0.4,야면석").items()} == { + "B": 2.3, + "H": 0.4, + } + assert hm.numbers("L3=35cm적용,장비사용") == {"L3": (35, "cm")} + assert hm.numbers("Φ40×200") == {} and mm.plain_spec("Φ40×200") == "ø40x200" + + +def test_단위_정규화(): + got = [mm.plain_unit(u) for u in ("㎥", "M", "Ton", "TON", "EA", "㎡")] + assert got == ["m3", "m", "톤", "톤", "개", "m2"] + + +@pytest.mark.parametrize( + ("have", "want", "spec", "times", "kind"), + [ + ("㎥", "㎥", "", 1, "같음"), + ("M", "m", "", 1, "같음"), + ("kg", "ton", "", 0.001, "단위표"), + ("㎡", "㎥", "T=150mm", 0.15, "추정"), + ("M", "㎡", "B=2.3,H=0.4", 2.3, "추정"), + ("M", "㎥", "B=2.3,T=0.2", 0.46, "추정"), + ("개소", "㎥", "", None, "입력"), + ("㎥", "㎡ · ㎥", "", 1, "두 단위"), + ("M", "㎡ · ㎥", "", None, "입력"), + ], +) +def test_환산(have, want, spec, times, kind): + assert hm.factor(have, want, spec) == {"배": times, "갈래": kind} + + +SPECS = [ + {"이름": "뒷길이", "단위": "㎝", "고르기": [25, 35, 45], "자동값": None}, + {"이름": "관경", "단위": "mm", "자동값": None}, + {"이름": "설치높이", "단위": "m", "자동값": None}, + { + "이름": "기계", + "고르기": ["0201-0020", "0201-0060"], + "고르기이름": { + "0201-0020": "굴착기(무한궤도) 0.2㎥", + "0201-0060": "굴착기(무한궤도) 0.6㎥", + }, + "자동값": None, + }, + {"이름": "횟수", "고르기": ["3회사용시", "4회사용시"], "자동값": None}, + {"이름": "단위당", "고르기": ["㎡당", "㎥당"], "자동값": None}, + {"이름": "지역", "고르기": ["전국평균", "서울"], "자동값": "전국평균"}, + {"이름": "보정작업", "고르기": ["해당 없음", "전석섞인토사"], "자동값": None}, +] + + +def test_규격에서_입력값(): + line = {"이름": "찰붙임(4회)", "규격": "L3=35cm적용,굴삭기0.6㎥,D=1,000mm,H:1.2m", "단위": "㎥"} + values, unread = hm.spec_inputs(line, SPECS) + assert values == { + "뒷길이": 35, + "관경": 1000, + "설치높이": 1.2, + "기계": "0201-0060", + "횟수": "4회사용시", + "단위당": "㎥당", + } + assert unread == ["보정작업"] # 지역 = 자동값 · 못 읽은 것만 표시 + other = {"이름": "찰붙임", "규격": "L3=40cm", "단위": "㎡"} + values, unread = hm.spec_inputs(other, SPECS[:1]) + assert values == {} and unread == ["뒷길이"] # 고르기 밖 수 = 빈 값 + 표시 + + +# ── 맞춤 차례 ──────────────────────────────────────────────────────── +def _hopyo(client, no, layer="personal"): + res = client.get(f"{URL.format(layer)}/HP-000001/{no}") + assert res.status_code == 200, res.text + return {one["이름"]: one["맞춤"] for one in res.json()["호표"]["줄"]} + + +def test_이름_같음_후보_호표_퍼센트(client): + before = master_hash() + assert _post(client).status_code == 200 + first = _hopyo(client, 1) + plywood = first["합판거푸집"] + assert plywood["상태"] == "같음" and plywood["대상"]["로직"] == "GF000160" + assert plywood["입력값"] == {"횟수": "4회사용시"} and plywood["들어감"] is True + remicon = first["레미콘(25-21-120)"] + assert remicon["상태"] == "후보" and remicon["들어감"] is False + assert all(c["규격"] == "25-21-120" for c in remicon["후보"][:3]) # 규격 같은 자재 먼저 + dig = _hopyo(client, 3)["구조물터파기(인력100%)"] + assert dig["상태"] == "후보" and len(dig["후보"]) == 5 + assert dig["후보"][0]["이름"].startswith("구조물터파기") + ref = _hopyo(client, 45)["고화매트재료비"] + assert ref["상태"] == "호표" and ref["대상"] == {"호표": 46, "단위": "㎡"} and ref["들어감"] + pct = _hopyo(client, 46)["잡재료비"] + assert pct["상태"] == "%" and pct["들어감"] is False + whole = client.get(f"{URL.format('personal')}/HP-000001").json() + assert whole["맞춤"][1] == 415 and 0 < whole["맞춤"][0] < 415 + assert next(h for h in whole["호표"] if h["번"] == 61)["맞춤"] == [0, 0] + assert master_hash() == before + + +def test_미리_채움_확정_기억_우선(client): + assert _post(client).status_code == 200 + url = f"{URL.format('personal')}/HP-000001/3/guess" + got = client.post(url, json={"차례": 1, "대상": {"로직": "GF000101"}}).json() + assert got["환산"] == {"배": 1, "갈래": "같음"} and got["대상"]["단위"] == "㎥" + assert client.post(url, json={"차례": 1, "대상": {"로직": "XX"}}).status_code == 400 + key = _hopyo(client, 3)["구조물터파기(인력100%)"]["열쇠"] + assert key == "구조물터파기(인력100%)|토사|m3" + body = {"줄": {key: {"로직": "GF000101", "입력값": {"지역": "서울"}, "환산": 1}}} + assert client.post("/api/m02/layers/personal/hopyo-memory", json=body).status_code == 200 + dig = _hopyo(client, 3)["구조물터파기(인력100%)"] + assert (dig["상태"], dig["대상"]["로직"], dig["입력값"], dig["들어감"]) == ( + "기억", + "GF000101", + {"지역": "서울"}, + True, + ) + # 이름 같음보다 기억이 앞섬 · 빼기 + plywood = _hopyo(client, 1)["합판거푸집"]["열쇠"] + out = {"줄": {plywood: {"빼기": True}}} + assert client.post("/api/m02/layers/personal/hopyo-memory", json=out).status_code == 200 + assert _hopyo(client, 1)["합판거푸집"]["상태"] == "빼기" + clear = {"줄": {plywood: None}} + assert client.post("/api/m02/layers/personal/hopyo-memory", json=clear).status_code == 200 + assert _hopyo(client, 1)["합판거푸집"]["상태"] == "같음" + + +def test_확정_검사와_위_층_기억(client): + bad = [ + {"로직": "GC000422", "환산": 1}, # 돈 아닌 로직 + {"로직": "GF000101", "환산": 0}, + {"로직": "GF000101", "자재": "MT004258", "환산": 1}, + {"환산": 1}, + ] + for one in bad: + res = client.post("/api/m02/layers/personal/hopyo-memory", json={"줄": {"a|b|c": one}}) + assert res.status_code == 400, one + assert client.post("/api/m02/layers/system/hopyo-memory", json={"줄": {}}).status_code == 403 + client.who.update(role="SYSTEM_ADMIN") + key = "구조물터파기(인력100%)|토사|m3" + body = {"줄": {key: {"로직": "GF000103", "환산": 1}}} + assert client.post("/api/m02/layers/system/hopyo-memory", json=body).status_code == 200 + client.who.update(role="USER") + assert _post(client).status_code == 200 + assert _hopyo(client, 3)["구조물터파기(인력100%)"]["대상"]["로직"] == "GF000103" # 위 층 + mine = {"줄": {key: {"로직": "GF000101", "환산": 1}}} + assert client.post("/api/m02/layers/personal/hopyo-memory", json=mine).status_code == 200 + assert _hopyo(client, 3)["구조물터파기(인력100%)"]["대상"]["로직"] == "GF000101" # 같은 층 먼저 + assert hm.chain(layers.personal_dir(7, 42))[1] == layers.company_dir(7)