From ff1913872731fcc92a784522597103d8c6c59ee4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 11 Nov 2026 19:46:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(816-2):=20=ED=9B=84=EB=B3=B4=20=EC=97=AC?= =?UTF-8?q?=EB=9F=BF=20=EA=B8=B0=EA=B3=84=EB=A5=BC=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=95=88=20=EA=B5=AC=EC=84=B1=20=EB=8C=80?= =?UTF-8?q?=EC=A1=B0=EB=A1=9C=20=EA=B0=80=EB=A6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 잣대 = 같은 파일 · 같은 때의 구성 줄(손료 계수 · 가격 · 주연료 · 조종원) — 우리 기계표 값과 맞대지 않음(요구 773-3) - M02_Machine_Find.peers · narrow — 구성이 같은 단독 기계 이름 · 규격으로 다시 찾아 후보 가운데 키 하나면 이음 · 여럿 · 없음 = 그대로 - EST 원파일 MachMainDat 묶기(M02_San_Estx.makes) · EST 엑셀 「기계경비」 시트(M02_San_Xlsx.makes) → 자원 「같은구성」 - 저장 묶음(M02_Doc_FromNative) · 줄 만들기(M02_Doc_NativeEst · M02_San_Emit) 가 「같은구성」 을 넘김 - 앞서 짠 우리 표 값 대조는 걷음 · STC = 구성 표(BDQTY) 난독화로 없음 · STmate 엑셀 중기사용료 = 같은 파일 단독 짝 없음 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Doc_FromNative.py | 2 + M02_MasterTemplete/M02_Doc_NativeEst.py | 6 +- M02_MasterTemplete/M02_Machine_Find.py | 42 ++++++ M02_MasterTemplete/M02_San_Book.py | 10 +- M02_MasterTemplete/M02_San_Emit.py | 7 +- M02_MasterTemplete/M02_San_Estx.py | 35 ++++- M02_MasterTemplete/M02_San_Xlsx.py | 33 +++++ M02_MasterTemplete/M02_San_Xlsx_Est.py | 6 +- resources/tester/test_816_2_machine_parts.py | 129 +++++++++++++++++++ 9 files changed, 261 insertions(+), 9 deletions(-) create mode 100644 resources/tester/test_816_2_machine_parts.py diff --git a/M02_MasterTemplete/M02_Doc_FromNative.py b/M02_MasterTemplete/M02_Doc_FromNative.py index cd8fdad89..125ac650b 100644 --- a/M02_MasterTemplete/M02_Doc_FromNative.py +++ b/M02_MasterTemplete/M02_Doc_FromNative.py @@ -90,6 +90,8 @@ def _est_line(ln: dict) -> dict: one = {k: ln.get(k) for k in _EST_KEYS if ln.get(k) not in (None, [], False, "")} if one.get("자원"): one["자원"] = {k: ln["자원"].get(k) for k in _RES_KEYS} + if ln["자원"].get("같은구성"): # 816-2 같은 파일에서 구성이 같은 단독 기계 + one["자원"]["같은구성"] = ln["자원"]["같은구성"] return one diff --git a/M02_MasterTemplete/M02_Doc_NativeEst.py b/M02_MasterTemplete/M02_Doc_NativeEst.py index d915b553e..5c15bbd76 100644 --- a/M02_MasterTemplete/M02_Doc_NativeEst.py +++ b/M02_MasterTemplete/M02_Doc_NativeEst.py @@ -320,7 +320,8 @@ class Block: r |= {"단가": prices} if quoted := self.quote(r)[1]: # 815-1 K5 — 세 줄 견적 = 값묶음 그대로 · 표지 한 번 self.mark(rows[0], quoted.removeprefix(NOTE)) - parts = em.machine_parts(r) if self.keys else [] # 812-9 기계 찾기 · 조합 = 줄 둘 + make = (rows[0].get("자원") or {}).get("같은구성") # 816-2 같은 파일 구성 대조 + parts = em.machine_parts(r, make) if self.keys else [] # 812-9 기계 찾기 · 조합 = 줄 둘 r |= {"찾기": parts[0]} if parts else {} r |= {"조합첫": True} if len(parts) > 1 else {} head = next( @@ -366,7 +367,8 @@ class Block: price = (ln.get("연결단가") or [ln["결과"][-1]])[0] r = self.res(ln, ln["비목"], price if got else ln["결과"][-1]) name = self.o.names.item(f"{ln.get('품명') or r.get('이름') or '줄'} {COMP[ln['비목']]}") - parts = em.machine_parts(r) if self.keys else [] # 812-9 기계 찾기 · 조합 = 줄 둘 + make = (ln.get("자원") or {}).get("같은구성") # 816-2 같은 파일 구성 대조 + parts = em.machine_parts(r, make) if self.keys else [] # 812-9 기계 찾기 · 조합 = 줄 둘 r |= {"찾기": parts[0]} if parts else {} r |= {"조합첫": True} if len(parts) > 1 else {} link, many = (parts[0]["키"], None) if parts else self.quote(r) diff --git a/M02_MasterTemplete/M02_Machine_Find.py b/M02_MasterTemplete/M02_Machine_Find.py index 0236d692f..23e934bbe 100644 --- a/M02_MasterTemplete/M02_Machine_Find.py +++ b/M02_MasterTemplete/M02_Machine_Find.py @@ -305,6 +305,48 @@ def find_all(name: Any, spec: Any = "") -> list[dict[str, Any]]: return out +# ── 같은 파일 안 구성 대조로 가름(816-2 · 요구 773-3) ─────────────────────────── +MADE = "같은 파일 구성 같음" + + +def peers(machines: dict[Any, tuple[str, str, dict]]) -> dict[Any, list[list[str]]]: + """같은 파일 기계 {코드: (이름, 규격, 구성 {손료 [[계수, 가격]], 주연료, 조종원})} → {코드: 구성이 같은 + 단독 기계(손료 줄 하나) [이름, 규격] 들} — 낱 기계 = 구성 통째 같음 · 조합 = 손료 줄마다 그 줄 하나뿐인 + 단독 기계. 같은 파일 · 같은 때의 값끼리(우리 표 값과 안 맞댐).""" + alone = {c: m for c, m in machines.items() if len(m[2].get("손료") or []) == 1} + out: dict[Any, list[list[str]]] = {} + for c, (_, _, make) in machines.items(): + loss = make.get("손료") or [] + if len(loss) == 1: + got = [o for o, m in alone.items() if o != c and m[2] == make] + else: + got = [o for o, m in alone.items() if o != c and m[2]["손료"][0] in loss] + if got: + out[c] = [[alone[o][0], alone[o][1]] for o in got] + return out + + +def narrow(parts: list[dict[str, Any]], same: list | None) -> list[dict[str, Any]]: + """「후보 여럿」 부분 → 같은 파일에서 구성이 같은 단독 기계 이름 · 규격으로 다시 찾아(`find`) 그 후보 + 가운데 키 하나로 모이면 그 줄 · 여럿 · 없음 · 대조 없음 = 그대로(우리 값으로 넘겨짚지 않음).""" + if not same: + return parts + found = [(n, s, find(n, s)) for n, s in same] + out = [] + for p in parts: + if p["판정"] == MANY: + mine = {c["키"] for c in p["후보"]} + keys = { + g["키"] for _, _, g in found if g["판정"] in (LINKED, ALIAS) and g["키"] in mine + } + if len(keys) == 1: + key = keys.pop() + n, s = next((n, s) for n, s, g in found if g["키"] == key) + p = {**p, **_ans(LINKED, [table()[2][key]], n, f"{MADE} — {n} {s}".strip())} + out.append(p) + return out + + def mark(got: dict[str, Any], name: Any, spec: Any) -> str | None: """못 이은 기계 표지 글(「참고 · 」 머리) — 이음 · 별칭 = None.""" said = f"「{' '.join(x for x in (str(name or ''), str(spec or '')) if x)}」" diff --git a/M02_MasterTemplete/M02_San_Book.py b/M02_MasterTemplete/M02_San_Book.py index 32ccdfb0d..151f4c99b 100644 --- a/M02_MasterTemplete/M02_San_Book.py +++ b/M02_MasterTemplete/M02_San_Book.py @@ -22,7 +22,15 @@ STM_SHEETS = ( "경비목록표", "중기목록표", ) -EST_SHEETS = ("단가산출총괄표", "단가산출", "기계경비총괄표", "자재단가", "노임단가", "견적단가") +EST_SHEETS = ( + "단가산출총괄표", + "단가산출", + "기계경비총괄표", + "기계경비", + "자재단가", + "노임단가", + "견적단가", +) _X7, _X5 = "_x0007_", "_x0005_" _HEAD = re.compile(r"BDCOD\|([A-Z]\d+).*?POSS\|(\d+).*?POSE\|(\d+)") diff --git a/M02_MasterTemplete/M02_San_Emit.py b/M02_MasterTemplete/M02_San_Emit.py index ecf9ba6c5..9a7441489 100644 --- a/M02_MasterTemplete/M02_San_Emit.py +++ b/M02_MasterTemplete/M02_San_Emit.py @@ -178,13 +178,14 @@ def reprice(logic: dict, where: dict, price: dict[str, float]) -> int: # ── 기계 부분(812-9 · 조합 기계 = 줄 둘) ───────────────────────────────── -def machine_parts(src: dict) -> list[dict]: - """기계 자원 → 부분 찾기(`M02_Machine_Find.find_all` · 조합 = 둘 이상) · 기계 아님 = [].""" +def machine_parts(src: dict, same: list | None = None) -> list[dict]: + """기계 자원 → 부분 찾기(`M02_Machine_Find.find_all` · 조합 = 둘 이상) · 기계 아님 = [] · + same = 같은 파일에서 구성이 같은 단독 기계 [[이름, 규격]](816-2 · 있으면 「후보 여럿」을 가름).""" if src.get("갈래") != "기계": return [] from M02_MasterTemplete import M02_Machine_Find as mfind - return mfind.find_all(src.get("이름"), src.get("규격")) + return mfind.narrow(mfind.find_all(src.get("이름"), src.get("규격")), same) def first_spec(one: dict, parts: list[dict]) -> dict: diff --git a/M02_MasterTemplete/M02_San_Estx.py b/M02_MasterTemplete/M02_San_Estx.py index fcbd4288c..098f665b8 100644 --- a/M02_MasterTemplete/M02_San_Estx.py +++ b/M02_MasterTemplete/M02_San_Estx.py @@ -228,9 +228,36 @@ def marks(text: str) -> dict[str, Any]: return out +def makes(t: dict[str, list[dict[str, Any]]]) -> dict[str, dict[str, Any]]: + """기계 구성(816-2 · MachMainDat — TitleCode 머리 다음 줄부터 빈 줄까지) → {코드: {손료 [[계수, 가격] + (Index -3 GN · TU)] · 주연료 ℓ(-2 「주연료」 GN) · 조종원(-1 GN > 0)}} — 손료 줄 없는 기계는 뺌.""" + out: dict[str, dict[str, Any]] = {} + cur = None + for r in t.get("MachMainDat", []): + if r.get("TitleCode"): + cur = out.setdefault(r["TitleCode"], {"손료": [], "주연료": None, "조종원": False}) + elif not r.get("Name"): + cur = None + elif cur is not None: + i, gn = r.get("Index"), r.get("GN") + if i == -3 and gn: + cur["손료"].append([gn, r.get("TU")]) + elif i == -2 and cur["주연료"] is None and re.sub(r"\s", "", r["Name"]) == "주연료": + cur["주연료"] = gn + elif i == -1 and (gn or 0) > 0: + cur["조종원"] = True + return {k: v for k, v in out.items() if v["손료"]} + + def resources(t: dict[str, list[dict[str, Any]]]) -> dict[tuple[int, str], dict[str, Any]]: - """(LinkIndex, 코드) → 자원 머리(표 · 이름 · 규격 · 단위 · 저장 금액).""" + """(LinkIndex, 코드) → 자원 머리(표 · 이름 · 규격 · 단위 · 저장 금액 · 기계 = 같은 파일에서 구성이 + 같은 단독 기계 `같은구성` [[이름, 규격]] · 816-2).""" + from M02_MasterTemplete import M02_Machine_Find as mfind + out = {} + heads = {r.get("Code"): r for r in t.get("MachTitleDat", [])} + made = {c: (heads[c].get("Name"), heads[c].get("Size"), m) for c, m in makes(t).items() if c in heads} # fmt: skip + same = mfind.peers(made) for link, name in SOURCE.items(): for r in t.get(name, []): if r.get("Code"): @@ -240,7 +267,11 @@ def resources(t: dict[str, list[dict[str, Any]]]) -> dict[tuple[int, str], dict[ "규격": r.get("Size"), "단위": r.get("Unite"), "저장": _amounts(r, AMT), - } + } | ( + {"같은구성": same[r["Code"]]} + if name == "MachTitleDat" and r["Code"] in same + else {} + ) return out diff --git a/M02_MasterTemplete/M02_San_Xlsx.py b/M02_MasterTemplete/M02_San_Xlsx.py index 69799a205..abb69a007 100644 --- a/M02_MasterTemplete/M02_San_Xlsx.py +++ b/M02_MasterTemplete/M02_San_Xlsx.py @@ -145,9 +145,42 @@ def resources(sheets: dict[str, list]) -> dict[str, list[dict[str, Any]]]: vals = {k: float(v or 0) for k, v in vals.items()} got.append({**_drop(one), "표": sheet, "값": vals} | ({"칸": note} if note else {})) out[kind] = got + from M02_MasterTemplete import M02_Machine_Find as mfind + + same = mfind.peers(makes(sheets.get("기계경비") or [])) + for one in out["기계"]: # 816-2 같은 파일에서 구성이 같은 단독 기계 + if got := same.get((flat(one["이름"]), flat(one["규격"]))): + one["같은구성"] = got return out +def makes(rows: list[list[Any]]) -> dict[tuple[str, str], dict[str, Any]]: + """EST 엑셀 「기계경비」 시트(816-2) — 「N. 이름 · 규격」 머리 아래 빈 줄까지: 「손료 가격 ₩× 계수 × + 10(-7)」 · 「주연료 종류 ℓ」 · 「직종 1 인」 → {(이름, 규격) 붙임: (이름, 규격, {손료 [[계수, 가격]] · + 주연료 ℓ · 조종원})}.""" + out: dict[tuple[str, str], tuple[str, str, dict[str, Any]]] = {} + cur = None + for row in rows: + v = [c for c in row if c not in (None, "")] + head = re.match(r"\d+\.\s*(.+)", str(v[0])) if v else None + if head: + spec = v[1] if len(v) > 1 and isinstance(v[1], str) else "" + name = head.group(1).strip() + make = {"손료": [], "주연료": None, "조종원": False} + cur = out.setdefault((flat(name), flat(spec)), (name, spec, make))[2] + elif not v: + cur = None + elif cur is not None: + word = flat(v[0]) + if word.startswith("손료") and "₩×" in v and (at := v.index("₩×") + 1) < len(v): + cur["손료"].append([number(v[at]), number(v[1])]) + elif word == "주연료" and len(v) > 2 and cur["주연료"] is None: + cur["주연료"] = number(v[2]) + elif len(v) > 2 and v[2] == "인" and (number(v[1]) or 0) > 0: + cur["조종원"] = True + return {k: m for k, m in out.items() if m[2]["손료"] and None not in m[2]["손료"][0]} + + def _drop(one: dict) -> dict: return {k: v for k, v in one.items() if k != "줄"} diff --git a/M02_MasterTemplete/M02_San_Xlsx_Est.py b/M02_MasterTemplete/M02_San_Xlsx_Est.py index 8146b2aac..d38a64122 100644 --- a/M02_MasterTemplete/M02_San_Xlsx_Est.py +++ b/M02_MasterTemplete/M02_San_Xlsx_Est.py @@ -154,7 +154,11 @@ class Lines: f"단산{one['번']}" if kind == "산근" else f"{one['표']}#{one['번']}" ) # 원 코드 없음 = 표 줄 번 out = {"코드": code, "종류": kind, "표": one["표"]} - return out | {k: one[k] for k in ("이름", "규격", "단위")} | {"저장": dict(one["값"])} + out |= { + k: one[k] + for k in ("이름", "규격", "단위") + (("같은구성",) if "같은구성" in one else ()) + } + return out | {"저장": dict(one["값"])} def link(self, ln: dict, kind: str, one: dict | None) -> None: ln["연결"] = LINK[kind] if kind in LINK else 62 diff --git a/resources/tester/test_816_2_machine_parts.py b/resources/tester/test_816_2_machine_parts.py new file mode 100644 index 000000000..12e74aaf0 --- /dev/null +++ b/resources/tester/test_816_2_machine_parts.py @@ -0,0 +1,129 @@ +"""816-2 지킴 — 「후보 여럿」 기계를 같은 파일 안 구성 대조로 가름(요구 770-2 · 773-3 · 방향 장부 N1). + +잣대 = 같은 파일 · 같은 때의 값끼리(손료 계수 · 가격 · 주연료 · 조종원) — 우리 기계표 값과 맞대지 않음. +(a) EST 원파일 MachMainDat 묶기 — main 기계 1,049 대 모두 구성 +(b) 견본 셋 — 대형브레이카 +굴착기(1.0㎥) → 같은 파일 굴착기(무한궤도) 1.0㎥ → EQ000022 · 크레인(트럭) 10Ton → + 크레인(타이어) 10Ton → EQ000128 · 굴삭기(유압식백호) 0.6㎥ → EQ000019 +(c) 파일 값이 우리 표와 달라도 같은 파일 대조로 가름 · 우리 표 값과 같다는 것만으로는 안 이음 · 같은 구성 + 없음 · 여럿 = 그대로 +(d) EST 엑셀 「기계경비」 시트 길 — 산출근거 줄 → 같은 대조 · 2016 사방 엑셀 기계 12 대 구성 읽음 +""" + +from __future__ import annotations + +import sys +from functools import lru_cache +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent +for p in (ROOT, HERE): + if str(p) not in sys.path: + sys.path.insert(0, str(p)) + +from M02_MasterTemplete import M02_Machine_Find as mf # noqa: E402 +from M02_MasterTemplete import M02_San_Book as book # noqa: E402 +from M02_MasterTemplete import M02_San_Emit as em # noqa: E402 +from M02_MasterTemplete import M02_San_Estx as estx # noqa: E402 +from M02_MasterTemplete import M02_San_Xlsx as sx # noqa: E402 + +MAIN = ROOT / "resources/knowledge/original/경쟁사 프로그램/EST Plus 2/다산소프트/2026-07-main.estx" +COMBO = ("대형브레이카", "+굴착기(1.0㎥)") + + +@lru_cache(maxsize=1) +def _tables() -> dict: + return estx.tables(MAIN.read_bytes()) + + +@lru_cache(maxsize=1) +def _main() -> dict: + res = estx.resources(_tables()).values() + return {(r["이름"], r["규격"]): r for r in res if r["표"] == "MachTitleDat"} + + +def _keys(name: str, spec: str, same: list | None) -> list: + return [(p["판정"], p["키"]) for p in em.machine_parts({"갈래": "기계", "이름": name, "규격": spec}, same)] # fmt: skip + + +def test_main_makes() -> None: + made = estx.makes(_tables()) + assert len(made) == 1049 and all(m["손료"] for m in made.values()) + code = next(r["Code"] for r in _tables()["MachTitleDat"] if (r["Name"], r["Size"]) == COMBO) + assert made[code] == {"손료": [[2085.0, 142069000.0], [6601.0, 28692000.0]], "주연료": 19.5, "조종원": True} # fmt: skip + assert ["굴착기(무한궤도)", "1.0㎥"] in _main()[COMBO]["같은구성"] + + +def test_samples() -> None: + for (name, spec), want in ( + (COMBO, "EQ000022"), + (("크레인(트럭)", "10Ton"), "EQ000128"), + (("굴삭기(유압식백호)", "0.6㎥"), "EQ000019"), + ): + same = _main()[(name, spec)]["같은구성"] + assert any(v == mf.MANY for v, _ in _keys(name, spec, None)), name + after = _keys(name, spec, same) + assert (mf.LINKED, want) in after and all(v != mf.MANY for v, _ in after), name + got = em.machine_parts({"갈래": "기계", "이름": "크레인(트럭)", "규격": "10Ton"}, _main()[("크레인(트럭)", "10Ton")]["같은구성"]) # fmt: skip + assert got[0]["까닭"].startswith(mf.MADE) and "크레인(타이어)" in got[0]["까닭"] + + +def test_file_values_only() -> None: + """파일 값이 우리 표와 달라도 같은 파일 대조로 가름 · 우리 표 값과 같아도 같은 파일에 짝 없으면 그대로.""" + odd = {"손료": [[9999.0, 1.0]], "주연료": 1.0, "조종원": True} # 우리 표에 없는 값 + files = { + "a": ("굴착기", "1.0㎥", odd), + "b": ("굴착기(무한궤도)", "1.0㎥", odd), + "c": ("굴착기(타이어)", "1.0㎥", {**odd, "주연료": 2.0}), + } + same = mf.peers(files) + assert same["a"] == [["굴착기(무한궤도)", "1.0㎥"]] + assert _keys("굴착기", "1.0㎥", same["a"]) == [(mf.LINKED, "EQ000022")] + ours = { + "손료": [[2085.0, 142069000.0]], + "주연료": 19.5, + "조종원": True, + } # 우리 표 무한궤도 값 그대로 + alone = mf.peers({"a": ("굴착기", "1.0㎥", ours), "c": ("굴착기(타이어)", "1.0㎥", odd)}) + assert "a" not in alone and _keys("굴착기", "1.0㎥", alone.get("a")) == [(mf.MANY, None)] + both = mf.peers( + {**files, "c": ("굴착기(타이어)", "1.0㎥", odd)} + ) # 같은 구성 둘(형식 다름) = 여럿 + assert _keys("굴착기", "1.0㎥", both["a"])[0][0] == mf.MANY + + +def test_keep_when_unsure() -> None: + same = _main()[("굴삭기(유압식백호)", "1.0㎥;암보정")].get( + "같은구성" + ) # 같은 파일 무한궤도(발파) 2,405 + assert ["굴착기(무한궤도)", "1.0㎥(발파)"] in same + assert _keys("굴삭기(유압식백호)", "1.0㎥;암보정", same) == [(mf.LINKED, "EQ000022")] + assert _keys("크레인(트럭)", "10Ton", None) == [(mf.MANY, None)] + + +def test_excel_sheet() -> None: + rows = [ + ["기계경비"], + ["명 칭", "규 격", "산 출 근 거"], + ["1. 굴삭기", "1.0㎥", 0.0, 28259.0], + ["손 료", 100000000.0, "₩×", 2038.0, "×", "10(-7)", 20380.0], + ["주연료", "경유", 19.5, "ℓ", "×", 993.0, 19363.5], + ["건설기계운전사", 1.0, "인", "×", 135644.0], + [], + ["2. 굴삭기(무한궤도)", "1.0㎥", 0.0, 28259.0], + ["손 료", 100000000.0, "₩×", 2038.0, "×", "10(-7)", 20380.0], + ["주연료", "경유", 19.5, "ℓ", "×", 993.0, 19363.5], + ["건설기계운전사", 1.0, "인", "×", 135644.0], + [], + ] + made = sx.makes(rows) + assert made[("굴삭기", "1.0㎥")][2] == {"손료": [[2038.0, 100000000.0]], "주연료": 19.5, "조종원": True} # fmt: skip + same = mf.peers(made)[("굴삭기", "1.0㎥")] + assert same == [["굴삭기(무한궤도)", "1.0㎥"]] + assert _keys("굴삭기", "1.0㎥", same) == [(mf.LINKED, "EQ000022")] + import test_797_1a_import_flags as practice + + sad = practice.FILES.get("2016사방") + if sad: + sheets = book.sheets(sad.name, sad.read_bytes()) + assert len(sx.makes(sheets["기계경비"])) == 12