From 933fe26791f851549bfd492c9cdbe9386bcf5b7f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 21:12:05 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02=20=EC=8B=A4=EB=AC=B4=EC=9E=90=EB=A3=8C?= =?UTF-8?q?):=20358-2=20=EC=9E=90=EC=9E=AC=20=C2=B7=20=EB=B3=84=EB=8F=84?= =?UTF-8?q?=EA=B3=84=EC=83=81=20=EC=A4=84=20=EB=A7=9E=EC=B6=A4=20=E2=80=94?= =?UTF-8?q?=20=E3=80=8C=EB=B3=84=EB=8F=84=EA=B3=84=EC=83=81=E3=80=8D=20?= =?UTF-8?q?=EA=B8=80=20=ED=91=9C=EC=8B=9C=20=C2=B7=20=EB=B0=94=ED=83=95=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=C2=B7=20=EA=B7=9C=EA=B2=A9=20=EC=88=98=20?= =?UTF-8?q?=C2=B7=20=EC=A7=80=EC=97=AD=20=C2=B7=20=EA=B4=80=EA=B8=89/?= =?UTF-8?q?=EC=82=AC=EA=B8=89=20=EB=B3=80=ED=98=95=20=C2=B7=20=EA=B0=80?= =?UTF-8?q?=EC=A0=B8=EC=98=AC=20=EB=95=8C=20=EC=84=B8=20=EC=B9=B8=20=C2=B7?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=9E=90=EC=9E=AC=20=EB=91=90=20?= =?UTF-8?q?=EB=B2=88=20=EB=A7=89=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M02_Hopyo_Material — 별도계상 글 떼기 · 괄호 · 지역 · 관급/사급 꼬리 뗀 바탕 이름 · 규격 수(단위 배 · 범위) · 같은 단위 먼저 · 변형 = 한 품목 + 「못 가림」 꼬리표 · 후보 변형 접음 - 갈래: 재료비만 있는 줄 = 자재(단산 참조여도 · STmate D 코드도) · STmate 별도계상 글 포함 - 만들기: 자재 세 칸(별도계상 · 자재구분 · 운반비) · 로직 자재와 같은 품목 = 자재표시로 옮기고 빠진 줄 까닭(400 없음) - 북부청 임도 자재 · 별도계상 30줄 맞음 2 → 8 · 자재 정답 표 · build · flow 시험 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- M02_MasterTemplete/M02_Hopyo_Build.py | 38 +++- M02_MasterTemplete/M02_Hopyo_Excel.py | 4 +- M02_MasterTemplete/M02_Hopyo_Line.py | 36 ++-- M02_MasterTemplete/M02_Hopyo_Material.py | 251 +++++++++++++++++++++++ M02_MasterTemplete/M02_Hopyo_Stmate.py | 7 +- resources/tester/test_m02_hopyo_build.py | 23 +++ resources/tester/test_m02_hopyo_flow.py | 3 +- resources/tester/test_m02_hopyo_match.py | 65 +++++- 8 files changed, 402 insertions(+), 25 deletions(-) create mode 100644 M02_MasterTemplete/M02_Hopyo_Material.py diff --git a/M02_MasterTemplete/M02_Hopyo_Build.py b/M02_MasterTemplete/M02_Hopyo_Build.py index 41309a762..aaf612349 100644 --- a/M02_MasterTemplete/M02_Hopyo_Build.py +++ b/M02_MasterTemplete/M02_Hopyo_Build.py @@ -12,7 +12,9 @@ 로직 = {로직, 별칭 = 엑셀 품명 40 자, 메모 「실무 0.14㎥ × 1 · 규격」, 입력값, 수량 {손 = 엑셀 수량 × 배}} · 한 줄 로직 여럿 = 같은 별칭 · 같은 로직 둘째부터 = 한 줄 사전조합으로 감싸 참조(같은 로직 두 번 금지) · - 자재 = {코드, 수량 {손}, 별도계상}(같은 자재 = 수량 더함) · 호표 = 확인된 사전조합 참조. + 자재 = {코드, 수량 {손}, 세 칸}(같은 자재 = 수량 더함 · 세 칸 = `Material.marks` + 별도계상 · 자재구분 · 운반비 · 358-2) · 로직 자재와 같은 품목 = 사용자 자재에서 빼고 + 세 칸만 그 로직 `자재표시` 로(빠진 줄에 까닭) · 호표 = 확인된 사전조합 참조. 같은 본뜬 사전조합이 있으면 확인한 내용으로 덮음(`다시씀`) · 확인한 줄은 별칭 장부에 남음 (`M02_Hopyo_Ledger` · 호표 참조 줄 뺌). 빠진 줄(확인 안 함 · 뺌 · %) = 엑셀 금액과 함께 보고 · 만든 값 ↔ 엑셀 호표 단가(참고). @@ -29,6 +31,8 @@ from typing import Any from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Hopyo_Ledger as ledger +from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match +from M02_MasterTemplete import M02_Hopyo_Material as hmat 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 @@ -39,6 +43,7 @@ LINES_MAX = 500 UNCHECKED = "확인 안 함" PERCENT = "% 줄 — 우리 로직 덧줄이 셈" WHOLE = "호표 통째로 대신" +TWICE = "로직 자재와 같은 품목 — 세 칸만 로직 자재표시로(수량 = 로직 셈)" def mark(key: str, no: int, at: int | None = None) -> str: @@ -131,6 +136,7 @@ class Builder: self.source = refs.where(source) self.done = confirmed(folder, self.source) self.known = ledger._known() + self.mat_names = {x["자재"]: x["이름"] for x in hopyo_match.targets()["자재"]} self.hopyo = next((h for h in self.doc.get("호표") or [] if int(h["번"]) == self.no), None) if self.hopyo is None: raise _bad(f"없는 호표 「제{no}호표」", 404) @@ -197,20 +203,26 @@ class Builder: def build(self, checked: dict[int, dict[str, Any]], whole: list[dict[str, Any]]) -> dict: stamp = datetime.now().isoformat(timespec="seconds") hopyo, file = self.hopyo, self.doc.get("파일") or "" - lines, mats, missing, used = [], {}, [], set() + lines, mats, missing, used, mat_lines = [], {}, [], set(), {} def put(line: dict[str, Any], one: dict[str, Any], alias: str | None) -> None: qty = Decimal(str(line.get("수량") or 0)) * Decimal(str(one["배"])) memo = _memo(line, one) if "자재" in one: - apart = "별도계상" if line.get("갈래") == "별도계상" else "직접계산" have = mats.get(one["자재"]) hand = qty + Decimal(str(have["수량"]["손"])) if have else qty + name = self.mat_names.get(one["자재"], "") mats[one["자재"]] = { "코드": one["자재"], "수량": {"손": _num(hand)}, - "별도계상": apart, + **( + have + and {k: have[k] for k in labor.MARKS if k in have} + or hmat.marks(line, name) + ), } + if not have: + mat_lines[one["자재"]] = line return if "호표" in one: ref = { @@ -261,6 +273,8 @@ class Builder: pairs = [p for p in got["대상"] if "호표" not in p] if pairs: book[ledger.key(line)] = self._row(line, "짝", pairs) + for code in self._inside(lines, mats): # 로직 자재와 같은 품목 = 세 칸만 로직 자재표시로 + missing.append(_missing(mat_lines[code], TWICE)) name = " ".join(x for x in (hopyo["이름"], hopyo.get("규격")) if x) doc = { "이름": name, @@ -275,6 +289,22 @@ class Builder: wrote = ledger.write(self.folder, book, self.who, file or None) return {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}} + def _inside(self, lines: list[dict[str, Any]], mats: dict[str, dict[str, Any]]) -> list[str]: + """로직 자재와 같은 품목(Labor_Doc `no_twice` 400 막음) — 사용자 자재에서 빼고 + 세 칸을 그 로직 줄 `자재표시 {재료 줄 차례: 세 칸}` 으로(수량 = 로직 셈) → 옮긴 코드.""" + whole, moved = m01.loaded()[1], [] + for row in (r for r in lines if r.get("로직")): + got = labor.run_line(whole, row["로직"], row.get("입력값"), None, None) + for m in got.get("재료") or []: + code = (m.get("품목") or {}).get("키") + if code in mats and code not in moved: + marks = {k: mats[code][k] for k in labor.MARKS if k in mats[code]} + row.setdefault("자재표시", {})[str(m["차례"])] = marks + moved.append(code) + for code in moved: + mats.pop(code) + return moved + def _row(self, line: dict[str, Any], kind: str, pairs: list[dict[str, Any]]) -> dict[str, Any]: """장부 한 줄 — 엑셀조항 = 줄 근거 출처(있으면).""" origin = {"품명": line.get("이름"), "규격": line.get("규격"), "단위": line.get("단위")} diff --git a/M02_MasterTemplete/M02_Hopyo_Excel.py b/M02_MasterTemplete/M02_Hopyo_Excel.py index e103d1d07..f1c114f8c 100644 --- a/M02_MasterTemplete/M02_Hopyo_Excel.py +++ b/M02_MasterTemplete/M02_Hopyo_Excel.py @@ -151,6 +151,8 @@ def kind_of(line: dict[str, Any]) -> str: return "%" if ref.get("종류") == "일위": return "호표" + if price.get("재료비") and not price.get("노무비") and not price.get("경비"): + return "자재" # 재료비만 = 자재(「단산N참조」여도 · 358-2) if ref.get("종류") == "단산": return "단가산출" if unit in _MACHINE_UNITS: @@ -159,8 +161,6 @@ def kind_of(line: dict[str, Any]) -> str: return "노임" if not any(price.get(k) for k in ("계", *COSTS)): return "별도계상" - if price.get("재료비") and not price.get("노무비") and not price.get("경비"): - return "자재" return "기타" diff --git a/M02_MasterTemplete/M02_Hopyo_Line.py b/M02_MasterTemplete/M02_Hopyo_Line.py index 651edd4b3..9cee76c6b 100644 --- a/M02_MasterTemplete/M02_Hopyo_Line.py +++ b/M02_MasterTemplete/M02_Hopyo_Line.py @@ -10,9 +10,9 @@ 없음」). 장부 짝이 규격 다름(품명|단위 느슨 열쇠)이면 ⑤ 아님 — 꼬리표 「별칭 · 규격 다름」 · 짝은 후보 끝 덤만. -자재 · 별도계상 = 이름 · 규격 같음 하나면 맞음(체크) · 그 밖 후보. -로직 줄 후보 묶음 표시(358-1) = 이름후보(옛 324 다섯 `Match.name_cands`) · 후보마다 뜻겹침 · 출처같음 · -책 — 차례 · 무게 · 상태는 그대로. +자재 · 별도계상 = 이름 · 규격 같음 하나 · 바탕 이름 · 규격 수 같은 한 품목이면 맞음(358-2). +로직 줄 후보 묶음 표시(358-1) = 이름후보(옛 324 다섯 `Match.name_cands`) · 후보마다 +뜻겹침 · 출처같음 · 책 — 차례 · 무게 · 상태는 그대로. 입력값 출처(칸마다) = 별칭 > 블록(옵션 찾기 값이 규격 읽기와 다름) > 규격(`spec_inputs`) > 자동값. 체크 = 처음 제안일 뿐 — 들어감은 [확인하고 사전조합 만들기](다 창)만. 저장 없음 · 셈은 서버 단독. 장부 = `M02_Hopyo_Ledger`(다 창) · 확인된 사전조합 = `M02_Hopyo_Build.confirmed`(다 창). @@ -29,6 +29,7 @@ from M02_MasterTemplete import M02_Hopyo_Finger as finger from M02_MasterTemplete import M02_Hopyo_Fit as fit from M02_MasterTemplete import M02_Hopyo_Ledger as ledger from M02_MasterTemplete import M02_Hopyo_Match as hm +from M02_MasterTemplete import M02_Hopyo_Material as hmat from M02_MasterTemplete import M02_Hopyo_Option as option from M02_MasterTemplete import M02_Hopyo_Ours as ours from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store @@ -174,21 +175,30 @@ def _names_only(line: dict, ctx: Ctx, why: str, full: bool) -> dict: def _material(line: dict, ctx: Ctx, full: bool) -> dict: - found = hm._exact(line, "자재", ctx.got) - if len(found) == 1: + """자재 · 별도계상 줄(358-2 · `M02_Hopyo_Material`) — 「별도계상」 글 = 꼬리표 · 맞음 = + 이름 · 규격 같음 하나 · 또는 바탕 이름 · 규격 수 같은 한 품목(변형 = 첫째 + 꼬리표).""" + me, apart = hmat.clean(line) + tags = [hmat.APART] if apart else [] + found = hm._exact(me, "자재", ctx.got) + text = "이름 · 규격 같음" + if len(found) != 1: + sure, more = hmat.sure(me) + found, text, tags = [r["one"] for r in sure], "바탕 이름 · 규격 같음", tags + more + if found: one = found[0] conv = hm.factor(line.get("단위"), one["단위"], line.get("규격")) target = {**hm._public(one), "입력값": {}, "못읽음": [], "출처": {}, "환산": conv} target.update(몫=None, 배=conv["배"]) ok = conv["배"] is not None and conv["갈래"] != "추정" - return {"상태": "맞음", "체크": ok, "대상": [target], "후보": [], "글": "이름 · 규격 같음"} - cands = [{**c, "판정": "후보"} for c in hm.candidates(line, "자재", ctx.got, 5)] if full else [] - return { - "상태": "후보" if cands or not full else "없음", - "체크": False, - "대상": [], - "후보": cands, - } + cands = [ + {**hm._public(x), "점수": None, "판정": "맞음" if not at else "후보", "글": text} + for at, x in enumerate(found[:6]) + ] + out = {"상태": "맞음", "체크": ok, "대상": [target], "후보": cands if full else []} + return {**out, "글": text, "꼬리표": tags} + cands = hmat.candidates(me) if full else [] + state = "후보" if cands or not full else "없음" + return {"상태": state, "체크": False, "대상": [], "후보": cands, "꼬리표": tags} def match_line(line: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, Any]: diff --git a/M02_MasterTemplete/M02_Hopyo_Material.py b/M02_MasterTemplete/M02_Hopyo_Material.py new file mode 100644 index 000000000..a3d269972 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Material.py @@ -0,0 +1,251 @@ +"""M02 일위대가 실무자료 자재 · 별도계상 줄 맞춤(PLAN 358-2) — `Line._material` 이 부름. + +줄 다듬기 = 「별도계상」 글은 표시로 뗌(「조약돌(별도계상)」 · 규격 「별도계상」) · m/m = mm. +바탕 이름 = 괄호 · 지역 · 관급/사급 꼬리 뗀 이름(「레미콘(서울)-사급」 → 레미콘). +규격 맞음(0~1) = 정규 규격 같음 1 · 범위(「3.0≤T<4.5」)에 듦 1 · 그 밖 = 줄 규격 수가 +자재 규격 수에 있는 몫(단위 배 1 · 10 · 1000 · 2 % 허용 · 면목 0.03 m = 30 mm) · +줄 괄호 글 낱말(「몸통」)은 자재 이름에 들어야 함. +맞음 = ① 이름 · 규격 같음 하나(`Match._exact`) ② 바탕 이름 · 단위 · 규격 맞음 1 이 한 품목뿐 +(지역 · 관급/사급만 다른 변형 = 한 품목 · 같은 단위 먼저 · 줄 글의 관급/사급 · 지역 먼저 · +없으면 별도계상 줄 = 관급 · 재료비 줄 = 사급 · 지역 없는 것 · M01 차례 → 꼬리표 「못 가림」). +그 밖 = 후보 다섯(변형은 하나로 접고 글에 수). 곳 낱말 = M01 레미콘 이름에서(자료). +가져오기 세 칸(`marks`) = 별도계상(표시 → 별도계상 · 아니면 직접계산) · 자재구분(고른 +자재 이름 · 줄 글) · 운반비(「운반비 미포함 · 별도」 → 미포함). 로직 판정과 무관. +""" + +from __future__ import annotations + +import re +import unicodedata +from functools import lru_cache +from typing import Any + +from M02_MasterTemplete import M02_Hopyo_Match as hm + +mm = hm.mm +APART = "별도계상" +SUPPLY = ("관급", "사급") +NO_REGION = ("전국", "전지역") +REGIONS = ( + *"서울 인천 부산 대구 광주 대전 울산 세종 제주 경기 강원 충북 충남 전북 전남 경북 경남".split(), + *"충청 전라 경상 용인 포천 강릉 원주 창원 경주 구미 김천 안동 포항 광양 목포 전주 공주".split(), + *"보령 서산 아산 천안 제천 청주 충주 춘천 김해 양산 거제 통영 진주 밀양".split(), + *"여주 이천 가평 연천".split(), +) +SKIP_WORDS = frozenset({"조합공통품목"}) +_APART = re.compile(r"\(?\s*별도\s*계상\s*\)?") +_NUM = re.compile(r"\d+(?:\.\d+)?") +_RANGE = re.compile(r"(\d+(?:\.\d+)?)\s*(<=|≤|<)\s*[A-Za-z]\s*(<=|≤|<)\s*(\d+(?:\.\d+)?)") +_TAIL = re.compile(r"[\s,\-]*([가-힣]+)$") +SCALES = (1, 10, 100, 1000, 0.1, 0.01, 0.001) + + +def _nfkc(text: Any) -> str: + return unicodedata.normalize("NFKC", str(text or "")).replace("m/m", "mm") + + +@lru_cache(maxsize=1) +def _places() -> frozenset[str]: + """곳 낱말 — M01 레미콘 이름의 지역 · 시군 낱말(자료에서 · 「홍천군」 · 「미원면」).""" + names = (one["이름"] for one in hm.targets()["자재"] if one["이름"].startswith("레미콘(")) + words = {w for n in names for w in re.findall(r"[가-힣]{2,}", _nfkc(n))} + return frozenset(words - {"레미콘", "조합공통품목", *SUPPLY}) + + +def _region(word: str) -> bool: + return word.startswith(REGIONS) or word in NO_REGION or word in _places() + + +def _paren_words(name: str) -> set[str]: + return {w for g in re.findall(r"\(([^()]*)\)", name) for w in re.findall(r"[가-힣]{2,}", g)} + + +def base(name: Any) -> str: + """바탕 이름 — 괄호 글 · 끝 지역 · 관급/사급 꼬리 뗌(정규화).""" + text = _nfkc(name) + while "(" in text and (cut := re.sub(r"\([^()]*\)", "", text)) != text: + text = cut + while (m := _TAIL.search(text)) and (m.group(1) in SUPPLY or _region(m.group(1))): + if m.start() == 0: + break + text = text[: m.start()] + for w in SUPPLY: + text = text[: -len(w)] if text.endswith(w) and len(text) > len(w) else text + return mm.plain(text) + + +def clean(line: dict[str, Any]) -> tuple[dict[str, Any], bool]: + """(「별도계상」 글 뗀 줄, 별도계상 표시) — 갈래 별도계상 줄도 표시.""" + out, apart = dict(line), line.get("갈래") == APART + for k in ("이름", "규격"): + text = str(line.get(k) or "") + if _APART.search(text): + apart, text = True, _APART.sub("", text).strip(" ,") + out[k] = text + return out, apart + + +def _nums(text: str) -> list[float]: + return [float(x) for x in _NUM.findall(text)] + + +def _ranges(text: str) -> list[tuple[float, bool, float, bool]]: + return [(float(a), op1 != "<", float(b), op2 != "<") for a, op1, op2, b in _RANGE.findall(text)] + + +def _words(text: str) -> frozenset[str]: + return ( + frozenset(w for w in re.findall(r"[가-힣]{2,}", text) if not _region(w) and w not in SUPPLY) + - SKIP_WORDS + ) + + +@lru_cache(maxsize=2) +def _pool(got_id: int) -> tuple[list[dict[str, Any]], dict[str, list[dict[str, Any]]]]: + """자재마다 바탕 이름 · 규격 수 · 범위 · 지역 · 관급/사급 — (목록, {바탕: [자재]}).""" + rows, index = [], {} + for at, one in enumerate(hm.targets()["자재"]): + name, spec = _nfkc(one["이름"]), _nfkc(one["규격"]) + words = set(re.findall(r"[가-힣]+", name)) + row = { + "one": one, + "at": at, + "base": base(name), + "nums": _nums(spec), + "ranges": _ranges(spec), + "spec": mm.plain_spec(spec), + "region": sorted(w for w in words if _region(w) and w not in NO_REGION), + "supply": next((w for w in SUPPLY if w in name), None), + "key": ( + tuple(sorted(_nums(spec))), + mm.plain_unit(one["단위"]), + _words(spec) | _words(" ".join(_paren_words(name))), + ), + } + row["grams"] = hm._grams(row["base"]) + rows.append(row) + index.setdefault(row["base"], []).append(row) + return rows, index + + +def pool() -> tuple[list[dict[str, Any]], dict[str, list[dict[str, Any]]]]: + return _pool(id(hm.targets())) + + +def _near(x: float, y: float) -> bool: + return any(abs(x * s - y) <= 0.02 * max(abs(y), 1e-9) for s in SCALES) + + +def spec_fit(line: dict[str, Any], row: dict[str, Any]) -> float: + """규격 맞음 0~1 — 같음 · 범위에 듦 · 줄 규격 수가 자재 규격 수에 있는 몫.""" + text = _nfkc(f"{line.get('규격') or ''} {' '.join(hm._PAREN.findall(str(line.get('이름'))))}") + if mm.plain_spec(line.get("규격")) == row["spec"]: + return 1.0 + mine = _nums(text) + if row["ranges"]: + inside = any( + (a < x or (lo and a == x)) and (x < b or (hi and x == b)) + for x in mine + for a, lo, b, hi in row["ranges"] + ) + return 1.0 if inside else 0.0 + if not mine or not row["nums"]: + return 0.0 + return sum(any(_near(x, y) for y in row["nums"]) for x in mine) / len(mine) + + +def _wants(line: dict[str, Any]) -> tuple[str | None, list[str]]: + """(관급/사급, 지역 낱말) — 줄 글 · 없으면 별도계상 줄 = 관급 · 재료비 줄 = 사급.""" + text = _nfkc(f"{line.get('이름')} {line.get('규격')} {line.get('비고') or ''}") + words = set(re.findall(r"[가-힣]+", text)) + supply = next((w for w in SUPPLY if w in text), None) + if supply is None: + supply = ( + "관급" if clean(line)[1] else "사급" if (line.get("단가") or {}).get("재료비") else None + ) + return supply, [w for w in words if _region(w) and w not in NO_REGION] + + +def _prefer(rows: list[dict[str, Any]], line: dict[str, Any]) -> list[dict[str, Any]]: + """변형 차례 — 줄 글의 관급/사급 · 지역 먼저 · 지역 없는 것 · 엑셀(M01) 차례.""" + supply, regions = _wants(line) + + def rank(r: dict[str, Any]) -> tuple: + hit = any(w[:2] == x[:2] for w in regions for x in r["region"]) + return ( + supply is not None and r["supply"] not in (None, supply), + bool(regions) and not hit, + bool(r["region"]), + r["at"], + ) + + return sorted(rows, key=rank) + + +def _paren_ok(line: dict[str, Any], row: dict[str, Any]) -> bool: + return _paren_words(_nfkc(line.get("이름"))) <= set(re.findall(r"[가-힣]+", row["one"]["이름"])) + + +def sure(line: dict[str, Any]) -> tuple[list[dict[str, Any]], list[str]]: + """맞음 ② — 바탕 이름 같음 · 단위 · 괄호 낱말 · 규격 맞음 1 이 한 품목 → (변형 차례, 꼬리표) · + 아니면 ([], []).""" + _, index = pool() + rows = [ + r + for r in index.get(base(line.get("이름")), []) + if hm.fits(line.get("단위"), r["one"]["단위"]) and _paren_ok(line, r) + ] + full = [r for r in rows if spec_fit(line, r) == 1.0] + unit = mm.plain_unit(line.get("단위")) + full = [r for r in full if r["key"][1] == unit] or full # 같은 단위 먼저(kg ↔ Ton 둘 다면) + if not full or len({r["key"] for r in full}) > 1: + return [], [] + full = _prefer(full, line) + tags = [f"지역 · 관급/사급 못 가림 — {len(full)}개 중 첫째"] if len(full) > 1 else [] + return full, tags + + +def candidates(line: dict[str, Any], limit: int = 5) -> list[dict[str, Any]]: + """자재 후보 — 바탕 이름 글자 묶음 + 바탕 같음 1 + 단위 0.15 + 규격 맞음 0.5 + 괄호 낱말 0.2 · + 뜻 낱말 · 규격 수 둘 다 안 닮으면 뺌 · 변형(지역 · 관급/사급)은 하나로 접음(글 = 변형 수).""" + rows, _ = pool() + me = base(line.get("이름")) + grams, mine = hm._grams(me), hm.words(line.get("이름")) + parens = _paren_words(_nfkc(line.get("이름"))) + scored: dict[tuple, list] = {} + for r in rows: + if not grams & r["grams"]: + continue + fit = spec_fit(line, r) + if not hm.near(mine, r["one"]["words"]) and fit < 0.5: + continue + score = 2 * len(grams & r["grams"]) / (len(grams) + len(r["grams"])) + score += (r["base"] == me) + 0.15 * hm.fits(line.get("단위"), r["one"]["단위"]) + score += 0.5 * fit + (0.2 if parens and _paren_ok(line, r) else 0) + fold = (r["base"], *r["key"]) + have = scored.setdefault(fold, [score, []]) + have[0] = max(have[0], score) + have[1].append(r) + best = sorted(scored.values(), key=lambda x: (-x[0], x[1][0]["at"]))[:limit] + out = [] + for score, group in best: + first = _prefer(group, line)[0] + one = {**hm._public(first["one"]), "점수": round(score, 3), "판정": "후보"} + out.append( + {**one, "글": f"변형 {len(group)}개 — 지역 · 관급/사급"} if len(group) > 1 else one + ) + return out + + +def marks(line: dict[str, Any], name: str = "") -> dict[str, str]: + """가져오기 세 칸(Labor_Doc `MARKS` 값) — 별도계상 · 자재구분(고른 자재 `name` → + 줄 글 → 줄 갈래) · 운반비(모르면 칸 없음 = 기본).""" + _, apart = clean(line) + out = {"별도계상": APART if apart else "직접계산"} + supply = next((w for w in SUPPLY if w in str(name)), None) or _wants(line)[0] + if supply: + out["자재구분"] = supply + text = re.sub(r"\s", "", _nfkc(f"{line.get('규격')} {line.get('비고') or ''}")) + if "운반비미포함" in text or "운반비별도" in text: + out["운반비"] = "미포함" + return out diff --git a/M02_MasterTemplete/M02_Hopyo_Stmate.py b/M02_MasterTemplete/M02_Hopyo_Stmate.py index 5d3a6cdd8..e81ea07ad 100644 --- a/M02_MasterTemplete/M02_Hopyo_Stmate.py +++ b/M02_MasterTemplete/M02_Hopyo_Stmate.py @@ -168,9 +168,12 @@ def _blocks(rows: list[list[Any]], table: dict[str, dict[str, str]]) -> dict[str def _kind(code: str, line: dict[str, Any]) -> str: if code.startswith("PR") or _flat(line["단위"]) == "%": return "%" + price = line["단가"] if code[0] == "M": - apart = line["비고"].startswith("별산") or line["규격"] == "별도계상" - return "별도계상" if apart or not any(line["단가"].values()) else "자재" + apart = line["비고"].startswith("별산") or "별도계상" in f"{line['규격']}{line['이름']}" + return "별도계상" if apart or not any(price.values()) else "자재" + if code[0] == "D" and price.get("재료비") and not price.get("노무비") and not price.get("경비"): + return "자재" # 재료비만 = 자재(358-2 · 엑셀꼴과 같음) return _KIND.get(code[0], "기타") diff --git a/resources/tester/test_m02_hopyo_build.py b/resources/tester/test_m02_hopyo_build.py index 06b15d287..132f61375 100644 --- a/resources/tester/test_m02_hopyo_build.py +++ b/resources/tester/test_m02_hopyo_build.py @@ -123,6 +123,29 @@ def test_로직_여럿_같은_별칭_감쌈_자재_몫_덮음(client): assert sorted(p.name for p in (mine / "precombo").glob("*.json")) == files +def test_자재_세_칸_로직_자재_두_번_막음(client): + """358-2 — 별도계상 줄 자재 = 세 칸(별도계상 · 관급) · 로직 자재와 같은 품목 = 400 아니고 + 사용자 자재에서 빼고 그 로직 `자재표시` 로 + 빠진 줄 까닭.""" + assert _post(client).status_code == 200 + apart = {"차례": 0, "대상": [{"자재": "MT004258", "배": 1}]} # 레미콘(25-21-120) 별도계상 줄 + got = _build(client, 1, [apart]) + mine = layers.personal_dir(7, 42) + mats = _doc(mine, got["참조"]["키"])["자재"] + assert [{k: m.get(k) for k in ("코드", "별도계상", "자재구분", "운반비")} for m in mats] == [ + {"코드": "MT004258", "별도계상": "별도계상", "자재구분": "사급", "운반비": None} + ] # 자재구분 = 고른 자재 이름(-사급) 먼저 + twice = [ + {"차례": 1, "대상": [{"로직": "GC000207", "배": 1}]}, # 철근 현장조립 = 로직 자재 결속선 + {"차례": 0, "대상": [{"자재": "MT000209", "배": 1}]}, # 같은 결속선을 별도계상 줄에 + ] + got = _build(client, 1, twice) + doc = _doc(mine, got["참조"]["키"]) + assert doc["자재"] == [] + line = next(x for x in doc["담은로직"] if x.get("로직") == "GC000207") + assert {"별도계상": "별도계상", "자재구분": "관급"} in line["자재표시"].values() + assert any(m["까닭"] == hb.TWICE and m["차례"] == 0 for m in got["빠진줄"]) + + def test_몸_없음_재검사_400(client): assert _post(client).status_code == 200 one = {"로직": "GF000101", "배": 1} diff --git a/resources/tester/test_m02_hopyo_flow.py b/resources/tester/test_m02_hopyo_flow.py index a65f991c9..fc0643847 100644 --- a/resources/tester/test_m02_hopyo_flow.py +++ b/resources/tester/test_m02_hopyo_flow.py @@ -82,7 +82,8 @@ def test_끝까지_확인한_줄만_별칭(client): assert "굴착기 0.29 → 0.2909 ✓" in remicon["compare"] assert "소량감률=50(블록)" in remicon["inputs"] and remicon["target"].endswith("[㎥]") states = {x["상태"] for x in screen["lines"]} - assert {"맞음", "다른 점", "후보"} <= states + assert {"맞음", "다른 점"} <= states # 358-2 — 레미콘 · 면목 자재 줄도 맞음(후보 남지 않음) + assert seen["면목"]["상태"] == "맞음" and seen["면목"]["checked"] body = screen["body"] # 화면이 만든 몸 = 서버 체크 제안 줄만 suggested = [x["차례"] for x in hopyo["줄"] if x["맞춤"]["체크"]] assert [x["차례"] for x in body] == suggested and screen["leftOut"]["확인 안 함"] > 0 diff --git a/resources/tester/test_m02_hopyo_match.py b/resources/tester/test_m02_hopyo_match.py index adc1a1ba7..761e25592 100644 --- a/resources/tester/test_m02_hopyo_match.py +++ b/resources/tester/test_m02_hopyo_match.py @@ -115,9 +115,11 @@ def test_새_상태_체크_제안(client): assert remicon["품"]["같음"]["콘크리트공"] and remicon["품"]["엑셀"]["인력"]["콘크리트공"] plywood = first["합판거푸집"] assert plywood["상태"] == "맞음" and set(plywood["갈리는칸"]) == {"측압", "폼타이규격"} - mat = first["레미콘(25-21-120)"] - assert mat["상태"] == "후보" and mat["체크"] is False - assert all(c["규격"] == "25-21-120" for c in mat["후보"][:3]) # 규격 같은 자재 먼저 + mat = first["레미콘(25-21-120)"] # 358-2 — 「별도계상」 글 = 표시 · 지역 변형 = 첫째 + 꼬리표 + assert mat["상태"] == "맞음" and mat["체크"] is True and "별도계상" in mat["꼬리표"] + assert mat["대상"][0]["규격"].endswith("25-21-120") and "레미콘" in mat["대상"][0]["이름"] + assert any(t.startswith("지역 · 관급/사급 못 가림") for t in mat["꼬리표"]) + assert all(c["규격"].endswith("25-21-120") for c in mat["후보"]) # 변형 = 고를 수 있게 dig = _hopyo(client, 3)["구조물터파기(인력100%)"] assert dig["상태"] == "다른 점" and dig["체크"] is False assert dig["대상"][0]["로직"] == "GC000083" and len(dig["후보"]) == 5 @@ -268,3 +270,60 @@ def test_이름후보_뜻겹침_출처_표시(client): assert [c["로직"] for c in dig["후보"]][0] == "GC000083" # 차례 · 판정 그대로(표시만) mat = _hopyo(client, 1)["레미콘(25-21-120)"] assert "이름후보" not in mat # 자재 줄은 로직 이름후보 없음 + + +# ── 자재 · 별도계상 줄(358-2 · `M02_Hopyo_Material`) ───────────────────────── +MATERIAL_ANSWER = { # 북부청 임도 자재 · 별도계상 줄 → 우리 자재(정답 표 · 사람 확인) + ("면목", "0.03×0.03×0.0424"): "MT007567", # 면 목 42, 30×30(0.03 m = 30 mm) + ("자물쇠", "50m/m"): "MT008011", + ("열연강판", "3.0mm"): "MT000884", # 3.0≤t<4.5 kg(같은 단위 먼저) + ("원형돌망태(몸통)", "#8,45×45"): "MT006349", + ("원형돌망태(뚜껑)", "#8,45×45"): "MT006350", +} + + +def test_자재_다듬기_규격_수(): + from M02_MasterTemplete import M02_Hopyo_Material as hmat + + line, apart = hmat.clean({"이름": "조약돌(별도계상)", "규격": "Ø100~200mm내외", "갈래": "자재"}) + assert apart and line["이름"] == "조약돌" + assert hmat.clean({"이름": "레미콘(25-21-120)", "규격": "별도계상"})[0]["규격"] == "" + assert hmat.base("레미콘(관급)-강원도(홍천군(내면 제외))") == hmat.base("레미콘(서울)-사급") + assert ( + hmat.base("와이어메쉬(용접철망)관급") + == hmat.base("와이어메쉬(용접철망),서울") + == "와이어메쉬" + ) + row = {"spec": "", "nums": [42.0, 30.0, 30.0], "ranges": []} + assert hmat.spec_fit({"이름": "면목", "규격": "0.03×0.03×0.0424"}, row) == 1.0 + rng = {"spec": "", "nums": [], "ranges": hmat._ranges("2.0≤T<3.0")} + assert hmat.spec_fit({"이름": "열연강판", "규격": "3.0mm"}, rng) == 0.0 # 끝 3.0 은 안 듦 + got = hmat.marks( + {"이름": "레미콘", "규격": "별도계상", "갈래": "별도계상", "비고": "운반비 별도"} + ) + assert got == {"별도계상": "별도계상", "자재구분": "관급", "운반비": "미포함"} + assert hmat.marks({"이름": "면목", "단가": {"재료비": 360}}, "면 목") == { + "별도계상": "직접계산", + "자재구분": "사급", + } + + +def test_북부청_자재_별도계상_맞음_늚(client): + _ready(client) + seen, sure = {}, 0 + for no in range(1, 69): + res = client.get(f"{URL.format('personal')}/HP-000001/{no}") + if res.status_code != 200: + continue + for one in res.json()["호표"]["줄"]: + key = (one["이름"], one["규격"]) + if one["갈래"] in ("자재", "별도계상") and key not in seen: + seen[key] = one["맞춤"] + sure = sum(m["상태"] == "맞음" for m in seen.values()) + assert len(seen) == 30 and sure >= 8 # 옛 2 → 8(면목 · 레미콘 둘 · 와이어메쉬 · 열연강판 …) + for key, code in MATERIAL_ANSWER.items(): + assert seen[key]["상태"] == "맞음" and seen[key]["대상"][0]["자재"] == code, key + pipe = seen[("탄소강관(백)", "D=125mm(외경139.8mm)")] # 이름 달라도 규격 수로 후보 + assert pipe["상태"] == "후보" and "139.8" in pipe["후보"][0]["규격"] + mortar = seen[("드라이모르터", "조적용레미탈")] # 이름 오타 · 규격 같음 = 1위 후보 + assert mortar["후보"][0]["자재"] == "MT004251"