From 92fd88e94c89e92935dc1c6103e9be055ab279e1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 15 Nov 2026 16:19:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(840-3):=20=EB=B6=88=EB=9F=AC=EC=98=A4?= =?UTF-8?q?=EA=B8=B0=20=EC=9E=AC=EB=A3=8C=20=EB=8B=A8=EC=9C=84=20=EC=85=8B?= =?UTF-8?q?=20=E2=80=94=20=EA=B0=99=EC=9D=80=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=EC=8C=8D=EB=91=A5=EC=9D=B4=20=EB=A8=BC=EC=A0=80=20=C2=B7=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EC=84=B1=EA=B2=A9=20=ED=99=98=EC=82=B0=20?= =?UTF-8?q?=C2=B7=20=EC=84=B1=EA=B2=A9=20=EB=8B=A4=EB=A5=B4=EB=A9=B4=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=BC=20=EC=9E=AC=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 찾기: 줄 단위를 별칭 · 정확 일치 고르기까지 넘김 · 이은 판정마다 단위 맞춤(MatFind `_by_unit` · 코드 길 포함) · 이름 · 규격 같은 쌍둥이 중 같은 단위 줄 › 같은 성격 = 이음 그대로 › 성격 다름 = 판정 「단위 다름 — 고르기」(까닭 「단위 다름 ㎡ ↔ 매」) - 값: 자재품목 바로 이음 줄 = 셈이 줄 단위로 환산 · 표시 「단위 환산 톤 → kg」 · 성격 다름 = 경고(master_pick 한 벌) - 문서 연결(호표 길): 자재 이음에 줄 단위 · 사람이 고른 줄도 단위 글(설명) - 시험 827-3 고침: 고른 줄 연결에 줄 단위 칸이 붙음(840-3 설계) - 전수(네 원천 · 산근 + 일위): 단위 어긋난 48 줄 = 같은 단위 줄 5 · 고르기 37 · 1:1 그대로 6 · 단위 같은 줄 이음 · 값 전후 같음 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Doc_Import.py | 17 +++++ M02_MasterTemplete/M02_Hopyo_Row.py | 14 ++-- M02_MasterTemplete/M02_San_Link.py | 5 +- M02_MasterTemplete/M02_San_MatFind.py | 37 +++++++++- M02_MasterTemplete/M02_San_MatPick.py | 1 + resources/master_data/scripts/master_pick.py | 18 ++++- resources/tester/test_827_3_material_pick.py | 3 +- resources/tester/test_840_3_material_unit.py | 74 ++++++++++++++++++++ 8 files changed, 157 insertions(+), 12 deletions(-) create mode 100644 resources/tester/test_840_3_material_unit.py diff --git a/M02_MasterTemplete/M02_Doc_Import.py b/M02_MasterTemplete/M02_Doc_Import.py index 921b5a796..0db7ddac7 100644 --- a/M02_MasterTemplete/M02_Doc_Import.py +++ b/M02_MasterTemplete/M02_Doc_Import.py @@ -257,9 +257,26 @@ def _link_of(item: dict) -> tuple[dict | None, str]: link = {"갈래": "요소", "키": key} if key[:2] == dc.cv.mm.LINKED: # 품셈재료 = 줄 단위로 값이 갈림(M01 변환과 같음) link["단위"] = str(item.get("단위") or "") + if key[:2] == dc.cv.mm.MARKET and (unit := str(item.get("단위") or "")): + link["단위"] = unit # 840-3 자재품목 = 셈이 줄 단위로 환산(`master_pick.priced`) + return link, _unit_note(key, unit) return link, "" +def _unit_note(key: str, unit: str) -> str: + """840-3 자재 단위 ↔ 줄 단위 글 — 같음 · 1 : 1 = 빈 글 · 같은 성격 = 환산 · 성격 다름 = 경고(사람이 고른 줄도).""" + mu = dc.cv.mm.mu + try: + have = str(imp._whole().get(key).get("단위") or "") + except Exception: # noqa: BLE001 — 없는 키 = 셈이 끊김 표지 + return "" + if not have or mu.factor(have, unit) == 1: + return "" + if mu.same_kind(have, unit): + return f"단위 환산 {have} → {unit}" + return f"단위 다름 {unit} ↔ {have} — 값은 자재 단위 그대로" + + OPTION = "_옵션" # 연결이 부르는 옵션 변수(문서에 없으면 `_attach` 가 더함) diff --git a/M02_MasterTemplete/M02_Hopyo_Row.py b/M02_MasterTemplete/M02_Hopyo_Row.py index 9f4f24a20..fe40da924 100644 --- a/M02_MasterTemplete/M02_Hopyo_Row.py +++ b/M02_MasterTemplete/M02_Hopyo_Row.py @@ -77,7 +77,7 @@ def m01_picks(kind: str, line: dict[str, Any]) -> list[dict[str, Any]]: @lru_cache(maxsize=4096) def _m01_picks(kind: str, name: str, spec: str, unit: str, _whole: int) -> list[dict[str, Any]]: - if kind in ("인력", "재료") and (named := _alias(kind, name, spec)): + if kind in ("인력", "재료") and (named := _alias(kind, name, spec, unit)): return named # 815-1 K2 — M01 줄 「별칭」 칸 먼저 if kind == "인력": trade = est.trade(name) @@ -130,17 +130,21 @@ def _found(line: dict[str, Any]) -> list[dict[str, Any]]: return out -def _alias(kind: str, name: str, spec: str) -> list[dict[str, Any]]: +def _alias(kind: str, name: str, spec: str, unit: str = "") -> list[dict[str, Any]]: """M01 별칭 칸 이음(`M02_Elem_Alias`) — 인력 = 이름 · 재료 = 이름 + 규격 › 표기 정규화 정확 일치 · - 같은 품목 지역 줄은 하나.""" + 같은 품목 지역 줄은 하나(840-3 줄 단위 넘김 = 같은 단위 줄 먼저).""" from M02_MasterTemplete import M02_Elem_Alias as ea table = "LB" if kind == "인력" else ea.MARKET keys = ea.keys(table, name, "" if kind == "인력" else spec) if kind == "재료" and len(keys) > 1: - keys = [k] if (k := ea.one({"이름": name, "규격": spec}, keys)) else keys + keys = [k] if (k := ea.one({"이름": name, "규격": spec, "단위": unit}, keys)) else keys if kind == "재료" and not keys: # 815-1 K3 — 표기 정규화 정확 일치(바탕 이름 길보다 먼저) - keys = [k] if (k := ea.one({"이름": name, "규격": spec}, ea.exact(name, spec))) else [] + keys = ( + [k] + if (k := ea.one({"이름": name, "규격": spec, "단위": unit}, ea.exact(name, spec))) + else [] + ) rows = [(k, ea.row(table, k)) for k in keys] if kind == "인력": return [pick("인력", k, r.get("이름"), "", "인", 단가=_plain(r.get("값"))) for k, r in rows] diff --git a/M02_MasterTemplete/M02_San_Link.py b/M02_MasterTemplete/M02_San_Link.py index 31d7fc0cb..3ca5c670f 100644 --- a/M02_MasterTemplete/M02_San_Link.py +++ b/M02_MasterTemplete/M02_San_Link.py @@ -44,7 +44,7 @@ def m01(kind: str, src: dict, label: str = "") -> tuple[str | None, str | None]: return got["키"], mfind.mark(got, line["이름"], line["규격"]) if kind == "재료" and src.get("종류") == "자재" and (hit := ea.est_code(src.get("코드"))): key = ea.one(line, hit) or hit[0] # 815-1 K1 — EST 원파일 자재 = 우리 자재 출처 코드 - return mf.done(key, "코드", src)["키"], None + return mf.done(key, "코드", src, line["단위"])["키"], None # 840-3 단위 맞춤 oil = ea.keys("MO", line["이름"], line["규격"]) if kind == "재료" else [] if kind == "재료": if oil or elem.oil_rows(line["이름"]): @@ -183,6 +183,9 @@ def why(item: dict) -> str: return mp.apart_note(res.get("재료찾기") or {}) if elem.oil_rows(name): return "유가 후보 여럿 — 고름" + found = res.get("재료찾기") or {} + if found.get("판정") == "단위 다름 — 고르기": # 840-3 `M02_San_MatFind.UNIT` + return f"{found.get('방법')} — 고름" line = {"이름": name, "규격": res.get("규격") or "", "단위": res.get("단위") or ""} if picks := hrow.m01_picks("재료", line): # 817-1 ㉯ 1 순위를 글에(고르기 창은 이름 찾기) return f"자재 후보 여럿 — 고름 · 1 순위 {picks[0]['이름']} {picks[0]['규격']}".strip() diff --git a/M02_MasterTemplete/M02_San_MatFind.py b/M02_MasterTemplete/M02_San_MatFind.py index ce8d57575..f89376c6c 100644 --- a/M02_MasterTemplete/M02_San_MatFind.py +++ b/M02_MasterTemplete/M02_San_MatFind.py @@ -9,6 +9,9 @@ - 「규격 없음 — 고르기」 — 가져온 규격 빔 · 별도계상 · 바탕 이름 같은 후보 있음 - 「후보 여럿」 — 규격은 있는데 하나로 못 정함 - 「우리 표에 없음」 — 바탕 이름 같은 후보 없음 · 규격 수가 하나도 안 닮음(후보 = 닮은 줄 · 참고) +- 「단위 다름 — 고르기」 — 이은 우리 줄 단위가 줄 단위와 성격 다름(840-3 · 방법 = 「단위 다름 ㎡ ↔ 매」 · 후보 첫째 = 그 줄) +단위 맞춤(840-3 `_by_unit` · 이은 판정 모두) = ㉠ 이름 · 규격 같은 쌍둥이 중 줄과 같은 단위 줄 › ㉡ 같은 성격(kg ↔ 톤 등) += 그대로 이음 · 값은 셈이 줄 단위로 환산(`master_pick.priced`) · ㉢ 성격 다름 = 이음 풂. 후보 = [{키, 이름, 규격, 단위, 단서?}] 차례 = 단서로 좁힌 것 › 이름 같은 묶음 › 옛 후보. 별도계상 줄(요구 811-2 — 품목은 정해짐 · 셈만 프로젝트에서) = 같은 찾기 · 키는 판정에만(줄 이음 · 단가 0 그대로). @@ -35,6 +38,7 @@ from M02_MasterTemplete import M02_Hopyo_Material as hmat from M02_MasterTemplete import M02_Hopyo_MatSame as ms LINKED, NO_SPEC, MANY, NONE = "이어짐", "규격 없음 — 고르기", "후보 여럿", "우리 표에 없음" +UNIT = "단위 다름 — 고르기" TOP = 8 _CTX: ContextVar[dict] = ContextVar("재료단서", default={}) _LEAD = re.compile(r"^[\s\-·\d.)]+") @@ -126,16 +130,16 @@ def find( code = str((src or {}).get("코드") or "") me = ms._item(text[0], text[1]) keys = tuple(sorted(k for k in keys if k)) - got = dict(_find(*text, keys, _texts(me, code, label), apart, id(hm.targets()))) + got = _by_unit(line, dict(_find(*text, keys, _texts(me, code, label), apart, id(hm.targets())))) got |= {"후보": [dict(x) for x in got["후보"]], "단서": list(got["단서"])} if src is not None: src["재료찾기"] = got return got -def done(key: str, how: str, src: dict | None = None) -> dict: +def done(key: str, how: str, src: dict | None = None, unit: str = "") -> dict: """이미 정한 이음(코드 길) 판정.""" - got = {"판정": LINKED, "방법": how, "키": key, "후보": [], "단서": []} + got = _by_unit({"단위": unit}, {"판정": LINKED, "방법": how, "키": key, "후보": [], "단서": []}) if src is not None: src["재료찾기"] = got return got @@ -169,6 +173,33 @@ def _find( return _out(verdict, "", None, cands, used) +def _by_unit(line: dict, got: dict) -> dict: + """이은 판정 단위 맞춤(840-3) — ㉠ 같은 단위 쌍둥이 › ㉡ 같은 성격 = 그대로 › ㉢ 성격 다름 = 고르기.""" + from M02_MasterTemplete import M02_Elem_Alias as ea + + key, want = got.get("키"), str(line.get("단위") or "") + if got["판정"] != LINKED or not key or not want: + return got + row = ea.row(ea.MARKET, key) + have, plain = str(row.get("단위") or ""), hm.mm.plain_unit + if not have or plain(have) == plain(want): + return got + twins = [k for k in ea.exact(row.get("이름"), row.get("규격")) if plain(ea.row(ea.MARKET, k).get("단위")) == plain(want)] # fmt: skip + if twin := ea.one({"이름": line.get("이름") or row.get("이름"), "규격": line.get("규격") or row.get("규격"), "단위": want}, twins): # fmt: skip + return {**got, "키": twin, "방법": f"{got['방법']} · 같은 단위 줄"} + if hm.mp.same_kind(have, want): + return got # 값 = 셈이 줄 단위로 환산 · 표시도 셈(`단위경고`) + first = dict(_pub({**row, "자재": key}, "")) + rest = [dict(c) for c in got["후보"] if dict(c).get("키") != key] + return { + **got, + "판정": UNIT, + "방법": f"단위 다름 {want} ↔ {have}", + "키": None, + "후보": [first, *rest], + } + + def _alike(me: dict, x: dict) -> bool: """이름 같은 우리 줄이 이 물건일 수 있음 — 우리 규격 빔 · 가져온 수 없음 · 수 하나라도 같음(BUTTON BIT Ø38 ↔ 105mm = 아님).""" return ( diff --git a/M02_MasterTemplete/M02_San_MatPick.py b/M02_MasterTemplete/M02_San_MatPick.py index 8de03be7b..2b03c7dbc 100644 --- a/M02_MasterTemplete/M02_San_MatPick.py +++ b/M02_MasterTemplete/M02_San_MatPick.py @@ -31,6 +31,7 @@ DIRNAME = "재료고름" CENSUS = "재료판" KINDS = ( "규격 없음 — 고르기", + "단위 다름 — 고르기", # 840-3 "후보 여럿", "우리 표에 없음", "이어짐", diff --git a/resources/master_data/scripts/master_pick.py b/resources/master_data/scripts/master_pick.py index cb1db3e8a..ffc906650 100644 --- a/resources/master_data/scripts/master_pick.py +++ b/resources/master_data/scripts/master_pick.py @@ -12,7 +12,7 @@ from contextvars import ContextVar from decimal import Decimal import master_material as mm -from master_unit import UNITS, convert, kind_of, same_kind # noqa: F401 — 단위 한 벌(712-2) +from master_unit import UNITS, convert, factor, kind_of, same_kind # noqa: F401 — 단위 한 벌(712-2) REGIONS = ( "전국평균 전국 서울 경기 강원 충북 충남 전북 전남 경북 경남 부산 대구 인천 대전 울산 세종 제주 광주" @@ -163,7 +163,9 @@ def _priced(master, item: dict, ref, env: dict, index: int | None): price = convert(price, have, unit) if have and unit else price else: price, source = mm.element(master, ref, env, unit) - warn = None + warn = _direct(master, ref, unit) + if warn and warn.startswith(SWAP): # 840-3 자재품목 바로 이음 = 줄 단위로 환산 + price = convert(price, master.get(ref).get("단위"), unit) except mm.mf.FormulaError as e: return None, None, str(e), None why = None if price is not None else f"「{master.label(ref)}」 값 없음 — 관리자가 채울 값" @@ -171,6 +173,18 @@ def _priced(master, item: dict, ref, env: dict, index: int | None): HAND = "손 단가" +SWAP = "단위 환산" + + +def _direct(master, ref, unit) -> str | None: + """자재품목 바로 이음 줄(불러오기 · 840-3) 단위 글 — 같음 · 1 : 1 = None · 같은 성격 = 「단위 환산 톤 → kg」 · + 성격 다름 = 검색어 길과 같은 경고(값 그대로).""" + if not unit or not isinstance(ref, str) or ref[:2] != mm.MARKET: + return None + have = master.get(ref).get("단위") + if not have or factor(have, unit) == 1: + return None + return f"{SWAP} {have} → {unit}" if same_kind(have, unit) else f"줄 {unit} · 자재 {have}" def bundled(item: dict, base): diff --git a/resources/tester/test_827_3_material_pick.py b/resources/tester/test_827_3_material_pick.py index 9a1d7a8a2..43e2ce09a 100644 --- a/resources/tester/test_827_3_material_pick.py +++ b/resources/tester/test_827_3_material_pick.py @@ -83,7 +83,8 @@ def test_pick_whole_file(world) -> None: conv = dim.derive(doc, None, imp._whole()) e = _entry(conv, WIRE) assert e["고름"]["키"] == pick["키"] and e["처음"]["갈래"] == "숫자" - assert _var(conv, e["기호"])["연결"] == {"갈래": "요소", "키": pick["키"]} # 우리 값 + got = _var(conv, e["기호"])["연결"] # 우리 값(엑셀 기억 없음) · 840-3 줄 단위 실음 + assert got == {"갈래": "요소", "키": pick["키"], "단위": got["단위"]} assert sorted(mp.made(me, fp)) == sorted(made) assert mp.uses(fp, WIRE, (made[0],)) == [made[1]] # 다른 파일 = 영향 0 diff --git a/resources/tester/test_840_3_material_unit.py b/resources/tester/test_840_3_material_unit.py new file mode 100644 index 000000000..4d3610ebf --- /dev/null +++ b/resources/tester/test_840_3_material_unit.py @@ -0,0 +1,74 @@ +"""840-3 불러오기 재료 단위 — ㉠ 같은 이름 · 규격 쌍둥이 중 경쟁사 줄과 같은 단위 줄 먼저 · ㉡ 같은 성격 +단위는 이은 뒤 셈이 줄 단위로 환산(표시 = `단위경고` 「단위 환산 톤 → kg」) · ㉢ 성격 다르면 잇지 않고 고르기(요구 860 · 862). + +판정 = `M02_San_MatFind`(`M02_San_Link.m01`) · 값 = `master_pick.priced`(자재품목 바로 이음) · 문서 연결 = `M02_Doc_Import._link_of`. +""" + +import sys +from decimal import Decimal +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path[:0] = [str(ROOT), str(ROOT / "resources" / "master_data" / "scripts")] + +import master_material as mm # noqa: E402 +import master_pick as mp # noqa: E402 + +from M01_MasterData import M01_MasterData_Store as m01 # noqa: E402 +from M02_MasterTemplete import M02_Doc_Import as dim # noqa: E402 +from M02_MasterTemplete import M02_San_Link as sl # noqa: E402 +from M02_MasterTemplete import M02_San_MatFind as mf # noqa: E402 + + +def _go(name, spec, unit, code="", kind=""): + src = {"이름": name, "규격": spec, "단위": unit, "갈래": "재료", "코드": code, "종류": kind} + key, _mark = sl.m01("재료", src) + return key, src["재료찾기"] + + +def test_twin_same_unit_first(): + """잔디 ㎡ 줄 — 매 줄(MT006796)이 아니라 ㎡ 쌍둥이(MT006799).""" + key, found = _go("천연잔디(한국형)", "0.18×0.18×0.01", "㎡") + assert key == "MT006799" and found["판정"] == mf.LINKED + key, _ = _go("천연잔디(한국형)", "0.18×0.18×0.01", "매") + assert key == "MT006796" + + +def test_unit_kind_differs_goes_to_pick(): + """골재시험 회 ↔ 자재 kg · 각재 ㎥ ↔ 재 = 이음 없음 · 판정 고르기 · 까닭 글 · 후보에 그 줄.""" + for name, spec, unit, was in ( + ("골재시험", "0.08mm체 통과량시험", "회", "MT009890"), + ("각재(외송)", "3.6×3.0×3.0", "㎥", "MT000393"), + ): + key, found = _go(name, spec, unit) + assert key is None and found["판정"] == mf.UNIT + assert found["방법"].startswith(f"단위 다름 {unit} ↔ ") + assert was in {c["키"] for c in found["후보"]} + + +def test_same_meaning_units_stay_linked(): + """본 ↔ EA · 개소 ↔ 개(개수 1 : 1) = 같은 단위로 봄 — 이음 그대로 · 표시 없음.""" + key, found = _go("시스템동바리(수직재)", "D60.5×2.6T P1725", "본") + assert key == "MT000512" and found["판정"] == mf.LINKED and "단위" not in found["방법"] + + +def test_ton_to_kg_price(): + """자재 Ton 줄을 kg 줄에 이음 → 단가 ÷ 1,000 · 표시 「단위 환산 Ton → kg」 · 성격 다름 = 값 그대로 + 경고.""" + whole = m01.loaded()[1] + base, _ = mm._lowest(whole, "MT000811") + price, _src, _why, extra = mp.priced(whole, {"단위": "kg"}, "MT000811", {}, None) + assert price == base * Decimal(1000) / Decimal(1000000) + assert extra["단위경고"] == "단위 환산 Ton → kg" + price, *_rest, extra = mp.priced(whole, {"단위": "㎡"}, "MT000811", {}, None) + assert price == base and extra["단위경고"] == "줄 ㎡ · 자재 Ton" + price, *_rest, extra = mp.priced(whole, {"단위": "ton"}, "MT000811", {}, None) + assert price == base and "단위경고" not in extra + + +def test_doc_link_carries_unit(): + """문서 연결(호표 길) — 자재 이음에 줄 단위 · 사람이 고른 줄도 성격 다르면 설명에 경고.""" + link, note = dim._link_of({"이음": "MT000811", "단위": "kg"}) + assert link == {"갈래": "요소", "키": "MT000811", "단위": "kg"} and note == "단위 환산 Ton → kg" + link, note = dim._link_of({"이음": "MT000811", "단위": "㎡"}) + assert link["단위"] == "㎡" and note == "단위 다름 ㎡ ↔ Ton — 값은 자재 단위 그대로" + assert dim._link_of({"이음": "MT000811", "단위": "Ton"})[1] == ""