diff --git a/A00_Common/line_stack_edit.ts b/A00_Common/line_stack_edit.ts index 7219591bb..3cea33fc9 100644 --- a/A00_Common/line_stack_edit.ts +++ b/A00_Common/line_stack_edit.ts @@ -10,6 +10,7 @@ * 고르면 줄이 생김(닫으면 안 생김) · 있는 줄 = 자원 이름 단추 * ========================================================================== */ +import { createBadge } from "@ui/ui_template_badge"; import { createButton, createIconButton } from "@ui/ui_template_button"; import { createRowMenu } from "@ui/ui_template_context_menu"; import { eqText } from "@ui/ui_template_equation"; @@ -97,6 +98,36 @@ const KIND_KEY: Record = { 율: "K_율", }; +/** 가져온 기계 찾기 판정(서버 기계 찾기 · 문서 `가져온.기계`) — 그 단가 기호 칸 */ +interface FoundMach { + 기호?: string; + 판정?: string; + 찾기어?: string; + 조합?: boolean; + 후보?: { 이름?: string; 규격?: string }[]; +} + +function machineFound(d: FormulaDoc, sym: string): FoundMach | undefined { + const head = (d as { 가져온?: { 기계?: FoundMach[] } }).가져온; + return head?.기계?.find((x) => x.기호 === sym); +} + +/** 못 이은 가져온 기계 줄 표지(고르면 사라짐 — 연결이 단가산출로 바뀜) · 조합 기계 다음 줄 */ +function machineMark(f: FoundMach | undefined, link: { 갈래?: string }): HTMLElement | null { + if (!f) return null; + const picked = link.갈래 === "단가산출"; + const key = f.판정 === "후보 여럿" ? "Mach_Many" : f.판정 === "못 찾음" ? "Mach_None" : null; + const badge = + key && !picked + ? createBadge(lt(key), "warning", { size: 18 }) + : f.조합 + ? createBadge(lt("Mach_Combo"), "neutral", { size: 18 }) + : null; + if (badge && key && f.후보?.length) + badge.title = f.후보.map((c) => `${c.이름 ?? ""} ${c.규격 ?? ""}`.trim()).join(" · "); + return badge; +} + /** 금액 줄 몸 — 한 줄(804-7 · 요구 687-7): [종류][이름 단추 | 이름 칸 · 단가 식][수량][단위][비목] · 좁으면 접힘 */ function moneyBody(h: EditHooks, i: number): HTMLElement[] { const d = h.doc; @@ -122,6 +153,8 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] { ? (h.subDoc(link.키)?.이름 ?? h.name(link.키)) : h.name(link.키) : ""; + // 가져온 기계 줄(812-9) — 찾기 판정 · 조회어 = 기종 이름만(규격 뺌 · 이은 우리 기계 › 정규화 이름) + const found = kind === "기계" ? machineFound(d, own) : undefined; const pick = createButton({ label: (size && !name.includes(size) ? `${name} ${size}` : name) || "—", variant: "ghost", @@ -133,13 +166,15 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] { kind, (p) => h.change((doc) => repick(doc, i, own, kind, p)), undefined, - String(mat?.검색어 ?? line.이름 ?? ""), + found?.찾기어 ?? String(mat?.검색어 ?? line.이름 ?? ""), ), }); pick.classList.add("lstk-res"); pick.title = `${lt("Repick")}${res ? ` — ${res}` : ""}`; pick.dataset.repick = String(i); head.push(pick); + const mark = machineMark(found, link); + if (mark) head.push(mark); } else head.push( field(lt("Name"), line.이름, (t) => put((l) => (l.이름 = t)), "m"), diff --git a/A00_Common/line_stack_text.ts b/A00_Common/line_stack_text.ts index ff8df78b7..10ef00a1d 100644 --- a/A00_Common/line_stack_text.ts +++ b/A00_Common/line_stack_text.ts @@ -87,6 +87,9 @@ const TEXT = { ], Direct_Hint: ["표 값을 손으로 고침 — 표 연결 끊김", "Table value typed by hand — table link off"], Tbl_Missing: ["표를 못 읽음", "Table not found"], + Mach_Many: ["기계 후보 여럿 — 고르기", "Several machines — pick one"], + Mach_None: ["기계 못 찾음 — 고르기", "Machine not found — pick one"], + Mach_Combo: ["조합 기계 — 경비는 첫 줄", "Combined machine — cost on first line"], Tbl_Hint: ["값 칸을 누르면 그 값이 들어감", "Click a value cell to use it"], Tbl_Bound: [ "이 값의 줄은 위 옵션이 고름(다른 줄은 옵션에서 바꿈 · 여기선 칸만)", diff --git a/M02_MasterTemplete/M02_Doc_Import.py b/M02_MasterTemplete/M02_Doc_Import.py index 8afe71c33..9950f112c 100644 --- a/M02_MasterTemplete/M02_Doc_Import.py +++ b/M02_MasterTemplete/M02_Doc_Import.py @@ -239,6 +239,21 @@ def _link_of(item: dict) -> tuple[dict | None, str]: return link, "" +MACHINES = "_기계" # 문서 셈 동안 잠깐 — `derive` 가 머리 `가져온.기계` 로 옮김 + + +def _machine_entry(item: dict, sym: str) -> dict: + """기계 줄 찾기 판정 한 칸 — {기호, 원래 {이름, 규격, 단위}, 판정, 키, 후보, 찾기어, 까닭, 조합?}.""" + res = item["자원"] + got = res["찾기"] + one = { + "기호": sym, + "원래": {"이름": got["이름"], "규격": got["규격"], "단위": item.get("단위")}, + } + one |= {k: got.get(k) for k in ("판정", "키", "후보", "찾기어", "까닭")} + return one | {k: True for k in ("조합", "조합첫") if res.get(k)} + + def _excel(item: dict) -> Any: if item.get("값묶음"): return {k: Decimal(str(item["값묶음"].get(k) or 0)) for k in imp.COSTS} @@ -246,12 +261,16 @@ def _excel(item: dict) -> Any: def _attach(doc: dict, row: dict, ids: list[str], src: dict[str, dict]) -> None: - """금액 줄 단가 숫자(엑셀 값) → 이은 연결 + 엑셀 값 기억(SK 고침으로 바뀐 값 = 숫자 그대로).""" + """금액 줄 단가 숫자(엑셀 값) → 이은 연결 + 엑셀 값 기억(SK 고침으로 바뀐 값 = 숫자 그대로) · + 기계 찾기 판정(812-9) = 문서 `가져온.기계`(단가 기호마다 · 화면 고르기 · 저장 때 별칭).""" by_id = {dc.md.var_id(v): v for v in doc.get("변수") or []} + found: list[dict] = doc.setdefault(MACHINES, []) for at, line in enumerate((doc.get("금액") or [])[: len(row["호표"])]): item = src.get(ids[at]) if at < len(ids) else None price = str(line.get("단가") or "") var = by_id.get(price.removesuffix(".계")) + if item is not None and var is not None and (item.get("자원") or {}).get("찾기"): + found.append(_machine_entry(item, dc.md.var_id(var))) if item is None or var is None or (var.get("연결") or {}).get("갈래") != "숫자": continue excel = _excel(item) @@ -322,8 +341,13 @@ def derive(sk: dict, ctx: tuple | None = None, whole: Any = None) -> dict: if not (doc.get("금액") or doc.get("결과") or row["호표"] or row.get("덧줄")): _empty(doc) # 빈 산근 = 엑셀 0 원(줄 없음 합) — 「—」 아님 marks.append(EMPTY) + found = doc.pop(MACHINES, None) + head |= {"기계": found} if found else {} # 812-9 기계 찾기 판정(줄 쌓기 고르기 · 저장 때 별칭) doc["가져온"] = head | {"표지": marks} | ({"알림": notes} if notes else {}) shape.shape(doc, meta) # 809-1 · 요구 704 — 지은 이름 → 엑셀 글 머리 · EST 기계 세 줄 = 한 줄 + if found: # 세 줄을 한 줄로 합쳐 사라진 기호의 기계 판정은 뺌 + alive = {dc.md.var_id(v) for v in doc.get("변수") or []} + doc["가져온"]["기계"] = [x for x in found if x["기호"] in alive] return {**doc, "키": str(sk.get("키") or key), "이름": str(sk.get("이름") or doc.get("이름"))} diff --git a/M02_MasterTemplete/M02_Doc_Match_Note.py b/M02_MasterTemplete/M02_Doc_Match_Note.py index 8a9f42e17..7fe1fe20d 100644 --- a/M02_MasterTemplete/M02_Doc_Match_Note.py +++ b/M02_MasterTemplete/M02_Doc_Match_Note.py @@ -14,6 +14,7 @@ from decimal import Decimal, InvalidOperation from typing import Any from M02_MasterTemplete import M02_Doc_Copy as dc +from M02_MasterTemplete import M02_Machine_Find as mfind md = dc.md PRICED = ("노임", "재료", "기계") # 시간당 · 단위 단가 = 값(참고) @@ -130,15 +131,9 @@ def txt(v: Any) -> str: return str(v) -_ALIAS = (("로울러", "롤러"), ("굴삭", "굴착"), ("브레이카", "브레이커"), ("로우더", "로더")) -_ALIAS += (("물탱크", "살수차"), ("아스팔트페이버", "아스팔트피니셔")) # 같은 기계 다른 이름 - - def _plain(text: str) -> str: - t = re.sub(r"\s", "", text) - for old, new in _ALIAS: - t = t.replace(old, new) - return t + """표기 정규화(소리 표기 · 빈칸) — 기계 찾기와 같은 규칙(812-9 `M02_Machine_Find.norm`).""" + return mfind.norm(text) def _head(text: str) -> str: @@ -148,6 +143,11 @@ def _head(text: str) -> str: def _same_machine(a: str, b: str) -> bool: + """같은 기종 — 기계표 · 별칭 장부로 둘 다 기종을 찾으면 그 판정(812-9 · 박힌 짝 없음) · + 못 찾으면 이름 머리 품음.""" + got = mfind.same_kind(a, b) + if got is not None: + return got x, y = _head(a), _head(b) return bool(x and y) and (x in y or y in x) diff --git a/M02_MasterTemplete/M02_Doc_NativeEst.py b/M02_MasterTemplete/M02_Doc_NativeEst.py index a20a046d4..7034bedb1 100644 --- a/M02_MasterTemplete/M02_Doc_NativeEst.py +++ b/M02_MasterTemplete/M02_Doc_NativeEst.py @@ -79,6 +79,7 @@ class Block: self.got = {"확정": 0, "표지": 0, "추정": 0} self.memo: list[int] = [] # 글 줄 행(812-3 차림 · 분수 그림 위아래 줄 뺌) self.frac: set[int] = set() + self.noted: set[tuple] = set() # 기계 표지 단 자원(812-9 · 블록에 한 번) # ── 작은 도구 ────────────────────────────────────────────────── def mark(self, ln: dict, why: str) -> None: @@ -313,6 +314,9 @@ class Block: prices = {ln["비목"]: (ln.get("연결단가") or [0])[0] for ln in rows} r = self.res(rows[0], None, None) r |= {"단가": prices} + parts = em.machine_parts(r) if self.keys else [] # 812-9 기계 찾기 · 조합 = 줄 둘 + r |= {"찾기": parts[0]} if parts else {} + r |= {"조합첫": True} if len(parts) > 1 else {} head = next( (b for b in reversed(self.lines[: self.lines.index(rows[0])]) if (b.get("자원") or {}).get("코드") == r.get("코드") and not b.get("비목")), @@ -321,7 +325,8 @@ class Block: title = head.get("품명") or r.get("원이름") or r.get("이름") or "줄" name = self.o.names.item(title) one = {"이름": name, "규격": r.get("규격") or "", "단위": r.get("단위") or "", "수량": qty} - one |= {"종류": "로직", "값묶음": em.bundle(r), "이음": self.logic_link(r), "자원": r} + link = em.hourly_text(parts[0]["키"]) if parts else None + one |= {"종류": "로직", "값묶음": em.bundle(r), "이음": link, "자원": r} one |= {"자리": place} if place else {} value = total["결과"][-1] if total and total.get("결과") else None for ln in rows: # 노 · 재 · 경 글 = 펼침(맞댈 값은 한 줄 계 — 소계 글 · 없으면 첫 줄) @@ -329,37 +334,38 @@ class Block: at = total or rows[0] value = value if value is not None else sum(ln["결과"][-1] for ln in rows) self.o.add_line(one, True, at["행"], at.get("보임") or "", value) + call = self.parts(one, parts, at, rows[0]) if total: - self.register(total, f"줄.'{name}'") + self.register(total, call) self.got["확정"] += 3 + def parts(self, one: dict, parts: list[dict], at: dict, ln: dict) -> str: + """조합 기계 다음 줄(값 0 · 제 이음) · 기계 표지(블록에 한 번) → 이 기계 부름 글(줄 합).""" + calls = [f"줄.'{one['이름']}'"] + for p in parts[1:]: + extra = em.part_line(one, p, self.o.names) + self.o.add_line(extra, True, at["행"], at.get("보임") or "", None) + calls.append(f"줄.'{extra['이름']}'") + seen = (one["자원"].get("이름"), one["자원"].get("규격")) + if seen not in self.noted: + self.noted.add(seen) + for m in em.part_marks(one["자원"], parts): + self.mark(ln, m.removeprefix(NOTE)) + return calls[0] if len(calls) == 1 else f"({' + '.join(calls)})" + def machine_line(self, ln: dict, got: tuple | None) -> None: qty, place = got if got else ("1", self.frozen(ln, "수량 식 못 읽음")[1]) price = (ln.get("연결단가") or [ln["결과"][-1]])[0] r = self.res(ln, ln["비목"], price if got else ln["결과"][-1]) - r["갈래"] = "기계" if r["갈래"] == "기계" else r["갈래"] name = self.o.names.item(f"{ln.get('품명') or r.get('이름') or '줄'} {COMP[ln['비목']]}") - link, _ = em._m01(r["갈래"], r) if self.keys else (None, None) - one = em.resource(name, r.get("단위"), qty, r, link) + parts = em.machine_parts(r) if self.keys else [] # 812-9 기계 찾기 · 조합 = 줄 둘 + r |= {"찾기": parts[0]} if parts else {} + r |= {"조합첫": True} if len(parts) > 1 else {} + one = em.resource(name, r.get("단위"), qty, r, parts[0]["키"] if parts else None) one |= {"자리": place} if place else {} self.o.add_line(one, True, ln["행"], ln.get("보임") or "", ln["결과"][-1]) self.got["확정"] += bool(got) - self.register(ln, f"줄.'{name}'") - - def logic_link(self, r: dict) -> str | None: - """기계 = 시간당 기계경비 로직 부름 글(797-1 짝 고름) · 그 밖 = 없음(산근 = 형제 SK 참조).""" - if not self.keys or r.get("갈래") != "기계": - return None - key, _ = em._m01("기계", r) - if not key: - return None - from M02_MasterTemplete import M02_Doc_Import as dim - - got = dim.hourly_link(key, "노무비") - if not got: - return None - args = "".join(f", {k}={v}" for k, v in got["입력"].items()) - return f"로직({got['키']}{args})" + self.register(ln, self.parts(one, parts, ln, ln)) def subtotal( self, ln: dict, kids: list[str], part: str | None = None, glyph: bool = True diff --git a/M02_MasterTemplete/M02_Doc_NativeLayout.py b/M02_MasterTemplete/M02_Doc_NativeLayout.py index 685bd3fda..b1d94b1f3 100644 --- a/M02_MasterTemplete/M02_Doc_NativeLayout.py +++ b/M02_MasterTemplete/M02_Doc_NativeLayout.py @@ -166,6 +166,12 @@ def _items(doc: dict, texts: list[dict], rows: list[int], stm: bool) -> list[dic items: list[dict] = [] cur: dict | None = None cut = False # STmate 계 줄 뒤 — 다음 첫 글 줄 = 제목 + money = doc.get("금액") or [] + mach = { + str(x.get("기호")): x + for x in (doc.get("가져온") or {}).get("기계") or [] + if isinstance(x, dict) and (x.get("조합") or x.get("조합첫")) + } # 조합 기계(812-9 · 요구 738-3) = 기계마다 항목 하나 · 제목 = 그 기계 def new(title: str = "", sub: bool = False) -> dict: it = {"제목": title, "가나다": sub, "줄": [], "합계": False} @@ -186,6 +192,11 @@ def _items(doc: dict, texts: list[dict], rows: list[int], stm: bool) -> list[dic cur = new(title, sub) cut = False else: + one = mach.get(price_sym(money[val]) or "") if kind == "금액" else None + if one and one.get("조합"): + cur = new(_mach_title(one), bool(cur and cur["가나다"])) + elif one and cur is not None and "+" in cur["제목"]: + cur["제목"] = _mach_title(one) cur = cur if cur is not None else new() if kind == "공식" and cur["줄"] and cur["줄"][-1][0] == "공식": cur["줄"][-1][1].extend(x for x in val if x not in cur["줄"][-1][1]) @@ -200,6 +211,16 @@ def _items(doc: dict, texts: list[dict], rows: list[int], stm: bool) -> list[dic return keep +def _mach_title(one: dict) -> str: + """조합 기계 항목 제목 — 이은 우리 기계 이름 · 규격 › 가져온 이름 · 규격.""" + first = (one.get("후보") or [{}])[0] if one.get("키") else {} + said = first or { + "이름": (one.get("원래") or {}).get("이름"), + "규격": (one.get("원래") or {}).get("규격"), + } + return " ".join(str(said.get(k) or "") for k in ("이름", "규격")).strip() + + def _save_item(it: dict, money: list[dict]) -> dict: out: dict[str, Any] = {} if it["제목"]: @@ -269,6 +290,12 @@ def price_sym(line: dict) -> str | None: return None +def machine_syms(doc: dict) -> set[str]: + """가져온 기계 줄 단가 기호(812-9 `가져온.기계`) — 못 이어 숫자 연결이어도 그 줄 기계(이름 단추 · 고르기).""" + got = (doc.get("가져온") or {}).get("기계") if isinstance(doc.get("가져온"), dict) else None + return {str(x.get("기호")) for x in got or [] if isinstance(x, dict)} + + def owned_of(doc: dict) -> dict[int, str]: count: dict[str, int] = {} for ns in ( @@ -280,11 +307,17 @@ def owned_of(doc: dict) -> dict[int, str]: count[n] = count.get(n, 0) + 1 vars_ = {md.var_id(v): v for v in doc.get("변수") or []} lines = {str(x.get("기호") or "") for x in doc.get("식") or []} + mach = machine_syms(doc) out = {} for i, line in enumerate(doc.get("금액") or []): s = price_sym(line) kind = ((vars_.get(s) or {}).get("연결") or {}).get("갈래") if s else None - if s and kind in ("요소", "단가산출") and count.get(s) == 1 and s not in lines: + if ( + s + and (kind in ("요소", "단가산출") or s in mach) + and count.get(s) == 1 + and s not in lines + ): out[i] = s return out diff --git a/M02_MasterTemplete/M02_Doc_Router.py b/M02_MasterTemplete/M02_Doc_Router.py index 1f7b117f3..cafb3ab04 100644 --- a/M02_MasterTemplete/M02_Doc_Router.py +++ b/M02_MasterTemplete/M02_Doc_Router.py @@ -4,6 +4,7 @@ POST /api/m02/layers/{층}/sangeun/{키}/doc/preview 미리 셈(층 쓰기) {문서?, 끊음?, 할일?} → {…, 전, 후, 바뀐자원?, 버림?} POST /api/m02/layers/{층}/sangeun/{키}/doc/save 저장(층 쓰기) {문서?, 끊음?, 할일?, 판} → 읽기 꼴 + · 가져온 기계 줄에 고른 EQ = 이 층 별칭 장부(812-9) POST /api/m02/layers/{층}/sangeun/{키}/match {할일 찾기|바꾸기|되돌리기, 판?}(층 쓰기 · 810-4) → {연결후보?, 판}(바꾸기 = 확정만 · 아니면 409) ⚠ 등록은 `main.py`(로그인만 · 층 권한은 길 안에서). @@ -18,6 +19,7 @@ from pydantic import BaseModel from common_util.common_util_auth import verify_session from M02_MasterTemplete import M02_Doc_Sk_Path as path +from M02_MasterTemplete import M02_Machine_Find as mfind from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Sangeun Doc"]) @@ -84,4 +86,8 @@ def save_doc( session: Session = Depends(verify_session), ) -> dict: folder = _root(session, layer, owner, write=True) - return _call(folder, path.save, key, body.model_dump(exclude_none=True)) + got = _call(folder, path.save, key, body.model_dump(exclude_none=True)) + if body.문서 is not None: # 812-9 — 가져온 기계 줄에 고른 EQ = 이 층 별칭 장부 줄 + who = {"id": session.get("user_id"), "이름": session.get("name")} + _call(folder, mfind.learn, body.문서, who) + return got diff --git a/M02_MasterTemplete/M02_Hopyo_Elem.py b/M02_MasterTemplete/M02_Hopyo_Elem.py index 5d58dd1e1..df8d48226 100644 --- a/M02_MasterTemplete/M02_Hopyo_Elem.py +++ b/M02_MasterTemplete/M02_Hopyo_Elem.py @@ -4,8 +4,8 @@ 고르면 지금 잇기 길 `{노임|기계|자재|유가: 키}`). 자재 = `M02_Hopyo_Material.candidates` 점수(이름 글자 묶음 · 규격) · 단가 = M01 자재 값(엑셀 줄 단위) 노임 = LB 이름 글자 묶음 닮음 · 단가 = LB 값 - 기계 = EQ 갈래 겹침 · 규격 수(같음 1 · 겹침 0.5) · 이름 낱말 덤 · 괄호 낱말 어긋남 깎음 · - 단가 없음(기계경비는 로직 셈) · `machine` = 하나로 가름(778-1) + 기계 = 기계 찾기(812-9 `M02_Machine_Find` — 기계표 전체 · 표기 정규화 · 괄호 낱말 · 규격) 후보 · + 단가 없음(기계경비는 로직 셈) · `machine` = 하나로 정할 때만 유가(785-3) = MO 이름에 글 이름이 듦(경유 ⊂ 자동차용경유) · 전국 먼저 · 단가 = MO 값 차례 = 점수(0.1 단위로 접음) › 엑셀 단가에 가까운 것(엑셀 단가 = 블록 단위 줄 단가 · 모르면 점수만). 서버 단독 · 읽기만. @@ -21,18 +21,11 @@ from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Hopyo_Match as hm from M02_MasterTemplete import M02_Hopyo_Material as hmat from M02_MasterTemplete import M02_Hopyo_UnitLine as unit_line -from M02_MasterTemplete.M02_Hopyo_Words import mtypes, nfkc, specnums +from M02_MasterTemplete import M02_Machine_Find as mfind +from M02_MasterTemplete.M02_Hopyo_Words import nfkc TOP = 3 POOL = 5 -# 괄호 낱말(778-1) — 글에 있으면 EQ 이름에도 있어야 함 · 부속 = 글에 없으면 본 장비 아님 -FORMS = ( - ("타이어", ("타이어",)), - ("무한", ("무한궤도",)), - ("습지", ("습지",)), - ("트럭탑재", ("트럭탑재",)), -) -PARTS = ("자동덮개", "부착용", "콤팩터", "습지") def prices(block: dict[str, Any] | None) -> dict[str, float]: @@ -125,57 +118,24 @@ def oils(name: str) -> list[dict[str, Any]]: ] -def _form(text: str, name: Any) -> tuple[bool, bool]: - """괄호 낱말(778-1) — (글 형식 낱말이 EQ 이름에 다 있음, EQ 이름에 글에 없는 부속 있음).""" - t, n = nfkc(text), nfkc(name) - ok = all(any(w in n for w in ws) for k, ws in FORMS if k in t) - return ok, any(w in n and w not in t for w in PARTS) +def _eq(one: dict[str, Any]) -> dict[str, Any]: + raw = m01.loaded()[1].numbers.get("EQ", {}).get(str(one.get("원문번호")), {}) + unit = (raw.get("단위") or {}).get("내용시간") if isinstance(raw.get("단위"), dict) else "" + return {"키": one["키"], "갈래": "기계", "이름": one["이름"], "규격": one.get("규격") or "", + "단위": unit or "시간", "단가": None} # fmt: skip -def narrow(text: str, rows: list[tuple[str, Any]]) -> list[str]: - """EQ `[(키, 이름)]` → 괄호 낱말로 가른 키 — 형식 다름 뺌 · 부속은 남는 것 있을 때만 뺌.""" - keep = [(c, _form(text, n)[1]) for c, n in rows if _form(text, n)[0]] - return [c for c, part in keep if not part] or [c for c, _ in keep] +def machine(text: str, *_: Any) -> dict | None: + """기계 글(이름 규격) → EQ 한 줄 — 기계 찾기(812-9 `M02_Machine_Find`)가 하나로 정할 때만.""" + got = mfind.find(text) + if got["판정"] not in (mfind.LINKED, mfind.ALIAS): + return None + return m01.loaded()[1].numbers.get("EQ", {}).get(str(got["후보"][0]["원문번호"])) -def machine(text: str, kinds: set | None = None, nums: set | None = None) -> dict | None: - """기계 글 → EQ 한 줄 — 갈래 겹침 + 규격 수 같음 · 괄호 낱말로 하나일 때만.""" - kinds = set(kinds or ()) or mtypes(text) - nums = set(nums or ()) or set(specnums(text)) - rows = { - str(r.get("키")): r - for r in m01.loaded()[1].numbers.get("EQ", {}).values() - if nums - and kinds & mtypes(f"{r.get('상세구분')} {r.get('이름')}") - and nums == set(specnums(r.get("규격"))) - } - got = narrow(text, [(k, r.get("이름")) for k, r in rows.items()]) - return rows[got[0]] if len(got) == 1 else None - - -def machines(text: str, kinds: set | None = None, nums: set | None = None) -> list[dict]: - """기계 글(이름 규격) → EQ 후보 — 갈래 · 규격 수 없으면 글에서 읽음 · 괄호 낱말 어긋남 깎음.""" - whole = m01.loaded()[1] - kinds = set(kinds or ()) or mtypes(text) - nums = set(nums or ()) or set(specnums(text)) - mine = hm.words(text) - rows = [] - for row in whole.numbers.get("EQ", {}).values(): - types = mtypes(f"{row.get('상세구분')} {row.get('이름')}") - if not kinds & types: - continue - spec = set(specnums(row.get("규격"))) - score = len(kinds & types) / len(kinds | types) - score += 1.0 if nums and nums == spec else 0.5 if nums & spec else 0.0 - score += 0.3 * len(mine & hm.words(row.get("이름"))) - ok, part = _form(text, row.get("이름")) - score -= (0.0 if ok else 1.0) + (0.5 if part else 0.0) - one = {"키": str(row.get("키")), "갈래": "기계", "이름": row.get("이름")} - unit = (row.get("단위") or {}).get("내용시간") if isinstance(row.get("단위"), dict) else "" - rows.append( - (score, {**one, "규격": row.get("규격") or "", "단위": unit or "시간", "단가": None}) - ) - return _order(rows, None) +def machines(text: str, *_: Any) -> list[dict]: + """기계 글 → EQ 후보(보여주기만 · 812-9) — 이음 · 후보 여럿 = 그것 · 못 찾음 = 같은 기종(가까운 규격 먼저).""" + return [_eq(one) for one in mfind.find(text)["후보"][:TOP]] def lost(one: dict[str, Any], want: float | None = None) -> list[dict[str, Any]]: diff --git a/M02_MasterTemplete/M02_Hopyo_Fixed.py b/M02_MasterTemplete/M02_Hopyo_Fixed.py index a11ed1ec0..b057f679b 100644 --- a/M02_MasterTemplete/M02_Hopyo_Fixed.py +++ b/M02_MasterTemplete/M02_Hopyo_Fixed.py @@ -20,13 +20,12 @@ from decimal import Decimal from typing import Any from M01_MasterData import M01_MasterData_Store as m01 -from M02_MasterTemplete import M02_Hopyo_Elem as elem from M02_MasterTemplete import M02_Hopyo_Match as hm from M02_MasterTemplete import M02_Hopyo_Material as hmat from M02_MasterTemplete import M02_Hopyo_Shares as shares from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Labor_Fixed as fixed -from M02_MasterTemplete.M02_Hopyo_Words import mtypes, specnums +from M02_MasterTemplete import M02_Machine_Find as mfind COSTS = ("노무비", "재료비", "경비") _BASE = re.compile(r"(? dict[str, str | None]: @@ -117,14 +112,12 @@ class Linker: return out def _eqs(self, line: dict[str, Any]) -> list[str]: - """기계 줄 → EQ 후보 — 이름 · 규격 글 같음 › 갈래 + 규격 수 · 괄호 낱말.""" + """기계 줄 → EQ 후보 — 이름 · 규격 글 같음 › 기계 찾기(812-9 `M02_Machine_Find`).""" name, spec = mm_plain(line.get("이름")), mm_plain(line.get("규격")) - same = [c for c, _, _, n, s in self.machines if n == name and s == spec] + same = [c for c, n, s in self.machines if n == name and s == spec] if len(same) != 1: - text = f"{line.get('이름') or ''} {line.get('규격') or ''}" - kinds, nums = mtypes(text), set(specnums(text)) - same = [c for c, t, s, _, _ in self.machines if kinds & t and nums and nums == s] - same = elem.narrow(text, [(c, self.eq_names.get(c)) for c in same]) + got = mfind.find(line.get("이름"), line.get("규격")) + same = [] if got["판정"] == mfind.NONE else [c["키"] for c in got["후보"]] return same def candidates(self, line: dict[str, Any]) -> list[str]: diff --git a/M02_MasterTemplete/M02_Hopyo_Row.py b/M02_MasterTemplete/M02_Hopyo_Row.py index f71db2141..64107ecf7 100644 --- a/M02_MasterTemplete/M02_Hopyo_Row.py +++ b/M02_MasterTemplete/M02_Hopyo_Row.py @@ -20,6 +20,7 @@ from M02_MasterTemplete import M02_Hopyo_Elem as elem from M02_MasterTemplete import M02_Hopyo_Match as hm from M02_MasterTemplete import M02_Hopyo_Material as hmat from M02_MasterTemplete import M02_Hopyo_ReadEst as est +from M02_MasterTemplete import M02_Machine_Find as mfind mm, mp = hm.mm, hm.mp COSTS = ("노무비", "재료비", "경비") @@ -65,6 +66,11 @@ def _rows(table: str) -> dict[str, dict[str, Any]]: def m01_picks(kind: str, line: dict[str, Any]) -> list[dict[str, Any]]: """갈래 하나 → M01 고른 것 후보(LB · MT · MO · EQ · FX) — 유일 짝이면 하나 · 같은 글 한 번.""" + if kind == "기계": # 812-9 기계 찾기 — 별칭 장부가 바뀌므로 lru 밖(찾기가 기계표를 기억) + got = mfind.find(line.get("이름"), line.get("규격")) + rows = [] if got["판정"] == mfind.NONE else got["후보"] + unit = str(line.get("단위") or "") or "hr" + return [pick("기계", x["키"], x["이름"], x.get("규격"), unit) for x in rows] text = tuple(str(line.get(k) or "") for k in ("이름", "규격", "단위")) return copy.deepcopy(_m01_picks(kind, *text, id(m01.loaded()[1]))) @@ -80,13 +86,6 @@ def _m01_picks(kind: str, name: str, spec: str, unit: str, _whole: int) -> list[ ] got = elem.labor(str(name or "")) return [pick("인력", x["키"], x["이름"], "", "인", 단가=x["단가"]) for x in got] - if kind == "기계": - text = f"{name} {spec}" - one = elem.machine(text) - rows = [one] if one else [] - got = [{"키": r["키"], "이름": r["이름"], "규격": r.get("규격")} for r in rows] - got = got or elem.machines(text) - return [pick("기계", x["키"], x["이름"], x.get("규격"), unit or "hr") for x in got] if kind == "유가": return [ pick("유가", x["키"], x["이름"], x["규격"], x["단위"], 단가=x["단가"]) diff --git a/M02_MasterTemplete/M02_Machine_Find.py b/M02_MasterTemplete/M02_Machine_Find.py new file mode 100644 index 000000000..8ac99e096 --- /dev/null +++ b/M02_MasterTemplete/M02_Machine_Find.py @@ -0,0 +1,395 @@ +"""M02 기계 찾기(PLAN 812-9 · 요구 718 · 719) — 가져온 기계 글(이름 · 규격) → 우리 EQ · 서버 단독. + +과녁 = M01 기계표 전체(EQ) · 부르는 곳 = 불러오기 이음(`M02_Hopyo_Row.m01_picks` · `M02_San_Emit._m01`) · +일위대가 호표(`M02_Hopyo_Elem.machine(s)` · `M02_Hopyo_Fixed._eqs`) · 연결 찾기(`M02_Doc_Match_Note`). +차례: + 1. 별칭 장부(`M02_Hopyo_Ledger` · 마스터 층 + 지금 층 · 짝 갈래 기계) — 이름 + 규격 열쇠 = 그 EQ · + 이름만(규격 빈) 열쇠 = 그 EQ 이름으로 바꿔 2 로 + 2. 기종 = EQ 이름(괄호 뗌) · 괄호 붙임 · 상세구분과 같은 글 › 그 글을 품은 EQ(표기 정규화 `norm`) + 3. 괄호 낱말 — 기계표 괄호에 있는 낱말(무한궤도 · 타이어 · 자주식 …)만 거름 · 표에 없는 낱말(발파 · 리핑)은 안 봄 + 4. 규격 = 수 + 단위(범위 · 여럿) 모두 EQ 규격에 같은 값(단위 한쪽 없음 = 봄) › 없으면 EQ 범위 안 + 하나 = 이음 · 여럿 = 「후보 여럿」 · 0 = 「못 찾음」(가까운 규격으로 대신 잇지 않음) +조합 기계(「+」 · 괄호 속 다른 기종 · 「…조합」 · 「덤프트럭(자동덮개)」) = `parts` 로 갈라 각각 찾음 · +규격 없는 쪽은 앞 쪽 규격. +""" + +from __future__ import annotations + +import math +import re +import time +import unicodedata +from functools import lru_cache +from pathlib import Path +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 + +# 소리만 다른 표기 → 한 글(양쪽에 같이 씀 · 차례대로) +SOUND = ( + ("로우더", "로더"), ("로울러", "롤러"), ("브레이카", "브레이커"), ("브레카", "브레이커"), + ("굴삭기", "굴착기"), ("굴삭", "굴착"), ("불도우저", "불도저"), ("램머", "래머"), + ("크로울러", "크롤러"), ("콤프레샤", "공기압축기"), ("컴프레셔", "공기압축기"), + ("컴프레서", "공기압축기"), ("컴팩터", "콤팩터"), ("타이어형", "타이어"), ("젯트", "제트"), + ("후렉시블", "플렉시블"), ("스프레야", "스프레이어"), ("코어", "코아"), +) # fmt: skip +_UNIT = ( + ("m3/min", "m3/min"), ("m3/분", "m3/min"), ("m3/hr", "m3/hr"), ("m3/h", "m3/hr"), + ("km/hr", "km/hr"), ("km/h", "km/hr"), ("l/min", "l/min"), ("t/hr", "t/hr"), + ("kg/cm2", "kg/cm2"), ("kg/min", "kg/min"), ("m3", "m3"), ("톤용", "ton"), ("ton", "ton"), + ("톤", "ton"), ("t", "ton"), ("kw", "kw"), ("mm", "mm"), ("cm", "cm"), ("kg", "kg"), + ("km", "km"), ("m", "m"), ("l", "l"), ("hp", "hp"), ("amp", "a"), ("cc", "cc"), ('"', "in"), + ("inch", "in"), ("인치", "in"), +) # fmt: skip +_NUM = r"\d+(?:\.\d+)?" +_TOKEN = re.compile( + rf"({_NUM})(?:\s*([~∼\-])\s*({_NUM}))?\s*(" + + "|".join(re.escape(u) for u, _ in _UNIT) + + r")?(?![a-z])" +) +_PAREN = re.compile(r"[(\[]([^()\[\]]*)[)\]]") +_EXTRA = re.compile( + r"[:,]?\s*\(?할증율?[^)]*\)?" +) # 「0.2㎥:할증120%」 · 「(할증125%)」 = 율(규격 아님) +NOTE = "참고 · " +LINKED, ALIAS, MANY, NONE = "이어짐", "별칭", "후보 여럿", "못 찾음" +SHOW = 8 # 후보 글 · 목록 상한 + + +def norm(text: Any) -> str: + """표기 정규화 — NFKC · 작은 글자 · 빈칸 뺌 · 소리 표기 한 글.""" + t = re.sub(r"\s+", "", unicodedata.normalize("NFKC", str(text or "")).lower()) + for old, new in SOUND: + t = t.replace(old, new) + return t + + +def _clean(text: Any) -> str: + t = unicodedata.normalize("NFKC", str(text or "")).lower() + t = _EXTRA.sub(" ", t) + return re.sub(r"(?<=\d),(?=\d{3}(?!\d))", "", t) # 천 자리 쉼표 + + +def tokens(text: Any) -> list[tuple[float, float, str | None]]: + """규격 글 → [(아래, 위, 단위)] — 범위 「10~12」 · 여럿 「32m,80~95㎥/hr」 · 단위 없음 = None.""" + out = [] + for m in _TOKEN.finditer(_clean(text)): + lo, sep, hi, unit = float(m.group(1)), m.group(2), m.group(3), m.group(4) + top = float(hi) if hi else lo + if sep == "-" and top <= lo: # 「50-2.6m」 = 범위 아님 — 앞 수만 + top = lo + canon = dict(_UNIT).get(unit) if unit else None + if canon == "in": # 인치 = ㎝(×2.54 · 우리 표는 ㎝) + lo, top, canon = round(lo * 2.54, 6), round(top * 2.54, 6), "cm" + out.append((lo, top, canon)) + return out + + +def _unit_ok(a: str | None, b: str | None) -> bool: + return a is None or b is None or a == b or {a, b} <= {"ton", "t/hr"} + + +def spec_fit(mine: list, theirs: list) -> str | None: + """가져온 규격 수가 EQ 규격에 다 있음 = 「같음」 · EQ 범위 안 = 「안」 · 아니면 None.""" + if not mine: + return "같음" if not theirs else None + same = all( + any(abs(a - c) < 1e-9 and abs(b - d) < 1e-9 and _unit_ok(u, w) for c, d, w in theirs) + for a, b, u in mine + ) + if same: + return "같음" + inside = all( + any(c - 1e-9 <= a and b <= d + 1e-9 and c < d and _unit_ok(u, w) for c, d, w in theirs) + for a, b, u in mine + ) + return "안" if inside else None + + +def _words(inner: str) -> list[str]: + return [norm(w) for w in re.split(r"[\s,;·/]+", inner) if w.strip()] + + +def split_name(text: Any) -> tuple[str, list[list[str]], list[str]]: + """이름(+규격) 글 → (기종 글 정규화, 괄호 낱말 묶음들, 규격 조각들) — 괄호 속 수 = 규격.""" + raw = _clean(text) + groups, specs = [], [] + for inner in _PAREN.findall(raw): + bits = [b for b in re.split(r"[,;]", inner) if b.strip()] + specs += [b for b in bits if re.search(r"\d", b)] + groups.append([w for b in bits if not re.search(r"\d", b) for w in _words(b)]) + base = _PAREN.sub(" ", raw) + specs += [m.group(0) for m in _TOKEN.finditer(base)] + base = _TOKEN.sub(" ", base) + return norm(base), groups, specs + + +# ── 기계표 ──────────────────────────────────────────────────────────── +class Row: + __slots__ = ("raw", "key", "base", "detail", "joined", "full", "words", "spec", "said") + + def __init__(self, r: dict) -> None: + name = str(r.get("이름") or "") + self.raw, self.key = r, str(r.get("키")) + inner = _PAREN.findall(unicodedata.normalize("NFKC", name)) + self.words = {w for x in inner for w in _words(x)} + self.base = norm(_PAREN.sub("", unicodedata.normalize("NFKC", name))) + self.detail = norm(r.get("상세구분")) + self.joined = {w + self.base for w in self.words} | {self.base + w for w in self.words} + self.full = norm(name) + self.spec = tokens(r.get("규격")) + self.said = norm( + _TOKEN.sub(" ", _clean(r.get("규격"))) + ) # 규격 글 낱말(「더블」 · 「엔진식」) + + +@lru_cache(maxsize=2) +def _table(_whole: int) -> tuple[list[Row], frozenset[str], dict[str, Row]]: + rows = [Row(r) for r in m01.loaded()[1].numbers.get("EQ", {}).values()] + words = frozenset(w for r in rows for w in r.words) + return rows, words, {r.key: r for r in rows} + + +_SEEN: list = [0.0, None] # (때, 표) — M01 판 확인(파일 도장)은 1 초에 한 번 + + +def table() -> tuple[list[Row], frozenset[str], dict[str, Row]]: + now = time.monotonic() + if _SEEN[1] is None or now - _SEEN[0] > 1.0: + _SEEN[:] = [now, _table(id(m01.loaded()[1]))] + return _SEEN[1] + + +def by_code(code: Any) -> str | None: + """원문번호 → EQ 키.""" + want = str(code or "").strip("'\"") + return next((r.key for r in table()[0] if r.raw.get("원문번호") == want), None) + + +def kind_rows(base: str) -> list[Row]: + """기종 글 → EQ 줄 — 이름 · 괄호 붙임 같음 › 상세구분 같음 › 그 글을 품음(첫 층만).""" + if len(base) < 2: + return [] + rows = table()[0] + for test in ( + lambda r: base == r.base or base in r.joined, + lambda r: base == r.detail, + lambda r: base in r.full or base in r.detail, + ): + got = [r for r in rows if test(r)] + if got: + return got + return [] + + +def _kind_word(word: str, base: str) -> str | None: + """괄호 낱말이 다른 기종(조합)이면 그 기종 글 — 「부착용집게」 · 「브레이커조합」 · 「자동덮개」.""" + w = word.removesuffix("조합") + if len(w) < 2 or w == base: + return None + rows = table()[0] + if any(w in (r.base, r.detail) for r in rows): + return w + if any(r.base.startswith(base + w) for r in rows): + return base + w + return None + + +# ── 조합 기계 ───────────────────────────────────────────────────────── +def parts(name: Any, spec: Any = "") -> list[tuple[str, str]]: + """기계 글 → [(이름, 규격)] — 「+」 · 괄호 속 다른 기종이면 둘 이상 · 규격 없는 쪽 = 앞 쪽 규격 · + 하나면 받은 그대로.""" + text = f"{name or ''} {spec or ''}".strip() + known = table()[1] + out: list[tuple[str, str]] = [] + for piece in [p for p in text.split("+") if p.strip()] or [text]: + main, base, extra = _clean(piece), split_name(piece)[0], [] + for m in _PAREN.finditer(_clean(piece)): + bits = [b for b in re.split(r"[,;]", m.group(1)) if b.strip()] + words = [w for b in bits if not re.search(r"\d", b) for w in _words(b)] + kind = next((k for w in words if w not in known and (k := _kind_word(w, base))), None) + if kind: + main = main.replace(m.group(0), " ") + extra.append((kind, ",".join(b for b in bits if re.search(r"\d", b)))) + bare = _TOKEN.sub( + " ", _PAREN.sub(lambda m: " " if re.search(r"\d", m.group(1)) else m.group(0), main) + ) + own = " ".join(split_name(main)[2]) + out += [(" ".join(bare.split()).strip(" ,"), own), *extra] + if len(out) < 2: + return [(str(name or ""), str(spec or ""))] + first = next((s for _, s in out if s), "") + return [(n, s or first) for n, s in out] + + +# ── 별칭 장부 ───────────────────────────────────────────────────────── +def _alias_rows() -> dict[tuple[str, str], list[str]]: + """(이름, 규격) 정규화 → EQ 키들 — 마스터 층 + 지금 층 별칭 장부 기계 짝.""" + from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store + from M02_MasterTemplete import M02_MasterTemplete_Store as store + + here, top = Path(store.root()), Path(store.FOLDER) + folders = [here] + ([top] if here != top else []) + files = [(f, hopyo_store._dir(f) / f"{hopyo_store.MEMORY}.json") for f in folders] + stamp = tuple((str(f), p.stat().st_mtime_ns if p.is_file() else 0) for f, p in files) + return _alias_read(stamp) + + +@lru_cache(maxsize=8) +def _alias_read(stamp: tuple) -> dict[tuple[str, str], list[str]]: + from M02_MasterTemplete import M02_Hopyo_Ledger as ledger + + out: dict[tuple[str, str], list[str]] = {} + for folder, _ in stamp: + for row in ledger.read(folder).values(): + pairs = row.get("짝") or [] + eqs = [str(p["키"]) for p in pairs if p.get("갈래") == "기계" and p.get("키")] + if row.get("갈래") != "짝" or not eqs: + continue + origin = row.get("원래") or {} + out.setdefault((norm(origin.get("품명")), norm(_clean(origin.get("규격")))), eqs) + return out + + +def alias(name: Any, spec: Any) -> list[str]: + """별칭 장부 기계 짝 — 이름 + 규격 열쇠(규격 속 할증 글은 뺌) · 없으면 [].""" + return _alias_rows().get((norm(name), norm(_clean(spec))), []) + + +# ── 찾기 ────────────────────────────────────────────────────────────── +def _brief(r: Row) -> dict[str, Any]: + raw = r.raw + return {"키": r.key, "이름": raw.get("이름"), "규격": raw.get("규격") or "", + "원문번호": raw.get("원문번호")} # fmt: skip + + +def _query(rows: list[Row], base: str) -> str: + names = {str(r.raw.get("이름")) for r in rows} + if len(names) == 1: + return names.pop() + details = {str(r.raw.get("상세구분")) for r in rows} + return details.pop() if len(details) == 1 else base + + +def _ans(kind: str, rows: list[Row], query: str, why: str = "") -> dict[str, Any]: + key = rows[0].key if kind in (LINKED, ALIAS) else None + return {"판정": kind, "키": key, "후보": [_brief(r) for r in rows[:SHOW]], "찾기어": query, + "까닭": why, "수": len(rows)} # fmt: skip + + +def find(name: Any, spec: Any = "") -> dict[str, Any]: + """기계 하나(조합 아님) → `{판정, 키, 후보, 찾기어, 까닭, 수}`.""" + rows_by = table()[2] + hit = alias(name, spec) + if len(hit) == 1 and hit[0] in rows_by: + r = rows_by[hit[0]] + return _ans(ALIAS, [r], str(r.raw.get("이름"))) + base = split_name(name)[0] + _, groups, specs = split_name(f"{name or ''} {spec or ''}") # 규격 괄호 낱말 「3.6m(일반용)」 + groups.append( + _words(_TOKEN.sub(" ", _PAREN.sub(" ", _clean(spec)))) + ) # 규격 글 낱말 「탑승유압식」 + named = alias(name, "") + if len(named) == 1 and named[0] in rows_by: # 이름만 별칭 = 그 EQ 기종으로 + base = rows_by[named[0]].base + mine = tokens(" ".join(specs)) + kinds = kind_rows(base) + known = table()[1] + rows = kinds + for w in (w for g in groups for w in g): + if w in known: # 기계표 괄호 낱말 = 꼭 맞아야 함 + rows = [r for r in rows if w in r.full] + elif len(w) > 1 and (kept := [r for r in rows if w in r.said]): # 규격 글 낱말 = 좁힐 때만 + rows = kept + if not mine: # 규격 수 없음 = 그 기종 전부(괄호 · 규격 낱말로 거른 것) + got = rows + else: + same = [r for r in rows if spec_fit(mine, r.spec) == "같음"] + got = same or [r for r in rows if spec_fit(mine, r.spec) == "안"] + if len(got) == 1: # 이름만 별칭으로 기종을 정했으면 「별칭」 + return _ans(ALIAS if len(named) == 1 else LINKED, got, str(got[0].raw.get("이름"))) + if got: + return _ans(MANY, got, _query(got, base), f"후보 {len(got)}") + if not kinds: + return _ans(NONE, [], base, "기종 모름 — 별칭") + why = "우리 표에 없음(규격)" if rows else "우리 표에 없음(괄호 낱말)" + return _ans( + NONE, sorted(rows or kinds, key=lambda r: _gap(mine, r.spec)), _query(kinds, base), why + ) + + +def _gap(mine: list, theirs: list) -> float: + """가까운 규격 차례(보여주기만) — 첫 수끼리 비.""" + if not mine or not theirs or mine[0][0] <= 0 or theirs[0][0] <= 0: + return math.inf + return abs(math.log(mine[0][0] / theirs[0][0])) + + +def find_all(name: Any, spec: Any = "") -> list[dict[str, Any]]: + """기계 글 → 부분마다 `find` + {이름, 규격}(조합 = 둘 이상) · 글 통째 별칭(짝 여럿)이 먼저.""" + whole = alias(name, spec) + rows_by = table()[2] + if len(whole) > 1 and all(k in rows_by for k in whole): + made = [_ans(ALIAS, [rows_by[k]], str(rows_by[k].raw.get("이름"))) for k in whole] + return [{**x, "이름": str(name or ""), "규격": str(spec or "")} for x in made] + out: list[dict[str, Any]] = [] + for n, s in parts(name, spec): + if out and s and not split_name(n)[2] and s == out[0]["규격"] and out[0]["키"]: + s = str(out[0]["후보"][0]["규격"]) # 앞 줄 규격을 받은 쪽 = 이은 우리 규격(단위까지) + out.append({**find(n, s), "이름": n, "규격": s}) + 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)}」" + if got["판정"] == MANY: + names = " · ".join(f"{c['이름']} {c['규격']}".strip() for c in got["후보"]) + return f"{NOTE}기계 후보 {got['수']} — 고르기 {said}: {names}" + if got["판정"] == NONE: + return f"{NOTE}기계 못 찾음 — {said} {got['까닭']}" + return None + + +def same_kind(a: Any, b: Any) -> bool | None: + """두 기계 이름이 같은 기종인지(연결 찾기) — 둘 다 기종을 찾으면 판정 · 아니면 None.""" + found = [] + for t in (a, b): + named = alias(t, "") + row = table()[2].get(named[0]) if len(named) == 1 else None + found.append(kind_rows(row.base if row else split_name(t)[0])) + if not all(found): + return None + return bool({r.base for r in found[0]} & {r.base for r in found[1]}) + + +# ── 고른 것 → 별칭 장부(저장 때) ────────────────────────────────────── +def learn(doc: Any, who: Any) -> int: + """가져온 식 문서 [저장] — 기계 줄에 사람이 고른 EQ(찾기 판정과 다름) = 지금 층 별칭 장부 줄 + (갈래 기계 · 열쇠 「이름|규격|단위」) → M01 별칭 장부 후보로 모임(승인은 M01) · 쓴 줄 수.""" + from M02_MasterTemplete import M02_Doc_Copy as dc + from M02_MasterTemplete import M02_Hopyo_Ledger as ledger + from M02_MasterTemplete import M02_MasterTemplete_Store as store + + head = (doc or {}).get("가져온") if isinstance(doc, dict) else None + if not isinstance(head, dict) or not isinstance(head.get("기계"), list): + return 0 + by_id = {dc.md.var_id(v): v for v in doc.get("변수") or [] if isinstance(v, dict)} + rows: dict[str, dict] = {} + for one in head["기계"]: + link = ((by_id.get(str((one or {}).get("기호"))) or {}).get("연결")) or {} + code = (link.get("입력") or {}).get("기계") if link.get("갈래") == "단가산출" else None + eq = by_code(code) + origin = one.get("원래") or {} + if not eq or eq == one.get("키") or not origin.get("이름"): + continue + if alias(origin["이름"], origin.get("규격")) == [eq]: # 이미 장부에 같은 짝 + continue + plain = {"품명": str(origin["이름"]), "규격": str(origin.get("규격") or ""), + "단위": str(origin.get("단위") or "hr")} # fmt: skip + full = ledger.key({"이름": plain["품명"], "규격": plain["규격"], "단위": plain["단위"]}) + pair = {"갈래": "기계", "키": eq, "입력값": {}, "배": 1} + rows[full] = {"원래": plain, "갈래": "짝", "짝": [pair]} + if rows: + ledger.put(store.root(), rows, who) + return len(rows) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts index e94deb27f..968bb60bd 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -101,6 +101,9 @@ export interface HopyoBuiltAll { export interface LedgerPair { 로직?: string; 자재?: string; + /** 고른 것 꼴(785-3 · 812-9 기계) — 갈래 + 키 */ + 갈래?: string; + 키?: string; 입력값: Record; 배: number; 몫: number | null; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts index 7aad4013f..af851a47f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts @@ -106,7 +106,7 @@ export function mergeRows(rows: LedgerRow[], edits: Edits, scope: HopyoScope): L /** 짝 글 — 「GC000198 × 1 · 몫 0.1 · 소량감률=50」 */ export const pairText = (p: LedgerPair): string => [ - `${p.로직 ?? p.자재 ?? ""} × ${p.배}`, + `${p.로직 ?? p.자재 ?? [p.갈래, p.키].filter(Boolean).join(" ")} × ${p.배}`, p.몫 != null ? `몫 ${p.몫}` : "", Object.entries(p.입력값 ?? {}) .map(([k, v]) => `${k}=${formatNumber(v)}`) diff --git a/M02_MasterTemplete/M02_San_Emit.py b/M02_MasterTemplete/M02_San_Emit.py index 08fd2f9f1..c462f8f08 100644 --- a/M02_MasterTemplete/M02_San_Emit.py +++ b/M02_MasterTemplete/M02_San_Emit.py @@ -177,7 +177,7 @@ def reprice(logic: dict, where: dict, price: dict[str, float]) -> int: def _m01(kind: str, src: dict) -> tuple[str | None, str | None]: """1차 줄 판정 유일 짝 M01 키 → (키, 표지) — 없음 = (None, None) · 여럿 = (None, 표지 · 유가 · - 환율만 — 재료 · 인력 · 기계는 지금처럼 조용히 이음 없음). 재료 줄 이름이 M01 유가전력(MO)이면 + 환율만 — 재료 · 인력은 조용히 이음 없음) · 기계 = 기계 찾기(812-9) 못 이으면 늘 표지. 재료 줄 이름이 M01 유가전력(MO)이면 유가 · 화폐 이름 그대로(달러 · USD …)면 환율(FX)로 판정(797-1 · 「유로폼」 같은 글 속 낱말은 아님).""" if kind not in ("인력", "재료", "기계"): return None, None @@ -185,6 +185,13 @@ def _m01(kind: str, src: dict) -> tuple[str | None, str | None]: from M02_MasterTemplete import M02_Hopyo_Row as hrow line = {k: src.get(k) or "" for k in ("이름", "규격", "단위")} + if ( + kind == "기계" + ): # 812-9 — 하나 = 이음 · 아니면 표지(후보 여럿 · 못 찾음 · 조용히 넘기지 않음) + from M02_MasterTemplete import M02_Machine_Find as mfind + + got = mfind.find(line["이름"], line["규격"]) + return got["키"], mfind.mark(got, line["이름"], line["규격"]) if kind == "재료": if elem.oil_rows(line["이름"]): kind = "유가" @@ -211,6 +218,60 @@ def _m01(kind: str, src: dict) -> tuple[str | None, str | None]: return None, None +def hourly_text(key: str | None) -> str | None: + """기계 EQ → 시간당 기계경비 로직 부름 글(797-1 짝 고름) · 키 · 자료 없음 = None.""" + if not key: + return None + from M02_MasterTemplete import M02_Doc_Import as dim + + got = dim.hourly_link(key, "노무비") + if not got: + return None + return f"로직({got['키']}{''.join(f', {k}={v}' for k, v in got['입력'].items())})" + + +# ── 기계 부분(812-9 · 조합 기계 = 줄 둘) ───────────────────────────────── +def machine_parts(src: dict) -> list[dict]: + """기계 자원 → 부분 찾기(`M02_Machine_Find.find_all` · 조합 = 둘 이상) · 기계 아님 = [].""" + if src.get("갈래") != "기계": + return [] + from M02_MasterTemplete import M02_Machine_Find as mfind + + return mfind.find_all(src.get("이름"), src.get("규격")) + + +def part_line(main: dict, part: dict, names: "Names") -> dict: + """조합 기계 다음 줄 — 같은 수량 식 · 값 0(파일 경비 = 한 덩이 = 첫 줄) · 제 이음.""" + res = {**main["자원"], "이름": part["이름"], "규격": part["규격"], "찾기": part, "조합": True} + res.pop("조합첫", None) + res["단가"] = dict.fromkeys(res.get("단가") or COMP, 0) + title = part["후보"][0]["이름"] if part["키"] else part["이름"] + one = {"이름": names.item(str(title)), "규격": part["규격"], "단위": main.get("단위") or ""} + one |= {"수량": main["수량"], "자원": res} + if main.get("종류") == "로직": + one |= {"종류": "로직", "값묶음": bundle(res), "이음": hourly_text(part["키"])} + else: + one |= {"종류": main["종류"], "비목": main["비목"], "단가": Decimal(0), "이음": part["키"]} + return one | ({"자리": main["자리"]} if main.get("자리") else {}) + + +def part_marks(src: dict, parts: list[dict]) -> list[str]: + """부분 표지 — 못 이은 부분(후보 여럿 · 못 찾음) · 조합 기계(경비 한 덩이 = 첫 줄 · 다음 줄 0).""" + from M02_MasterTemplete import M02_Machine_Find as mfind + + out = [m for p in parts if (m := mfind.mark(p, p["이름"], p["규격"]))] + if len(parts) > 1: + said = " ".join(x for x in (str(src.get("이름") or ""), str(src.get("규격") or "")) if x) + names = " + ".join( + f"{p['후보'][0]['이름']} {p['후보'][0]['규격']}" if p["키"] else p["이름"] + for p in parts + ) + out.append( + f"{NOTE}조합 기계 — 경비는 한 덩이 「{said}」 = {names} · 엑셀 값은 첫 줄 · 다음 줄 0" + ) + return out + + # ── 내보낼 로직 틀 ───────────────────────────────────────────────────── class Out: def __init__(self, name: str, unit: str, form: str, code: Any) -> None: @@ -290,6 +351,7 @@ class _Stm: self.open: list[tuple[int, list[str]]] = [] # 위 단계가 아직 안 담은 단계(엔진 `_cover`) self.out: dict[str, int] = {} # 제외 단계 줄 → 그 계 줄까지 줄 수(합에서 빠지는 때) self.fix: list[tuple[dict, int]] = [] # 누계 % 덧줄 · 그때 줄 수 + self.noted: set[tuple] = set() # 기계 표지 단 자원(812-9 · 블록에 한 번) def brackets(self, node: tuple, src: dict, kept: dict) -> None: """끼움 `[Q=0.208]` → 중간(왼쪽부터 · 원문 차례).""" @@ -395,11 +457,24 @@ class _Stm: if not one_ and len(sole) == 1: # 비목 꼬리 없어도 값 든 비목 하나면 단가 줄(셈 같음) one_, to = sole[0], None res |= {"비목": one_, "옮김": to} if one_ else {} - link, many = _m01(res["갈래"], res) if self.keys else (None, None) + parts = machine_parts(res) if self.keys else [] # 812-9 기계 찾기 · 조합 = 줄 둘 + if parts: + res["찾기"], link, many = parts[0], parts[0]["키"], None + res |= {"조합첫": True} if len(parts) > 1 else {} + else: + link, many = _m01(res["갈래"], res) if self.keys else (None, None) one = resource(o.names.item(name), pr.get("단위"), cf, res, link) + if parts and one["종류"] == "로직": + one["이음"] = hourly_text(link) self.line(one, node, True, src) - if many: - o.glyph(src["행"], src["글"], mark=many) + bare = {k: v for k, v in node.items() if k != "번지"} + for p in parts[1:]: + self.line(part_line(one, p, o.names), bare, True, src) + seen = (res.get("이름"), res.get("규격")) + marks = [] if seen in self.noted else part_marks(res, parts) + self.noted.add(seen) + for m in [*marks, *([many] if many else [])]: + o.glyph(src["행"], src["글"], mark=m) def rate(self, node: dict, src: dict, cf: str) -> None: rate, o = node["율"], self.o diff --git a/common_util/common_util_line_stack.ts b/common_util/common_util_line_stack.ts index 149be38ab..6ee2d9be7 100644 --- a/common_util/common_util_line_stack.ts +++ b/common_util/common_util_line_stack.ts @@ -99,6 +99,12 @@ export function priceSym(line: MoneyLine): string | null { } /** 금액 줄 하나만 단가로 쓰는 연결 기호(요소 · 단가산출) — {줄 차례: 기호} */ +/** 가져온 기계 줄 단가 기호(812-9 `가져온.기계`) — 못 이어 숫자 연결이어도 그 줄 기계(이름 단추 · 고르기) */ +export function machineSyms(doc: FormulaDoc): Set { + const got = (doc as { 가져온?: { 기계?: { 기호?: string }[] } }).가져온?.기계; + return new Set((Array.isArray(got) ? got : []).map((x) => String(x?.기호 ?? ""))); +} + export function ownedOf(doc: FormulaDoc): Map { const count = new Map(); const bump = (ns: string[]): void => ns.forEach((n) => count.set(n, (count.get(n) ?? 0) + 1)); @@ -107,12 +113,13 @@ export function ownedOf(doc: FormulaDoc): Map { if (doc.결과) bump([doc.결과]); const vars = new Map((doc.변수 ?? []).map((v) => [varId(v), v])); const lines = new Set((doc.식 ?? []).map((l) => String(l.기호 ?? ""))); + const mach = machineSyms(doc); const out = new Map(); (doc.금액 ?? []).forEach((l, i) => { const s = priceSym(l); const kind = s ? vars.get(s)?.연결?.갈래 : undefined; - if (s && (kind === "요소" || kind === "단가산출") && count.get(s) === 1 && !lines.has(s)) - out.set(i, s); + const mine = kind === "요소" || kind === "단가산출" || (!!s && mach.has(s)); + if (s && mine && count.get(s) === 1 && !lines.has(s)) out.set(i, s); }); return out; } @@ -137,6 +144,7 @@ export function kindOf( const lb = link.키 ? String(link.키).startsWith("LB") : line.비목 === "노무비"; // 고르기 전 = 비목으로 return lb ? "인력" : "재료"; } + if (s && machineSyms(doc).has(s)) return "기계"; // 못 이은 가져온 기계 줄(812-9) return "율"; } diff --git a/common_util/common_util_line_stack_pick.ts b/common_util/common_util_line_stack_pick.ts index 9bf3a97fb..41252cbb1 100644 --- a/common_util/common_util_line_stack_pick.ts +++ b/common_util/common_util_line_stack_pick.ts @@ -138,6 +138,11 @@ export function repick(doc: FormulaDoc, i: number, id: string, kind: MoneyKind, const now = linkOf(kind, p); if (now.갈래 === "요소" && was.지역) now.지역 = was.지역; // 지역 옵션 이음 그대로 if (now.갈래 === "단가산출" && was.키 === now.키) now.입력 = { ...was.입력, ...now.입력 }; // 같은 로직 = 다른 넘길 입력 그대로 + // 가져온 기계 줄(812-9) — 엑셀 값 기억 그대로(엑셀 단가 기준 금액 안 바뀜 · 못 이은 기계 = 숫자 값 · 묶음) + const kept = (was as { 엑셀?: unknown }).엑셀 ?? (was.갈래 === "숫자" ? was.값 : undefined); + if (kind === "기계" && kept !== undefined) (now as { 엑셀?: unknown }).엑셀 = kept; + const cost = (line as { 비목?: string }).비목 ?? ""; + if (kind === "기계" && ["노무비", "재료비", "경비"].includes(cost)) now.칸 = cost; // 비목 한 칸 줄 = 그 칸 v.연결 = now; v.이름 = p.이름; line.이름 = p.이름; diff --git a/docs/raw/요구_1104/_목록_812-9_기계이음.xlsx b/docs/raw/요구_1104/_목록_812-9_기계이음.xlsx new file mode 100644 index 000000000..e71decd2a Binary files /dev/null and b/docs/raw/요구_1104/_목록_812-9_기계이음.xlsx differ diff --git a/resources/master_template/실무자료/맞춤.json b/resources/master_template/실무자료/맞춤.json new file mode 100644 index 000000000..7b37a261a --- /dev/null +++ b/resources/master_template/실무자료/맞춤.json @@ -0,0 +1,124 @@ +{ + "줄": { + "아스팔트페이버(피니셔)||시간": { + "원래": { + "품명": "아스팔트페이버(피니셔)", + "규격": "", + "단위": "hr" + }, + "갈래": "짝", + "짝": [ + { + "갈래": "기계", + "키": "EQ000193", + "입력값": {}, + "배": 1, + "몫": null + } + ], + "엑셀조항": [], + "누가": { + "id": "요구735-5·736", + "이름": "사용자 승인(시스템 관리자)" + }, + "때": "2026-11-09T11:13:18Z", + "처음파일": null + }, + "디젤엔진||시간": { + "원래": { + "품명": "디젤 엔진", + "규격": "", + "단위": "hr" + }, + "갈래": "짝", + "짝": [ + { + "갈래": "기계", + "키": "EQ000500", + "입력값": {}, + "배": 1, + "몫": null + } + ], + "엑셀조항": [], + "누가": { + "id": "요구735-5·736", + "이름": "사용자 승인(시스템 관리자)" + }, + "때": "2026-11-09T11:13:18Z", + "처음파일": null + }, + "워터제트||시간": { + "원래": { + "품명": "워터제트", + "규격": "", + "단위": "hr" + }, + "갈래": "짝", + "짝": [ + { + "갈래": "기계", + "키": "EQ000400", + "입력값": {}, + "배": 1, + "몫": null + } + ], + "엑셀조항": [], + "누가": { + "id": "요구735-5·736", + "이름": "사용자 승인(시스템 관리자)" + }, + "때": "2026-11-09T11:13:18Z", + "처음파일": null + }, + "버킷준설기||시간": { + "원래": { + "품명": "버킷준설기", + "규격": "", + "단위": "hr" + }, + "갈래": "짝", + "짝": [ + { + "갈래": "기계", + "키": "EQ000425", + "입력값": {}, + "배": 1, + "몫": null + } + ], + "엑셀조항": [], + "누가": { + "id": "요구735-5·736", + "이름": "사용자 승인(시스템 관리자)" + }, + "때": "2026-11-09T11:13:18Z", + "처음파일": null + }, + "바이브레이트|봉상후렉시블|시간": { + "원래": { + "품명": "바이브레이트", + "규격": "봉상후렉시블", + "단위": "hr" + }, + "갈래": "짝", + "짝": [ + { + "갈래": "기계", + "키": "EQ000248", + "입력값": {}, + "배": 1, + "몫": null + } + ], + "엑셀조항": [], + "누가": { + "id": "요구735-5·736", + "이름": "사용자 승인(시스템 관리자)" + }, + "때": "2026-11-09T11:13:18Z", + "처음파일": null + } + } +} diff --git a/resources/tester/test_812_3_native_layout.py b/resources/tester/test_812_3_native_layout.py index 41868858c..5981217c5 100644 --- a/resources/tester/test_812_3_native_layout.py +++ b/resources/tester/test_812_3_native_layout.py @@ -134,8 +134,10 @@ def test_sample_stone_wall_reference_and_direct_q() -> None: and got[1][2][-1] == "뒷채움돌기초잡석채집운반" ) assert got[1][2][0].split()[0] == "=Q" # 단산 참조 Q 직접 = 그 항목 공식 묶음 - assert titles[3].startswith("전석쌓기 기계") and got[3][2] == ["=Q_3", "굴삭기(무한궤도)"] - assert got[4] == ("전석쌓기 인부", False, ["석공", "보통인부"]) + # 조합 기계 「굴삭기 0.6㎥+부착용집게」 = 기계마다 항목 하나 · 제목 = 그 기계(812-9 · 요구 738-3) + assert titles[3] == "굴착기(무한궤도) 0.6㎥" and got[3][2] == ["=Q_3", "굴삭기(무한궤도)"] + assert got[4] == ("부착용 집게 0.6∼0.8", False, ["부착용 집게"]) + assert got[5] == ("전석쌓기 인부", False, ["석공", "보통인부"]) assert "합" not in "".join(titles) # 합계 절 = 화면 자동 합계(두 번 안 보임) @@ -155,8 +157,10 @@ def test_sample_ganada_sub_items() -> None: _, doc = _doc("EST", "거창", 2) got = _show(doc) assert got[0] == ("기 계(100%)", False, []) - assert got[1][:2] == ("굴삭기(무한궤도) (0.7+브레이카)", True) - assert got[2] == ("치즐(0.7㎥용)", True, ["치즐(0.7㎥)"]) # 「다. 소 계」 절 = 뺌 + # 「가. 굴삭기(무한궤도)(0.7+브레이카)」 = 조합 기계 둘 = 가나다 항목 둘(812-9 · 요구 738-3) + assert got[1][:2] == ("굴착기(무한궤도) 0.7㎥", True) + assert got[2] == ("대형 브레이커 0.7㎥", True, ["대형 브레이커"]) + assert got[3] == ("치즐(0.7㎥용)", True, ["치즐(0.7㎥)"]) # 「다. 소 계」 절 = 뺌 # ── (b) 전수 ───────────────────────────────────────────────────────────── diff --git a/resources/tester/test_812_9_machine_link.py b/resources/tester/test_812_9_machine_link.py new file mode 100644 index 000000000..7e3940e75 --- /dev/null +++ b/resources/tester/test_812_9_machine_link.py @@ -0,0 +1,215 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""812-9 지킴 — 가져온 기계 잇기(`M02_Machine_Find` · 요구 718 · 719). + +(a) 기계표 전체에서 찾음 — 낱말표 밖 기종(로우더 · 공기 압축기 · 램 머 · 라인마커 …) · 후보 여럿이던 것 + (물탱크 · 크레인(무한궤도) · 머캐덤 · 콘크리트 펌프차 …) · 지어 맞추기 없음(1.0Ton = 우리 표에 없음) +(b) 표기 정규화 · 괄호 낱말(무한궤도 · 타이어 · 일반용 · 표에 없는 낱말 발파 = 안 봄) · 범위 · 복수 규격 +(c) 조합 기계 = 둘(대형브레이카 +굴착기 · 0.7+브레이카 · +부착용집게 · +진동콤팩터 · 덤프트럭(자동덮개)) +(d) 별칭 먼저 — 지금 층 장부 기계 짝 = 그 EQ · 이름만 열쇠 = 그 기종으로 · 후보 여럿 표지 · + [저장] 때 고른 EQ = 지금 층 장부 줄(`learn`) +(e) 금액 전후 같음 — 조합 줄 둘(첫 줄 엑셀 값 · 다음 줄 0) · 원파일 저장 금액 그대로 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +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_Hopyo_Ledger as ledger # noqa: E402 +from M02_MasterTemplete import M02_Machine_Find as mf # noqa: E402 +from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402 +from resources.tester.test_m02_labor_ref import world # noqa: E402, F401 — 같은 임시 층 + + +def one(name: str, spec: str = "") -> tuple[str, str, str]: + got = mf.find(name, spec) + first = got["후보"][0] if got["후보"] else {} + return got["판정"], str(first.get("이름") or ""), str(first.get("규격") or "") + + +LINKED = [ # 낱말 모름 · 후보 여럿이던 예(812-8) → 하나 + ("로우더(타이어)", "0.57㎥", "로더(타이어)", "0.57㎥"), + ("로우더(타이어)", "1.72㎥", "로더(타이어)", "1.72㎥"), + ("공기 압축기", "21.0㎥/min", "공기압축기(이동식)", "21.0㎥/min"), + ("공기 압축기", "7.1㎥/min", "공기압축기(이동식)", "7.1㎥/min"), + ("라인마커", "10Km/hr", "라인 마커", "10km/hr"), + ("램 머", "80kg", "래 머", "80㎏"), + ("진동파일해머", "60kW", "진동파일 해머(전동식)", "60㎾"), + ("진동파일해머", "45kW", "진동파일 해머(전동식)", "45㎾"), + ("진동파일해머", "40kW", "진동파일 해머(전동식)", "40㎾"), + ("노면파쇄기", "2.0m", "노면파쇄기", "2.0m"), + ("아스팔트 디스트리뷰터", "3800ℓ", "아스팔트 디스트리뷰터", "3,800ℓ"), + ("고소작업차", "3Ton", "고소작업차", "3ton"), + ("고소작업차", "5Ton", "고소작업차", "5ton"), + ("취부기(녹생토)", "18.65kW", "취부기(녹생토 암절개면 보호식재용)", "18.65㎾"), + ("아스팔트 스프레이어", "400ℓ", "아스팔트 스프레이어", "400ℓ"), + ("커 터", "320~400mm", "커터(콘크리트 및 아스팔트용)", "320~400"), + ("경운기", "1000kg", "경운기", "1,000㎏"), + ("건설용펌프(자흡식)", "50mm", "건설용펌프(자흡식)", "50(1.49×10)"), + ("강연선인장기", "120Ton", "강연선인장기", "120ton"), + ("디젤 엔진", "11.19kW", "엔진(디젤)", "기종 디젤 엔진 · 규격 11.19㎾"), + ("물탱크", "5500ℓ", "물탱크(살수차)", "5,500ℓ"), + ("물탱크", "16000ℓ", "물탱크(살수차)", "16,000ℓ"), + ("콘크리트 펌프차", "32m,80~95㎥/hr", "콘크리트 펌프차", "32 [80∼95]"), + ("머캐덤 로울러(자주식)", "10~12Ton", "머캐덤 롤러(자주식)", "10∼12"), + ("타이어 로울러(자주식)", "8~15Ton", "타이어 롤러(자주식)", "8∼15"), + ("크레인(무한궤도)", "40Ton", "크레인(무한궤도)", "40 (1.53)"), + ("크레인(무한궤도)", "35Ton", "크레인(무한궤도)", "35 (1.33)"), + ("크로울러 드릴(공기식)", "17㎥/min", "크롤러드릴(공기식)", "17 (120㎜)"), + ("모터 그레이더", "3.6m(일반용)", "모터그레이더(일반용)", "3.6m"), + ("콘크리트 믹서", "0.3㎥", "콘크리트 믹서", "0.30㎥"), + ("진동 로울러(자주식)", "12Ton", "진동롤러(자주식)", "12ton"), + ("굴삭기(무한궤도)", "0.7㎥", "굴착기(무한궤도)", "0.7㎥"), + ("굴착기(타이어형)", "0.6㎥", "굴착기(타이어)", "0.6㎥"), + ("굴착기(무한궤도)", "0.4㎥(발파)", "굴착기(무한궤도)", "0.4㎥"), + ("불도우저(무한궤도)", "32Ton", "불도저(무한궤도)", "32ton"), +] + + +@pytest.mark.parametrize("name,spec,our,size", LINKED) +def test_linked(name: str, spec: str, our: str, size: str) -> None: + kind, *got = one(name, spec) + assert kind in (mf.LINKED, mf.ALIAS) and got == [our, size] # 승인 별칭(디젤 엔진) = 별칭 + + +MASTER = [ # 마스터 층 승인 별칭(요구 735-5 · 736) — 이름 별칭 + 규격은 수로 맞댐 + ("아스팔트페이버(피니셔)", "3.0m", "EQ000193"), + ("아스팔트페이버(피니셔)", "1.7m", "EQ000192"), + ("디젤 엔진", "11.19kW", "EQ000500"), + ("디젤 엔진", "26.11kW", "EQ000503"), + ("디젤 엔진", "52.22kW", "EQ000504"), + ("워터제트", "96kW", "EQ000400"), + ("버킷준설기", "7.46kW", "EQ000425"), + ("바이브레이트", "봉상후렉시블", "EQ000248"), +] + + +@pytest.mark.parametrize("name,spec,eq", MASTER) +def test_master_alias(name: str, spec: str, eq: str) -> None: + got = mf.find(name, spec) + assert got["키"] == eq and got["판정"] in (mf.LINKED, mf.ALIAS) + + +def test_no_guess() -> None: + """우리 표에 없는 규격 = 못 찾음(가까운 규격으로 안 이음) · 기종 모름 = 별칭 몫 · 여럿 = 여럿.""" + got = mf.find("덤프트럭", "1.0Ton") + assert got["판정"] == mf.NONE and got["까닭"] == "우리 표에 없음(규격)" and got["키"] is None + assert got["후보"][0]["규격"] == "2.5ton" # 보여주기만 — 가까운 규격 먼저 + for name, spec in (("에어 호스", "1.91cm"), ("모노펌프", "5-20㎥/hr")): # 승인 전 = 별칭 몫 + got = mf.find(name, spec) + assert (got["판정"], got["까닭"]) == (mf.NONE, "기종 모름 — 별칭") + got = mf.find("크레인(트럭)", "10Ton") # 「트럭」 = 기계표 괄호에 없는 낱말 → 안 봄 + assert got["판정"] == mf.MANY and got["수"] == 2 + assert mf.mark(got, "크레인(트럭)", "10Ton").startswith("참고 · 기계 후보 2 — 고르기") + assert "기계 못 찾음" in mf.mark(mf.find("덤프트럭", "1.0Ton"), "덤프트럭", "1.0Ton") + assert mf.mark(mf.find("물탱크", "5500ℓ"), "물탱크", "5500ℓ") is None + + +def test_norm_paren_spec() -> None: + assert mf.norm("로우더") == mf.norm("로더") and mf.norm("로울러") == mf.norm("롤러") + assert mf.norm("브레이카") == mf.norm("브레이커") and mf.norm("굴삭기") == mf.norm("굴착기") + assert mf.norm("불도우저") == mf.norm("불도저") and mf.norm("램 머") == mf.norm("래 머") + assert mf.tokens("10~12Ton") == [(10.0, 12.0, "ton")] + assert mf.tokens("32m,80~95㎥/hr") == [(32.0, 32.0, "m"), (80.0, 95.0, "m3/hr")] + assert mf.tokens("5,500ℓ") == [(5500.0, 5500.0, "l")] + assert mf.spec_fit(mf.tokens("0.7㎥"), mf.tokens("0.6∼0.8")) == "안" + assert one("굴착기(타이어)", "0.6㎥")[1] == "굴착기(타이어)" # 괄호 낱말이 가름 + assert one("굴착기(무한궤도)", "0.6㎥")[1] == "굴착기(무한궤도)" + + +COMBO = [ # 조합 기계 = 둘 · 규격 없는 쪽 = 앞 규격 + ("굴삭기(무한궤도)", "0.7+브레이카", [("굴착기(무한궤도)", "0.7㎥"), ("대형 브레이커", "0.7㎥")]), + ("대형브레이카", "+굴착기(0.7㎥)", [("대형 브레이커", "0.7㎥"), ("굴착기(무한궤도)", "0.7㎥")]), + ("대형브레이카", "+굴착기(0.4㎥)", [("대형 브레이커", "0.4㎥"), ("굴착기(무한궤도)", "0.4㎥")]), + ("굴삭기+진동콤팩터", "0.7㎥", [("굴착기(무한궤도)", "0.7㎥"), + ("유압식 진동콤팩터(굴착기 부착용)", "0.7㎥")]), + ("굴삭기(무한궤도)", "0.2㎥+부착용집게", [("굴착기(무한궤도)", "0.2㎥"), ("부착용 집게", "0.2㎥")]), + ("굴삭기(무한궤도)", "0.6㎥+부착용집게", [("굴착기(무한궤도)", "0.6㎥"), ("부착용 집게", "0.6∼0.8")]), + ("로우더(타이어)+소형노면파쇄기", "0.95㎥", [("로더(타이어)", "0.95㎥"), ("소형노면파쇄기", "0.95㎥")]), + ("덤프트럭(자동덮개)", "15Ton", [("덤프트럭", "15ton"), ("덤프트럭 자동덮개시설", "15톤용")]), + ("굴삭기(무한궤도)", "0.7㎥,(브레이커조합)", [("굴착기(무한궤도)", "0.7㎥"), ("대형 브레이커", "0.7㎥")]), +] # fmt: skip + + +@pytest.mark.parametrize("name,spec,want", COMBO) +def test_combo_two(name: str, spec: str, want: list) -> None: + got = mf.find_all(name, spec) + assert [g["판정"] for g in got] == [mf.LINKED, mf.LINKED] + assert [(g["후보"][0]["이름"], g["후보"][0]["규격"]) for g in got] == want + + +def test_combo_many_part() -> None: + """대형브레이카 +굴착기(1.0㎥) — 굴착기 무한궤도 · 타이어 둘 다 1.0 = 그 쪽만 후보 여럿(사용자 고름).""" + got = mf.find_all("대형브레이카", "+굴착기(1.0㎥)") + assert [g["판정"] for g in got] == [mf.LINKED, mf.MANY] + assert mf.find_all("덤프트럭", "15Ton") == [{**mf.find("덤프트럭", "15Ton"), "이름": "덤프트럭", "규격": "15Ton"}] # fmt: skip + + +def _pair(eq: str, name: str, spec: str, unit: str = "hr") -> dict: + full = ledger.key({"이름": name, "규격": spec, "단위": unit}) + row = {"원래": {"품명": name, "규격": spec, "단위": unit}, "갈래": "짝"} + return {full: row | {"짝": [{"갈래": "기계", "키": eq, "입력값": {}, "배": 1}]}} + + +def test_alias_first_and_learn(world) -> None: # noqa: F811 + me = world["personal"] + me.mkdir(parents=True, exist_ok=True) + with store.at(me): + assert mf.find("아스팔트페이버(피니셔)", "3.0m")["판정"] == mf.NONE + ledger.write(me, _pair("EQ000193", "아스팔트페이버(피니셔)", ""), {"id": "sub3임시"}) + got = mf.find("아스팔트페이버(피니셔)", "3.0m") # 이름만 열쇠 = 그 기종 → 규격으로 하나 + assert (got["판정"], got["후보"][0]["이름"], got["후보"][0]["규격"]) == ( + mf.ALIAS, "아스팔트 피니셔", "3m") # fmt: skip + ledger.write(me, _pair("EQ000129", "크레인(트럭)", "15Ton"), {"id": "sub3임시"}) + got = mf.find("크레인(트럭)", "15Ton") # 이름 + 규격 열쇠 = 그 EQ(찾기보다 먼저) + assert (got["판정"], got["키"]) == (mf.ALIAS, "EQ000129") + assert ( + mf.find("크레인(트럭)", "15Ton(할증125%)")["키"] == "EQ000129" + ) # 규격 속 할증 글 = 안 봄 + assert mf.same_kind("아스팔트페이버(피니셔)", "아스팔트 피니셔") is True + doc = { + "변수": [{"열쇠": "P_크", "기호": "P", "아래": "크", "연결": { + "갈래": "단가산출", "키": "GC000270", "입력": {"기계": "'2104-0010'"}}}], + "가져온": {"기계": [{"기호": "P_크", "판정": mf.MANY, "키": None, + "원래": {"이름": "크레인(트럭)", "규격": "10Ton", "단위": "hr"}}]}, + } # fmt: skip + assert mf.learn(doc, {"id": "sub3임시"}) == 1 + got = mf.find("크레인(트럭)", "10Ton") + assert got["판정"] == mf.ALIAS and got["후보"][0]["원문번호"] == "2104-0010" + assert mf.learn(doc, {"id": "sub3임시"}) == 0 # 이미 같은 EQ = 안 씀 + assert mf.find("크레인(트럭)", "10Ton")["판정"] == mf.MANY # 다른 층(마스터) = 안 봄 + + +def test_combo_money_same() -> None: + """원파일 거창 측구터파기 연암 — 「굴삭기(무한궤도) 0.7+브레이카」 = 줄 둘 · 금액 = 원파일 저장 그대로.""" + from test_810_2_native_doc import doc_and_money, native + + from M02_MasterTemplete import M02_Doc_FromNative as fn + + saved = native("EST", "거창") + blk = fn.numbers(saved)[2] + sk, doc, money = doc_and_money(fn.emit(saved, 2)) + names = [(m["이름"], m.get("규격")) for m in doc["금액"]] + assert ("굴삭기(무한궤도)", "0.7+브레이카") in names and ("대형 브레이커", "0.7㎥") in names + assert float(money["계"]) == pytest.approx(float(blk["저장"]["T"]), abs=1) + found = {x["원래"]["이름"]: x for x in doc["가져온"]["기계"]} + assert found["브레이카"]["조합"] is True and found["브레이카"]["찾기어"] == "대형 브레이커" + assert any("조합 기계 — 경비는 한 덩이" in m for m in doc["가져온"]["표지"]) + from M02_MasterTemplete import M02_Doc_NativeLayout as lay + + from test_812_3_native_layout import _show + + groups = [(g[0], g[2]) for g in _show(doc)] # 조합 기계 = 기계마다 항목(요구 738-3) + assert ("굴착기(무한궤도) 0.7㎥", ["=Q", "굴삭기(무한궤도)"]) in groups + assert ("대형 브레이커 0.7㎥", ["대형 브레이커"]) in groups + owned = lay.owned_of(doc) # 못 이은 기계 줄(시간당 자료 없음 = 숫자 묶음)도 기계 = 이름 단추 + at = next(i for i, m in enumerate(doc["금액"]) if m["이름"] == "대형 브레이커") + assert owned.get(at) == found["브레이카"]["기호"]