From 3d4b2ec1d15bc69408d5bd77991550ff6e5afc59 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 9 Nov 2026 21:02:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(812-9):=20=EA=B0=80=EC=A0=B8=EC=98=A8=20?= =?UTF-8?q?=EA=B8=B0=EA=B3=84=20=EC=9E=87=EA=B8=B0=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=E2=80=94=20=EA=B8=B0=EA=B3=84=ED=91=9C=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=C2=B7=20=ED=91=9C=EA=B8=B0=20=EC=A0=95=EA=B7=9C=ED=99=94=20?= =?UTF-8?q?=C2=B7=20=EA=B4=84=ED=98=B8=20=EB=82=B1=EB=A7=90=20=C2=B7=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=20=EA=B7=9C=EA=B2=A9=20=C2=B7=20=EB=B3=84?= =?UTF-8?q?=EC=B9=AD=20=EB=A8=BC=EC=A0=80=20=C2=B7=20=EC=A1=B0=ED=95=A9=20?= =?UTF-8?q?=EA=B8=B0=EA=B3=84=20=EB=91=98=20=C2=B7=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=96=B4=20=EA=B8=B0=EC=A2=85=EB=A7=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 `M02_Machine_Find` — 기종 = 기계표 이름 · 괄호 붙임 · 상세구분(낱말표 24 안 씀) · 소리 표기 한 글(로우더 = 로더 · 로울러 = 롤러 · 브레이카 = 브레이커 · 굴삭기 = 굴착기 · 램 머 = 래머 …) · 기계표 괄호 낱말로 좁힘(표에 없는 낱말 발파 · 리핑 = 안 봄) · 규격 = 수 + 단위 전부(범위 · 복수 · 인치 = ㎝) · 하나 = 이음 · 여럿 = 「기계 후보 n — 고르기」 · 0 = 「기계 못 찾음」 표지(가까운 규격으로 대신 잇지 않음) - 별칭 먼저 — 마스터 층 + 지금 층 별칭 장부 기계 짝(이름 + 규격 · 이름만 · 규격 속 할증 글 뺌) · `M02_Doc_Match_Note._ALIAS` 여섯 쌍 걷음(소리 표기 = 정규화 · 같은 기종 = 기계표 · 별칭 장부) - 조합 기계 = 기계마다 항목 하나(요구 738-3 · 제목 = 그 기계 · 줄 쌓기 차림 `M02_Doc_NativeLayout`) · 줄 둘(「+」 · 괄호 속 다른 기종 · 「…조합」 · 덤프트럭(자동덮개) = 덤프트럭 + 자동덮개시설) — 같은 수량 식 · 엑셀 값은 첫 줄 · 다음 줄 0 + 표지 「조합 기계 — 경비는 한 덩이」 · 우리 단가로 = 각자 시간당 경비 - 원파일 EST · STmate · 엑셀 읽기기 · 일위대가 호표(`Hopyo_Elem` · `Hopyo_Fixed._eqs` · `Hopyo_Row.m01_picks`)가 같은 찾기 · STmate 묶음 기계 줄도 시간당 경비 이음 - 문서 머리 `가져온.기계`(기호마다 판정 · 후보 · 찾기어) · 못 이은 기계 줄도 기계 줄(이름 단추 · `owned_of` 파이썬 · TS 짝) · 이름 단추 조회어 = 기종 이름만(규격 뺌) · 고르면 엑셀 값 기억 그대로(금액 안 바뀜) · 문서 [저장] = 고른 EQ → 이 층 별칭 장부 줄 → M01 별칭 장부 후보(승인 흐름 그대로) · 장부 짝 글 = 「기계 EQ…」 - 마스터 층 별칭 장부(resources/master_template/실무자료/맞춤.json) = 사용자 승인 다섯(요구 735-5 · 736) — 아스팔트페이버(피니셔) = 아스팔트 피니셔 · 디젤 엔진 = 엔진(디젤) · 워터제트 = 워터젯트 · 버킷준설기 = 버킷식준설기 · 바이브레이트 봉상후렉시블 = 콘크리트 진동기 엔진식 ø45(2.6㎾) - 시험 — 새 test_812_9(예 전부 · 정규화 · 괄호 · 범위 · 조합 둘 · 별칭 먼저 · 표지 · 고르면 별칭 줄 · 금액 전후) · test_812_3 전석쌓기 · 측구터파기 연암 항목을 기계마다 항목으로 고침(조합 기계 둘로 가름 · 요구 738-3 이 바꾼 까닭) - 전수 목록 docs/raw/요구_1104/_목록_812-9_기계이음.xlsx Co-Authored-By: Claude Opus 5.5 --- A00_Common/line_stack_edit.ts | 37 +- A00_Common/line_stack_text.ts | 3 + M02_MasterTemplete/M02_Doc_Import.py | 26 +- M02_MasterTemplete/M02_Doc_Match_Note.py | 16 +- M02_MasterTemplete/M02_Doc_NativeEst.py | 48 ++- M02_MasterTemplete/M02_Doc_NativeLayout.py | 35 +- M02_MasterTemplete/M02_Doc_Router.py | 8 +- M02_MasterTemplete/M02_Hopyo_Elem.py | 76 +--- M02_MasterTemplete/M02_Hopyo_Fixed.py | 21 +- M02_MasterTemplete/M02_Hopyo_Row.py | 13 +- M02_MasterTemplete/M02_Machine_Find.py | 395 ++++++++++++++++++ .../M02_MasterTemplete_Hopyo_Api.ts | 3 + .../M02_MasterTemplete_Hopyo_Ledger.ts | 2 +- M02_MasterTemplete/M02_San_Emit.py | 83 +++- common_util/common_util_line_stack.ts | 12 +- common_util/common_util_line_stack_pick.ts | 5 + docs/raw/요구_1104/_목록_812-9_기계이음.xlsx | Bin 0 -> 48347 bytes resources/master_template/실무자료/맞춤.json | 124 ++++++ resources/tester/test_812_3_native_layout.py | 12 +- resources/tester/test_812_9_machine_link.py | 215 ++++++++++ 20 files changed, 1011 insertions(+), 123 deletions(-) create mode 100644 M02_MasterTemplete/M02_Machine_Find.py create mode 100644 docs/raw/요구_1104/_목록_812-9_기계이음.xlsx create mode 100644 resources/master_template/실무자료/맞춤.json create mode 100644 resources/tester/test_812_9_machine_link.py 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 0000000000000000000000000000000000000000..e71decd2a8311d9237f60b8891c0d681762056d3 GIT binary patch literal 48347 zcmZ^}V{k5A&@PIq#qIepTO(nW?FowN_78 zcVFFCw}LbXC<+h|5Co85nT3v+W*hhV zGa!h3OPy`ms@xDT5-b+(j(JJ2v&9(;y9R%F|LKd};Mrz~>OdV=A0&2T4H+!;MH~|c zYJnCT5MC(96eq>9P{$LP_J~AYGaDqS!yW0;fdJgaOCPRnvm zwPd7tNwvC<30~>KZ?Std0P9VB15@~4^4=s9O#pu6oB##_Li~T_HL`Ow`A^{Fcv-6f z2AGgQu{E#*6)`j2YjhxK7po;$LeUtS9{cqi*YiAK7{00KbA_oFO{?U!QHNCUBiH*ts z+!_DV4nR}OX`KVb_qul0-R#K*dYH|>r3u-|!gZr5JqRF&AB?CTZS7$kALQ37&W13N z0t-lx?a1T-r8#?_78O4^mosS`ItnZ^sV=geOtf*VH&-{Wqp&V^e2#l;INY)+CsFrr|2YU1Xou9*EJM=&l!Le1xS zJiowRvsj^U;<0gmnPE4I5e8Xw-r&`e#AHXps>1!el&$-LZ zHz9s#RIw)V>5pDc#k))|$HO5e=(7KOI~^H^*ozm!A(a6ra8L*R%+Vc?>^lvZF#T-v z4OwEgripnLUqqKWgeNQIKVxD6FU-(@><&`3%&iDz;VZ*qf&#m3$KyNlbcbS38SYZ3 z3Gf2`o?B<{uY3vk$LUAsGgExs{=l0*-^xzxW7fA2skJR;t2Ovu!??b}LPuew<~TJv zqFkFuxyqUQ@AA`}FXtBUwnh?Eo1sBVj20*D-zZ4Z_7ORLLp2C_YOF;HIib8@!XAN? zBZO^x_UBP5dF80KouZ9L>+q~L-Y;wpUJh>p zhTDuM0k_3150~}*Nyqh&xR~Kjs&E^YP!EVrNA@NcM^0LNJJf$&B^NA-1|wNVqM`iPYUix*j)kPReng?%z$vHhnHTOS@N&~3yO_(6$y=4tA5+c zEe*=9u|91ccwnvKuRA3beMYTe5bC%BZ<#33Q~Q%tZM}oJgoBL|lV;mmN;ga!A|=j# zi!@}j%JFTJ`aTuh85OO&x*kj3CRDkZFOBNaTo^<-G-xCVqa^ease*gVKRqYZl7lK7 zy`s!5NU0cI2gf50a(~*8TwCCOF|~JcmV25onw8w`(855{XoYDY?aS%_mQFIWYCMTt z-%{QB^zT0H$Hd`}xKH#RbcAbspSBL(Y!kU9WU@Sl5P_XVe-S6+{6<4(I#jqyi2IEU zJe4#ia`P*|xCG~K+8cED9ZWkV>U1?9r=KWIV2GUVJSh@NL>&wmA-dzi^>w19Thz)M zjlWHhGL!KV`nYg-A1||`@TxNDea$)C~Q51GF7fTwLXE&2C`?jmm!I>`B%~`?k2BW ze}c@9<&C1Jtz8)J#Y)X$V`@ZxX>f1+gx^L{S`RFGu_%VsCH2+A>x?iSf7S0{@=ZB* zLPayMn3p1}IHRHL1eXMk_%{||&7WXdaN@yq8Pi+X3J>L=cs9STVe9$_WA*@%y}|MUm>XIlB!JoRRP3#N2U^#%t@5 zu_Q4=8w4H#*^9qs?CmVemmI6M%-d8+w?gb6?6JFHYNQbLTib*M#Y0Dz#E{RsH-MTAV@YYsBc3z`9Za53?A85^6Yb8O& zmC$Zg-41cuab8>H?c#3cH@8V(b$>V)Fg4+_UsAXQ@o;UVuJKXGSiEjQ{>sE-TdE`EStM@VbjB2~#h@ju^zPnr&EYg?iiI2S=-xcgN z$b$0_cVZ&YVG0WnkI@h?BIXuBu7HXM7Q-p}T5f`~9vfx`FP)ieLi(QUu_17WnZ~aM zvRmUdNFLWMLqtJ(&!#Y6ugzqT5+uENaEn1&3LW_Hua^% zSFx9&+P(Xsqgx}9CO4{pcl{3W`)D?@YgZ{nri-L1Gl^wp(73bf&Fb@%N5Cz!`|N;C z^+Gaygbz@8F#VUv-1)V+M^Zrk&5-odI zZTh9Ia;2C{9()m?7QKAlu#j6O^T3Ksis6ef5+2^WdMLg%hd;62LHmlBjj^IQ3&u z!o#Ja<&E1Q3(J8kHV-MsX>Z+4ViuRA_#xR>f(PzmA=rn$@vBQ(`rZhx`aaG#fPTlOQlmraxz?E+82{W?#WY*&BB|WwQ(R_i$QLpfa%U0gFMQ zA$-nW!`Zy$$W~IoD^O6Al_1WzdhKSst&72FY8K-NPwHPbVU+1&o?rrN%)l8I6kVX; zlod<+%@o5I+eiEvKm!?n&3UjO3Hvv;lig>viw{!U!Xfox4K`>^B2G*>XOXTqh!uXj zZCv(XE?MKucK7%HCm&Y2!?+@}0Rn0Q0sa3AXH5Sk#Yf#bwKyZmH6A+x-=P72f42~L zF!%Ca|GQA?RYDIp9lUO;6(A}tTo1^>_D^V8Y!VG8l4+qxFKRtj(8KHS zk}fbgPrCy;m;g5Y3iNKJzTlTJ6;U-YR^g#$4$w%#+CsOrsN7CYr{0c=Qn#yW?)h9e znf^QtE}hBZy-DKkoTg$oE>yBn)taGYO+%bRcq@TP;UQb`I*Lg6r zJm2%>4&Qy?y}aK^{C@lz{Pf4?pYNN?*YCSc`4hACQmN#=YyRg;=yN|a{w036KylNZ z?@;t^W*1-_Co9QENj<;*ubE+Tfd#@jRrMQX1$@cz2F3)1V{Aog?7=MC7iiU|&gcEs z`9Q1nz%@ulq1iW8**86PW<6{J!J8_Ah{Zj934Eqst&h|8c_s+cXDM{5r&yEQxAR_f zh}F{fZAHPt)SmB@bX*xtEw8cUHwxES}fK+R~BK%8}IeuppN^E>Z^c9Wjm?aZL0H z#*viG5mAmL^}qGYhvTr0q63G&{#z#ED9TNs)fV@MrSUs zl`i_i@V8da5}{It&0Tc)d`JndQQ}C2WX#N?VqWB%n@3~M5{CYTJ3$+ zU7NdT+jHb2OSh@ComCUALJ^mDz*BQ$5jtFe{1F&CYX4t>&w?vs@7E6 z>LY#mnvhAf1GxM8iBd9w+2Akf{i^Kt1_&O zsn%&XmO2~3e{Sw|ij%q9Qppn8h@p&T-EK8DgxM2C{`|l5Z4ooFj_Ah~z|a&bkS+0V0{Zk3TMKL&%+IzKX}2kxG1+M~Ia z*$kJPr6^{fB~hf2-rLLfxh^!1N z;jizw@@3Hj;SqbDFW=i&QB-lRni#sMJgoF^V)fZjQyIP~wsbZkvfT9(mfEr|bj#^! zRT1b#bS4PYxqS&Ax@I+Yo%N4Wj({az-Qrj6eTu216LK?6^bB}hqx+Zlqa|r7A-C4; z;>iknJ9>JbE}o{d7Wsm3gy-md%xrn~z%uhX{`@1=0uRyCZUv{OW~ls-2rUCVa9IeI z`spj>N9IkjeU%J`iY?u?L|l{Ap;O@z0^ToOJO9*L?Q+{K4c*=rIA|>oF|jxk(=xuT zH|vCUz6mMniLU+!-7?O1&3YQj4FR9G>ZQ$=y+u0v+7=)YW=c)?LY584sg5w8Q;dd@ z9qzfPs5~N+48?bnox`=TIv-Q?Km@9VNowbr9#s};kU-OFJd!yWQNpg6!NzO~VFUCY zc-({M7cb5T!7>JDjwpi=Kw`BISvn4nP2lJrL5SK-j!sn|h9deP#G?fylwr`?!D$Uh zmkfPGz2nPa(z)SNzR@h6-!+1pDE6MOfR;3KQ0`#n15u#q=~55eJ=C68{_&+{cVkpX zwh0CdfM!kw+#QqftD;&~u92<{pG)ld_E|1hf(dC~X%gmUMC*MV5%G(r5pPtmY?8T_ zg8yC9k4X3)nX?NC+JTY`^z9Y7J+fJS5$uh4M1cQmd$H%KLd%YpuD8+sKu<2lPQ8?c zB~UqXvdyol+^Mj8yMn(ZheqUbmmE2M$!lc73V)`j!``K(&9S@Z4RGx4z{|z;5qFZT*K^1^#g8 zjaKXUfbXx)na#OK48+0J4O@QB&35$h*H3DWF!YAw!E?W7E^*yr@7#qsrd?@q05>+xTnaMyWs=!|aScqifn=!pJ~ zRI#!w-Q83@0%_dfjIsfW(Tuq0jNoP?^y{rFZWS==o(}gNGGY8(l?;<#@QX6sevTb3 zmg0wHuDjQj@bDLuYwi(8&0djuo90NVw%gU;z#b#*5S+KuD12CJy))dCu^4=`|K!P$ zW~ATrXQ=m+Gu-QAP%GACJKIQehPta)#n$KoAec;VOg#n2P7F&!MPfW5zI+|dgu1tN zH?2UfmeAd*Cy8H-w%`=6A#DbEanm~_nlL3r4vyKzuxiB)Z^luR<86;Lx4L>?KKf;R zx;<{G$XN!{w~MUmMd-znN6fM({QNpM^1fel0gqRoikk~R+LgoPXPC)4aP>PKE7h2^ zt*>Y=Y^?uIaQ#dYyUc%7E)ttyuu^Z&yOMf$to3}AORTWJs9qa06W_R~{pwsU7m6>W z3hJ%x>Dsoo=5JdV)tq3bU$S~Qe<8kj7bDw0y*v=TF-%{k<9K}FyqGW@7oIbf`9+&a zDb(&$?r*uWyK!8MP`{I@Mjf#vl3R4{ofgx$j$8G3wdO|~;5nvP@;&vY3c-Y^zJ(Dn zM*c2GAA$2o^zk`n)@!q!cfQ_yesA^Ud_<0jbB2DsxMp@q4f}^Sf(_)&d`ZTOrkYSK zsKchRYmxZLTey!y2}P#RbPw@tO{dcxu-9#>X#bkMlpy@H2drC@4#Yk@-NefI%mqnF zvC#i%QgE&D;IWLqdFAEAKNw-5nQ|5B(Mys9C_2AYCOG{L}qbf0>zV*Ge!=Uh{iv_I8@ zuy#yJM`$V460MZtY1B;J?=r3wgawH#H;y}3E7QHljlJ>AS1f_umFafP%01vRA4 zSbsj~2@!7-)^s}`fxrr|Q0tPh%bAzU?OGKfhZ~C7NBFUrdtaCP?frqsN7ETLTtWtz zuhPc4=~%CT*<%CYCUdDxLCY=Gd!x3hw%G`6Zy`CTxwB>u>#NdaJG1rnx2WmZe`et3 z-D{0lMl4pJe4MRZM}I|JO+AkOy5}EXt9R$; zusTwa2VLVy3Ox2C3Ui4JQ)q>RuJ(3S_c8ElJ-+mFaW05*xfJw)19_w`DKxYCuE<^U zb`?-Fr$Xhs?Z21z`JQyc^Z43KN1x7mA4?ZkZZgy-F`GJNNlL;nMbUPwZVRHs!{4g> z8quO-!p?vSL>C@^2s4T|B4*+ZGXlkp@+DNt=`o%voGrZ(CT%$*ek}>54bWN*>J{1; zJ^-p+nl#&Qn^HAmd?i`2z^yM__V302P%<0_#~ly%>K`@@U|+u&vr134(S61H52T*^ z_>+VZMutNj(Q(cVU+Rru@cnwXt7CG9N0o1h?x|2cScyXLQ^(g&^Y27z^@va#dDY?fmN4?ix1e)fX+*^8q`5f@kJ5>N5b z>%FkI8HpA-oQ=p5U+ig7VsM-}O)V{_yOJ%s9Ln^_0=YzQN}rnj{EYf#A=9tUQ3_(z zjV#HHoLn9~FqSbVijX>}O}WRpb_nH!D4PMvE;e3sCcBPVMAswu5RuiU37Ya!6%Ijd78h=f zKtfOD$W8z8ZSsIjwytnTS{Pvofulc$IZN&C#@~F&)Fg234|4FA#=7>ua(;Ee@g9

U8zIJdRkcO;pI{0hgizaVu6u&|0DzWHjBowZstHaj1jE861lpoGUBt$aF<=b}mpO ziFg0<+wpM-Ik9etSJ(?IT7fjYpXFJ{uBSXc!OSY}W@202@ib|tYM4`@`#n-on9^rW zzbRimf&+IW)qNzmW7-gT2QFEvLlUzN_NANqs;Aqb{1e@~T+!K>A!Z0(5(bH8#LQ7g znUxK)j+yB90=jFaWm2dx5{u6xYvj?=ud*$^uT;cChug=ZezCaMyOe}*S~gmP`)3TA z6Zqe{eFeNlph67hIVOZT4`A2-HoLST`FN9fVNF=gw)8K-kFbn)t`H4ibUQ{)@x$V) zd?E>WT))5VK3`utpQUojl2eM;c_G<2*_3)OCt)K@n2>G}SZwm=?}|D~+pso*AuH7( zS0#xt1pC@9MuuZghNwr6q0|k^v@kKBd+}WlKQ7k(i657iTbe}7VI?ZQ@#cpWAYCyS zhH!3kNaSZh(S!ATs9)PA+yZQdAGXItQ({RK6{!}>5x^fjfg8Z|yn&z9tsuemxM2(a z0D;dyPi}+(SAjT|QPZx(T8mP15)_0`%f~yJKeBeh!IsSilGLn{2S8rq_lXNt3p_~Apddd^QY%EvQJYM(Fq-AV zub(Wiib}NCMS>tAt0j*@e_wYIUXqQJVE7jsb0iKMy^_AB6|Cwq`v~Ffjhy_MF6goG zDtOOV{p-}O8atRJ&OCJaf>lAa+uO{c3+iAv{_zk8%O1+-MmVDTcBzC0U7P~vrbW?0KzFt=}8O3~Wp2;K`xn(IsQOr^P%`;pA z?)olXriHxu>SW$YCQ~rig?E@jJ8VH}(Z8OYrUf!xvBg2xdCSH`OeOJjV882r<}1HB zV-~(;Z{}Ej+jDtu_oKr6@8)`83fUmE(Is^;N$4vdD)P%;MC6sD*lA1WZ4D(W^Lg#i?U^sxX7Az zuf=;ri6h+3(g(iKNcXi^3fxm4XOVt|?i7?;y4`Y|qEoWz70GzVGnKCEyge&-MnY4; z@V;K@R)fFEq}MK=gEUX9>y*#AY&8Qq7TtW3xe8j0@He-ttUd*{;WMC|@}rb8BJ4K8 zCUaLa4xV{^8~UE7YT-L6@`pMYbTdwY^m`&$E0c0*pDQr*b?rO~cjfIgp)K)ukJkS( z$8K5k;w8$zW*4P!Nm;(U4^=*+;qTbMv3?+%028tWbX~F_bFzzxH@12omfA-Qxl>wQ zIt=xS;D*aN8GU!|X`<3Md0hfUv)`YDq?b*#<|Jukhoo>>n!k8yA)bs>tkALI2O<_7 z7!#@x&Wv~F)U{|}fzxJmL0?ikdyA28=MNt@X{I`bIlBe7Fu9d?Zmv^1QM0#1K5~R> zzPU17C}1j3$AaTIWPk;CbGfJUjb`+JH316czB*ip49_Wu_OEq>q_wN6UY&XCyIhrh z>>Iy0zJecVsRvtOmUvwlN?q^Awy3Q%=5$G8ZNlbA>2(klk?jn@rBWR_Rg753N)5=F zO0I~Ag|yQo*n;#uDd*wsEIHDyNa;L(a)JzwG`@<6{v+Rzv_R2^IOs>TUfhv_` zFr@Amu@EbjTPNv{JE1AW$K6nkxd-8Lm1LKFy<>{6z3VM5+WhpedH#l+k5-K@7(lw# z%_fyBv9mpNj6`WMMeJWwjrDpOs4AMsUyXAKs`#}Cmdiy(l! zxrAPwGm~?a=}E=C!5wa#IZ^Bb)M*+dy=ugCmGEvVP@$tSPbB}uk$r4J`yW^^{zWd* z(fj(#Y#A~uz3^XnFTPeC5Uf6yJX+-SEQAU(bkzn&(D-$nS^~zqM)q=~YUyJ_4IK&! z&2DgP9F{3-OQLm7RpKX;_!_+5P9KpPyU-r;-Ols)z4bFN5wDnvTDbbu5 zgrBGZ>BFqcpKhum z{=iPG#EUo$>4#al#6L!r8zrxaKZ6~Nv(gwwsq%3bvBUUEHGa#Ix|kLu2$VxR@V{Xd zuB9l<*WP>RW31(n)K3pDz=i2JVWpt*Fn2@E9d;{y1krO8y8i)mM97_^Zebfp=*=}^c7i-*YpN;pX9-fb4Yf1YR($b zN&~_hfdGr&QlTREa_Qr^$&?@610-Ik7VgW>f8$OufdTh`gP84EJ?C+4!s+A1@*e64~>b{ z<<8RJMP8$jO+FgRKRpJf`W^&l5LaXmLmOeqhm<oY}4LjD_ zhxZ@j!wnrrj@NM~UyfEl(mcX$BD$ZHt`St`zWu!=f=g0vQ&8gr#wgd+&BHKm?hdt zv7F^5B!87Q@>6~KJ}dIBl(C;L(O0P1(&=$G%=j7;&^$3m038v5h#S+07B{nC3uS{^ zJcUb0D8frYHIvTVv#7Jf*SEPZCz0}YsM9~7*fAKnXwksy7q{Tp?5)q3r4@+8|01zqXAa#qNJvD#9wW=rx zYblsB%fb;06{4hUBLJv~7Aa}kjr@L8a!sOmFRuo#)&7`U%}O|{5HQkrr)0dICMFsD zxJf9qb<9U!91Ol&o77R7l)_>0a9LEnO6IN!V80>3(6zcJSuI&ICNKRLA?HL%u@A?s z`SBS>bWj>8u#cG0f>RA~FxVkzf<3=@^kWc~nm!oxM&&4coQhn4H_yWP@StdQ z>=d5+5^ZA@iu)4A)!8BH>9V@#5btWC+(g8iis}dRU{&99ccyiPt9?wprz~;(iXeSE z>?dKnbX+ePeG#;if3UD{?$d#{_mfF^;)l` z)RoLXcleu%W{DTDrLWG9 zm(;tEZC!{21r?1oi{RaUVJSn*{$BB-_%ZP^ga@R%>W@35-(3n$KPlcd@J|_Z!Hbg3 zprEgeJwy@Y4A00A?eL5b<&By?+8ED?^TT+C1OFFT$)ddXSUqVrs027UhdR{#{2eSy zFobKC^*)ZQa@9SC7{A1;ipE8i;+w0HbDru3Nf>=w?$!(*hwRQBII6b0J62+Furm_# z*Z6aMp2E>{{LbIadhSQ>Po}%SkWpXd}F{~G2uFf&Ov;z&R3z=XY`Ul03zjGiyx{pI zN4ZlOcp*BAtKIsEC*@A+ZSQl+=%&z5y`~6BZ`V0Lu}Dcs3N}iZ6WHq(c9Iv32%9q@ z;a#e5aB9;Apifr2rK4ZOQCv80c2DnntLL`5(RR&{I7_4Ysrl1rs!$FC)Ho`$=#1jM z4id1q)>nu(1H+B)8nk8(jk%KqtlEH z8s^sN{e1}Kf^?Cf3dnLGz%Y+4G9b8Ri=E}W1j1hF%C%d&kF3OhdI*;&Qld~llq-Hf zM8ReUtr<2^YS$r>2(SCAAxk%-*CP)frxBM+k+YgOLW9pY>j9tVrk!N#= zvAZErT}nJ0%#YuB7TF&YadJ^*T-G;w)a@?}PSE(`os!~erzaY8v{6pF3))1OOaRAx zV>}>$KbuI2i;C#Qt3C+6pr4?w($R#n-Yn=hl2|xz$es$`_z#+%P$9~xAxtfpnp)1R zx|ldEmUjRZv9fGdb`@rEv$|OpTHUdHUk>8^hLPouf_Xjz-GVYAN7Q1`KuKL@mV?2I z5`*Boq4+hl{HBuU>93w_v4i5VE|Jsv!P@u{%@^lQuSF~Kq-J)*6DbrdXkHg5w1z-x z^p61=viui=A%kHsDce5pqmj|_5nADp#uPl8*=ivD)mlq4?DVuDya56xsFN}f#$RfA z?u|MEWgrt#RRki;-`@Z*w2RoJY+GHSzxdYEb*}#SY0w|~?*8)R;jcm))ZPO{SW#!M z=-3}lNx&EkMAz4nVzChIHUC5eV1{C8M`_aH+D}M-F<7skQtOQ*u(w^bSz(a;e%>7T zio*Qrfl5xYPfiVs8t|%5=>VSg7<&KIcM6eJkPeU5il7AOUuewC|8CtZeEVg}oNev_ z*xpBHmy5|n)_dev_(IpJ(qlfpbF*Chc&=J;;lIBRQ)>qnx5=s)W8IY2!R3d#5E-6Y4_*XZU>N7x^ zO5?w!V;mnWXj?aILQm~T55P7a z_OO6~$uw?fS2bIe1xzwfz`C}#mg`4pW#%R0?!#`U%j(SRUYr)wKrp1<4_hB+&e53P zFn;^EKtl-&i$dzCpn$43h?EE6V(%$$ql)#44jpR^wwfG|_hm)uzXEOprod9=Q>Q1; z>rd{l>JoG?upA{%({TF#CeHY7bXhGmTP>Ze2)vm%_uwzbdY)f0KYhwzL}+8HQH*rBZq0qU9sJ^a_vjq(XeR{<;S}@QZ60)0>Wx3Da|7+ z3yK*k#!%?~RQ=;oD=G8Y{)oXXJn+f%(bL?&2qnCUy(AA(#x5C7*%xKx$f=$f2!%5w{db{c2-D}DC zZVSxd$>Sf>2|$uic%c?R{T8&4-lCYC5kyEMH~+P#6bG0%4C#AZng08B~u3UDE1)l;wnsDuhd=oPh_40X3pN?Y@f|mqggyYRz4ZE6r zS1%OD@!Ra8!`J0}T|`^t?QRfU6mA}Nv8lx!`nooEegogxg7{Y#_trMAw zTKpmvKLi-}f3ntXVKB5|ED%_E83by2VkP;#+bxrefq>aBbx2i3@GqYo0ejEZhdRpC zq-RX3$$Z(nv6caWYUWC}u6@^@HxoU(s$r?ru5lil_3R)L0ye;Pp_o1jMuXHbSp{L1 zW>igl$MmPArS5I-R?F%Ca8F->EiPM?Bga%Q#8C9?wvtC4E@YKp-G{BR#8${Zf0_SdNvzPvGv;U#32N>VS7$mWn~;;0(fN1Hjfk)702D5nk`%6j^I zkYRpE8C9_p6u1U#W)Fd;%0hu}2Phu66? z8e=ZjP4KCWtQ^NQuRv_mB+>3S|T3tgD0GD)+%YhSiR?l3=g-OVB2_)X@Pqq zZoW`yB_h+Zi9}!s18C9-heh+mh&P$-gMmBpMN_-no97`q z>IpRmF>KIB$zR#@Un$Wb7E0}!RF`u+V27A%y)U6_ z13q&TEi!q3@(TwC%#;(qSva8;8Qjf#}?khy)en57@OEHcldc`V2+DM?Ya4z z4yZJWd8=srlenp*g2FJb?+t5A`9S+1n6dCZ2kT$2wJ`9OP28)cOUyZ`2aZuo1D>Q$95Rc{< z+j;$8L##1d#JpI6@Sq1$=uX6b%aG5^Nd$t8Wrkzf{GFf64uKTU0$huD#O?14fY`TE z3SwqXMMwc+ClwY1$Lpc3%j!dpMvsSu$77vG!O_$+LqL@4{B9L5CNo=elyzj0w_YHu zCg$a+d@l+L9>`Xq%006hFq*9DQ!}4j9r_jd5&jMw$Zp}{aF#4PF*7eFEHAO^5;H>G z4;9AMu~otC`ei^S)rOyfa1hBHAF!(80|R!exJeOasxEKT%Kjn!n3RTY;Pk~W!@XoJ z@72!EpW(|6k)I_+$lt*SZP8uX1j0s=H5B5D*m;f)F$<>9@kXw<+1wa?6YBzv-_76; z?@Whe7Zd@EZ)(lO)CD4lJ8-tk4dleX|MvT3O3 zBn|I)?Dtv6t#yrcE^}EiO|X0TqWSpb;Nt$%Yf;M|_h()Zmt6 zSavvnN;F%2mCBi`45fi>v;#B1Zr^llh`@6gXVEPnTyLqjXUANq&mSL*0Z3u&dfoxU z1|u@nqNKAIgiFQ?M{V+t1>q7k%F@xRrfltnrr7lFg)#^YOa6+se-ec4Kb(|?Nhfod zA{7*H0o1*{0*;C+CvtbRdOW&0u^#@PHY&j9XjQWFL$t+91bR?|B78A8;|BkEoK0rCCZALX+5boIs#|JJ)^g z*VXKP@br=nXM;-Lz?T?Av>|pFPq6W!j7h($ebaR{fY)L{xTcxR$ZMT`5m8IG-O6Ue zxogErE$pyq3Q&;YKfsid-CutM1-8|ZpM^mSmL-b!F67D)f8yeRz|+sol?i0v?Ity; zgsh;tv=paH=^7>uamTHn82aQoN>m{yYhjpaARkdR0Acc zd<;F(k`!_nCqdKTkQxdH1Q7C9L`!Yi6nvze(=tEXz# z!3ElZN#=uH^vEwcFg`2->*c+^*rr(mWcZ&k17w8iJxEs4zK&f@5t-KUuMAVeEMaz( zV+~vbURim5Py@3B-7}p?UCV{HVVMaf=BDs=$`c$LFE_^^WIVy$nOSgh^o4!j>-HMw zU?-fS>t;(s{@@>_RTC4Aj6o{bQ2$(%-shtVjd#Pbv2bjAr}_&Bssp>x9yjHF4#vFF z{#-Q}$|Co_EJ;K%}`oy9Q9O?uYhAK_O?{9p%jG5r@f|6GrQwI3N=kHb!p-jK^zoaG}VE<5G6~bO%ZY(95 zKr3Fn+@B(k!FF{!wri@WS6)$g^~N^~dI-xY+sh4Z%a@_nw5hGU)%$6ey&ygN$Dro* z-Ap{3^nZloC3%s|5Eh;`J)hgSIs|k&bBtfM@0ttGkGP+%ZK$XiKQLOhh1)*|BE>== z$5zlG!|N>Gi}p-9_$;>4rc?(Qc&}j`$R0=21t%r85xX>8fX+k&Lvs71xV(s1urLL= z_+5S*AScQpnYKu-MD;yHF(;)h5}W;IJl@=DF(N?Y9aBwNiK&3MRb}V%B-)I%em`HJ zreQ?L9UB+$as|OgT=Cbf;~XHf@bh10?`b_wn|k{>6B72nGs^^{d5x>wfDqYWA8b6IwL0$W|ZXZ7$Fv`u@A4q zLHezvNGj;G5w2420}_EICL>7fs5>0lZ(glu;;GZXWEs>{V%wxR=y?AhQC$iV%+nR9 zTRy2x4)rqruROo7sAi1{f?*|rSV?+LPJO<STjB!Dp{#r|{)g{oudk__!-{JC(OOl)67HT|VNo-6%MB zoz=puvEwfglU?I6>TS((<|ctYjlqP}tivA4mAmgZZ)(f9^IVn)TkOopNq_$lwvo_b z=0RX+xkAH=JGU(^9&JKw$94Fnr`+41^Mz2@vUx#l^_@ZF>dx=4(8|ofgXejjfzihr`LB4f$}>PK z+h@(6fbAh~AuE8<&QbLDr#bvA#5h9-;?Qj~b&qL?dP2MDPkMSJh(o{K_A`?iA=dxN zgQeMEn*Wj#EnyOFfY>*4+!Q9je5X7koWGqWlG+iO$!eWeDN?b1r1fRXTv|7at6RL6 z?*n0mUC6{s2un-qWD^na*JZ8gwp_)W+NVB`)dnrx8E1|Q zbu^ggBMl-)OOZyDV-&vm8MWmV|1p04h$2ZzM%=9>MLM{6*)1RI_iU(aAdu9$HHNPvbC z1fHOHuLxRD^B9N?*g=srUR-yL{G^*M{wVCfiApRtx`EEhKX2*zTgE7B;^zgfMtEa! z@N+-pT&Ovd;y=L&v}i6GXfI4`HX^^|PZFGcmNkV`fPkBf_OkfQK6)p3A+V11Ij*%fPHs?_UD}^eoAsS{q zoi*oQDYK|OTl9zm`+)N^6x97Oh!!O4I0;{E*WAC%mz$#%vihTMPQkzOy5LxmNQAY- z$jRtU8ajR!x5LeeGjQnFdU`&HiL1Vr(9u!qgRf%@5LOe@pgJrYu8tFUQ)rv+|I~2e zx9fjmBfFDu-WcVk+@{FC-L6oHVsM`P6$pc*ui)gk_OkIj8(v`5S{<%-ui}lv^;C0f zku0F@{}`SdAJGl1g`JR#(vysQr9+e^u^Iyszc{NPW%?!E=YLN_+H?`f=9^M2i*y2w zi0Rh86`=iO9E}kzt&Z;OKK#5JFpxyg5+?#OnHZYi*VS(4*^m1#cXxL)KcoGYU-Lns zSMm1L>OGT3p*Ic$)x@ z!2|sj?rU@lYB;lNwp7)}Awuz9MP3BMM@@uSkWck%W^|Tl=JmuS9>-XwqWpmV|KL?? zjhJN`8*ShOw*H!2SDeSLEwLrHozVIaPm(aq;cPolEa3B_-rthFRL#7Aa64~}>|YPD z;N?wBkk6SjANbVEN`^-El0&tfk=fl(jqJ!3>IHoSZ_i~`csM1eODEhj#(QUqz}&S> zYRsWsMuzT!)l=#V3H~0={uIEI`yS2$k!dK++On$NwjspYp5J(g*F22SsQ4~Y^CwVM zNV(KW(MT{*5y50>=ps_D79Fmx;Bi6i$D=};#fJt|v*lJ~bp{Pte*r-2gT<`Tu0*n? zVUtPSWa9<@c{cc23wJl&$0QmngigzvkgXBnZL8N-$7MVD2EYt#E<=IvUf>&7*BNno zje;!D5#u%WZw-T7TR-ZLDz;leA^gf(J~9@0mhiRa?O6&{ljCcyzk3+}-EHHZdAwoD z|KPvs-wJDBYnNH2_S#fuyVF9VI@ijh1kS|j3iv;Cy<>2lUBE8fpfPrA+qSb~+qTm< zO=H`(Z8mnA#_rQ8W$R$#! zaUmL)wPyd*`PR<4NYcdlFGlEz#!m}z5+kz!qoF4_NJ%Z?pD^Tbg>G!UD`Wg(NYHwK zGtQbjrIQdPD=2wV7Aq6EQ-eGRx~MQh7Z33OA}bZHDDXdKyi5rC4c{hxMg3(V31UtT z3)0`!g7@CoDPMjIrjV)Sj)5k5|INI{$jEv4&7F7JZ-tNocFfw}3NZ2WasW)@Olgz| zF}#GO(E%vF_t`HOzC4so~N~NT{sQEolM|TSwpU2DJR{htEn1%b*iitBg zXtY-1GRc-0#EB*mlvI*oO>^YaD{bhF^QkL7y4>#!=6~GZ^4SVT`tt9bSBwdLps>ft zD~ba7==ILeN%|`~#y3W9c$3s(xfvlij3h05gyQ5D;d zpzd$I2$#||o-`yC9Gkztw++l7!fS2nwBLcunVr*2?SkyQgQ6 z&gQ$z4+Iaj#$xv1^f~o#5#csbea(bMN9q}HKUG;}Z;>DY)Kqr?w0>tR5O%=YsBuSo z?epl)lIn0QAFg?Qlcwc0C~AvguD(3F)5dj{J*90U?xRqP1+8&b+?ECCZrIJ6QAYs> zVLQ9}^rD-hhy29IxU?Qi+n4x+o)h|#<5aRbE7JL0>}utSu3yJy#Qnz`fb4}F8^4|= z8kS5g(iUZvBL~Ls*>En}cVP(ASIba}W+u4E8LU8(R}SXAuvhX+Iv?+vF0P51^etqM z=}MUOkL-&-Y4Hw{VMnsczM@g72+wnb-sWhx@P}ohBz9wI!e^Ui4Xt#wI5MiIIzRwe zs`qKZf=4sPSGB`(bPOtXD`R~mftuA&IX?7iJ-)F7itSiuP+JH2ldsnsglifdB16Uf zC=fVtosmR^qt~m-1c!4`QceNeX2wwS1>sIJWhL(RfU;61LgC0dF6&^xDd_^_AhSJQ zwWj2>UE`l^zzsg7;sVOjDubbDafz2vvZG!>leGpGHV3y};>!gDnVCaEmq{xC*)AhF z&NQm+`_#1>LS-;@>a^JQO>08HpS5hWbEc8$ASNHBCVk|as1_8tKWr_+%_-pc2W&Y( z`7GQDOWN!m@$P8H@d?48nOwui3@ngV!&$6?i`Qf4ISBoicjr9(t;UWjn+?4YRh=-v zCD>^X-=kt|gfN^z93{qa{=f)^G|aFR6GT(H^0{P-QJJ``}X+wsiM3*B-qsaO7b{4>5 zz=r-5ygbo%O+lu9BmSmKep)b4bH7#i#ej@SWZ^#zO0(T;eTg=0o{g3j`_N!Dk!4f2 z^p@ACFy%mY*O&eSMTIz0Q!wM7_GI1U@xt4ED$nQZH8uogzHu(cI#T?jk;(0lyPH*X zqJO=SnJU_!*I+LDKcy-bwu7hC{;-4R7r9nzZj7TGYv^)WnY_u&NVh1t={-bx$ke4F zvz_;_A+vNfD&fEqenEX|s2N!h3P(Rl>>8NP?E=#C)ROD@1Iy-nWBbWh<#^m?=en3t z?6RD&jS(At7=ikwe8c_-OQc&hR4wE?7|w(C2|AQW1F+j<|oNWQ^JIoW=b#kTvD-o21ZF=HGo6`ZpF(I_B4Bq@G$2z7_gR)}r% z+kWv$w*9vq?S^7RRH+w+qmz@E2|E2=mkiBo|54z)n~HOFQ+}b5evhha?NVmCnkZ7T zoo3A7J({8M%rpx|B$zzzvJi(yPn-AMx0}=5y`zt#fLO*aPxbJa5$_sjkY&#c)>O6+MDLZYM)?a&ZLlHq4CvVlLl`P}*Wlpm zFmyN#>E1{o3T{aUK?@STP(|qUG764e?7;my+kM;&3r#eb!u$T2_blQLg$ zl#md|(0>=($K%zp#bY$d{>8BY>0XQ@Uyk<_?Pyi*1XJ>~cEbZq=Y}s*Jc7fLXih+B zfnm^()Sx+hEDa*s+OK!ytp!QCt4?QU{t-G%k;C`0tN@v#xT^EWa5UhYQ164Q(8Gt2@Aesu9HZgu_%wa(Zz8n5`a z?5b9cG4rNwugBLQ4yG)o#febNqH~e-T({78G+vE9Q{g{2sg4FZ1}&I;#t=@|CYTG; z<4K03sE%TXeB^|*EDG!1>}W6n$%A%T!E4KtdHXw{b?4mhV)%_GUJ2lC zCo##AksB6zi^iMUV=BxhT(^}3BQ<)jn^4Jmf6v4Bp_1TBR=G9&=w&+B#69a+sYl*? zP(`(u!?A8)+GVi`cpi%=AlV9zExEw{AF@;5&%aiK2)wG^U%#119KepumY_l=vqDlL zal&Lu9b!Qb(LfrP?v@PIaUhK%aSoHzs54O`vE|yy@|!wOMJ=b+k;-wfZ%cjc z#^#yob|T(JRI>sKOo30k{cv$>FNkm4R?~=zu9Cy5UDJ|Ku*BCZ2Fs4o(^sbew~noF zri(E2v^O_Y3#}uP14b!FF&4{Fj{qpFVRLI0A5dr(RQdy<`A8II`iS%4#hQ~JJu)Ez zsdk9AmtomWcB`}D(6MIe0AB~?02g~;b);NQQ3}`$t1pfRsb*;4I0n1;^nUmHs8*GC zn6;dxlax0QmQEistIdZd-T*a;BR3&jBXSY`QLV|t*Z;?_ zKVku6ZIMkDpy<+`PxsfL$x|DK#0spjp37gh4)l`MBkLlWf1`|H_!Fx7g1!g^CbpE* zKgp7omp|nHCKS1}H67wbT~K`5ZJ2>%;0vTgf>f^+3xoroAr=AMn42GpiQaA_0)sst z6fmp2hM!@M!jI}k%)ZVf;eeb)x{{FMD+)mNzv@@AQrGinQjNlChYq?Jh3LtxfetdZ z^TNcHQ97uEBr$RwcIHGG`RRkUNX))cr-ekGErDdxJ4%l3;+H6OdAt^RXm7|^twlH@ zKYZ)iE8Wr;dFU-(tyLRp4tmC%vKT28x|Er4)^!J9IjV zTH+zu1s5;rw?|sGXhIlzPxU&}bR>Fo-uietZM|QO5*<3b<@G#|bAnk%rCix%vDL-0e* z%t`>|^otbU#q-_E@bKYl4Id~>8(gvpp~$L7nNfb5FRx4E&)ilvUYLNAl#sU(#4OM5 zvp_my58N0LpKJyh5i1a%qR z1g$(X%Dn&lO1cl=WZ1uEbsxDb-jcQ}7hoCQNN_NPa|;R3B0rXHEvzC^`(H*g>H);f z%_56K(tTXKfpL=hs{dmOS_~I|zEy?>wDc*QJM!&Iq~5UpvRfhwJ;kva#bXI+O9((? z=Q1Iyii~c-s|4C6j-O(rV~5WTJfvYd>hZl?e7pJk;8qTK+q4=!AuOZ~VT_i?Ie3nc z%c+YzdauWx9KF(j2Y`SY$0TK4`{iRvABkx-TY!ta9&de2r9mt=ukciEM;9f&6v2dg z)SZwrY_YaGe>VmoHu;6bjqt7FBUqxjSxD`T26GXH-N9HFdxcLDM#8M)r2+tx#Vvqd zM~3OQhMjo&VkL#rV48*xg)r^1f+pGReomzj5L}2GFk(+Ghnjx(P8&ZrpdduZBNt#asA|9E;!Plyf`Jxl8d1e|VU9<4ON zKHr$lz0e@R%oo$a*I0A3oM=9{C#OAtX$}s0H3i(Gp+gz3SnM+LmP<4g&<B z&=u59k7*R<-d+J+!nL?nV(QpHtR!M?+v&(~ab>8Owe3;__doMyXZ5-Vs|IS;r1~+m z(wR1D(tPWmf88$+yM3JpZw|Hpj{@{Sq#@a_^JmQvqhckKaBLOi(y=Tk*c`h0aK)bY zhK<`Y^!C-NTRok@3QYPS{g_p}R6{Sp>mlI7FgAIz$=`?mM@GlV3J`Hw+9itCIlT3# zFH{RiSR&qlPnxwE%lzUWk}^L6p}p9`r6u zI{Y6c(v)?Ums{QxY|rBrW%!_(*X7oSwJHNPf25;3?=l&0gBsUL8;!%WzYZ&YY;H4h3S(SMg@h;+J=|IWnr_!HJZ|5lSmprN|4 znh#88A<~DuqdK^U6JsBG!vO+?7XK4F)QEJuzIfZ&^36~Chw>+R-n)v(t<%1y&@I;R z$)~RIZq7}y)6;}PYoICDhy=x$KAc^>$}Tc#21lKNwWv-hHvA{m#MPFHWT9o%;w=_t z9V($3l-u6gv*27Ec-ojy^F&DcVGkidO{1)aKaBDzx9vjLKZMd;b|B&RqTb_I?3y7ZvZ}T1FQlF zw4d}i!!>5bI*cbN)XjPTudp2s+*o_W`C6|~^3b1&KbL?&|9N%=30E9I1HO(*cs8Co z65>FtZw1r8{r0<$XF2oHxtV8=LcB|0@W8xz;DUR7RZ?R(vB3D} zH*)94xRo$9p1-^q0V&Ow;Ig=p)p+wGCCyc%;RFWTBnvzQ8T2ouWcxYxOEs&6t=e3u zJ+NaHB-zEa6g_|)ajxp?MM3ffEP1j72eL?Sl1&)CK!@^e?s-J|@ALL{1WW-A2|}9) z1Qc!zDT=?;NKCl{`8EC>%RtYQ`vL7#_>A3Nngo58M7d zyY}#5>-Q#nm8AUK=dnKDC2E2@S)?<`CFmXQ)HFQvNT7Ep#H)r{wF!`J-LtW$EU2rB z_+x#HYJD0v`JYgoFcH4Q{Rl%B_l6pKq&OJ^q%aV%qg1p(b$c2G<578Fx(V ztWu7+&w#bH!>0sgf=Rz#GJIACyI2d78>0o5-I{aMpLQ|atvEMNqu;b3%e-7zKbzxJ z&6pGetTx9$|3}%)8?=Yba^1K(m!SBWG!HQZkk3}q_H`wf#JIQ?6$-p-OueWDMEHc4 z7lsC4PTDLc=no24lPQ0&BND>t*YGZisSAYQSE_U6ih0G_U757hgjqSeTZHx`w4dR@ zoU~VZ{qelH*&GhT!t%IU@+(9$wxZu=QvIPtiB^Kd&K;N)?zT_KCv`xH9Gok*NX5^H z>u_}Fo*Uu*JaPZCkn;J)zpkw!&*w)YN)oVkiKoNu7L(slPdqWm^3T1 zZF^HM^wP3yl1Q`KLKtw&UtE<%Kr`GiB4xPkeK|G38KXEWr58YdW6$~5EO@pr2#^Z?nQJ?`Fg!37yL(EH5W3$Zj6|k0UVA)XQh=I+mlT?!38;8VS5*~|W8gZteF(By49 z=aXguRIJOnWH9Aei{pd)N!zDa@*W^S)-oS={m&iWx_s}$+k<2>a*B}|PK~k9xWw(R zU+wpa5{z`MqR=|(TCt;6bLm>S+S-Y@T~Q-;smMoBlBl7pVVSkh%0u7+j2YGMOdvL5 zgIJUSmG)I&wwDWCf2`10DLRVL-4>txlc7JbEhW4h$%~&%0&KecGx+#)siv>U*GajC zLN;1pVZrsrii%Kj811OC%9-=UiL*Xd;B;_^v+|n#nLqhm`Hsnbz@iHOkUR2miG^;o zyupBv$bZ{#C;y?mmqX2cEdVOI5IO}ffY`vJHsTN`fanVKsnv}cxG!d7kJxCLgaOZ& zYs>j@MN#dnoFN?KzBXKt*l`rpvW>%tN+H7$|0B3_kxsyk4lw%g*NLb|fKck^@O zt#d)QQ#0n<>%NVnm(ut0s{NHZaBNL2HqL7KF*iJeF5tRmAHebA_i;hFX<2xUTB>icE$W40t=jq6XP!VQy zOAF&u6@B;o|M7vXlmK6Fq+-61NF-H$(srAy(S2jS;vXWQ!`%D zUvbL(mJv9}J=B4Ko%>p+kP54M0!zk9605aWU+o`LzKYDGy@tM(FaxCqR$hylXNdof z-$U^m>IfqKXjttl=+Fh?1dt4CG%zeyy*8@P1}IcFU@{ z_@AR=z4b_%6SLIyx&#mc=;5>F%9!cF5Tyh8b=@nZ#DZJk_4PfNY~dbS4}U>#)PQrz zlN^KkqiT|e=o|>U8&@=n*aX-RYl^Q=zz48@8AMu9N5`iWngl{!RtL|`TA71Dh#FdO zi}BDL44Fib@lZIYiDw3=`e#!mPhe!dbzS89X#{Hh;Q?z7Myj_8m1(y~P1Xq>vM#gK` z=@1Qx*CGt`v2~PpXSoPjil&Z#mhm>9#z zg-HJJ9Ev8(oWU~Sh0zGtLETOo7h!H@HPJsw^9`(rMGoBMAiNc+9{}jW#TlH2<9y#C zx+y|>$0}SJcsg<>MW~71$;;abtS7*K`eo}Q|LX%D_>G3Yqm*rA*Nzl-w<#iox;P@u$P-Y+eIR1st59ssprgh1x7!cI z+WxKrm(Z(+`Z%OtN3LJ&-ED@LR1tP}FRMj8yPb0F(;c|ix7>>r=5Qc6pS?r~NavAT zHC?(Z2weWRoyIbNi`$MGw&YP#aSO> z+bcIAW?Czl56YXd?x0UEIrnKsU?;UeGyG7r(Q;C>fdOBo;7{$%=aYzULX8-Pg53kC zY&p33Xlx(49{)W^fK9Tb_~a)0AjHat@Kf3C>DSe?4&lXGTSs6&*-j#b{vo{<^;ywl=~uf_(My z^_O&)X}Ta`L1%s2FN=r2(Xo@EaYgGO^XyT>s`mNwDJ&)~q{{{8fd5%q+L@b}n`@DQ z&Ri~AY{=_Tl~&};H@PQjB1GYWFd5h@Rh za=;X$#9A~t&dutkq@ZqUcsmb|zBFr&Q-&ar`%w=enb_># z|La|1IrMDaMiNc||6t_pa77^(ES60mBVyi6Xrw|AYrBG_`>+O;21rom#dIiv4f$a} zzNwaXFsRz?PBQfnXfSA@Imjx210NI}D{zdnBPGF_1o)kOPVCmj_!3_2s# zNZ&u-L{PF@%)@dGjKk$jbymD_=S#Z3i02ALC_4zA;^4unH$ujYq;hobmAmPbdlRysI)5 z7MQ>6^}THg{!U8gyx|GynRY;@u7yhg4AGd<6~h9sL+V|Kk|WD2^Nb*#099u+GAvRe za?zLOkyy(;5Po81wjfTn?kp(EuJYzm<6W!p?q1?=H%3Kuk0iLSV6K=##h-IgLFDq< zHm;N@s#yXH7DIa@n{uM2h}j!l0-0ttZW<{Xd4{UP4txcJj4e$_TZuNM<8MA#g^T-B zy(;t&W?L%R*Mwq#kL#s;Qi(;fbCA@zXgoh<@^VyixHxE$)7|w#=onkSq!Xzut;{4E z1l1miUTRf6e|~zWBSFd6lV1sE45cMWD-!&9KplgEL9d zDS@P3tQH&|Jarj4%kAkVAvLse(f(VXT zx25atttmJYuwzkTa-3H_pD&Q)7yoq#d2d8WC@1uzw)N`ui)7|GIa2ra#ao-chmLJt z^#?PpR{*iS_DS)EsLx~Dyakopk8ZK2>_10G$f_eZ4RPMo@tA+x{93g(HESk~j9a z2_Zftck<}kdwO>aEP^F#ZD&yx{GoK5B3jn=)p~*gRR|;$&KN2H=4&T%Y(HG_0?5l4 z(v&VwQT-Kzac&;fTJ!I5WTg#-SW^1PJspJUT|kNsk7 zdKzILweVc08|BQEy{X>|-{7o2dwg9kO&-vkH^2s@3biy!QHHpre(prF-$^YHIuw|It zf9o~*&&yLsH%yz+T!W6+sSz3D#1fXKuNj&0%L((389Xi?uINQ232|Rux*3bHNn{5& z?giR>?J=3Bf4VO(c&8y68%KD?PgFKz6B!@l5dhmNA`4)zVESO8gztbfqiEuKmfp7d4z4;1}{pif!qDbQm&$nZ^~%hb&5 zAr`h}K-0*pPRSJK&ft5?^bII1%xV9I-xCX}6j_3a16k!uzX>`6zDC^m1g}bAixBN% z1gD4DME&NEL0#4A>Z)v#^mMt{fe`b z!P&gll2nbLN(GeXMCN^NS5?2D@H1b&Nb|znDY`eXDFQsL*4DAZPkF4Ug;;37Mnx*D zC#;a1=hLI-zL+Y15siBM3u5JlK%u0ad_xM>;Fi3tx#xR}!90U&kRw6z{oaC4`vHek zQO*QHMNumATX|@hMKEe(nI?51Q6z)LYY54;BkrlBbMla+_D4oDDU*TiPf|yFXJ-S- zP`%{%OyMpuf4z2iV-z<}(6Rpu{EI}kR=CB1_RN2F))CpV*h*Fdp`TBCVDHi(T|v_L@VU}gu3GWwZ> z*m)qQE;LOh1>p$u4em!1`ba~x5f)z5kkWV5$S1Px;#^i!EH^92v|s;dCwxku6&_9G z!W>s$(zt(AMMN8>XHI|3@cjW>a(1xEtCXM|K?D2OCN|_d@kam7T8cu?L65^xfzOM7 zY?9)FGb+L6(&n;gcTL}wEvU>El-`J4^_^_~i5qizYI(vDQDqS1e>Q$T`To(LQ(BW2 zi@K{4Ye#VV|^~>{v&a`+m{7D?RMwZ-SJhTKNP29yY5Rezo{SLV{wK~ zdF)TfBB}&IM3^qQ?j= z1k39EW|!ODh-r)SMk{vQYrxK1rjYKK@k_W%4*_#-No`q{zrft z=^2^T5B`sLaa2dp8kcpCW`n&**PH3Ki98_?Q9ps+oHPlxDo}^;Lhh8J2w)D>RAlX_ zn0@b`JR6Zz41P;sAi2DUlg$=`HD#x|*$U-3h0kGgeagl!sXXb=e)whigwC;$GNi6A zBK)LwW(O_jQz#bL%D~=~Cv6IU8#t>-D6%p{g?7y=z-ry5*gL@nnJ}ad5b@7wtV3&h zO06SUWQW=cUxP9j9?3#a-(R-;7fwpIP$&&)nUFI7v&bFav^x7NQ4!p;O+}?(LycKc zWfI`eu`SthE5cT(AC+QLL2Z;T0>3yw+!~g?8a~Pq%9A52#zRVXH*Tqr}pyVX&b@LrS8d$qKYj83Ua2BW|ELaLk z)6df+>Je^EB0H}j`7ExzMFyYnU)eM4Ek|-(7|%~GbyptWwY5v4Z_5LiqOlIYo8bqa zdYV_$N|K+ki^Uh~-pE|mx}Rq3CDWyRfFCPP69BuV^%wCV(`ApdITynT8!8mUKcE;i zmko8goNE5YGi>$tDCCrs1RJ8pf+jojXGu_wYv8vg9-Gw7yXFnH;;aSr4iW!$r0scl zryU~g6MwyaVf7XN--Q85>v`QLw3`$OYK&YV&e!La`G& zB1vGrS^o%-(2$(`Rf<+x_%krA!l2XD`Dtvg3H~9LMUUb3;+SG3R8ANaR@gf9PKG`S zKpDzNP+v(UZMDRJ3;0t2LQ8G^%tS7d&LKPf8pG5K zLSmz=6aN~A9}6b-_Hm$=`u|G$vOJKWh+N84|3Zvdaq$sd(n{EdThYi!I2u+MbQ}4t zD@ne+n_PyTZSJ3)45VWr@hv7}XJC|Y?T#4OOuuaMRy$g@RJP^anWl~YNy~ca})e$7!b*m7;iypz@2lx4+7T9=C%I)I!&U||GegusrbBdVA2;agA;uk~Ba zoMJ#>hhttM$X!s}zr2V+d4agNAKa|C2EMw@tRpU(E~XYInmCaR;2f~~ji@3ZoX*~Q zX+mkE<@t4|qWcRtpVSe}K(+>{RjI&rA|73Wl_v#i23^90R)pAq4CGrllE8j!8p$jO z+~qWLO(GyN0%xT?7weaDo$@`P!a^?p5R{sQsKE-Nsq?eL)Me<8njVkVW#q+&G3$dH z_#w@Sm2h}S`|tH%SeT>kt=e_%jI7$>j%DEHoSs^bdgIWlfeTQSINqoEN5iBLu_~Vc zqX;Mrdz!fadM>WaPDu$BO#jQ`4TTV0^X{p2CJi8G6x^9pE5aV&rIeJwzpFq)G$#aG z5-|m0jZ2gYfl872V8vUI<=3TQhNWi5u2~!&<0iy-Xy`QLIAfjx3iCVbBefDU`$?dO>@)xXDy_$JCb`-P} z89Ej+Ev&aPy#~$ArIALJXW?J8Xu$N@Hxc@$(X5_;UmI@84shB>QJsA2!MJc%p{S|X znZ|nyD_+=E0erD$g-a1omrNQ60VqN(^Z)I6NWjU*Drf9d8wxvyuz*TF1Bh|si{egs z3zz_$NKNql`s7Wc@;zy&{lBmGa6r5L^@aw9hA6)am<)U0J)!|hcxjl*?ZLHMJW|6@ z(HLPidR;Epc$kDUpEX_lZ7lVnLG%95kIgcs%YiNu6$n}8iKC4QOUIPP7Lze4#Y;#>57djweWZCqok zEMU0Z*am|$w(OFM>+4QfQ(xiXB5&USCIX{rKNrO0)hei+eg|`kUeV@7(cK!JjB4%O|Ee?26Y1te{UgwA*-Q2VIi^xtaHQaGBIk8^$eM&lT#-F!`O z2iB;?7Q{&c2XI5Ezxm>22yoH4vzFn|YF`y3l9Z-BgYwZ;G=?uDz1jHN{v~*;yTrbX zm8MqplC>*aWw8B&h1h(v_CoCOf?TW@EpWUC9fc~GP>LG;0$P8kwmi`c@QmT@wO;?KSZ6SWQ{M!?&OFX({C`L zag(P;&0%L=NZuO>1$ShwJNGM+0JYX@bhyYyRa^AbvnZm9EahcpXNNO6qT>EcomJ#p zNY9geEaj-1DZHe{#_bBQCoqrKPpu_n=4OY6rv9+93g-dE;03-fOwB+GWM=q%zZ2je z4tKB5@?wm%9`K3Tp`vwic)y=t04&|I&Ah9v(+C7L)h&*3t-wDe-NreF1$67N8q{mg zG@j}xOY+ch6?r87Ss)vdNB=Vre{ zJO$?m`AIOp_T1?E!vme^_{q73es)<{naPpuh%T(n+^1_s5s>!i{Kp3~wnZ(!;NpQ{ zg)*yq!l*_&oqUa!_=)w6A|W8-RTuz{`GL@RYvkSBy;V$cZv}S6OL?7k>7nof{XJP=I+59b&OR8#Wjq-$n1hdu|EbB zSek6gz+9&roS&ZuXv!cvePs!v+Ckwd9hRh_rF|zxVh~&j=cjt5p@YdP9EQgATVW8C z0Rm|R*wKd&nquM;yv+E1i`fVNF?6$;57}IP@C`8a9Si>lOr(1{1Gi?`iU19jWW1`+u-!{AMLSFv!DxpFzLoX^Az*4MCSWE( zq)N+*3k;J9U%#4!TJj|`FO0YOo}hbDSK_==V-oHyFDi~-gEIG=zbCj_1C~eueCh02CIuo~7-A=`NsKR>I}Dh1@`rSD&eALryJW%vC=+_B z@XP9bIA<-33UqNqUj)2mnIBRaF{65Ss_Xzx<4uJwi9w#I7J?K)T11MXdnHyt(RR9< z>KTYc=>NNCMJI~WhW-`NmzeJ1!WEv(2g>Rl(@|N34>vk6lUKR|u0u zY087U0Nweuqvn3(X$uR@V2W{(g) zAYC~l&m^G!Q8&|VTSd26A>{B{m$pUntY(wbN9bWA6*5O^_PvR27jhy^)Ar99B^>7f z3+s=ek^03ymk6e||0mHn-bu9-jMU1Ejewa=tQ>9J@h)BX2yI>QSP?K~S)1c^sl=zw zwES3fpy;^O+z>rk)s(+^GSMhmTBdF_Gp6rn6zv%vYfR&+rcDyAUal8yk)rvPMF>JXu-N_%kw)6v;2EpHuZ6xLDb9q z<8fkp*+Yft;SBM*-O~5@{?%|w=;_^qu;S0%pIkn_zwJ$DM0tXD3&>O1w!TWAPrZIu z?RV#XJnDu!Wz#ut2_2&zpML-MS0a&*y$DlbzI=J%_AQQ2m%BiNSxTKcrrf-SAU&o` z%I}pxd!}4PO1dU^3l>djttg8KUpB{-D4why219ub%yA2c*r3P|at(Dd?TJI#h6>of ztLujqu;(56qiGXu<1&X{4e4}#uKn~{vPmrHd@nmHf6oFZg`^bZ>5pnN)8yZ_qtJ!7 zDStQ}-HJD<{nn)@v71sH`5Q4c4N5~^t8R^F$)@z#hM2nyH`X?AIZn!ts4Y=98BMLx zOT-d7q|aLv^oh=vdhdwGyl9$_?wluGvvqxG_R)04a9N}6%FAoQqBPA&XSJLP>`>dmnNJ_v|wW!myt2!w-4T*Y57K(<_MrRw9^Y6KS%_UN_hjsy+3M!W^%MV(N0|ds z+c^ZS@S^>zr<(b2@{;^t5LY+;1puLD>2u|_`9zd20ZO7iH!sVLjqs3ox(;%?_hPpa z^^Hp0%ETenTsdLAV{iyyOg%+&LebhUYSo3y>cy2^sZ`p-ITq{swyu5~E?0|F4RvDc zpEsFdlQ8s&#+!;&$%MCg?q8^jpsVf6#Q*-7Kh`*Hy-KR76n(TYXdJscqfH9dtxKvg zEm^PN?L-`Yd8G2PNjW1`5VTH&jvIZ9oD3o-90t-kC#bW$#uEg$Qas=$`&OnP&V zOix97TTj{zQh*XAC7A#QIl)5$((rBMzV{Deq6a2XVI5W+SffId>yor{!lex+Tr;1; zVCXtnvk%PG%3szAzul7>4mVa^dakH1%w*uSx0FU7Sp_Jn_q^)%c(raYXemNspAH^# ztIq%cibkteYPv2$YVXpzE2ZYq4xn)Ul~ISKsz;Hg2L!IZpaU--yByWk))hDM+gnVz z0HnlOr-rnK7g_PWk!;}z56tK<+F*7K^5|#ouxG8qmL9|*a52)GjpIb@>{_r|@K=~CGH|%8jf+5ASo@DZ< zh^Pp_E&K9f4m^PV8kI~0iHihbt>~dE6s^q1 z-?;dhw`{FioC*WEVEF6ehP_=WwS&Y)7*i*YzArtY<4ZzcHVXe|G-V9+j;u)Tn9&Dn zaL^qTGMLgAUj77h;Zdgy-oA<$&ExwTR>&8ggJVQamo3M+W^t?%4wdJLR>TL*8PR!b z5BvAl*kWYQljOK1rc{_-8ZmkR>RFu;#Z-Cks~~3JM5?h=fJsh#;9;?qaj{ss2uZQ9 zR5*G)s-;+@BP+>_H%=h>jQ6e7>_KtyfE3PJFn9uhhOD(9{tu93Cgb0a^1K_DtyNSD z#rNApQom;hM{(9FKo{|lJyym$YLJXdq3z8fJXKH6omAybA z@f*3v@lGwV91hfMtBGA*nCkoVcuy!qkdYf=K-j{{k&0btvS?p#QbB5y4FcfS=*wUx zMT1KRVF=c;z!U0nmFiPJJw$!ZVML&Y{E?yZjUfdZisZ2Efw~gJ&U%Dvy9*=d{e5lD zfCIMr*q(J_WUDFp3W`kfPo97Leq#XXKmhL7q8{~N5oof^S$N_V{dJNz-D0P2 zgcP932LXo$fz73Yup2vjG7Q)ai4jEz@KM{Lr1YAcIWNv=n-Si18+?xad$^#9taSORH0g3+Elp#8xpp7#?%5hQ%L+@_6 zXzAUp^{CGLo#WH)+hi~D)-npoKBnC+%v*t@fbr-z96Ls+!J=amZj4eG=5NKM#0T8d z!+jEQO5{Blp(0V%@$Kn^5^U%^nHDfSjBVR6x(?{kuYYl%d@4kaOm?7ZPTJE^C_@Xl zd;>g0;vDb5fB|pb-+1LvfSvZlw~jv-p=;+1P3sg{uLrRnlgj-L z$29LsB2}1=t#xcj<-_J0pCb}HO?9K*%h)yP*IOgs4+Ar@*v2m)Uq8CcSG&0`o;bP< zf5?9z|KDdBI*~8OeJEePctrj`&ob=)&N4MQn=WX}I3u4aY&|Ma@lUFaY@rz@O|2WJ za)N6|h4b)VAs>yj7c^vk48{|eLqKIm=x+>BD#-Sk$yNEo7dp*l90>*R{Wh1o`Kj`|i8{%{s4Vb@$9# zwZG|Iwf9%mRlBORPJ$mirdLFr&{iIKK+E&_@Os^^G*r|@R0ZEeutiDeyp-{9^|*Um z6mN82-d|qs9H&V%bZgpuwwMTgcWQ2$sch*d!kR*2($lo~F+GrfuwHA5owRNC)z#-{ zK=Y-;4O1bqNx*7)a*FGx7D@yWN5);SV2;g16C)uJYbe0y>gQ=&n(Ir%HkvWkuS47m zuGOA46!<=(P?)pKG+WLCEOVV3qddp^#~G%HmJ&KU`mp!CmWHT^h)}%<3wEMr?ZW~h zEAwb%oVc^FKsuugmmKCB}HF4LMCxHiDtzdaQ$R`m|SBvW~ z4d$zC!=Vx(18GAm&yvn(587BIxW~Bwt<2Jv6RIIhZLC2N_Ej|Fk^(HHlx?D^DM>ZY zo-WWvTb!q5U;8#aaBUbVrLYHZGAl`mbJ;yOSVvQ^zL(%-}nn*yN{k9+y=_r;AY9uzqajM+`bIU zzTOe$Wd!i5cH`0$(XO(q^6TH*^N-Pmh)O``YC;0(RRk-`8wc&!nkeJju!r zCl{38$Mzj+d-dIDW`^Q^Qg$L!8`5Yir}$IH9gJ~-tM{t#a$ zRr-wXeMo3UCDGShiu^)8*P6Ud$VQ)#u3hZj57|?6S`PC=%2VC;R>9?m5D464v{dzSR|&h#(t?5iXZ1WqS#op%3cuJibvU0VBwuu}4I8_d*?&VBGDE_5vvl*EH3gNy+}p;>cGSH9#Nj^*lC6EacB*Y#(qDtR z;7!zsydjG9z&(xe+C*qY&oxJGj;nN%q%#?V9zGmGQI&#CJPjYPr&<2RZ2h_TJCptE z$gCr^OK@CaA|MsBeAa(28sW%bPC5keB?aR4w3kzvS2rF&E_3Way^t9HS)A9wpTfmN zx}uDrNu-FBK4bpC3`Tn>X-lbbd@GDgIAa;h65B(Z#Y z3fJogL@OIlq9vNv6}w_1B>h%w4zM2Y-IxyG(+E80lt!Fe?XipFp+~=LrPfBz)j{Tr zt=*oJ#3rwoD}ZoL;-WT3N@;lg)q0I@QU}tYL!<~EM(TtV(3r2G76IXM(hIE&IO2f_ z190aqZ=z)Pg$~>I#=o)~Y0j zlBz?U{|rQ;$a@)~9)&_xtFUW?XtH30cgO5qwW@7u+}wa&BJT68U(>$`9-rMhSGXJ$ z^;8;L)T?Kl+)-8GQw$zk&tvgyTD@vXj&DgRgyq=VYJxq@2$mYI^JC;+)|-eIA1X)y zn()o8$SvcI&P}VHYUddp=8ZxqsqlRa9y?OT)vhFsvc*XeS(AU3lc2*+dP399AylOj zTE5+Z48zBS9kMuTK!2(I`R-1o8#8n=e}1M53phlVF2dohq!(*y6jd!<_k9t)9AxAk z4W?zI=OsuNh3i?6vr_!d_=#EplU(iElL%c)!Fx_b-+z|Y7%An-V)-Lco{pB3m1Srl zus-e7#yWN}vfoUXSw#pOS=6xwg8R}y=Quy;r}sCp1uOiB zI33IqpKQ{lRMM1*dba0zf_e7{NXl_}hF6AiskY6}2TmLJ3%%sJeJ z>>NU6=IJvEZl4g(k@VIy*r-9AV?$!rOOb+@091{#!}_KWE{$nGir+y`k$o%;Azkpv z%D0F1y8m^v;r8C&WUD5tcfeMmpA!b_w{vrlx`qd3!{y=}z`m}&1NUtEh=Ho&-oa5K z0B*k{2Un7L`S0VW7rUvlSe4Uh04Wq0xdImIN8-5 zF{w8z>4=>Np1wL^jU#W4J|?~7m;$Hq>Fj~@=ndP3F=2+*1HE?Ba`}5NJ8Ft#XKN>I zX^cYmFD+H;yhJ9C!+tmS;rCxXCJTlm9Suu8*Pp*3XgQ|RwlgdHV-BCIGWjZH% z83K{L4E4#|Vkdgf%#Y0%#Sa zDII7is6J`cTIGQ$OhHDPL|lP!S&I-hI;m51gl@3f)aF$v6)h!6nW)M^HstMS<2Msj zF9ZNQPM}nd-3WEA#`W4t8KD;lB zW+)k`6g!SK&NxZX`7F3FU{ev=uzg;NO`|7tu8&wP+*YddRn)%1Rk@1I*7!Zup8(a+ z?8+RG1f%Gv916q1;RHA22<&etFxNKt>tR+dB7Wqhr_eVxa zb#?gq2FD!EBN>|?WZtIid~J>2k3YS;hDt!&Q3fAJsQc*rs1SPfsx7*WgK&8a5KG3{ ze1=iT`q}xNqxgHiN%H564wMU%wyJ`xHKQV4%3376NjhM!plemGhX3<}fh-N>9xHaZ zvYd?*?-LHZt}#5mq0vpMiDPvE!J&~UcX(i%?qiPqhKZI;mzuj{vhz*w14Yq?QTANt+>Dh)-Kn-8W}AhZjSqW3db z7rKGB2EBTii5_X&N8C`)4qeOSfXPoi9e{JOR6lLPY>4RK^n{m- zQ8cT?>*{76U4>(l`!UHW5SXTInO--z3y8&1RYOT zpO@j49WW-`6Hu<}3F75(!kLECNq;R&My~a_5_LOrK~=i~-QfL2_)U0sK+Lh)rP5xD z{Mg^eEC;k4Tr8>V-F%3)nXv`XpMjUn zI78czXR(c#V++mpW(psWp!Ur1KtAAVBpZ{BPwA$L7;+j!7yL^Ny!)(xvHqK(lO$Qd zX1!foe@n)o0;8O|Lz`i00Z5POq&5Hj#Z@=W@6MW_nUm;G1fjPZ%0QP(r9!~73Tn@F z4CyIHHT|#zW>lL)0ihYBorj7sGsDS2zFND%?J$Wef*IVu@ z;)Lc}NvOqhs5153)4^IFi$4e~)PYfyB2maR?(mm--c_PZl6d<`=3wqPI+X-3m+yck z@Kpyz(swyWl(+#|iFc(?g`ox4PnZNhC^iLFeSa{6@G=dHT^%F=|55J@HFeV37SK?} ztASo-nf;Bv!8yX*6{s8u>)yi(+El28NPS=4Y5s$+C}&p%Hu8*9swG7G#&US~@%d1( z`=A?*4*ioNQ)tah(X|N1^i6zlwB6OrN11SZ9S3U6uV;sGy(57zIcI{!)j^5|hxDJI zCPUG+3|zO#q?`gL$q8Eq`R{T#s(3ptG0ZK>Ojt~c+!5g@pe+1tOXmZ!8aDG_*?xG( z1kf4bg$*xP?@UM?5<6G_4zWPaaqu{@Ga_@vjVcp<**k11<884YFFv{J zfZCimMY&gG&VO#gnU_PCKI8I5ohj>|)7!Nq7W_k|U_m4T`E!=XDFcN;%l_$jtBQOMJ-S7`sBU zA*a-av(5wmR_euEB-IU`-5Cmn%=VOzbA4i2%sqa6;m4%D%1^5`q)n4uCo+ncA5b71 z8Lqw+u9m3b{~JV>$8MT&Vf^TAYJg;sVrc_-3~ppO%x?AY&>Eof-lYmTs@yhcW9*1@ zQ-xGR72Ws%m@x{PO6!FM>8l@K+#QQ&EfjyO-TkYNqNkiGGb2kM-8rhkP&SXz#>w!0 zePS!r^`!4A&jI7suwuiGV>g8@>N#yl`6&3`y*~H% z$JRd~!NA~=|Ic0@j=y?+f>RXj*Eq3O`T{S6`lPR#8{WZ>XiSezxYJEu7gOtwZO6x&hu8u2I-`jS?k0(aj^7`R+igwf6QTQAX`<Pb%x7l~rm(1l0Y* zdx6a)h6OZv6;zt*`lj)!lyJ;_Yvzg(phYVZmT-({P5}tlWhINSo$ zzFgHGHIzy#GK|w*O5)GieLPe%1w{r27p|`nF-m?5aqxz3tttZptBsp?)m%Hx_?~(_ z9BYLJ`@hakyr*WIx+68Q470OsjLEe)YUS@|^uKIfJX}{$J=IW^E(`i>?m^>zSf7-K zlkdP;i|66od--ML!6P}o6tOSpi@Rb>2SuvT4g!8@Q~RIsLJ-N8OT+V6A2i z*x!n92_@TY2T@hg}=U6S7p18!Qb|OUx50 zJd@SA=T&AXYnWv5Q=6fYdQWr*5ZI~1e>lLx=YU6Gp#=7*sf;YTj0U*ww~rv|W=jVe z9=QCX^eeR!sRxMGjw5GdMeun3K5cz`>pK!1Obt8{Xru=Bew+(Mp53`#xL$}bx}bhP zL&1n>co~!}z|;R6`sM9J3E6=xFM{L^qeOCWT2cb?v$&CMN~y3=wv<8K&^*?&{RIoF zpZgce0H=L@v3W~5o@n7#?bjPvtjm>er4~+EjLha?lyly1wEG6?Xc(fgsX-|I#D(8D z@cGn!ph(H*hxnD!96GB|s8ym60TVPyh}8?VMM>0rwwe)6bxGkw?6?^>nE9q#ZCzNx z;2;b|K3YFI;6ktS<}>WWQ=EUxfjV~O9tDa?@{f1#Uopk$ZMIbtX|gh0Bxb-FOY3qD zzf{YpTtURd#0RM%?@VcY_Jw(e{BKS`YKPlE zi?g{Jo_{ayAd5u*hUDc~Sdegf-UJD$S;u0n*q5C_7An^%a7F%IO9#7FtH3xw3+K#0 z&ZyC$fyi_^tK*Q*=I(Y`?UUNH&g&HSD&5e~v<}DXgwE#+`|@(8^(_5It1I=cpQSG+ z>!)XfVR1T9Ls^PL!&o&N<nE}}234$j_JQzBoVJvG?5_|DZU zny%W8)7Hqam|T1xLW3i)4tne+rq-+94fER=W9zIX#zTWRlyqt}RybcYFluS`+5DcGLwfo*EvfXt= zjGf5byLM;hywm>PCaPA9RBg~(Fh5zs)rz&en&!G2zgt}v+2CLGReQ?O zr#l{#xF+&F?p|H5D&(6ym%WNP?!uV3R+zY6HuDJA63)?})?W3V*ZQe&aYj)C7$~9n z`eblB_qg!u=~Aw}Evvewq(bL-I7{I~tz?Cetqry#j6W^tvv9XQsEx6-k@)Gg@0_?y z&+dKC>36Bdv>q&%e9D@JbN@-lPiEb=bw8(6JSl%?NSf01qot8`zq1@YyAM=mP&N$^ zq+AA#hR%>sLv7$45YCXzhT~B_dC7Q93FDtdyF6FqmgFDKWjScM3|gJq!1tNCVj&Kp zm4R)uHF0eqJ-gOn6(`_V%!0-2zFYJkK*L8mL%Kp8K-=^uoONt5XQq>7{q{1Fak%x- z=uDU`Z2GaHsR+;+y_aeK68rWrmGz!X6$ zQqLBS$uP~^2_4<9T-BngKoieuQKYf(}fd|b)#mui?dDPF zH((nAk}|^k@)BYIR12qa^Z}XFy{NOf4f|)iuprbP#h+Z$#X3as_Y!YzW^0esU^3r2 zMdH_+xmi~^nT2N(J*}iewsJ|ac}rM73?s9j0EOasSLBX-8u&;&yJBmqDXa)Ka0-=`AX3`uc00*9K88!_5#a(M?J z5|XWoWdPa{4ot+g8?so<+`^fS0lHghmAVA|PkWPt&P{9#A9$ugqOcz)3g>(e`h3Do zypqw$-eccW^m^+p^B3I=Ec)9)1(hi#{lY~&jd~P#3OyIp+VyU+ zpS4Xp&nBgNQZLIlyz-9fJOXgd&{P|)Y${ns{{pfPv6@=G9td>Bug_L~P=yo64}!>@ zrbUP2C{W_&5taW*=#spNBp|(i^KqTVn@t?mDIQ)J@vI1=oIAdp8x~fmM>Tt=HQkUp zo4SLqO5IHJZ5E%8%d_#FrQys58bX~@qUUiw>$&ay)u+SgZq|kzuS#xb%7~04`WP1{ zgQ_7GZB`rs;s5=S4wk zWOTSy{>_HW^{?Fc_vuu8`spI36tSc@0k2en=bskDHh@DkeO4%nHvektmz~sc6(?uM zhl$ICdM#F*lyMW?q|wK0Gd3|atF)Zvwfc#NBUT~_@=O!i;@m=w13n2VL%wFuy3Bg^ zM%Sc4JYBop@^#8;s}K=AlJYlo*+LW7hZFvFIpoc#C?8M84&?N&CJial)V*%p)T4Bg z(~aGS@IEmv66w^6vshu)x{TG12(1!CiX_!q_QisyF51>BDS|wqqur1)7hUK47#9++ zn|s44$J%sX=cb%kTdG-qxnwt9_XwVZr+kWcZvMSiOw9|j!BT;&g8Smf52;xPXoS=x znZ>X%4y)WLy&6%jtKn|CVQ#vNKONON(SE1Tn_`A7_hzQ{QBJ~qNDVwgX%@Z=vMrba zKsdKrFZwTj2o9f;?%Q>Oi&>L&XFpL$xD>zI~l)*GcIb>HHDl4(v&Gt z*u;fsdC+zPLAMFK%m^y99$c8FWsGc98;!<<6#kx*V{F9^@#Z{Y=pG~{n}1QNAzOwt zH3>fj3o@6KVdz~4|0DG{sLkoA=UPC^;>7%o| z?UW6!uOm_-3K|(IbX%NdK`+KS$H~A*JN0k^^T=)=v0|{WW78%V_Ozy|UY|GxqF5!J zB&rQk;;jtQZYE5Rq?5@HlXj-~pYFPzyJfeOi{}o^KXb+Ri&W@n4VPd`p2i(>gJ@~5 z)C#hi8HcaP7q411hv^Im^qt!$s`0&)Xo^2&-7SeJg{}D$6Uba5A4|4zHsxp1FcJ``vcdwVBzK*>!#)AY1HD)cl26+ za5||1&F;>9$B5I(kAU9>M9{b8vb$WQK=$Ql@O4|yq`IaLcjRQ(t&t-tIB{z|Y7ikR zl4ieYmr$ylg0J9io+^BD$!mBg=hfE%HNOT@YE|=PzwJ4a#B2CTwYN}bZ}T1R-c@%o z733g4nzGx(*W%ryZdyynf9Q@3A&ux*w3W-=6D3SR_J9&lUBDN+;?;@iXc|#&iCpKE zYu1m<8h-%4?ar-^VZN4ubnwvuMuxgIkO?x&s3g>dKg@-naqddBaQC=?MhwR$#QY((r#O@; zEdX_Hw|DV&>-_3dvg*jLGKpf5NO!X%`cVJ!RQ&>&#K1B@2A0Ya;=_TUI}b`Xz!X|r ze}^b1!uN&rZPvA`;d0tE($|1E(-0UyMGKq@gc#b;c>ENPQ#^Xx089RM)%G~dv_+(U z0~LUXhdyGSY}(@Y-4%B2kse0I6Zjog)p5y2NKNI}lyv5Y!bvOWP#9G8QF!Wm_n zcb!+FYw8JxXKjKyvR%SgpH5=3hOKF(2wXIfZIYPddOjh@P{+cHW1>~9HF(3>doy-k zsk!f}bD(YiN>e80(D?%6y+afXNipy!lYq-6wIDX8WhKfmensJT)dn`C0UlXGh4%p& zF}D3Gui=gMledOd6~0ZJ6uUsKKPr6Fc7#&14^7ih3Azgc`8-^!92GDvx+d!yZkOUg zXOt;s-hA@ZA?8Ib?6V*g3CKX}13Sz-q&d_$NZZc%e_7G>^&HD4 zeCS5FDV)&8JNYqOYatc&Y3E$1yp#qYIbJ`xUAH7dtDp-q4gZCEVKXfueA?ZC zsWudS*AZ-YR!l}5uH*84W45EqBg&XoxKnr#>HD_kf9 zBR+h1g)_VL_AI{{&|pOgrvGEvD3u^kBp|?_%REUX*|mYPPBALts}=2iSiTRJVP{`9 z@$6Zi>sGIN?I8Rj*FvVi&$}K9&`M4>l3*jZV7Ou&BMI{L5 z@#k5A>*{d*AEB>EP!ew!7jWEcx81azx{cw4kP6~YezfYAsPlVk-PmvSryzJQwi6&X zi(-!prh^PeAfm%;lK^FsMCZOhYgq4|?5BKvyN=xoa&$D(F4J%Sc*1B7 z^POYH-@O}zsKblo8)c5i3i3sKO+JT8jU*2A0#8uQ65gnf)_%D`eRUw0jShAkn{-N| zQgXxrR(9oTAt$6CK9mBA zIFid3^6S`HM3W0*L4GVsCzs)1R4T^^i#KCUpw{B_>UD5GhD$_&MixnAk2AUiW^{>2 ze-mLOfk!|H-0^WU;Aim;PXltwJ~M8DdORx{7UkADj0=w6np>UB>2iE`#D=N%;SN1F z%TtNaVTIAigUvfI27RFCy(P!UzSAfG*Xs*6ZFgnfBdG@eftbu{1(+tKu&2ZS>>iSwxUs6 z;|60F8DdDkCP1dP3OLvAG&ClLCaq)G>2xkNg62q~sJi^Icmo^cq2X~a!sNqr65mSr zwjG#{bAud01+rJT6m;0*b`+k59j*XxQRI7Jt+76s?ySSx(FlcU=anGAQiou39^WTcqn5xhKoSdNjB zD&m*KboJ0Mawe)bAxWCu=jC!3O{b&3-t*hqMY(dOub|~FE)5Xqy)2%h6HRa38Q)!RY<7iBa4%ukL%tX4jDAI-@fB z‹LQvuDMQ06y(A>PF`f#K*(HsrsRf9N`-a`$!H3O&J{Qbkc@QD_M;GnG8Cv3)n;tZ>(3c96B`8rYYTilDzk>ODIw;m#;e^br`n^!CHMvl;LEZj&KhP~M9;8*mO1 zdgv=px)e*Io}wrZC62;`DYxU5$MN&}9(qm+p$=h6^jaSo1<|b#++C{+H7+7lZJ*MV zECAh^2dKLx9zY_BfJk)0(~+MMq%;0H6z-tuR8n`0Oe;u31zp{+0v>mG zI>+mR$$1P7#^KU5_vZRGd3zxKdO&v!4T~n`fF7=1Y0B z5gpbRK3#Or%aU#Iqp2y=E|ji-&^Guw_CAmA62{_QV~JHJ!pE)#FS`x*ebvu{5{$Pz z>X|7aVrD12gy`cR0JR|x%Yp^G}u?&>zCkd z7Ar1I5Wytj!nHi-KnE9;OcNIR{*JA-E8;_LkWW+u3}lX+vgBYGBJHzyKsW5-DDEz( zU31FZLQpy7D0Lg52Vp1z=2IpsG1ol}UCS<)K@mY5DZr!COcO)ZrOclNViSZ{SICP% zLM;V3Xpe7jD6F;KK=z(>`F;mJ)_N_t~I$wak$(0-}yP{IX$U9XT0t-;O2j7ors#T(C>|yUY>_B zJy%}LQc&PfMf79uw-N$xYxTDSga7QQxtoAlnO7g;UKWFOhJObdXH*PUlm2 zIN@CmUZ`ja-s0nlacv0eHxr5(;!d%Uqrt6y?MiNFS@#NKTC&mUoKt5+m3S@>#w?p& z4zD5oCy_>JL|aHE*28TPZpZ!y{!;>~m~G6yI&Q#Tn@BA}TQ^Xu>{xC+ zZngD&Gxwt3S=*R;1okZj{>MLYv)U}+l>wC-DV{dPd{}|$2|Jj5mnvt|uMHYhbTa-ET_(%!wFp{+5 zqm4a#gv?aq>}8vq?wC1m4bjP)P`K?g%?3%BX8DwSOyuJEB`igCdqf~M@9^5qm$O;7 ziv08$yr+Dd0k6=j&F|#a%6B&kTh&ABPAn>_qWWHgcg({vOGRJqD1^vEC8~O3saLLZ zdpughpfk968v?~d97X6upEeNMM4YhlPVl~I;Hx+at*$^wa%$5+<%k!o+IC12Sj8=t zMH~H1C-v67`hqbI#(IB#E|C;01}e;AzLjss4H_gf3#V@?lKWVKF{Dm5wi2kjatiIk zeJr;r95mP>DS#iHTZtUvN4^qdC4Y%?2>iCS0C*BbkcCw5Ehf}zX@H%B-X+D@P5lQH ze<6u%(QkUoaIj;zWlWYX`xCC{%-LsxMbI0KrnZTr2twp*8HVAZ;Mdc;NOvYWX{{#Yfrq>}-Y_ zWcFNFeCM?yn<3@_-4%6?DzJKM8iQ=dd`(&oSs{iz_Sl)bWfb~@8;TKze!ImMIcL!R zRq$~q?aqy+Zt&=L;E&b)>0~mZ+AEHJY_ji@>(lT*(z9wRPhUznE{i8_?ucXaCB+@+ zZ{rH(B3u(|1hO}}3vY^yjT+rIkKS))O3^<(^<*&@h0QIA!%^DTK0*DrqB&9USzX{@ zU=g5ke$X)gUtZ>B?__OcZ*TpVpGCz_+szB14C$LTA%pg32oqC%n1e430#o|&dx&tU z;GD?XZC*$b-+OCD6gf;tJZ=n`zon@5xSD~rmn zfKDvk^fG{@l`SU{1X>Yssz*L+EMp2aL+Y120c6#9gAc>B=?Jl$*Z}ARAxLw~Bz79J zkmkmB!!$X_2zht|D0`)VyuwfzK686^xECp{Z@*Hkn$)LIFb_6^dC5~O976EVLSs2M z@u@#jD{Wy6kPrZkl$+f1*!5^EaRj44K{JJ3wmXQ8gQa9Si=3NvX_n~YX7(r9qP#fV z1_7(f2NM0}Q+pJxR5ls~7l1J$@SK;`uL0-q5*H#mNatRjipdJ*#;+ ztV*vx*8%pvl$0EyK9k$U`8*xeEw3LIY745KSjVuQ<{|PWUJKOt_)x8>ou9fVz7mf4 zgerf1+FOYBw36IdogO@p6fWOsIr*mQ_H=K3-aGWE))A(;tGn!6y^ub6e;7|$fN|X3 z8kzIhUw$?3j1ogM0Ch(LVErp-En|ei_A7e>ZuF3s;)c%zkD$}4^^N-+n#1unR5q|tMVIaqN77d^!KiEMR2t`@e_s{< zXjb7*ZiXnh?kGorn2zXWWHa&9GYelE-(m`Dr_aD=;`Mb|zDY4~xAH2%jVo%U(IKHi z4RN(YlMC&^ODGx%7hA2oEb{Y>fRIGD5klw9cYw?v`6!Nuaz-%g1DU`4anL&an?A$b z#M6(dm;Uq{5ATSZ$FdjpjXMDaSO=fFTcrQa1;CE<6%)wbgP_5{&_KDMBWiEwVru82 zuj=7o>iqdHo1au0SLkO(c}b#PqIIO1fwb}jubzPvG>Dfs9%S-pp?LM;gsb{M%b-RZ zCtK-mZa%~p-{rpM@x7E~)d*8V>`fSUf{qzg<*?VO$fZ|mXR{Vl@1RLHFjc<9IgU@( zfF>`QOph@;HxW;M*13PJG;?wX40rda4yIXKi@s}#Hf7*TUDF^kXctA2{V70T(lxiC z5Pnzz>gQvP;IF$Um?A(ZF*=+haomtF353EdXgNh^e8=N4<$YkQw{a_lIFS&hCV1?u zr6Mm6nLNMAQ4d!jk_$T(22MNt6j=%8mQ&YPdRw=hG+2l-wora4)`u-Lkqme6LH$9G zJ|e~+pDC%^O&?Qx4!yT}evH`AOV|w??b*>woDUnByyZyz%&MsQO4Dp@Rag%LneXMD zoCA~vhd>8I2jwL!m~g4(X9?|I!~oE*I?#y%I{$t6fpQ%T%*5Up=w$EU%w*``@KLY+-8Z;>`5-?H^H;ud&Zb zY{9@9AwPhDf+ha=^GW<7+AGkBf=s+6% zr=9qx=%4lae?%`E{;QV%Q}EAv@;`zrjsN@P|E@Lv6#kQX|0CSr{I~G`LBxOJ{7GK_ d!5MA&M=Ar#LV=tC3=9DJWP)mVnzp}w{XZr$^cnyF literal 0 HcmV?d00001 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["브레이카"]["기호"]