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_359_3_b05_drainage_btn_noun.py b/resources/tester/test_359_3_b05_drainage_btn_noun.py new file mode 100644 index 000000000..2f2e29e60 --- /dev/null +++ b/resources/tester/test_359_3_b05_drainage_btn_noun.py @@ -0,0 +1,13 @@ +"""359-3 덤(요구 235-27) — B05 배수유역 버튼 글 명사형.""" + +from pathlib import Path + +LOCALE = Path(__file__).resolve().parents[2] / "ui_template" / "ui_template_locale_b2.ts" + + +def test_drainage_buttons_are_nouns(): + text = LOCALE.read_text(encoding="utf-8") + assert 'B05_Drainage_Btn_Analyze: ["유역 재분할",' in text + assert 'B05_Drainage_Btn_DeleteSelected: ["관 삭제",' in text + assert "유역 다시 나누기" not in text + assert "선택한 관 삭제" not in text 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" diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 69df77e87..677cdb4b9 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -32,12 +32,12 @@ export const ui_locales_b2 = { ], // 버튼 이름만으로 무엇이 바뀌는지 알기 어려워 동작을 이름·툴팁에 함께 적는다 // (2026-08-19 사용자 지시 4). - B05_Drainage_Btn_Analyze: ["유역 다시 나누기", "Recompute sub-basins"], + B05_Drainage_Btn_Analyze: ["유역 재분할", "Recompute sub-basins"], B05_Drainage_Btn_Analyze_Tip: [ "B04에서 분석해 둔 배수유역을 불러와, 지금 배치된 배관을 기준으로 세부유역(관이 담당하는 구역)을 다시 나눕니다. 관이 부족한 구간은 자동으로 보충합니다. B04 분석 결과가 없으면 B04에서 먼저 실행해야 합니다.", "Reloads the B04 drainage analysis and re-splits sub-basins around the current culverts, adding culverts where spacing requires. Run the analysis in B04 first if none exists.", ], - B05_Drainage_Btn_DeleteSelected: ["선택한 관 삭제", "Delete selected culvert"], + B05_Drainage_Btn_DeleteSelected: ["관 삭제", "Delete selected culvert"], B05_Drainage_Btn_DeleteSelected_Tip: [ "지도에서 고른 배관 한 개를 지웁니다. 관을 먼저 눌러 고른 뒤에 쓸 수 있습니다.", "Removes the culvert selected on the map. Select a culvert marker first.",